lightsoutAlpha

class-inheritance

Flags a class extending anything other than an Error.

Deterministic checkAdvises by default

Why this rule

Composition Over Inheritance

Never share behavior through extends. A base class couples every subclass to its internals: a change to the base silently changes them all, overriding rewires behavior at a distance, and what is shared can only be discovered by reading a second file. Share by composition instead — hold the common part as a value the composer creates and passes in — and state contracts as interfaces (implements is not inheritance and stays welcome).

The one licensed base is Error. Subclassing is the platform's only way to make a typed, instanceof-checkable error, so class RunLockError extends Error (and error-family chains like extends HttpError) are exempt.

Framework-mandated bases are a judgment carve-out. When a framework's contract is literally a base class, extending it is the framework's decision, not a design choice — judge, don't contort. A decorated class is treated as framework-owned outright.

The remedy is the same move each time: turn the base class into a plain value or factory, have each former subclass hold it, and delegate — this.runState.update(...) instead of inheriting update. What was protected becomes an explicit parameter or a method on the held value, which is the point: the sharing becomes visible at the seam.

Examples

The check flags the incorrect code and passes the correct code. Each example is a small repo, because this rule looks across files. It opens on the file that matters; the other files are the repo around it.

Incorrect

srcruns
src/runs/RefactorRun.ts
class RunState {
protected steps: string[] = [];
record({ step }: { step: string }): void {
this.steps.push(step);
}
}
export class RefactorRun extends RunState {
decline({ step }: { step: string }): void {
this.record({ step: `declined:${step}` });
}
}

Correct

srcruns
src/runs/RefactorRun.ts
interface RunRecorder {
record: (params: { step: string }) => void;
}
const createRunRecorder = (): RunRecorder => {
const steps: string[] = [];
return { record: ({ step }) => steps.push(step) };
};
// The shared part is held as a value and delegated to — visible at the seam.
export class RefactorRun implements RunRecorder {
private readonly recorder = createRunRecorder();
record({ step }: { step: string }): void {
this.recorder.record({ step });
}
decline({ step }: { step: string }): void {
this.recorder.record({ step: `declined:${step}` });
}
}

Configure

  • Block"blocking"Stops a run when a file the run changed breaks the rule.
  • Advise"advisory"Reports it and hands it to the refactor agent. Never stops a run.
    Default
  • Off"off"Not checked. Use it when your own linter already enforces the rule.

Add this to your lightsout.config.json, then change the value.

lightsout.config.json
{
"standards-checks": {
"class-inheritance": "advisory"
}
}