fix(cli): os generate refuses to write TypeScript that does not parse - #16724
Conversation
`generate.ts` ran no name validation at all, so a name that is legal as a
name but not as an identifier was interpolated straight into a binding
position and written out under exit 0 — `const foo.bar: Data.ServiceObject
= {` plus a matching barrel re-export line: two files that are not
TypeScript, from a command that reported success.
Both emissions are now rendered once, at the single point where the derived
identifier is finished, and handed to TypeScript's own parser (reached
through `ts-morph`'s re-export, as `detect-free-identifiers.ts` already
does) before anything is written. On a parse failure the command prints the
compiler's own diagnostics per file and exits 1 without touching the
filesystem, `--dry-run` included. One check covers all 14 emission sites
across all 7 generators plus the barrel.
The criterion is parseability, not a charset: nothing is rewritten, and no
name that already produced parseable output is refused. Which names this
command should accept, and whether it should normalise them, stays an open
decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 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 48ae8c28e92ef330998c6af6edb31b8ce25cb9e3 && git checkout 48ae8c28e92ef330998c6af6edb31b8ce25cb9e3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin be92d468043c1b9e100e6e0f2c2fc9216a5aa850 38aa6ed2db0db0d23bfbd693f365596ba453ef75 && git checkout -B drift-repro be92d468043c1b9e100e6e0f2c2fc9216a5aa850 && git merge --no-ff 38aa6ed2db0db0d23bfbd693f365596ba453ef75
node scripts/docs-audit/affected-docs.mjs --json be92d468043c1b9e100e6e0f2c2fc9216a5aa850
|
`os generate <type> <name>` accepted any name at all until #16724 taught it to refuse names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects — maintainer ruling, decision batch #82, option A: a gate, no sanitiser, no third charset. The judge is the schema itself (`ObjectSchema.shape.name`), so the charset is asked rather than transcribed, and the refusal quotes the schema's own message so the rule the author is shown is the rule that judged them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Fixes #16541
os generate TYPE NAMEran no name validation of any kind — novalidateProjectName, no sanitiser — so the name went into a binding position untouched.os generate object foo.barreported success and left two files that are not TypeScript:The author learns about it at the next
tsc, in a file the scaffolder had just told them it created.What lands
Both emissions — the scaffold file and the barrel re-export line — are now rendered once, at the single point where the derived identifier is finished, and handed to TypeScript's own parser before anything is written. If either does not parse, the command prints the compiler's own diagnostics per file and exits 1 without touching the filesystem.
--dry-runrefuses too. A preview that renders un-parseable TypeScript under exit 0 is the same defect in preview form: the author copies it, or a script trusts the status.generator.generate(name)a second time, which would have made "the bytes checked" and "the bytes written" two different things.The parser is reached through
ts-morph'stsre-export — already a CLI runtime dependency, and the callsrc/utils/detect-free-identifiers.tsalready makes for the same reason. It is imported lazily inside the check, so only agenerateinvocation pays for the compiler.The ruling this obeys
⛔
sanitizeIdentifieris not ported over from #15892. Triage, verbatim:and the line it elevated from the card to a hard constraint:
What lands is the half that needs no adjudication, verbatim:
⭐ The criterion could be built without touching the acceptance question, so no stop-and-report was needed: "do the bytes parse" is answered by the compiler, not by a charset opinion. Still open, and still the maintainer's: whether
os generategets an acceptance gate, a sanitiser, or both — and if a gate,create's npm charset or something narrower. Nothing here narrows that choice; a sanitiser can be added on top of this refusal later without moving it.Clause-②: no— with the evidenceThis diff narrows
os generateback toward a contract it already violates, and widens no accepted set:generate-scaffold-validates.test.ts(objectstack generatescaffolds a flow thatobjectstack validaterejects — the trigger key and the events vocabulary do not exist on protocol 17 #14087) already holds "whatos generatewrites,os validateaccepts", and a file the parser refuses never reachesos validateat all.Measured on this tree — the dispatch's three assumptions
runMetadataGenerationis it: every generator'sgenerate(name)and the barrel's export line are both derived there. Site count re-measured and it matches the card:toCamelCase(name)appears at 14 template sites across 7 generators (object/view/action/flow/dashboard/app/skill), 2 per generator, plus 3 references in the barrel rewrite.Views,Action,Flow,Dashboard,App,Skill). The two emissions therefore disagree about the same name, which is precisely why the check reads the actual bytes rather than judging one identifier.toCamelCasecopies are not the same function. ✅ Re-verified by symbol, not by the card's line numbers (init.ts's has since moved from 720 to 736):generate.tsandinit.tsboth fold/[-_]([a-z])/;create.tsno longer has atoCamelCaseat all —os create plugin foo.baremitsexport const foo.barPlugin— an npm-legal name that renders un-parseable TypeScript #15892 replaced it withsanitizeIdentifier.⭐ One correction to the card's framing, measured:
init.tsis not a third open door. It feedstoCamelCasethe output ofsanitizeNamespace(projectName), which is already constrained to^[a-z][a-z0-9_]{1,19}$, so its looser fold cannot produce an illegal identifier.os generatewas the only unguarded one.Evidence — the defect, and the refusal, on this branch
The refusal was ablated (revert
generate.tsto the merge base, prove the mutation reached disk, measure, restore, prove the restore by blob hash). Pre-fix leg, driven:Post-fix, same command:
exit 1, nothing written.
',' expected.is the compiler's own diagnostic — the same message the #15892 instrument reports on the pre-fixos createbytes, so a zero from this harness is a reading rather than an inert one.⭐ Why asking the compiler is not the same as writing a rule. Measured, driven:
os g object classconst class:is not a declarationos g view classconst classViews:— legalos g object order-lineorderLine, both files, unchangedA character-class rule passes all three (every character is a letter). A reserved-word rule refuses the first two. Only the bytes answer per emission position — and the same instrument also catches a name carrying a quote or a comment terminator, which breaks the emitted file without touching the identifier at all.
Tests
test/generate-emission-parses.test.ts(unit tier) — runs the derivedGENERATOR_SCAFFOLD_TARGETSroster through the shipped check, not a second copy of it. Carries the control (order-linestill emitsorderLine, zero failures, every generator), the canary (foo.barfails at both emissions, every generator), and the discriminator rows above.test/generate-refuses-unparseable-name.test.ts(integration tier) — a real child process, becauseprocess.exitCodeinside a vitest worker is not an exit status and the reported defect is an exit code. Asserts exit 1, both files named, the compiler's own diagnostic quoted, ⛔ nofooBaranywhere in the output (the silent-sanitiser outcome), nothing on disk,--dry-runrefused, and a control that writes both files and re-parses them..e2e, so the merge queue runs it.vitest-tiers.tsnames this exact combination as sanctioned ("a file that spawns the CLI without the name is queue (name) ANDintegration(behaviour)"), and nothing was renamed to make the two cuts agree. The defect pinned here is a command reporting success while writing broken files; that belongs in the run that gates the queue, not in the one that reports the next morning.Verification
All on
38aa6ed2d, the branch head.pnpm --filter '@objectstack/cli^...' build(dependency closure)VERDICT command-exit 0vitest --project unit— the 10 queue-tier files that reachgenerate.ts, incl. the new pinvitest --project integration— the new spawn pinvitest --project integration—generate-string-family-width.pin.test.ts(kernel tier)vitest test/vitest-tiers-partition.test.tsOS_TEST_TIERS=nightly—generate-agent-retired.e2e,generate-skill.e2e,scaffold-emission-policy.e2epnpm --filter @objectstack/cli typechecktsc --noEmitclean;check:test-typecheckOK, ledger unmovedpnpm lint(repo-scoped,eslint . --no-inline-config)Gates derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --commands, each run with its exit code captured before any pipe, then reconciled:✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED. 58 green.⊘ NOT MEASURED (1):
pnpm check:dual-build-cjs-loads— exit 3, the gate's own stated reason: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured." It needs a whole-repo build; declared to CI. Three other gates first answered exit 3 / 1 for the same class of reason (check:i18n,check:i18n-coverage,check:i18n-walk-parity), and were re-run green after building the closure each names.Five roster gates whose baselines sit under directories this diff touches were run beyond the derivation, since their silence is not evidence there:
check:scaffold-emission-policy,check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity— all green.验收备注
Noted while measuring, ⛔ not filed and ⛔ not fixed here:
os g view classis accepted, and the barrel it writes isexport { default as class } from './class.view';. That parses (a reserved word is legal as a module export alias), so this PR does not refuse it — but it is awkward to consume, and it is exactly the kind of case the open acceptance decision should rule on. Recorded here as evidence for that decision, not as a defect of this change.os generate typesderivesINTERFACE_NAME + Recordfrom the config's object names (generateTypesFromConfig) without a schema parse first. The spec constrains an objectnameto^[a-z_][a-z0-9_]*$, so the door is gated upstream and this was not driven to a repro — hence not filed. The succeeding author is the one who settles the acceptance question above: it is the same command family and the same file.Neither is a
packages/specchange, neither widens an accepted set, and neither is touched by this diff.Generated by Claude Code