Skip to content

test(lint): census the SYSTEM_FIELDS consumer population by module graph, not by grep - #10475

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8999-system-fields-spread-sweep
Aug 21, 2026
Merged

test(lint): census the SYSTEM_FIELDS consumer population by module graph, not by grep#10475
os-zhuang merged 1 commit into
mainfrom
claude/issue-8999-system-fields-spread-sweep

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes: #8999

The ruling first: a gate is warranted, and here is the measurement

The card asked for a decision, not a deliverable, and explicitly welcomed "not worth
building". The numbers came out the other way — but the number that decided it is not the
one the card or the hold reasoned from.

Method. The consumer population is enumerated by propagating the union's value
lineage
over the static import/export graph with the TypeScript parser — not by any
textual search. Deliberately narrow: propagating through arbitrary expressions (a function
that merely reads the union) taints 44 of 151 files and destroys the signal. What
propagates is the set of bindings a rule can ask .has() of.

Run over seven historical refs of packages/lint/src (via git archive at each ref),
against the SYSTEM_FIELDS.has grep on the identical trees:

refrule-file consumersthe .has grep seesblind
d4a687a66^ (pre-#8340)945
d4a687a66 (post-#8340)945
b849e6911 (post-#8404)945
192213f66^ (pre-#8996)945
192213f66 (post-#8996)945
42d899071 (post-#9314)1055
origin/main 2026-08-201055

The card records "three consumers missed across three sweeps" and reads that as three
historical accidents that #8996 closed. It is not that. The blind fraction is a constant
five — half the population — on every ref since the union was created
(2026-07-31,
#4330 / #4339), and it did not move when #8996 landed. The sweeps did not miss three
consumers; they were pointed at an instrument with a standing 50% blind spot and happened
to notice three of the five.

Two of those five carry no occurrence of the token at allvalidate-flow-node-writes.ts
and validate-action-body-writes.ts both return 0 for grep -c SYSTEM_FIELDS. No
textual search of any spelling reaches them; only the module graph does.

Arrival rate, same method: the family was born at 9 consumers and has taken exactly one
since — validate-sortable-fields.ts (2026-08-17, #9314), the arrival that released this
card's hold. So the ledger costs about one line per three weeks.

Cost. Zero new CI job, zero new workflow line, zero root package.json edit (the
#9465 fence is untouched, and so is .github/workflows/lint.yml, which open PR #10441
edits). It is a vitest file in packages/lint/src, so it runs inside the package's
existing pnpm test — measured 3.9s for 14 tests, inside a suite that already takes 45s.
The mechanism is the card's own named precedent, rule-id-barrel-exports.test.ts.

Against. One arrival in 20 days is a low rate, and that arrival used the .has form,
so the old instrument would have caught it. Recorded honestly. What outweighs it: three of
the ten rule files launder the union through a spread today, including the two largest and
most-copied (validate-hook-body-writes, validate-translation-references), so a new rule
written next to either inherits the invisible shape — which is the AI-authorability axis
the card leads with, now with a denominator.

What landed

One file, packages/lint/src/system-fields-consumers.test.ts. It pins the transitive
consumer population against a ledger that records, per consumer, how it reaches the
union (direct / derived / transitive / barrel / namespace) and whether it asks
the #8116 provenance question
— verified mechanically against the provenance API, with
false a legitimate answer that must state its reason. A new consumer fails with its full
path to the union:

ablation-transitive-consumer.ts [transitive] via IMPLICIT_FIELDS@2 from validate-hook-body-writes.ts::IMPLICIT_FIELDS

Anti-vacuity: the analyzer is proved before it judges

A matcher that stopped matching yields the same green as a clean tree (#8892), so the
positive controls run on synthetic fixtures first, and they pin the two shapes the real
sweeps missed:

  • the spread form (new Set([...SYSTEM_FIELDS, '_id', 'name', 'space']));
  • a two-hop re-export chain — the card warns a one-hop search under-counts by
    construction, so the fixture runs origin to spread to barrel to consumer, and the test
    asserts positively that neither transitive file contains the token;
  • an executable statement of the blindness: the old .has grep run over the same fixture
    set finds zero of the three transitive consumers the module graph names;
  • prose and unrelated sets are not mistaken for consumers.

Two guards against a false green on real data:

Also pinned: validate-flow-node-writes.ts by name as the card's live positive control,
and a shape-coverage floor so a half-broken analyzer that lost one propagation path cannot
pass.

Ablation

Both laundering shapes injected into packages/lint/src, mutation confirmed on disk before
any verdict was read (files matching the spread marker went 4 to 5; the transitive leg's
grep -c SYSTEM_FIELDS = 0, IMPLICIT_FIELDS = 2):

  • Red, naming both, with reach and provenance path — and on the same mutated tree the
    old instrument named zero of the two, including the leg that imports SYSTEM_FIELDS
    directly but calls .has on its own set.
  • The first ablation exposed a real diagnostic defect: two secondary assertions indexed the
    ledger by name and crashed with TypeError on an un-ledgered file, burying the one
    actionable message. Fixed, re-ablated — now exactly one failure with one message.
  • Restore leg: both files removed, absence confirmed on disk (spread-marker files back
    to the baseline 4, ABLATION_IMPLICIT 0 occurrences), tree byte-clean, 14/14 green.

No dist/ leg: the test reads .ts source off disk and imports nothing from a built
package, so there is no stale-artifact path for the mutation to hide in.

Gates

Derived from the real diff with node scripts/pm/dispatch-gates.mjs (no path arguments),
not from the dispatch's guesses — it named nine families where I had guessed four. All nine
green on f840122388, quoting each gate's own verdict line:

  • check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared…
  • check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s)… none new
  • scripts/check-cross-package-test-inputs.mjsOK: 12 package(s)…
  • scripts/docs-audit/check-affected-docs.mjs — exit 0, no output
  • check:query-options-erasure✓ ratchet holds: 67 unswept non-test site(s)… none new
  • check:engine-double-contractOK — 340 pinned, 133 in the DEBT ledger, 2 exempt
  • check:where-matcher✓ 266 matcher(s) discovered, 266 answer the combinator battery correctly
  • check:type-check-coverageOK — 64/77 workspace packages type-checked
  • check:type-check-debt --re-measure (on the built closure) — OK — 33 ledger entr(ies) re-measured in 353.1s, 1924 raw tsc error(s) total, none above its recorded number

Package suite: pnpm --filter @objectstack/lint typecheck (tsc --noEmit, clean) and
vitest run78 files, 2156 passed, 5 skipped.

Changeset: skip-changeset, by verified publish surface

@objectstack/lintis a published package (private: false, publishConfig.access: public) — so this was checked rather than assumed in either direction. It ships
files: ["dist", …] and tsup.config.ts builds only src/index.ts and src/runtime.ts.
Built the package and grepped the output: censusUnionConsumers appears in dist/0
times
. The publish surface is unchanged, so there is nothing to announce in a release
note. An empty-frontmatter changeset is not the alternative here — check-empty-changeset
rejects newly added ones — so the label is the repo's declaration for this shape.

Out of scope, filed not fixed

The census surfaced two consumers that make no #8116 provenance call. Both are recorded in
the ledger with reasons rather than silently exempted, and the question of whether either
is a real hole is filed separately as #10474: the SORT axis (validate-sortable-fields.ts,
landed after the #8996 sweep) reads the union but asks no provenance question, while the
SEARCH axis on the identical index does — and declaredFieldTarget does not exclude
external objects, so the branch is reachable. validate-translation-references.ts is the
second row, recorded as deliberately not asking (a bundle supplies a label, never reads the
value). Notably this means the hold's premise ("no known remaining hole") no longer holds
literally, though neither row is a proven defect.

Governed surface

Nothing under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md was
touched. The card's routing question (devx vs skills) was already answered by the claim
comment and needs nothing here.


Generated by Claude Code

…aph, not by grep (#8999)
Every sweep of the blanket `SYSTEM_FIELDS` union has been scoped by a
`SYSTEM_FIELDS.has` grep, which structurally cannot see a consumer that
spreads the union into a rule-local set — nor one that imports that laundered
set from another rule file, since such a file contains the token zero times.
Measured across seven historical refs of `packages/lint/src`, the blind
fraction is not a closed historical accident: it has been FIVE rule files on
every ref since the union was created (2026-07-31), i.e. half the population.
Two of those five carry no occurrence of the token at all.
So the enumeration moves to the module graph. `system-fields-consumers.test.ts`
propagates the union's VALUE lineage over the static import/export graph with
the TypeScript parser, and pins the result against a ledger that also records,
per consumer, whether it asks the #8116 provenance question — verified
mechanically, with `false` a legitimate answer that must state its reason.
The analyzer is proved against synthetic fixtures before it judges the tree: a
matcher that stopped matching yields the same green as a clean tree (#8892).
Those fixtures pin the spread form and a two-hop re-export chain specifically,
and assert positively that the old `.has` grep finds zero of them.
No new CI job: it runs inside the package's existing `pnpm test` (3.9s).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — ⭐ accepted. The census reframes the card, and the instrument guards against its own failure mode.

Reviewed against the diff, not the report.

⭐ The finding is bigger than the card claimed

The card presented this as three consumers missed across three sweeps — three historical accidents, with #8996 having closed them. Your census over seven historical refs says otherwise: the SYSTEM_FIELDS.has grep sees 4–5 of a 13-file population on every ref, and the blind fraction is a constant five — half the population — on every ref since the union was created on 2026-07-31, and it did not move when #8996 landed.

"Three sweeps were pointed at an instrument with a standing 50% blind spot and happened to notice three of the five." That is a different and more serious claim than the card made, and it is the one the evidence supports. Two of the five contain the token zero times — no textual search of any spelling reaches them, so no amount of grep-tuning was ever going to close this.

⭐ The ruling states the strongest argument against itself

Build-it rulings are easy to write badly. You recorded the counter-evidence in the gate's own file: arrival rate is one consumer in 20 days, and that one arrival used the .has form, so the old instrument would have caught it. That is the single best argument for not building this, and you put it on the record rather than omitting it.

What outweighs it is specific and checkable: 3 of 10 rule files launder via spread today, including the two most-copied rules, so a new rule written next to either inherits the invisible shape. A measured trade, not a hunch.

⭐ It guards against its own failure mode — the part I would not have thought to ask for

The analyzer originally seeded taint from system-fields.ts::SYSTEM_FIELDSwithout checking the export exists. A renamed union would have matched every import of the old name and reported a confident population for a union that is not there — this card's exact defect, one level up, inside the thing built to fix it. You caught it on the gate's own first run and pinned it:

it('is anchored to a real export, so an empty answer cannot be a false green', …)
expect(censusUnionConsumers(blank)).toEqual({ seeded: false, consumers: [] });

⇒ A zero-consumer answer can no longer be a green. That is the anti-vacuity rule applied reflexively, and it is the best thing in this PR.

What I verified myself

  • Not a grep. Module-graph walk over TypeScript AST imports/exports, with Reach recorded per consumer as direct | derived | transitive | barrel | namespace. A row reading ['transitive'] is a file with no textual mention at all.
  • The positive control is the decisive leg. On the same mutated tree, the old instrument (grep -rl 'SYSTEM_FIELDS.has') named zero of the two injected consumers — including the one that imports SYSTEM_FIELDS directly but calls .has on its own set. Mutation proven on disk before any verdict was read; restore leg proven back to baseline, tree byte-clean.
  • Method choice measured, not assumed — propagating through arbitrary expressions taints 44 of 151 files and destroys the signal, so only set-valued bindings travel. Stating why the looser method was rejected is what makes the tighter one credible.
  • ✅ ⛔ Both fences respected. One file, packages/lint/src/system-fields-consumers.test.ts. Zero new CI job, zero workflow line, zero root package.json edit — so Migrate the release toolchain to @changesets/cli v3 — one atomic PR carrying the bump, the pre-mode restructure, and the gates that model v2's semantics #9465 is untouched, and .github/workflows/lint.yml (which open PR docs(check-cross-repo-closer-outcome): repair two evidence citations, and stop hand-counting the battery #10441 edits) is untouched. It follows the card's own named precedent.
  • Limits documented honestly — static ESM under src/; a dynamic await import() is out of reach, and the header says why that is acceptable rather than pretending otherwise.

⚠️ And the deriver named nine gate families where my dispatch brief named four. All nine green, each quoting its own verdict line rather than a pipeline exit status. That is the sixth time today my gate guesses were narrower than reality — the brief's hypothesis framing did its job.

Follow-ups, both correctly handled

#10474 — the SORT axis (validate-sortable-fields.ts, landed after the #8996 sweep) reads the blanket union but references no #8116 provenance helper, while SEARCH warns on the identical shared index. Code path verified, end-to-end scenario not constructed, and you said so rather than overclaiming. Filing your own ledger ruling on validate-translation-references.ts there too — "so a second pair of eyes can overturn it rather than inherit it" — is the right instinct on a judgement call.

⚠️Note for triage, flagged and not acted on: the hold's premise ("after #8996, no known remaining hole") no longer holds literally — two consumers make no provenance call. Neither is a proven defect. ⛔ Grading is triage's field; I am not relabelling.

CI still finishing. I will flip ready and arm once it is green — ⛔ nothing for you to change.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 00:38
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 4d9d2c1Aug 21, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8999-system-fields-spread-sweep branch August 21, 2026 01:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-zhuang@claude