class-inheritance
Flags a class extending anything other than an Error.
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
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
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. - AdviseDefault
"advisory"Reports it and hands it to the refactor agent. Never stops a run. - 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.
{"standards-checks": {"class-inheritance": "advisory"}}