Skip to content

Probes

The failure mode that makes architectural linting worse than useless is a rule that has stopped matching anything. A folder gets renamed, an alias changes, a pattern loses a segment — and the rule keeps passing, forever, on every commit, indistinguishable from a rule that is working.

Nobody notices, because the signal for “this rule is fine” and the signal for “this rule is dead” are the same green check.

When the manifest is lowered, each compiled rule is given a probe generated from its own node in the tree: a synthetic path (and for members, exports and surface, a synthetic name; for graph, a small synthetic graph) that the rule must reject. The probe is derived from the same glob the rule was compiled from, so it moves when the node moves.

The plugin refuses to load if any rule fails its own probe. Not a warning, not a separate script someone remembers to run — a load-time error that stops the lint run.

Family The probe is
imports a path the rule’s from must select, importing a target it must refuse
exports an importer outside except, naming a restricted symbol
members a file the rule selects, declaring or calling a name it must reject
surface a file the rule selects, exporting a site it must reject — or, for count, a surface of the wrong size
structure a filename the layout must reject; for parity, a sibling it must demand
graph a synthetic graph the rule must report on: two files importing each other, a lone file, an edge that touches no via

A rule that admits everything by design — the layout rule emitted by layout: open — has no path it must reject, and is skipped rather than given a probe it could not pass.

Structure parity is checked structurally — that the rule still selects its probe path and still renders a required sibling — rather than by consulting the real filesystem, so the guard does not depend on a fixture existing.

A generated probe proves a rule can fire. It does not prove the rule fires on the thing you meant, and it cannot, for two reasons.

The first is that the probe is generated from the same pattern the rule was, so a pattern that is wrong in the same way in both places will pass.

The second is sharper: a synthetic probe never meets a parser. It is a path and a name handed straight to the rule’s patterns. Whether the adapter would ever extract that name from real source — whether it reads members out of a class body, say — is a question the probe does not ask. A members rule about a declaration shape the extractor is blind to passes its synthetic probe and enforces nothing, which is precisely the failure probes exist to prevent.

For members, exports and surface, a rule may carry its own probe: a source snippet and the name the rule must report out of it.

"*.repository.ts":
members:
- message: 'Port method "{name}" is not in the vocabulary.'
subject: members
declares: [type, interface]
in: "*RepositoryShape"
allow: [findOneById, save]
probe:
source: "export type TodosRepositoryShape = { findOneByEmail(): void };"
name: findOneByEmail

At load, the snippet is parsed — by the same extractor the CLI reads every file through — and the rule must report a site named findOneByEmail out of what the parser read. If the parser reads no such site, the rule is refused at load with the same error a drifted pattern gets, and architecture facts shows what the parser does read.

Write the snippet in the shape your real code takes. If your ports are interfaces, write an interface; if they are intersections, write one. The extractor reads members written in an alias or an interface, through intersections and unions — and not, say, a class body, so a rule probed with a class refuses to load. That refusal is the point. It is the difference between a rule you believe covers your ports and one that has been shown to.

For exports, the snippet is an import statement and the name is the binding:

exports:
- name: bus-factories
message: "Only a composition root may build a bus."
module: "**/node_modules/@org/cqrs/**"
symbols: [makeCommandBus]
probe:
source: 'import { makeCommandBus } from "@org/cqrs";'
symbol: makeCommandBus

Every edge in the snippet is taken to reach module, so the probe is about the binding form and not about resolution. symbol is "default" for a default import and "*" for a namespace one — which makes this the way to prove a rule with kinds: ["default"] actually sees a default import.

imports rules have no authored form. What an import probe would prove — that a given syntax is an edge at all — is pinned for every rule at once by the package’s own parity suite, which parses every edge form through both adapters and holds them to one answer.

Beyond the probe, two practices close the gap between can fire and fires on what you meant, and both are worth building into a repository that takes this seriously:

An edge table. A list of (importer, specifier, expected verdict) rows checked on every run. The allowed rows matter as much as the refused ones — a policy that refuses everything is as broken as one that refuses nothing, and only an allowed row catches an over-tightened rule. When migrating off an existing enforcement tool, generate the table against the old tool while both still run: the verdicts are then inherited evidence rather than assertions written to match whatever the new code happens to do.

Plant the violation. Before trusting a rule you just wrote, create the file it exists to catch and watch it fail. It takes twenty seconds and it is the only check that tests the rule against your intent rather than against itself.

Two more ways a policy can be quietly not enforcing anything, both closed elsewhere:

  • An unresolved import — no rule can police an edge that does not resolve, so resolution failure is an error.
  • A stale build — oxlint imports compiled JavaScript, so a stale build directory enforces a stale policy and still lints green. Make the build a prelint step, not a convention.