fix(tooling): a derived parse's verdict comes BACK, so one unparseable synthesis stops taking the whole run - #19112
Conversation
…e synthesis stops taking the whole run `scripts/ts-parse.mjs` answers "could I read this tree?" by ending the process, which is right for a tree the gate's own author controls. A gate that re-parses text it SYNTHESISED from a source it already read is asking a different question, and it was getting that answer: `tenant-audit-census.mjs` stores a declared type's text whitespace-collapsed and re-parses it as a synthetic type alias, so a receiver whose members are separated by a newline alone -- legal TypeScript, 0 diagnostics as authored -- collapses to an unparseable alias and takes the entire census down with EXIT_UNPARSEABLE, naming a file that does not exist in the tree. `parseDerivedText` answers the synthesis question and returns the verdict. The floor is held by construction rather than by review: it requires an `origin` this module has certified, and the only two doors that certify one exit on a source that does not parse -- so a source the gate could not read cannot reach the returnable door. An uncertified origin is itself a refusal. On failure no tree comes back, so there is no recovered wreckage to walk and no reading of the result that says "nothing to report". The three existing refusals are untouched: every line from each diagnostics read through its `process.exit` is byte-identical, and the only change inside an existing door is one `vouchedSources.add` on a success path, after the refusal has already been decided. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…, both lit controls, and the floor
Five batteries, 18 cases, on the fixtures the census itself produces: a
receiver type authored across lines, put through `.replace(/\s+/g, ' ')` --
the collapse the census performs -- and wrapped in the synthetic alias it
builds.
• the collapsed receiver STILL exits through `parseSourceFile`, and
through `parseDerivedText` returns a located verdict with the run
surviving. Two lit controls hold the fixture family capable of
SUCCEEDING: the semicolon spelling (the card's own control) and the
authored spelling both parse, so the defect is the collapse and not the
shape.
• a genuinely unparseable input is still refused at every door: an exit
at `parseSourceFile`, and NO TREE back from `parseDerivedText`, so
there is nothing to walk and no result that reads as "nothing to
report". Without this leg a repair and a switched-off check are the
same green.
• the fourth door is unreachable from an uncertified origin -- a raw
tree, null, undefined, a string -- and that refusal is not swallowable
either; a source file a checked Program was built over IS certified.
• the knobs are compared across the two doors rather than asserted in
prose, and the census counts a rejection as a run that continued.
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
|
Ready for the queue: green, non-governed, and its Seat: domain:spec#3 Flipped draft → ready at 2026-09-18T20:48Z via
⭐ Why
|
…ED, not a census takedown `tenant-audit-census.mjs` stores a declared type's text whitespace-collapsed and re-parses it as a synthetic alias to read the engine-door rule off it. A type literal may separate its members by a NEWLINE alone -- legal TypeScript -- and the collapse turns that separator into nothing, so the synthesis does not parse. Through `parseSourceFile` that did not fail the SITE: it ended the process, and every other site in the corpus lost its verdict with it. The synthesis now goes through `parseDerivedText` (the returnable door landed by PR #19112), and the verdict is acted on rather than swallowed: - `readTypeTextDoor` reads the door rule and reports the round trip separately, taking the origin tree the text was derived from -- so an unreadable SOURCE still cannot reach the returnable door. - `nonEngineReason` reads it once and classifies such a site on a new declared arm, `type-text-not-round-trippable`, placed in `UNDEFENDED_REASONS`. - the run prints the located parse verdict against the SITE and exits non-zero; `check-tenant-audit-census.mjs` refuses the same class through the one shared spelling, `notRoundTrippableSites`, because CI reads the gate and never the generator. The exchange is a loud process exit for a loud per-site refusal, never for a quiet subtraction. The corpus door is untouched: sources are still read through `parseSourceFile`, so a source that does not parse still ends the run. Census over the live corpus, before and after on `215840f4353`: exit 0, 576 sources, output byte-identical. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Refs #19077 (item 1 — the returnable derived-parse door; the census-side application is item 2, sequenced after PR #19073)
Seat: domain:spec#3,session_019srGWGCBBCBHqcDoRZpQRh— the closing keyword was changed to a reference, and that is the only change to this body. The dispatched dev reportedstatus: reworkdeliberately and per its dispatch word: acceptance input 1's census-level half ("that receiver is CLASSIFIED") cannot be delivered from this surface, because it needsscripts/tenant-audit-census.mjs, which this seat serialised behind open PR #19073. ⇒ this PR makes the repair available and provable; it does not apply it. AFixeskeyword would have closed card #19077 on merge while the defect it describes was still live onmain— the charter's partial-landing rule (SKILL.md: 部分落地 PR 带Refs #N (item k),⛔ 不Fixes) is exactly this case. ⛔ Nothing about the diff, its tests or its declarations was touched by this edit.Clause-②: no— measured, not assumed; the three readings are under "Clause ② and the changeset" below.[...]where the source has anglebrackets (
Promise[void]isPromiseofvoidin the real fixture). The body sanitizereats tag-shaped fragments, backticked ones included, so the real spelling cannot be written
here. The fixtures in
scripts/ts-parse.mjscarry the real characters.What was wrong
scripts/ts-parse.mjsanswers one question — "could I read this tree?" — and answers it byending the process. That is right for a tree the gate's own author controls, and the module's
header argues it at length: a throw is swallowable, and
try { parse } catch { continue }isalready written in this repo.
tenant-audit-census.mjswas asking a different question through that same door. Itreads a receiver's declared type out of a source that parsed, stores the type's text
whitespace-collapsed (
:459,getText(sf).replace(/\s+/g, ' ')), and later re-parses thestored text as a synthetic type alias (
:723,parseSourceFile('census-receiver-type.ts', ...)) to ask whether it declares a write door.A type literal may separate its members by a newline alone. The collapse turns that
separator into nothing, the synthetic alias does not parse, and the census takes
EXIT_UNPARSEABLEfor the whole run — with a refusal naming a file that does not exist inthe tree.
Reproduced on this branch's base (
origin/main@07c6f822e) before any edit:And the shape itself is legal TypeScript — so the collapse is the defect, not the receiver:
.replace(/\s+/g, ' ')';' expected.The refusal's own reasoning is false for this call: "a file the gate could not read, reported
as a file with nothing to report". The gate read the file. What did not round-trip is the
gate's own re-serialisation of a fragment of it — a fact about the synthesiser, never about
the corpus.
The repair
parseDerivedText(origin, fileName, text, scriptKind)answers the synthesis question andhands the verdict back, so one unanswerable site costs one site instead of every site.
The floor is held by construction rather than by review. The door requires an
origin—a
ts.SourceFilethis module has already certified — and the only two doors that certify one(
parseSourceFile,createProgramChecked) exit on a source that does not parse. So asource the gate could not read cannot reach the returnable door: it hits the refusal
first. An uncertified origin is itself a refusal (
EXIT_DERIVED_MISUSE, exit 4), an exit andnot a throw for the reason the module already gives.
Two deliberate divergences from
packages/lint/src/checked-parse.ts, the repo's existingruling on this same axis (it reports rather than refuses "because a
scripts/**gate auditsa tree its own author controls"), both in the strict direction:
sourceFile: null), where the sibling always returns therecovered one. There is no recovered wreckage to walk and no reading of the result that says
"nothing to report". A caller that forgets to branch gets a TypeError — loud, non-zero.
parseCensus()gainsderivedandrejections. A refusal ended the run, so every later source went unread andthe numerator is short by an unknown amount; a rejection left the run going. One combined
total would make the report whose job is to say how much was measured say it wrong.
⭐ Deliverable 1 — the dependency census: who depends on the fact that it EXITS
Instrument: TypeScript AST over every git-tracked
.mjs/.cjs/.js/.mts/.cts/.ts/.tsxfile in the repo (6873 scanned), resolving each module specifier to an absolute path and
counting call expressions per imported binding. ⛔ No
head -Nanywhere in the pipeline.Measured at
b09c8f550.ts-parseat allscripts/ts-parse.mjs(import / re-export / dynamic)Lit control, as triage required.
scripts/tenant-audit-census.mjsprovably callsparseSourceFile— it is the subject of this card — and the instrument counts it, atparseSourceFile x12. Verified independently:grep -n parseSourceFileon that file showsone import at
:177and 12 call sites (:319 :363 :416 :723 :860 :1189 :1686 :1738 :1757 :1778 :1827 :1897). The two numbers agree.A second, discriminating control. The instrument tells the three doors apart, which a
text match on
parseSourceFilecannot:check-published-readme-exports.mjsis counted atcreateProgramChecked x1and callsparseSourceFilezero times;platform-object-tenancy-census.mjsattranspileChecked x1, likewise zero.⭐ The seat's 35 is 34.
scripts/check-parse-guard.mjsmatchesfrom './ts-parse.mjs'as text — at:555inside its own failure message, and at:694 :697 :733inside self-test fixture strings — and imports nothing from the module.A
git grep -lon the specifier counts it; an AST edge does not. It is row D, not row B.Reachable radius, and what is outside it. The instrument sees static
import/export ... from, namespace imports, and dynamicimport()with a literalspecifier, over git-tracked source files, one hop, resolved by path. ⛔ Outside it:
a specifier built at runtime from a variable; a
createRequire/requireedge; an edgethrough
node_modules; untracked files; and any second-order importer reached through are-exporter — that last one measured to be vacuous here, because the re-exporter count is
0. Also outside it:
scripts/pm/dispatch-gates.mjsdeliberately does not fanwatch-hints out to the importers of the five shared utilities (
ts-parse.mjsis one), sothe derived gate list for this diff does not name those 34 families, by that file's own
measured decision. That is why the floor below is argued mechanically and not from the
gate list.
Acceptance — triage's two inputs, both pinned
Both live in
scripts/ts-parse.mjs's own--self-test, which CI runs(
.github/workflows/lint.yml:507). 30 cases before, 48 after; five new batteriesdeclaring 18 cases, and the roster floor rose 12 to 17 so deleting one reds.
① the newline-separated inline type literal. The fixtures are the census's own round
trip, spelled the way the census spells it — a receiver authored across lines, put through
.replace(/\s+/g, ' '), wrapped in the alias:723builds:parseSourceFile(the refusal is untouched);parseDerivedTextreturns a located verdict and the runcontinues (
status 0,tree null,failure.count 1,failure.line 1);same door and returns a usable tree;
indistinguishable from a door that rejects everything.
② a genuinely unparseable input is still REFUSED. Merge-conflict markers and a truncated
body, each through both doors:
parseSourceFilestill ends the run, andparseDerivedTexthands back no tree with
failure.countat least 1. Triage's words are the reason this legexists — without it "fixed" and "the check was switched off" are the same green.
⛔ Floor — nothing exits 0 where it exited non-zero
Every deleted line in the whole diff, all 9 of them —
git diff 07c6f822e..b09c8f550,439 insertions, 9 deletions:
⇒ not one of them is in a refusal path. Every line from each door's diagnostics read
through its
process.exitis byte-identical:The only change inside an existing door is one
vouchedSources.add(...)on each successpath — after the refusal has already been decided.
The covering mechanism for all 34, rather than 34 readings. The new door has zero
call sites anywhere in the tree outside the module that declares it
(
git grepforparseDerivedText,EXIT_DERIVED_MISUSE,derivedMisuseReport,derivedFailureReportover all tracked files: 0 files outsidescripts/ts-parse.mjs). Noimporter reaches it, no refusal path changed ⇒ no importer's behaviour on an unparseable
source can have moved.
Direct per-door reading at
b09c8f550, each fed a truncated body:parseSourceFilecreateProgramCheckedtranspileCheckedThe census's live behaviour is unmoved.
node scripts/tenant-audit-census.mjsover thelive corpus: exit 0, 573 sources scanned, identical counts — the repair adds a capability
and adopts it nowhere (see Acceptance notes).
The new cases can fail — three ablations, each on a different limb
Every mutation was confirmed on disk before the reading (anchor count to 0, injected marker
counted), driven through
scripts/ablation-replace.mjs, and every restore verifiedbyte-identical (
blob == HEAD blob be83f4e875df,git diff HEADempty).process.exit(EXIT_UNPARSEABLE)3 to 4vouchedSources.has(origin)1 to 0nullsourceFile: null,1 to 0Checks
scripts/pm/dispatch-gates.mjsderived the families from the changeset itself (1 path vsmerge base
07c6f822e, three-dot). 29 derived, 29 run, 0 NOT-MEASURED, 0 UNRUN, and thereconciliation's zero is DERIVED — every one of the 29 carries a recorded exit code and none
of them is 3. Every family exit 0, including
check:parse-guard,check:nul-bytes,check:watch-hint-literal,check:scripts-symbol-anchors,check:self-test-wired,check:self-test-workflow-commands,check:cross-package-test-inputsandcheck:pm-dispatch-gates.node scripts/ts-parse.mjs --self-testexit 0, 48 cases.Outside the derived total and therefore CI's, as that tool prints: the 53 artifact-roster
families, the 11 declared wide-population families, the 14 pending-changeset families and
the 1 path-scheduled job.
pnpm lintwas narrowed, and the narrowing is a measurement. The repo-wideeslint . --no-inline-configrun is CI's. Three readings, all three required:① the reachable population comes from eslint's own config text, not a guess —
eslint.config.mjs:328records that "this repo runs oneeslint.config.mjs, which neverenables type-aware linting (no
parserOptions.project, no typed@typescript-eslintrules)for ANY file", measured there with a positive control; ② the targeted run's file count is
read from
--format json: 1 file, 0 errors, 0 warnings, exit 0; ③ invariance — withno type-aware linting enabled anywhere, a one-file diff cannot move the verdict on any file
it does not contain. The targeted run is at the final commit,
b09c8f550.Clause ② and the changeset
skip-changeset, and the reading behind it. Nothing published moves.① the root package is
private: true; ② of the 70 non-private workspace packages,0 have a
files[]entry that climbs out of its own package directory, so rootscripts/**cannot be in any published payload; ③ the new symbols(
parseDerivedText,EXIT_DERIVED_MISUSE,derivedFailureReport,derivedMisuseReport)appear in 0 files under
packages/,apps/orexamples/— with the same grep lit asa control, finding 48 files for a symbol that is published
(
MetadataTypeSchema, reached bypackages/spec'ssrc/**/*.zod.tsentry).Clause-②: no, on the same three readings plus two more: the accept set of all threerefusing doors is byte-identical (above), and the new door has zero adopters, so nothing in
this tree accepts anything it previously refused. ⭐ The judgement left for the seat, stated
rather than buried: this does add four exports to a module's surface — that surface is
measured unpublished, which is why the answer is
no, and the seat can overrule withoutre-deriving anything.
Acceptance notes
scripts/tenant-audit-census.mjsis serialised behind PR fix(scripts): apply the tenant-audit census's engine-door rule to inline type literals (225 -> 227) #19073 (card [finding] tenant-audit census applies its engine-door rule to NAMED declarations only, so two real engine writes with inline type-literal receivers are subtracted from the certified population — placing them moves it 225 to 227 and rewrites both artefacts #18944) and thedispatch holds it: if the sound repair also has to edit the store/re-parse pair, stop and
report the minimal hunk rather than edit it. It does. The minimal hunk, for the
sequenced PR: in
typeTextDeclaresEngineDoor, take the already-parsedsfof the sourcethe type text came from as the origin, call
parseDerivedText(origin, 'census-receiver-type.ts', ...)instead ofparseSourceFile, and onfailureprintfailure.reportagainst the site and returnfalse; then add oneNON_ENGINE_REASONSarm (suggested:
type-text-not-round-trippable) and put it inUNDEFENDED_REASONS, sothe site is declared in both artefacts instead of dropping out of the population
silently. Until that lands, the defect on
mainis unchanged — this PR makes the repairavailable and provable, it does not apply it.
scripts/tenant-audit-census.mjs,content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md, and it does not changethe collapse at
:459or the re-parse at:723. What it does is route four morekind: 'other'returns throughinlineEngineDoorOrOther, which callstypeTextDeclaresEngineDoor— i.e. it enlarges the set of type texts that reach thedefect without creating it. The seat's reading is confirmed; the finding is not
discharged.
:485(parseSourceFile) gets the derivedcounterpart.
:575(createProgramChecked) needs none: a Program is built from rootpaths on disk, and its one caller passes real paths — and its source files are
certified, so a Program-based gate can use the new door for text it synthesises.
:617(
transpileChecked) is not answered here; see the next note for why, and it is afinding rather than silence.
transpileChecked's three callers are alreadyin the same derived-text position as the census.
check-objectql-double-limit.mjs:535andcheck-where-matcher-conformance.mjs:543bothtranspile a lifted snippet, named
${candidate.file}#L${candidate.line}.lifted.ts—synthesised text, not a file. A legal source whose lifted span is not independently
parseable would take either gate down process-wide, exactly as the census. Not fixed here:
it is a different call site with a different acceptance input (that span), and adding a
second new door while
check-parse-guard.mjs— the gate that polices which doorsscripts/**may use — is out of this card's surface is worse than adding one.(
platform-object-tenancy-census.mjs:226transpiles a real file's source, so therefusal is correct there.)
node scripts/tenant-audit-census.mjsover the live corpus exits 0 — no such receiver existsin the tree today, so the card stays latent. ⭐ The zero is lit on the same subject:
the run reached
typeTextDeclaresEngineDoorand that function returnedtruefor two realsites (
plugin-auth/src/audience-gate-test-support.ts:72,plugin-auth/src/sso-client-secret.ts:303, both reported as door-shaped subtractions), sothe instrument was not silently skipping the code path. ⛔ Grading is triage's — this
changes no priority.
scripts/ts-parse.mjs, on purpose.scripts/pm/dispatch-gates.mjsrecords that the five shared utilities it follows declareno path literals at all, and it follows them one hop; a literal here would fan a watch-hint
into every one of the 34 importer families — the precision loss that file explicitly
refuses. So the door's safety is the origin-vouch rather than a roster with a scan.
check:watch-hint-literalexits 0.🤖 Generated with Claude Code
https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Generated by Claude Code
Generated by Claude Code