Skip to content

feat(cli)!: refuse a generate name outside the charset spec declares for an object name - #17408

Merged
os-justin merged 3 commits into
mainfrom
claude/issue-16726-generate-name-gate
Sep 10, 2026
Merged

feat(cli)!: refuse a generate name outside the charset spec declares for an object name#17408
os-justin merged 3 commits into
mainfrom
claude/issue-16726-generate-name-gate

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #16726

os generate TYPE NAME now refuses any name outside the charset packages/spec already declares for an object name, before it derives anything from that name. Maintainer ruling, decision batch #82 (2026-09-08) — 「同意」 on option A: gate only.

Clause-②: no
Re-declared from the delivered diff. This narrows what a published command accepts, and narrows it back onto a charset packages/spec already declares — it declares no new accepted spelling, adds no public flag or option, and ships no bypass. The only new exported-ish surface is a file-local function in generate.ts.

⚠️ The card still READS like an open decision. It is not one.

Its title still says [Decision] and its body still ends with 「请选:A、B,还是 C?」. Both are stale, and a later reader should not mistake this PR for pre-empting a live decision:

  • comment 5578287269 (director seat, 2026-09-08) records the maintainer's verbatim 「同意」 adopting A and moved the card out of the decision inbox;
  • comment 5594057758 (2026-09-09) retired needs:contract-review from it.

⛔ Nothing here re-opens A/B/C, and the body's closing question is deliberately left unanswered — it was answered on the card.

What the gate is

  • The charset is asked, not restated. The judge is the declaration itself — ObjectSchema.shape.name from @objectstack/spec/data, reached lazily inside the check. Nothing in generate.ts says what the charset IS, so there is no second declaration to drift. Re-read as the ruling instructed: packages/spec/src/data/object.zod.ts declares name: z.string().regex(/^[a-z_][a-z0-9_]*$/), which matches the ruling comment's transcription. No discrepancy on that point.
  • The refusal names the value and the rule, and the rule it prints is the schema's own message (Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/), so the author is shown the pattern that judged them.
  • ⛔ No sanitiser. Nothing is rewritten and nothing derived from the refused input is printed — the suggestion examples are deliberately fixed strings, because a suggestion computed from the refused name is option B wearing a prompt.
  • Placed before every derivation. It sits after the generator-roster lookup (so an unknown type still answers about the type) and above metadataFileName(type, toSnakeCase(name)), which is the first line that derives anything from the name.
  • PR fix(cli): os generate refuses to write TypeScript that does not parse #16724's parse check is untouched and stays behind it. Only one line of its advice text moved: it used to offer order-line as an equally good spelling, which the gate now refuses one layer earlier.

The two layers are distinct — driven, both directions

driven (this branch) verdict which layer answered
os g object order-line exit 1 the gate — and the parse check would have accepted it (its emission parses clean)
os g object order_line exit 0 neither — accepted, writes order_line.object.ts binding orderLine
os g object class exit 1 the parse checkclass is inside the charset; 'class' is not allowed as a variable declaration name.
os g view class exit 0 neither — see the next section

⚠️ One sentence in the ruling comment does not follow from the ruled mechanism

The ruling comment closes with: 「os generate view class is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」

That does not follow from the mechanism the same ruling specifies. class is inside the charset packages/spec declares for an object name (^[a-z_][a-z0-9_]*$ — every character is a lowercase letter), so a charset gate admits it; the view generator emits const classViews: and export { default as class } from './class.view';, both of which parse. Driven on this branch, after the gate: exit 0, both files written.

⛔ Not settled here. Refusing reserved words is a third rule, and the ruling's other half is ⛔ no third charset — so this PR implements the mechanism as ruled and reports the gap rather than legislating over it. Recorded as a measured row in generate-refuses-name-outside-charset.test.ts so that whichever way it is answered, the answer is a deliberate edit. Reported on #16726 for the maintainer.

Acceptance notes

  • What this narrows, measured: kebab-case (order-line), uppercase (Order), dotted (foo.bar) and digit-initial (2fast) names were accepted before and are refused now. order-line in particular used to generate order_line.object.ts binding orderLine — a silent fold, which is precisely what option A refuses.
  • Two landed tests spawned the CLI with a kebab name and would have gone red: the order-line CONTROL in generate-refuses-unparseable-name.test.ts and os g skill lead-qualification in generate-skill.e2e.test.ts. Both moved to the underscore spelling. Both spellings derive the same written file, the same binding and the same barrel alias, so every assertion in those files is the one its author wrote, byte for byte — only the authored input moved.
  • os generate has NO name validation at all — os generate object foo.bar emits const foo.bar: Data.ServiceObject and a barrel re-export, both un-parseable TypeScript #16541's pin now measures its own subject through class. foo.bar is outside the charset, so the gate answers for it first and it no longer demonstrates the parse check. Nothing was deleted: every foo.bar assertion still about the COMMAND (exit code, no rewrite, nothing on disk) is unchanged, and a new class spawn carries the three assertions that were about the compiler's verdict. One property genuinely stopped being reachable through the command — a name that breaks the BARREL line as well as the scaffold, since a reserved word is legal as an export { default as ... } alias and every name that breaks both is now stopped one layer earlier. That half stays pinned where it still runs: the CANARY row in generate-emission-parses.test.ts.
  • Docs my own change would have falsified: content/docs/deployment/cli.mdx carried os g skill lead-qual and os g flow lead-qualification — copy-and-fail examples the moment this lands. Both corrected, plus one paragraph documenting what NAME accepts. ⚠️ This is outside the claimed file surface (generate.ts + its tests) and is declared as a deviation in the dev report; the file is held by no open PR (re-derived across all 15 open PRs).
  • packages/spec is untouched, as the claim required — the charset is read, never redeclared.
  • Noted, not filed: the CLI's name argument advertised use kebab-case, which the gate makes false; corrected in the same file. os create (npm charset) and os init (sanitizeNamespace) still answer differently — deliberately untouched, per the card's own scope note.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands derived 92 families for this diff; all 92 ran, exit codes recorded to disk before any verdict was read, and reconciled with --ran: 90 green, 2 NOT MEASURED, 0 unrun.
  • NOT MEASURED, both exit 3 = PREREQUISITE NOT MET, neither a finding: pnpm check:dual-build-cjs-loads (wants a repo-wide dist/, which is CI's Build Core job) and pnpm check:type-check-debt (its repo-wide re-measure OOMs at this container's 4 GB heap cap; the ledger-completeness half, pnpm check:type-check-coverage, ran green).
  • pnpm lint — the whole-repo eslint . --no-inline-config, exit 0. check:changeset-no-major green; the changeset is minor and carries an ADR-0087 not-required (no-migration-prescription) disposition.
  • pnpm --filter @objectstack/cli typecheck exit 0. Targeted tests exit 0: generate-refuses-name-outside-charset (new), generate-refuses-unparseable-name, generate-emission-parses — 59 tests — plus generate-skill.e2e under OS_TEST_TIERS=nightly, 13 tests.

Generated by Claude Code

`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
The #16726 charset gate answers first for `foo.bar`, so #16541's pin now
measures its own subject through `class` — inside the charset, refused by the
compiler — and keeps every `foo.bar` assertion that is still about the command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/deployment/cli.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/types.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 cfdd0e95dece7d6ccde99ac1e5042134f63788a4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7d382cde7fe9155bb3db49bbadbf7f5048dccba2 — the merge of head f8c8629ea5193c024adfe3e16698076f646306a5 into base cfdd0e95dece7d6ccde99ac1e5042134f63788a4, 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 7d382cde7fe9155bb3db49bbadbf7f5048dccba2 && git checkout 7d382cde7fe9155bb3db49bbadbf7f5048dccba2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cfdd0e95dece7d6ccde99ac1e5042134f63788a4 f8c8629ea5193c024adfe3e16698076f646306a5 && git checkout -B drift-repro cfdd0e95dece7d6ccde99ac1e5042134f63788a4 && git merge --no-ff f8c8629ea5193c024adfe3e16698076f646306a5

node scripts/docs-audit/affected-docs.mjs --json cfdd0e95dece7d6ccde99ac1e5042134f63788a4

⚠️ 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 cfdd0e95dece7d6ccde99ac1e5042134f63788a4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Development

Successfully merging this pull request may close these issues.

[Decision] Which names should os generate ACCEPT? — a gate, a sanitiser, or both, and whose charset

2 participants