Skip to content

Commit d0f06ff

Browse files
os-justinclaude
andauthored
feat(cli)!: refuse a generate name outside the charset spec declares for an object name (#17408)
* feat(cli)!: refuse a generate name outside spec's object-name charset `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 * test(cli): measure the parse-check layer through a name that reaches it 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 * chore(changeset): record the ADR-0087 disposition for the generate name gate Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cefe068 commit d0f06ff

6 files changed

Lines changed: 503 additions & 23 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726)
6+
7+
Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate <type> <name>` used to accept any name at all; since #16724 it has refused 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. The refusal names the value and quotes the schema's own rule, and writes nothing.
8+
9+
⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string.
10+
11+
**What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration).
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable or stored moves. No `packages/spec` key, no Zod schema, no authored metadata property and no stored `sys_metadata` shape changes its spelling, type or legality — the charset this gate applies is the one spec ALREADY declares for an object `name`, read rather than redeclared — so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. What moves is which ARGUMENT a scaffolding command accepts at authoring time; a name it now refuses was never a legal object `name` in the first place, so no metadata written from an accepted name needs conversion, and files already scaffolded from a refused name are untouched by this change. The remedy is to type a name spec accepts, delivered by the command's own loud refusal at the terminal and by this changelog — the source-code / invocation-side audience the ledger explicitly does not serve (ADR-0087 D8). The other four categories are closed on facts: `@objectstack/cli` publishes to npm (not `unpublished`); no ADR-0087 id is minted in this diff (not `registered`) and none pre-dates the base that would cover it (not `already-registered`); no exported declaration changes shape at all — the gate is a file-local function absent from the package entry — so neither `runtime-interface-only` nor `type-surface-only` has a subject. -->

content/docs/deployment/cli.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,14 +1259,21 @@ only.
12591259

12601260
Generates properly typed metadata files with barrel index management.
12611261

1262+
`<name>` is held to the charset `@objectstack/spec` declares for an object
1263+
`name` — lowercase letters, digits and `_`, never starting with a digit. A name
1264+
outside it is refused before anything is derived or written, naming the value
1265+
and the rule; it is **never** rewritten into one that fits, so the name you
1266+
write is the name that lands in the file (`os g object order_line`, not
1267+
`order-line`).
1268+
12621269
```bash
12631270
os g object customer # Generate a Customer object
12641271
os g view customer # Generate a Customer list view
12651272
os g action approve # Generate an action
12661273
os g flow customer # Generate an automation flow
12671274
os g dashboard sales # Generate a dashboard
12681275
os g app crm # Generate an app definition
1269-
os g skill lead-qual # Generate an AI skill
1276+
os g skill lead_qual # Generate an AI skill
12701277

12711278
os g object task -d lib/ # Override target directory
12721279
os g object task --dry-run # Preview without writing
@@ -1894,7 +1901,7 @@ os g object contact
18941901
os g object opportunity
18951902

18961903
# 3. Add business logic
1897-
os g flow lead-qualification
1904+
os g flow lead_qualification
18981905

18991906
# 4. Validate everything
19001907
os validate

packages/cli/src/commands/generate.ts

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import {
2424
isTenancyDisabled,
2525
isUniqueDeclared,
2626
numericColumnFor,
27+
// #16726 — the name gate below. IMPORTED for the same reason as the five
28+
// above: it asks the schema whether a name is legal instead of restating
29+
// the charset the schema declares.
30+
ObjectSchema,
2731
} from '@objectstack/spec/data';
2832
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js';
2933
import { metadataFileName } from '../utils/metadata-file-name.js';
@@ -472,6 +476,54 @@ function toSnakeCase(str: string): string {
472476
return str.replace(/[-]/g, '_').replace(/[A-Z]/g, c => `_${c.toLowerCase()}`).replace(/^_/, '');
473477
}
474478

479+
/**
480+
* Is this a name `os generate` accepts? (#16726)
481+
*
482+
* ## The declared answer, asked rather than restated
483+
*
484+
* The accepted set is the charset `packages/spec` ALREADY declares for an
485+
* object `name` — maintainer ruling, decision batch #82 (2026-09-08, option
486+
* A): a gate, ⛔ no sanitiser, and ⛔ no third charset. So the judge here is
487+
* that declaration itself (`ObjectSchema.shape.name`), reached through the
488+
* package's exported surface. Nothing in this file states what the charset
489+
* IS: a transcription is a second declaration that can drift green while spec
490+
* moves, and the ruling asks for the spec's rule, not for a copy of today's
491+
* reading of it. The refusal even quotes the schema's own message, so the
492+
* pattern the author is shown is the pattern that judged them.
493+
*
494+
* ## ⛔ Why it returns a REASON and never a repaired name
495+
*
496+
* The rejected option (B) was to derive a legal identifier the way
497+
* `os create` has since #15892. It was refused because it decouples the name
498+
* the author wrote from the name that gets emitted, silently: write
499+
* `foo.bar`, get `fooBar` in the file, and every later reference the author
500+
* types by hand is wrong with nothing announcing it. For metadata written in
501+
* bulk that divergence multiplies unseen. So this answers only *may this name
502+
* through*, and the caller refuses loudly — ⛔ it never rewrites, and no flag
503+
* bypasses it.
504+
*
505+
* ## What it deliberately does NOT decide
506+
*
507+
* Whether the TypeScript the accepted name would produce actually PARSES.
508+
* That is #16541's check (`findEmissionParseFailures`), it stays exactly where
509+
* it landed, and it is a genuinely different question: `class` is inside this
510+
* charset and is still refused by the compiler in a `const` binding position,
511+
* while `order-line` emits a perfectly parseable `orderLine` and is refused
512+
* here. Neither layer shadows the other — `generate-refuses-name-outside-charset.test.ts`
513+
* measures both directions.
514+
*
515+
* @returns `null` when the name is accepted, or the schema's own reason when
516+
* it is not.
517+
*/
518+
function nameCharsetRefusal(name: string): string | null {
519+
// Reached lazily, inside the call: `ObjectSchema` is a lazy schema, and a
520+
// module-top `.shape` read would materialize it for every CLI command
521+
// including the ones that never generate anything.
522+
const verdict = ObjectSchema.shape.name.safeParse(name);
523+
if (verdict.success) return null;
524+
return verdict.error.issues[0]?.message ?? 'not a legal object name';
525+
}
526+
475527
// ─── Field Type Mapping ─────────────────────────────────────────────
476528

477529
/**
@@ -669,6 +721,58 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
669721
process.exit(1);
670722
}
671723

724+
// ⛔ REFUSE a name outside the declared charset, BEFORE anything is
725+
// derived from it (#16726).
726+
//
727+
// Placed here on purpose, and the position is the ruling: every derivation
728+
// this command performs — `toSnakeCase` for the metadata name and the
729+
// filename, `toCamelCase` for the binding and the barrel alias,
730+
// `toTitleCase` for the labels — happens BELOW this line, so a refused
731+
// name is never folded into a legal-looking one on the way to a
732+
// diagnostic. It sits after the type roster so that `os g <unknown-type>
733+
// <name>` still answers about the type, which is the more useful answer.
734+
//
735+
// What it is NOT: a sanitiser (option B was refused — see
736+
// `nameCharsetRefusal`), a charset of this command's own (the judge is
737+
// spec's object-`name` declaration), and not a replacement for the parse
738+
// check further down, which stays as the backstop it was built to be.
739+
const charsetRefusal = nameCharsetRefusal(name);
740+
if (charsetRefusal) {
741+
printError(`Refusing to generate — \`${name}\` is not a name this command accepts`);
742+
console.log('');
743+
console.log(` ${chalk.dim('Name:')} ${chalk.white(name)}`);
744+
console.log(` ${chalk.dim('Rule:')} ${chalk.white(charsetRefusal)}`);
745+
console.log('');
746+
console.log(chalk.dim(
747+
` That rule is not \`${CLI_ALIAS} g\`'s own: it is the charset \`@objectstack/spec\``,
748+
));
749+
console.log(chalk.dim(
750+
' declares for an object `name`, asked of the schema itself. A metadata name',
751+
));
752+
console.log(chalk.dim(
753+
' that is refused there has no business being scaffolded here.',
754+
));
755+
console.log('');
756+
console.log(chalk.dim(
757+
' It refuses instead of folding your name into one that fits, so the name you',
758+
));
759+
console.log(chalk.dim(
760+
' write and the name that lands in the file are always the same string.',
761+
));
762+
console.log(chalk.dim(
763+
// ⛔ The examples are deliberately NOT built from what the author
764+
// typed. A suggestion derived from the refused name is option (B)
765+
// wearing a prompt: the author accepts it, and the divergence this
766+
// gate exists to prevent arrives one keystroke later.
767+
` Nothing was written. Names like \`${CLI_ALIAS} g ${type} customer\` or`,
768+
));
769+
console.log(chalk.dim(
770+
` \`${CLI_ALIAS} g ${type} sales_order\` are accepted.`,
771+
));
772+
console.log('');
773+
process.exit(1);
774+
}
775+
672776
const dir = flags.dir || generator.defaultDir;
673777
// The written name comes from the registry's `filePatterns` for this type
674778
// — see `metadataFileName`, which carries why it is derived rather than
@@ -774,10 +878,14 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
774878
' which would decide in silence which names this command accepts. Pick a name',
775879
));
776880
console.log(chalk.dim(
777-
` that survives as an identifier — \`${CLI_ALIAS} g ${type} order_line\` and`,
881+
// ⛔ This line used to offer `order-line` as an equal alternative. The
882+
// #16726 gate above refuses that spelling before this check is ever
883+
// reached, so offering it here would send the author to a second
884+
// refusal. The CHECK is untouched — only the advice it prints.
885+
` that survives as an identifier — \`${CLI_ALIAS} g ${type} order_line\` works,`,
778886
));
779887
console.log(chalk.dim(
780-
` \`${CLI_ALIAS} g ${type} order-line\` both work, and both fold to \`orderLine\`.`,
888+
' and binds `orderLine`.',
781889
));
782890
console.log('');
783891
process.exit(1);
@@ -2598,7 +2706,11 @@ export default class Generate extends Command {
25982706

25992707
static override args = {
26002708
type: Args.string({ description: 'Metadata type to generate (object, view, action, flow, dashboard, app)', required: true }),
2601-
name: Args.string({ description: 'Name for the metadata (use kebab-case)', required: false }),
2709+
// ⛔ NOT "use kebab-case" any more (#16726): a name outside the charset
2710+
// spec declares for an object `name` is refused at the door, and
2711+
// kebab-case is outside it. What this string advertises and what the
2712+
// command accepts have to be the same set.
2713+
name: Args.string({ description: 'Name for the metadata (snake_case)', required: false }),
26022714
};
26032715

26042716
static override flags = {

0 commit comments

Comments
 (0)