Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/17410-generate-reserved-word-barrel-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/cli": minor
---

feat(cli)!: `os generate` refuses a name whose barrel alias no consumer could import by name (#17410)

`os generate view class` exited **0** and wrote `export { default as class } from './class.view';`. That line parses — an ES module export clause admits a reserved word as a `ModuleExportName` — so both landed layers admitted it, each correctly by its own terms: the #16726 charset gate because every character of `class` is a lowercase letter, and the #16541 parse check because the bytes really are parseable TypeScript. The import side is not: `import { class } from './views'` needs an `ImportedBinding`, and a reserved word is not one. So the command reported success and produced a barrel entry nothing can name, with the failure deferred into the author's own file where it reads as their mistake.

A third layer now stands behind those two. After the identifier is derived and before anything is written or previewed, the 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 — naming the constraint, showing the line that would have been written, and writing nothing. This delivers the #16726 ruling's own closing sentence, 「`os generate view class` is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」, which the charset mechanism specified in that same ruling could not.

⛔ **No third charset** — the #16726 ruling forbids one and none is added: no character is judged. ⛔ **Nothing is rewritten.** Emitting a non-reserved alias while keeping the authored name was the other option and it loses on the reasoning that already refused option B: it decouples the name the author wrote from the name that gets emitted, silently. So this refuses, and the name you author stays the name that lands.

**What this narrows:** 46 names — the 36 always-reserved words (`class`, `new`, `enum`, `default`, `import`, …) plus the ten reserved because a module is automatically in strict mode (`let`, `yield`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, and `await`, reserved at a module's top level). Every one is charset-legal and every one used to reach `exit 0` for the six generators that suffix their `const` binding (`view`, `action`, `flow`, `dashboard`, `app`, `skill`). The seventh, `object`, binds the bare identifier, so the parse check already refused **some** of them there — but only the always-reserved ones: `os g object let`, `os g object yield` and `os g object static` also exited 0, because a strict-mode reservation is a semantic diagnostic and that check is syntactic. Pick a name that survives as an import binding — `os g view order_line` works, and binds `orderLine`.

**This is an observable change to accepted input:** those 46 names exit **0** today and will exit non-zero after this lands. Every one of them produced a barrel entry no consumer could name, so this is the fix rather than a break — but if you script `os generate`, a name in that set now stops the command instead of writing an unusable file.

**One durability note.** The refused set is decided by the TypeScript compiler, asked in position, rather than by a list this package keeps — which is why it is right in both directions today. The consequence is that a TypeScript upgrade can move it: a word that becomes reserved starts being refused, and a word that stops being reserved starts being accepted. Both are correct, neither is a regression, and neither is predicted by a changeset.

**What this deliberately does NOT narrow:** contextual reserved words. `type`, `as`, `from`, `async`, `get`, `set`, `of`, `keyof`, `readonly`, `satisfies`, `infer`, `declare`, `namespace`, `using`, `accessor`, `undefined`, `arguments`, `eval` and the rest are legal import bindings, they generate today, and they still generate. Refusing one of them would break a name that works — the expensive failure direction, and the one a hand-written keyword list gets wrong. There is no keyword list here for exactly that reason: a list is simultaneously too narrow (it stops at the obvious 36 and ships the defect for the other ten, which a syntactic-only check cannot even see, because the compiler reports strict-mode reservations as semantic diagnostics) and too wide (it swallows the contextual set). The judge is the compiler, asked in position.

⛔ Neither layer in front is relaxed or reordered. `os g object class` still meets the parse check's own diagnostic in the compiler's words, a name outside the charset still meets the schema's own pattern, and the new layer is asked last, so it can only narrow what all three would otherwise have admitted.

<!-- 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 — this layer mints no vocabulary at all, it asks TypeScript whether an identifier it already derives can appear in an import clause — 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, and a name it now refuses could never produce an importable barrel entry in the first place, so no metadata written from an accepted name needs conversion and files already scaffolded from a refused name are untouched. The remedy is to type a name whose alias can be imported, 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, declares no `private` and ships `dist` in `files[]` (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 — the new module is absent from every entry in the package's `exports` map and `src/index.ts` re-exports nothing from `src/utils/`, and the command module's own signature is untouched — so neither `runtime-interface-only` nor `type-surface-only` has a subject. -->
90 changes: 90 additions & 0 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js';
import { metadataFileName } from '../utils/metadata-file-name.js';
import { findEmissionParseFailures } from '../utils/emitted-source-parses.js';
import { findBarrelAliasRefusal } from '../utils/importable-binding.js';

// ─── Metadata Type Templates ────────────────────────────────────────

Expand Down Expand Up @@ -971,6 +972,95 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
process.exit(1);
}

// ⛔ REFUSE a barrel alias no consumer can IMPORT BY NAME (#17410).
//
// The two checks above are each satisfied, correctly, by a name whose
// emitted binding is still unusable — and the comment on the parse check
// says why in its own words: a reserved word "is illegal as a `const`
// binding and legal as an `export { default as … }` alias". `class` is
// inside the charset (all lowercase letters), `const classViews:` parses,
// `export { default as class } from './class.view'` parses, and
// `import { class } from './views'` is a syntax error at the call site.
// So `os g view class` exited 0 and wrote a barrel entry that can never be
// named — the failure deferred into the author's own file, where it reads
// as their mistake.
//
// The question is the CONSUMER's, which is why it is asked here and not in
// the check above: that one asks whether the bytes we write parse, this one
// asks whether the binding those bytes publish can be imported. Both ask
// the compiler; neither states a rule of its own. ⛔ Not a third charset
// (the #16726 ruling forbids one, and none is added — no character is
// judged), and ⛔ not a sanitiser: it refuses and rewrites nothing, so the
// name the author wrote stays the name that lands.
//
// Placed LAST of the three on purpose. Each layer asks a strictly narrower
// question than the one before — legal characters, then parseable bytes,
// then an importable binding — and being last means it changes the verdict
// of neither: every name the layers in front already refuse still meets
// their diagnostic, with their wording, and `os g object class` is still
// the compiler's "not allowed as a variable declaration name" rather than
// this. It only ever narrows, and only for names all three would otherwise
// have admitted.
//
// Ahead of the dry-run branch for the same reason the parse check is: a
// preview that prints an unusable barrel and exits 0 is the same defect in
// preview form.
const barrelAlias = toCamelCase(name);
const aliasRefusal = await findBarrelAliasRefusal(barrelAlias);
if (aliasRefusal) {
printError('Refusing to generate — the barrel line this would write could not be imported');
console.log('');
console.log(` ${chalk.dim('Name:')} ${chalk.white(name)}`);
console.log(` ${chalk.dim('Identifier:')} ${chalk.white(barrelAlias)}`);
console.log(` ${chalk.dim('Barrel:')} ${chalk.white(exportLine)}`);
console.log('');
console.log(` ${chalk.white(path.join(dir, 'index.ts'))}`);
for (const diagnostic of aliasRefusal) {
console.log(chalk.dim(` ${diagnostic}`));
}
console.log('');
console.log(chalk.dim(
` That line parses — an export clause admits a reserved word as an alias — so`,
));
console.log(chalk.dim(
` it would have been written. What cannot be written is the other half: a`,
));
console.log(chalk.dim(
` consumer has to name it, and \`import { ${barrelAlias} } from …\` is what the`,
));
console.log(chalk.dim(
' compiler refused above. Nothing was written.',
));
console.log('');
console.log(chalk.dim(
` \`${barrelAlias}\` is a reserved word in this position. The rule is not this`,
));
console.log(chalk.dim(
' command\'s and it is not a charset: it is the compiler, asked whether the',
));
console.log(chalk.dim(
' binding your name publishes can be imported by that name. Reserved only in',
));
console.log(chalk.dim(
' some contexts — `type`, `as`, `from`, `async`, `get`, `set` — are accepted,',
));
console.log(chalk.dim(
' because a consumer can import those.',
));
console.log('');
console.log(chalk.dim(
// ⛔ Deliberately NOT derived from what the author typed — a suggestion
// built from the refused name is the sanitiser this layer declines to
// be, arriving one keystroke later. Same reasoning as the #16726 gate.
` Pick a name that survives as an import binding — \`${CLI_ALIAS} g ${type} order_line\``,
));
console.log(chalk.dim(
' works, and binds `orderLine`.',
));
console.log('');
process.exit(1);
}

if (flags.dryRun) {
printInfo('Dry run — no files written');
console.log('');
Expand Down
163 changes: 163 additions & 0 deletions packages/cli/src/utils/importable-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Can a consumer IMPORT the barrel alias a scaffolder is about to write? (#17410)
*
* ## The defect this exists to end
*
* `os generate <type> <name>` writes two files, and the barrel is the one with
* a consumer: `export { default as <alias> } from './<file>'`. An ES module
* export clause admits a reserved word there — `ModuleExportName` is an
* `IdentifierName`, not a `BindingIdentifier` — so the line PARSES. The import
* side does not: `import { class } from './index'` needs an `ImportedBinding`,
* and a reserved word is not one. The command therefore exited **0** and wrote
* a barrel entry no consumer can name:
*
* os generate view class exit 0
* src/views/class.view.ts -> const classViews: UI.View = { // parses
* src/views/index.ts -> export { default as class } … // parses
* the consumer -> import { class } from './views' // SYNTAX ERROR
*
* Every layer in front is satisfied, each correctly by its own terms: `class`
* is inside the charset `packages/spec` declares for an object `name` (#16726 —
* every character is a lowercase letter), and both emitted files parse
* (#16541). The gap is exactly a name that is charset-legal AND
* emission-parseable whose emitted binding is unusable downstream, and
* `generate.ts` names it in its own words above the parse check.
*
* ## ⛔ What this is NOT
*
* **Not a third charset.** The #16726 ruling says ⛔ no third charset and this
* adds none: no character is judged here, and a name of any shape that a
* consumer can import passes. **Not a sanitiser** — it returns the compiler's
* reasons and never a repaired alias, so the name the author wrote stays the
* name that lands (option B, refused in `nameCharsetRefusal`, would decouple
* them). **Not a relaxation of anything**: it is asked AFTER both landed
* layers and can only ever refuse more, never less.
*
* ## ⛔ Why there is no list of reserved words
*
* The obvious implementation is an array of keywords, and it is wrong in both
* directions at once — which is the whole reason this question needed
* measuring rather than recalling:
*
* - **Too narrow.** The 36 always-reserved words (`class`, `new`, `enum`, …)
* are the ones everybody writes down. But modules are automatically in
* strict mode, so `let`, `yield`, `static`, `implements`, `interface`,
* `package`, `private`, `protected`, `public` are reserved **here** too,
* and `await` is reserved at the top level of a module. All ten are
* charset-legal, all ten reached `exit 0`, and a hand-picked list that
* stops at the obvious 36 ships the same defect for them. Measured: 46
* words, not 36.
* - **Too wide.** `type`, `as`, `from`, `async`, `get`, `set`, `keyof`,
* `satisfies`, `using`, `undefined`, `arguments`, `eval` and the rest of
* the contextual set are perfectly legal import bindings. Refusing a
* merely-contextual reserved word would break names that work today —
* the failure direction that costs an author a working command.
*
* No list gets both right and stays right: the boundary moves with the
* language, and the position matters more than the word (a reserved word is
* legal as an `export { default as … }` alias and illegal as an import
* binding). So the judge is **TypeScript's own parser, asked in the exact
* position the consumer must write** — the same instrument and the same
* reasoning as {@link findEmissionParseFailures}, one question further down.
*
* ## Why the probe is a two-file module graph
*
* A lone `import { <alias> } from './m';` is not enough, and the difference is
* measurable: the strict-mode reservations above are **grammar** checks the
* compiler reports as SEMANTIC diagnostics, so a syntactic-only verdict is
* structurally blind to all ten of them. Resolving the import makes the
* semantic bucket clean enough to read — the control returns **zero**
* diagnostics — so both buckets can be required empty and the ten are seen.
*
* Asking it as a real graph buys one more thing: the probe does not depend on
* the layers in front of it. A multi-token alias (`a, b`) or one carrying an
* escape (`a } from "./x"; const y = 1; //`) parses perfectly well as a bare
* import clause — it is simply a *different* import — and is refused here
* because `typeof <alias>` then does not hold together. So this layer stands
* on its own, exactly as the other two do.
*
* `noLib` keeps the verdict about the grammar of these bytes rather than about
* a `lib.d.ts` a scaffold has no business needing, and the module and
* resolution modes are stated rather than defaulted so the verdict does not
* drift with a compiler upgrade.
*
* ## Why `ts` arrives through a lazy import
*
* The same call, for the same reason, as `emitted-source-parses.ts`: `ts-morph`
* is already a CLI runtime dependency and re-exports the compiler namespace,
* and the parser is a heavy load that only a command which reaches this check
* should pay for.
*/

import type { ts as TS } from 'ts-morph';

const BARREL = '/barrel.ts';
const CONSUMER = '/consumer.ts';

/**
* The diagnostics TypeScript reports for importing `alias` by name from a
* barrel that exports it, flattened to text.
*
* Both buckets are read: the syntactic one carries the always-reserved words
* and the malformed shapes, the semantic one carries the strict-mode and
* module-level reservations. An empty array means a consumer can write
* `import { <alias> } from './…'` and refer to the result.
*
* Exported so a pin can reach the instrument the command actually uses instead
* of a second copy of it that could drift green.
*/
export function namedImportDiagnostics(ts: typeof TS, alias: string): string[] {
const sources: Record<string, string> = {
// The barrel re-exports under `alias` — legal for any `IdentifierName`,
// which is precisely why the emitted line is not where this shows up.
[BARREL]: `declare const value: unknown;\nexport { value as ${alias} };\n`,
// The consumer side: name it in an import clause, then refer to it. The
// reference is load-bearing — it is what refuses an alias that merely
// parses as some *other* import clause.
[CONSUMER]: `import { ${alias} } from './barrel';\ntype Used = typeof ${alias};\nexport type { Used };\n`,
};
const files = new Map<string, TS.SourceFile>(
Object.entries(sources).map(([name, source]) => [
name,
ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
]),
);
const host: TS.CompilerHost = {
getSourceFile: (requested) => files.get(requested),
getDefaultLibFileName: () => 'lib.d.ts',
writeFile: () => {},
getCurrentDirectory: () => '/',
getCanonicalFileName: (f) => f,
useCaseSensitiveFileNames: () => true,
getNewLine: () => '\n',
fileExists: (f) => files.has(f),
readFile: (f) => sources[f],
};
const program = ts.createProgram([CONSUMER, BARREL], {
noLib: true,
target: ts.ScriptTarget.Latest,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
}, host);
const consumer = files.get(CONSUMER)!;
return [
...program.getSyntacticDiagnostics(consumer),
...program.getSemanticDiagnostics(consumer),
].map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '));
}

/**
* The compiler's reasons a consumer could not import `alias` by name, or
* `null` when it can.
*
* `null` is the accept verdict and is the answer for every name that already
* produced importable output — ⛔ never that the check was skipped: `alias` is
* the same string the command interpolates into the barrel it writes.
*/
export async function findBarrelAliasRefusal(alias: string): Promise<string[] | null> {
const { ts } = await import('ts-morph');
const diagnostics = namedImportDiagnostics(ts, alias);
return diagnostics.length > 0 ? diagnostics : null;
}
Loading
Loading