LearnNewsExamplesServices
Frontmatter
id15908
titleConfig-parity collector is a line scan — a subtree built by a call silently leaves the declared set
stateClosed
labels
bugaiarchitecture
assigneesneo-kimi-iris
createdAtJul 25, 2026, 3:50 PM
updatedAtJul 25, 2026, 6:56 PM
githubUrlhttps://github.com/neomjs/neo/issues/15908
authorneo-opus-grace
commentsCount4
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 25, 2026, 6:56 PM

Config-parity collector is a line scan — a subtree built by a call silently leaves the declared set

Closed Backlog/active-chunk-9 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Jul 25, 2026, 3:50 PM

Context

Surfaced while implementing #15892 (PR #15896), and it is the reason that PR's first attempt shipped a false architectural rule before being caught.

Collapsing the plane leaf subtree behind a descriptor factory made lint-config-template-ssot report three declared paths GONE — plane, plane.id, plane.dataRoot — while the resolved tree was byte-identical and all specs stayed green. I read that as evidence the exports were load-bearing for gate visibility, and wrote "parity visibility" into ADR 0019 §5.5 as a sanctioned reason a config literal may live outside its leaf.

@tobiu rejected the shape. Re-measuring falsified my own inference: the gate needs the declaration inline, never the literals exported. The lint's limitation is real; my promotion of it to architecture was not. ADR 0019 §10.1 now records both and states that the collector's grammar is tracked separately — this is that tracker, and without it the ADR cites a ticket that does not exist.

The Problem

collectConfigPathKindsFromSource is a line scan, not an AST walk or a resolved-tree read. Its entire path grammar is one regex:

// ai/scripts/lint/lint-config-template-ssot.mjs
const match = code.match(/^(\s*)([A-Za-z_$][\w$]*)\s*:\s*(leaf\s*\(|\{)/);

name: leaf( and name: { are the only recognised forms. name: anyOtherCall( matches nothing, so the subtree and every descendant silently leave the declared set.

Measured against the collector's own grammar:

declaration form collector sees
plane: { id: {...}, dataRoot: leaf(...) } plane, id, dataRoot
same, with literals inlined and imports deleted plane, id, dataRoot
plane: planeLeafDescriptors({...}) (nothing)

Two failure modes, and the second is worse than the first.

  1. The diagnosis is wrong. The lint reports 3 declared path(s) GONE, which reads as "you deleted these config paths." The author deleted nothing — the collector simply cannot follow the call. A true statement about the collector is rendered as a false statement about the diff.
  2. The suggested remedy blinds the gate. The failure text offers --update-parity. Taking it on a factory-built subtree records the paths as legitimately absent, so every future change to that subtree is invisible to the gate, permanently — and the build stays green. That is the same de-scoping shape #15887 burned out of the B4 allowlist: a remedy that resolves the complaint by removing the coverage.

There is no precedent for a call-built subtree anywhere in ai/configBase.mjs today, so this is currently latent rather than live. It becomes live the first time anyone factors a config subtree — which #15892 attempted within hours of the ADR sanctioning a related shape.

The Architectural Reality

  • ai/scripts/lint/lint-config-template-ssot.mjs — owns config-leaf parity. collectConfigPathKindsFromSource walks source lines, tracking insideData, brace depth and a leafCallDepth skip; the regex above is the only path recogniser. Consumed by lintConfigLeafParity against the config-leaf-parity.json snapshot.
  • Sibling precedent, same folder: nine other lints in ai/scripts/lint/ (lint-skill-manifest, lint-identity-engine-coherence, lint-adr-seam-table, …). No new file — this is a change inside the existing lint. Structure-map gate: ai/scripts/lint confirmed as the owning folder, 10 files.
  • buildScripts/util/check-aiconfig-test-mutation.mjs is the in-repo precedent for the fix direction: it was migrated from a hand-rolled character scanner to an acorn tokenizer, and its JSDoc records the migration as provable — "0 verdict deltas at all 156 real pattern sites; both checkers byte-identical on the live 844 + 548 file scan." The same proof shape is available here.
  • ADR 0019 §10.1 cites this work by reference; the ADR is the consumer of this ticket's outcome, not its subject.

The Fix

Direction, implementer refines. Two shapes, and the ticket does not pre-decide between them:

(A) Teach the collector to resolve a leaf-descriptor call. Narrow: recognise name: someIdentifier( in leaf position and resolve the callee within the module to extract the descriptor keys it returns. Cheapest, keeps the static-scan model, but is only as good as the resolution depth it implements.

(B) Replace the line scan with an AST walk, or read the RESOLVED tree. Structurally correct — the collector wants the config's declared shape, which is exactly what an AST or a booted config yields. Costlier, and reading the resolved tree drags Neo boot into a lint that currently has none.

The migration must be proven behaviour-preserving before it is trusted, mirroring the B4 tokenizer swap: run old and new collectors over every config template and assert zero verdict deltas, so the change is provable rather than plausible.

And the failure text must be fixed regardless of which shape wins — that half is independent, cheap, and is what actually caused the harm here. A non-leaf call in leaf position should fail with a different message naming the real cause, and should not offer --update-parity as its remedy.

Acceptance Criteria

  • A config subtree declared via a call (e.g. plane: planeLeafDescriptors({...})) is collected with its full path set — plane, plane.id, plane.dataRoot all present.
  • Behaviour-preserving proof recorded: old vs new collector over every ai/**/config.template.mjs and configBase.mjs, zero verdict deltas, cited in the PR body with the command that produced it.
  • The un-followable-call failure emits a message naming the real cause (collector cannot resolve the call) and does not suggest --update-parity for that case.
  • A spec pins the three-variant table above as a regression fixture, including the currently-failing factory form.
  • node ai/scripts/lint/lint-config-template-ssot.mjs green on dev at the merge head, and the config-leaf-parity.json snapshot is unchanged by the collector swap — a snapshot delta means the new collector disagrees with the old one and must be re-scoped.
  • ADR 0019 §10.1's "tracked separately" reference resolves to this ticket.

Out of Scope

  • Re-opening #15892's shape. That PR removes the twin for reasons independent of this lint; it does not wait on this ticket. If this lands, a factory form becomes available — it does not become required.
  • Reinstating "parity visibility" as an ADR reason. It was falsified; this ticket removes the obstacle that made it seem plausible, which is the opposite of vindicating it.
  • Other lints in ai/scripts/lint/. Several are line scans too. Each needs its own consumer analysis; bundling would make the blast radius unreviewable.
  • The parity snapshot's contents. This changes how paths are collected, never which paths should exist.

Avoided Traps

  • Running --update-parity to make the complaint go away. That is the de-scoping act, and the tool suggests it — which is precisely why the failure text is in scope. #15887's allowlist burndown is the sibling anchor: a listed file is one the gate stops counting.
  • Assuming the exports were the gate's requirement. They were not, and I asserted it in an ADR before measuring. The three-variant table is the falsifier; anyone reshaping this should reproduce it rather than trust the prose.
  • Swapping the parser without a delta proof. The B4 checker earned its tokenizer migration with a zero-delta census over 1,911 files. A collector swap without that evidence is a bigger blind spot than the one it fixes, because it would be trusted more.
  • Treating "no live instance today" as "not worth fixing." The latent form became live within hours of an ADR sanctioning an adjacent shape. Latency here is a property of current code, not of the defect.

Decision Record impact

aligned-with ADR 0019 — this implements the tracker §10.1 names. It does not amend, challenge or supersede: the ADR's rule (a config literal may live outside its leaf only as a module-scope anchor) is unaffected by how the collector parses declarations.

Related

  • #15892 / PR #15896 — where this surfaced; the ADR §10.1 text that cites this tracker.
  • #15887 / PR #15888 — the de-scoping-remedy anchor (allowlist burndown), and the tokenizer-migration precedent in the same checker.
  • #15843 / PR #15903 — adjacent config-anchor work from the same session.

Live latest-open sweep: checked latest 20 open issues at 2026-07-25T13:47:55Z — nearest neighbours are #15892 (the parent context, not a duplicate: it removes a config surface, this fixes the lint that reads one) and #15905 (a different lint's anchoring bug, in MailboxService); no equivalent found. A2A in-flight claim sweep at 2026-07-25T13:48Z over the latest 12 messages, all read-states: no [lane-claim]/[lane-intent] on lint-collector scope. @neo-kimi-iris independently confirmed the variant-B measurement at 13:24Z.

Origin Session ID: 26e73986-66fa-4d28-9b02-6053541a5671

Retrieval Hint: get_session_memories('26e73986-66fa-4d28-9b02-6053541a5671'), or query_raw_memories("config parity collector line scan name: leaf( grammar factory call declared paths GONE")