Skip to content

fix(cli)!: os generate refuses a name whose barrel alias no consumer could import by name - #17685

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-17410-generate-reserved-word-refusal
Sep 11, 2026
Merged

fix(cli)!: os generate refuses a name whose barrel alias no consumer could import by name#17685
os-sales merged 4 commits into
mainfrom
claude/issue-17410-generate-reserved-word-refusal

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 with check-clause2-carriers --pair, not by grep.)

The defect, re-driven rather than inherited

The card's Measured block 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:

$ os g view class --dry-run
exit 0
$ os g view class            # real write
exit 0
src/views/index.ts  ->  export { default as class } from './class.view';

The card is not already fixed. Measured across the whole roster on 7880c184, class was admitted by 6 of 7 generators:

generator scaffold const binding pre-fix verdict on class
object ${camel} — the bare identifier exit 1, parse check
view action flow dashboard app skill ${camel}Views / Action / Flow / Dashboard / App / Skill exit 0

object is 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:

os generate view class is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」

⭐ 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: class is inside the charset packages/spec declares for an object name, 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:

"The criterion is PARSEABILITY, not a charset. findEmissionParseFailures asks the compiler about the bytes above and about nothing else, which is why it also covers what a rule about identifier characters would miss — a reserved word is illegal as a const binding and legal as an export { default as … } alias, and ${toCamelCase(name)}Views parses for a name that bare ${toCamelCase(name)} refuses."

This PR is that sentence's second half. Every PM coordinate verified unchanged on this base (:819 charset gate, :899-:900 the two emissions, :927 the parse check, :487-:519 the 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:

  • Too narrow. Modules are automatically in strict mode, so let, yield, static, implements, interface, package, private, protected, public are reserved in this position too, and await is reserved at a module's top level. All ten are charset-legal and all ten reached exit 0. A list stopping at the obvious 36 ships the same defect for them. ⇒ 46 words, not 36.
  • Worse: a syntactic check cannot even see those ten. TypeScript reports strict-mode reservations as semantic diagnostics ("'let' is a reserved word in strict mode. Modules are automatically in strict mode."), not syntactic ones. This is also why layer 2 admits os g object let / yield / static despite their scaffold binding the bare identifier — so the card's "object already refuses class one layer down" is true only for class, and a per-generator refusal would have been the wrong shape.
  • Too wide. type, as, from, async, get, set, of, keyof, readonly, satisfies, infer, declare, namespace, using, accessor, undefined, arguments, eval and 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 ALIAS then does not hold together.

Measured over 99 inputs in 5 classes — zero deviations:

class n verdict deviations
always-reserved 36 refuse 0
strict-mode / module-reserved 10 refuse 0
contextual only (⭐ control) 31 admit 0
real authored names, incl. classy klass letter statically awaited myClass (⭐ control) 14 admit 0
multi-token / injection-shaped 8 refuse 0

⚠️ The refused set is a function of the TypeScript version

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:

  • a word that becomes reserved starts being refused — which is the point of building it this way;
  • a word that stops being reserved starts being accepted — also correct, since a consumer could then import it.

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.

⚠️ "Zero deviations" above is a finite sample, not a proof. It is 99 inputs — 36 + 10 refused, 31 + 14 accepted, 8 malformed — and it is emphatically not the claim that no name which works today is refused; it is the claim that none of the 45 sampled accepted names is. What pins the boundary going forward is a deliberately small representative handful, asserted by message text so a TS bump fails loudly and specifically rather than silently widening:

pinned side what the pin holds
class refused refused, and for a reason carrying no strict-mode clause
let, await refused refused with the strict-mode / top-of-module wording quoted verbatim
type accepted contextual — generates, read back off disk
order_line accepted ordinary name — both files written and re-read
classy accepted near-miss, against a substring implementation

Those six are the words a future TS bump will fail on first, which is the intended behaviour.

Hard stops, each held

  • No third charset. None added — no character is judged. Asserted on the refusal's own output, not merely intended.
  • No sanitising or rewriting. It refuses; the authored name stays the emitted name. The card's "emit a non-reserved alias" option is the author-name/emitted-name divergence option A was chosen to prevent, and it is not taken.
  • No gate weakened, none reordered. findEmissionParseFailures and nameCharsetRefusal are byte-identical. The new layer is asked last, so it changes the verdict of neither: os g object class still 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 create not 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 control okname yields oknamePlugin. os create plugin class / let exit 0 and are correct to.
  • The premise this dispatch named held in full, so there is no fork to report.

The refusal

✗ Refusing to generate — the barrel line this would write could not be imported

  Name:       class
  Identifier: class
  Barrel:     export { default as class } from './class.view';

  src/views/index.ts
    Identifier expected.

...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.ts is new — the instrument, plus the two exports the command and the pins call. The dispatch declared src/commands/generate.ts + its sibling tests + a changeset, with src/utils/emitted-source-parses.ts read-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

  • Gate roster derived with 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, both exit 3 = PREREQUISITE NOT MET (their own words: "this is NOT a pass: nothing was measured") — check:dual-build-cjs-loads and check:i18n-coverage both need dist/ for packages this diff never touches. CI builds fresh and runs both.
  • Full lint union (this lane always adds it; dispatch-gates does not name it): eslint . --no-inline-config gave exit 0, 6628 files examined, 0 errors, 0 warnings, all four changed files present in eslint's own --format json output. The zero has a positive control.
  • @objectstack/cli unit 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-typecheck clean — and --listFiles confirms tsconfig.json does not include test/, while tsconfig.test.json includes all four changed files; all 28 pinned debt errors are in three pre-existing files, none mine.
  • Ablation, both resolution paths, on-disk proof taken before any result was read (occurrence counts 1 then 0 for the guard, 0 then 1 for the marker, plus a changed blob hash), marker uniqueness established first:
    • source leg (the pin resolves src/ through bin/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.
    • dist leg (the path the exit codes above came through): rebuilt, then ablation-dist-preflight both directions exit 0 — guard absent from dist/, mutation present in dist/ — then all six affected generators returned to exit 0, the exact pre-fix defect.
    • restore leg: git checkout HEAD -- PATH, hash re-matched the HEAD blob, git diff HEAD empty, and the package rebuilt — the first attempt restored src but left dist carrying 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-changeset is wrong here — @objectstack/cli publishes, and the new module plus its call site land in files[]/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

  • findEmissionParseFailures is 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.
  • Corrected rather than noted. The card's title says "no consumer can import it"; measured, that is one word short — the precise claim is the card's body's, cannot import it by name, because 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 published CHANGELOG entry 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

`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>
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 8 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json e9bba5006c8bcfb18c02c7dc107e11ce58613c54.

2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e9bba5006c8bcfb18c02c7dc107e11ce58613c54packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8cd435b6de8695dd481e7c5f134c6dd821e13c61 — the merge of head 166725810b00bdf4e21bc0610d667b3c806dfc08 into base e9bba5006c8bcfb18c02c7dc107e11ce58613c54, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e9bba5006c8bcfb18c02c7dc107e11ce58613c54 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
…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>
@claude claude Bot changed the title fix(cli)!: os generate refuses a name whose barrel alias no consumer could import fix(cli)!: os generate refuses a name whose barrel alias no consumer could import by name Sep 11, 2026
@os-sales
os-sales marked this pull request as ready for review September 11, 2026 12:21
@os-sales
os-sales enabled auto-merge September 11, 2026 12:21
@os-sales
os-sales added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 49cd715 Sep 11, 2026
39 of 41 checks passed
@os-sales
os-sales deleted the claude/issue-17410-generate-reserved-word-refusal branch September 11, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants