Skip to content

feat(core): refuse a build phase no listener is wired for; ban expected/actual - #422

Merged
martin-fleck-at merged 1 commit into
mainfrom
feat/phase-and-error-name-checks
Oct 13, 2026
Merged

martin-fleck-at merged 1 commit into
mainfrom
feat/phase-and-error-name-checks

Conversation

@martin-fleck-at

@martin-fleck-at martin-fleck-at commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

AstExtension and BuildPhasePass declared their phase as any DocumentState, and a registration at a phase the pipeline wires no listener for was accepted and never fired. Both now declare phase as a union of the wired phases, AstExtensionPhase and BuildPassPhase, each with an all() namespace that the pipeline wires its listeners from and that register refuses a value outside of, as IntegrityPhase already does. An error class declaring both expected and actual is read by vitest as an assertion failure; a new ast-grep rule, error-expected-and-actual, holds that over source and test trees, and conventions name it. conventions also gains the line for the existing connection-without-logger rule, so CONTRIBUTING can describe check:ast-grep as the rules conventions names.

Breaking changes

  • AstExtension.state and BuildPhasePass.state are renamed to phase.
  • phase is typed AstExtensionPhase or BuildPassPhase; a DocumentState-typed variable no longer assigns to it.
  • The services' protected bucket members follow the field: phaseBuckets, phaseBucketsFor and getPhaseBucket, formerly state….
  • A registration built outside the register call, in a const, a factory or an array, needs an AstExtension or BuildPhasePass annotation, or as const on phase; written inline it compiles as before.

Decisions and their cost

  • Both registries, not the one the issue names. BuildPhasePass had the same hole and the same private wiring list; guarding one and filing the other would leave the class half fixed.
  • phase, one word. IntegrityRule already says phase, the Langium hooks are onBuildPhase and onDocumentPhase, and DocumentState stays the name of what a phase is a value of. Cost: the rename above.
  • A plain Error. The throw fires at construction and nothing reacts to it at runtime; a code would be surface for nobody, and the sibling integrity guard is plain.
  • Any class body, source and test trees. vitest reads the pair as properties, whatever the class and wherever on the prototype chain they sit, and a test-only error with that pair breaks the reporting of the test that throws it. Interfaces describe records and are left out, as is a pair split across a hierarchy, which no single-file rule can see; the header lists both.
  • Dispatch stays wide. extendNode, extendDocument and runPasses keep phase: DocumentState; an unwired phase only finds an empty bucket there, so narrowing them would be one more break with nothing behind it.
  • BuildPassPhase, beside BuildPhasePass. BuildPhasePassPhase repeats the word, and BuildPassPhase reads as the phase of a build pass. The two are close in spelling, but one is the item interface and the other a union of enum values, so swapping them fails to compile rather than misbehaves.
  • A quoted or computed name counts. ['expected'] creates the same runtime property, so the rule matches those forms; a static member is flagged too, since excluding it buys nothing.

How I know it works

  • Disabling either guard reddens its refusal test; widening either phase type back to DocumentState reddens the types test with an unused @ts-expect-error.
  • A class with both getters planted in errors.ts fails ast-grep scan; the rule's test file holds fifteen invalid and six valid shapes, including the parameter-property, quoted, computed, abstract, declared, accessor and no-heritage cases, and the hierarchy split as not flagged.

Part of #178.

@martin-fleck-at
martin-fleck-at force-pushed the feat/phase-and-error-name-checks branch 2 times, most recently from 22f93c6 to e8a764d Compare October 13, 2026 15:44
…ed/actual

An AST extension or a build-phase pass could declare any DocumentState,
and one outside the phases the pipeline wires was accepted and never
fired, which reads as the derivation never running. And an error class
declaring both `expected` and `actual` is read by vitest as an assertion
failure, which only a comment on ConflictError recorded.

- Type `phase` on AstExtension and BuildPhasePass as a union of the wired
  phases, with a readonly `all()` namespace beside each, as IntegrityPhase
- Rename the field from `state` to `phase`, the word the pipeline and the
  Langium hooks use, and the services' protected bucket members with it
- Make both `register` methods throw on a phase outside `all()`, for a
  cast or a JavaScript caller the type cannot stop
- Wire the pipeline's listeners from `all()`, so the wired set and the
  accepted set are one list
- Add the ast-grep rule error-expected-and-actual over source and test
  trees, matching quoted, computed and abstract members too, and shrink
  ConflictError's comment to the constraint
- Name that rule and the connection-logger rule in conventions, so
  CONTRIBUTING can describe check:ast-grep as the rules conventions name

Relates to #178
@martin-fleck-at
martin-fleck-at force-pushed the feat/phase-and-error-name-checks branch from e8a764d to 6c025bc Compare October 13, 2026 15:56
@martin-fleck-at
martin-fleck-at merged commit e5ca727 into main Oct 13, 2026
10 checks passed
@martin-fleck-at
martin-fleck-at deleted the feat/phase-and-error-name-checks branch October 13, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant