fix(cli)!: os generate refuses a name whose barrel alias no consumer could import by name - #17685
Conversation
`os generate view class` exited 0 and wrote
`export { default as class } from './class.view'`. An export clause admits a
reserved word as a `ModuleExportName`, so the line parses and both landed
layers admit it — the charset gate (#16726) because every character is a
lowercase letter, the parse check (#16541) because the bytes are parseable
TypeScript. The import side is a syntax error, so the barrel entry can never
be named and the failure lands in the author's own file.
Adds a third layer after the other two: the derived barrel alias is put
through TypeScript in the exact position a consumer must write it, and the
command refuses when the compiler will not take it.
Measured, rather than listed: a keyword array is wrong in both directions.
Too narrow, because modules are automatically in strict mode, so `let`,
`yield`, `static`, `implements`, `interface`, `package`, `private`,
`protected`, `public` and `await` are reserved here too — all ten
charset-legal, all ten previously exit 0, and the obvious 36-word list misses
every one. Too wide, because `type`, `as`, `from`, `async`, `get`, `set` and
the rest of the contextual set are legal import bindings that work today.
Placed last of the three so it narrows only what all three would otherwise
have admitted: `os g object class` still meets the parse check's own
diagnostic, and no charset verdict moves. No third charset, no sanitiser.
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
…independence Two pins, split the way the two siblings are: `importable-binding.test.ts` measures the instrument across five word classes, and `generate-refuses-unimportable-alias.test.ts` measures that the command consults it before the writes, on a real child process, because `process.exitCode` in a vitest worker is not an exit status. The controls carry the expensive failure direction: 31 contextual words and 14 ordinary names (including the near-misses `classy`, `klass`, `letter`, `statically`, `awaited`, `myClass`) must all still generate, asserted per word rather than as a count, and `os g view type` is read back off disk. The charset pin's row that recorded the pre-fix `os g view class` behaviour asked for a deliberate edit whichever way the decision went. This is it: both of its assertions are unchanged and still true, because the new layer adds no verdict on those emissions. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…ed set Two properties a reader would otherwise meet by surprise. The 46 refused names exit 0 today, so scripted callers see the command stop where it used to write an unusable file; and because the judge is the compiler asked in position rather than a list this package keeps, a TypeScript upgrade can move the refused set in either direction, both correct and neither predicted by a changeset. Also corrects a claim this PR's own measurement retired: `object` did NOT already refuse the whole set one layer down. It refused the always-reserved words only — `os g object let`, `yield` and `static` exited 0, because a strict-mode reservation is a semantic diagnostic and the parse check is syntactic. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8cd435b6de8695dd481e7c5f134c6dd821e13c61 && git checkout 8cd435b6de8695dd481e7c5f134c6dd821e13c61
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e9bba5006c8bcfb18c02c7dc107e11ce58613c54 166725810b00bdf4e21bc0610d667b3c806dfc08 && git checkout -B drift-repro e9bba5006c8bcfb18c02c7dc107e11ce58613c54 && git merge --no-ff 166725810b00bdf4e21bc0610d667b3c806dfc08
node scripts/docs-audit/affected-docs.mjs --json e9bba5006c8bcfb18c02c7dc107e11ce58613c54
|
…true
`import { class as klass } from './views'` parses, and so does a namespace
import plus property access. Only the bare named form is a syntax error, so
"no consumer could import" is one word short of correct.
Prose only, and it matters because of where this sentence lands: the
changeset's first line becomes the published CHANGELOG entry and the PR title
becomes the squash commit subject, both permanent and both user-facing. A
reader who knows the aliased import works would read the old wording as wrong.
No source file changes, so no measurement moves.
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
os generate refuses a name whose barrel alias no consumer could importos generate refuses a name whose barrel alias no consumer could import by name
Fixes #17410
Clause-②: no— this narrows the accept set and publishes no new surface; no contract review is owed. (Carrier authority is the claim comment plus this line; verified withcheck-clause2-carriers --pair, not by grep.)The defect, re-driven rather than inherited
The card's
Measuredblock carried an explicit warning that its rows were driven by the #16726 dev and that no seat had re-driven them. First action was to re-drive them. They hold:The card is not already fixed. Measured across the whole roster on
7880c184,classwas admitted by 6 of 7 generators:constbindingclassobject${camel}— the bare identifierviewactionflowdashboardappskill${camel}Views/Action/Flow/Dashboard/App/Skillobjectis not special-cased anywhere — it is the only generator that binds the bare identifier in the scaffold, so its emission is the only one the parse check can see. The barrel line is rendered once, identically, for all seven.What was ruled, and what this delivers
The #16726 ruling (decision batch #82, 2026-09-08) closes with a sentence that names the end state. Quoted verbatim and untranslated, as a ruling is:
⭐ That outcome is ruled, and this PR is what delivers it. What the card correctly flagged is that the mechanism the same ruling specifies — a charset gate — cannot:
classis inside the charsetpackages/specdeclares for an objectname, so a charset gate admits it. Honouring the sentence therefore needed a layer the ruling did not describe, which is why it needed its own card rather than being folded into #17408. ⛔ And the same ruling's other half is no third charset — so this adds none.The landed code already states this gap in its own words
packages/cli/src/commands/generate.ts:910-:914, above the parse check, verbatim:This PR is that sentence's second half. Every PM coordinate verified unchanged on this base (
:819charset gate,:899-:900the two emissions,:927the parse check,:487-:519the doc block) — no corrections to report.⛔ Why there is no list of reserved words
The obvious implementation is an array of keywords. It is wrong in both directions at once, which is why this was measured rather than recalled:
let,yield,static,implements,interface,package,private,protected,publicare reserved in this position too, andawaitis reserved at a module's top level. All ten are charset-legal and all ten reachedexit 0. A list stopping at the obvious 36 ships the same defect for them. ⇒ 46 words, not 36."'let' is a reserved word in strict mode. Modules are automatically in strict mode."), not syntactic ones. This is also why layer 2 admitsos g object let/yield/staticdespite their scaffold binding the bare identifier — so the card's "objectalready refusesclassone layer down" is true only forclass, and a per-generator refusal would have been the wrong shape.type,as,from,async,get,set,of,keyof,readonly,satisfies,infer,declare,namespace,using,accessor,undefined,arguments,evaland the rest of the contextual set are legal import bindings that generate today. Refusing one breaks a working command — the expensive direction.So the judge is TypeScript's own parser, asked in the exact position a consumer must write — the same instrument and reasoning as
findEmissionParseFailures, one question further down. No list to curate, and the boundary moves with the language rather than with us.The probe is a resolvable two-file graph (barrel + consumer that imports the alias and refers to it), so both diagnostic buckets can be required empty. Asking it as a real graph also means the layer does not lean on the layer in front: a multi-token alias parses fine as a bare import clause — it is simply a different import — and is refused here because
typeof ALIASthen does not hold together.Measured over 99 inputs in 5 classes — zero deviations:
classyklassletterstaticallyawaitedmyClass(⭐ control)This is the real cost of asking the compiler instead of keeping a list, and it is the right trade — but it must not be discovered by surprise. Ten of the 46 are reachable only as semantic diagnostics, so the refused set is owned by the compiler, not by any list in this repo. ⇒ A TypeScript upgrade can move it, in either direction:
Neither direction is a regression, and both are correct by construction. But both are behaviour changes that no changeset would otherwise predict, so a TS bump should expect this family to move and should read the delta rather than re-green it.
classlet,awaittypeorder_lineclassyThose six are the words a future TS bump will fail on first, which is the intended behaviour.
Hard stops, each held
findEmissionParseFailuresandnameCharsetRefusalare byte-identical. The new layer is asked last, so it changes the verdict of neither:os g object classstill gets the compiler's "'class' is not allowed as a variable declaration name.", and a name outside the charset still gets the schema's own pattern. Pinned, not assumed.os createnot extended. Measured read-only, as asked: it does not share this gap. Its identifier is always suffixed (classPlugin,letPlugin) and it emits no barrel, so no reserved word lands where a consumer must name it. Positive controloknameyieldsoknamePlugin.os create plugin class/letexit 0 and are correct to.The refusal
...then why the line parses but cannot be imported, that contextual words are accepted, and a name that works. Reuses the existing
Name:/Identifier:print shape — no fourth style.Scope
One addition to the declared file surface, called out so a reader checking the dispatch's surface against the diff does not have to infer it:
packages/cli/src/utils/importable-binding.tsis new — the instrument, plus the two exports the command and the pins call. The dispatch declaredsrc/commands/generate.ts+ its sibling tests + a changeset, withsrc/utils/emitted-source-parses.tsread-only: that file is untouched, and the new module stands beside it as a sibling rather than extending it.A new module rather than an inline block because the rule is ~160 lines, most of which is the argument for why there is no keyword list, and a command file is the wrong place for that; it also gives the pins a seam onto the same instrument the command runs, instead of a second copy that could drift green. Same package, and no other open PR touches it.
Everything else is additive: +777 / −0 — zero deletions anywhere in the diff, which is the structural half of the claim that neither layer in front was relaxed or reordered.
Verification
dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each run redirected with$?captured before any pipe, then reconciled with--ran: 63 derived / 63 accounted for / 0 unrun. 61 exit 0. Two are⊘ NOT MEASURED, bothexit 3 = PREREQUISITE NOT MET(their own words: "this is NOT a pass: nothing was measured") —check:dual-build-cjs-loadsandcheck:i18n-coverageboth needdist/for packages this diff never touches. CI builds fresh and runs both.dispatch-gatesdoes not name it):eslint . --no-inline-configgave exit 0, 6628 files examined, 0 errors, 0 warnings, all four changed files present in eslint's own--format jsonoutput. The zero has a positive control.@objectstack/cliunit tier: 196 files / 2813 passed. The three generate-refusal pins in the integration tier: 38 passed (mine plus both siblings, whose assertions were not touched). Tier-partition pin: 22 passed.typecheck+check:test-typecheckclean — and--listFilesconfirmstsconfig.jsondoes not includetest/, whiletsconfig.test.jsonincludes all four changed files; all 28 pinned debt errors are in three pre-existing files, none mine.src/throughbin/run-dev.js+ tsx): command pin 9 failed / 4 passed, instrument pin 2 failed / 103 passed. The four that survive are the layer-independence and control rows — correctly independent of this layer.ablation-dist-preflightboth directions exit 0 — guard absent fromdist/, mutation present indist/— then all six affected generators returned to exit 0, the exact pre-fix defect.git checkout HEAD -- PATH, hash re-matched the HEAD blob,git diff HEADempty, and the package rebuilt — the first attempt restoredsrcbut leftdistcarrying the mutation, which was caught and repaired before anything else ran.Changeset
minor+feat(cli)!, deliberately declared breaking and following #16726's precedent for the same command and the same class of narrowing: 46 names that produced output now refuse.skip-changesetis wrong here —@objectstack/clipublishes, and the new module plus its call site land infiles[]/dist(verified, with a positive control at 0 hits). ADR-0087 disposition:not-required (no-migration-prescription)— nothing authorable or stored moves; the gate confirms it judged the changeset by name.Acceptance notes — noted, not filed
findEmissionParseFailuresis syntactic-only by design and therefore blind to strict-mode reservations. Not a defect in it (its header states the choice and its reason), and no residual user-visible gap: the command now refuses those names, so nothing is written. Noted because the next author to reach for that instrument for an identifier question should know its blind spot. Carrier: this PR's own doc block, which states it at the call site.import { class as klass } from './views'and a namespace import plus property access both parse. It does not change the verdict (a scaffold whose emitted name is unusable in the idiomatic form is the defect, and the ruling refuses it), so this began as an acceptance note. It was promoted to a fix once it was pointed out where the sentence actually lands: this PR's title becomes the squash commit subject, and the changeset's first line becomes the publishedCHANGELOGentry that ships to users permanently. Both now read import by name. Prose only — no source file changed, so every gate, test and ablation result below stands as measured.Generated by Claude Code