From cae468f49675015f19ccf5332f317ef1f8d7a129 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:24:11 +0000 Subject: [PATCH 1/8] fix(cli): generated scaffolds reach the stack, or os g says they do not os init's app and plugin configs now import every barrel os generate writes into (derived from the generator roster) and declare the capabilities the flow scaffold needs; os g loads the config after writing and reports whether the item reached the stack, refusing and rolling back a write that makes a loading config stop loading. The view scaffold's container name now equals the object key it binds to, and barrel membership is asked of the compiler instead of a substring test. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/src/commands/generate.ts | 297 +++++++++++++++++++--- packages/cli/src/commands/init.ts | 132 +++++++++- packages/cli/src/utils/scaffold-wiring.ts | 203 +++++++++++++++ 3 files changed, 594 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/utils/scaffold-wiring.ts diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 62cb7ec8b8e..195959a93ad 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -49,14 +49,34 @@ import { // #20197 — the namespace-prefix gate's own verdict, IMPORTED for the reason // the block above gives: `objectNameFor` asks it rather than restating it. import { validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; -import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js'; +// #20215 — the collection key a type's items live under in a stack, the same +// derivation the stack schema's own plural keys follow. Imported, not a second +// table: `os init` wires, and `os g` looks up, exactly this key. +import { singularToPlural } from '@objectstack/spec/shared'; +import { printHeader, printSuccess, printError, printInfo, printStep, printWarning, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js'; import { metadataFileName } from '../utils/metadata-file-name.js'; import { readProjectNamespace } from '../utils/project-namespace.js'; import { findEmissionParseFailures } from '../utils/emitted-source-parses.js'; import { findBarrelAliasRefusal } from '../utils/importable-binding.js'; +import { + barrelExportsBinding, + barrelSpecifier, + measureStackReach, + wiringLines, + type StackReach, +} from '../utils/scaffold-wiring.js'; // ─── Metadata Type Templates ──────────────────────────────────────── +/** + * The capability tokens a stack must declare for the `flow` scaffold to run + * (#20215): a record-change flow is fired by `triggers` and run by + * `automation`. The same pair `os serve`'s boot banner prescribes when flows + * are declared and the engine is off. See the `flow` generator for the + * measurement. + */ +const FLOW_SCAFFOLD_REQUIRES = ['automation', 'triggers'] as const; + /** * The scaffold templates, keyed by metadata type. * @@ -84,16 +104,39 @@ import { findBarrelAliasRefusal } from '../utils/importable-binding.js'; * `generate-object-namespace-prefix.test.ts` pins both the prefix and the * flag against the templates. * - * Only object names are prefixed. The scaffold's own `name` on a view, an - * action, a flow, a dashboard, an app or a skill is not judged against the - * namespace by any gate `os validate` runs, so it stays the name the author - * typed. + * Only object names are prefixed. The scaffold's own `name` on an action, a + * flow, a dashboard, an app or a skill is not judged against the namespace by + * any gate `os validate` runs, so it stays the name the author typed. A view + * container's own `name` IS an object name — the container is registered under + * the object it binds to — so it is prefixed with it (#20215). + * + * ## Every scaffold reaches the stack, or the command says it does not + * + * Each generator's items are collected under the `defineStack` key + * `singularToPlural(type)` names, which `os init` wires its barrel into and + * `os validate` counts. After writing, `runMetadataGeneration` loads the + * project's config and looks for `itemName` there (see + * `utils/scaffold-wiring.ts`, #20215). */ const GENERATORS: Record string; + /** + * Capability tokens a stack must declare in `requires` for this scaffold to + * RUN (#20215). `os init` declares the union of them, and `os g` names the + * missing ones. Absent: the scaffold needs none. + */ + requires?: readonly string[]; /** * @param name the name the author passed, already past the charset gate * @param namespace the project's `manifest.namespace`; omitted for a project @@ -136,6 +179,7 @@ const GENERATORS: Record objectNameFor(name, namespace), generate: (name: string, namespace?: string) => `import { ObjectSchema } from '@objectstack/spec/data'; /** @@ -192,15 +236,28 @@ export default ${toCamelCase(name)}; * views belong to. `objectName` is the spelling on the QUERY surface. It * names the object `os g object NAME` writes, prefix included, so the two * scaffolds compose. + * + * The container's own `name` is that SAME object name (#20215). A views + * container is registered under the object it binds to, and the runtime + * refuses one whose `name` disagrees with that key at boot + * (`ObjectQL.registerMetadataCollections`: "Register under one name: drop + * `name`, or set it to …"). `os validate` did not say so, and the scaffold + * was never loaded, so nobody met it until `os init` started wiring + * `src/views`: in a namespaced project the scaffold then stopped `os serve` + * from booting. Unprefixed and prefixed are the same string in a project + * with no namespace, so only a namespaced project sees the difference. */ namesObject: true, + itemName: (name: string, namespace?: string) => objectNameFor(name, namespace), generate: (name: string, namespace?: string) => `import * as UI from '@objectstack/spec/ui'; /** * ${toTitleCase(name)} Views */ const ${toCamelCase(name)}Views: UI.View = { - name: '${toSnakeCase(name)}', + // A views container is registered under the object it binds to, so its + // \`name\` is that object's name: the server refuses one that disagrees. + name: '${objectNameFor(name, namespace)}', label: '${toTitleCase(name)}', object: '${objectNameFor(name, namespace)}', list: { @@ -241,6 +298,7 @@ export default ${toCamelCase(name)}Views; * a FLOW, whose name no gate prefixes, so it does not. */ namesObject: true, + itemName: (name: string) => toSnakeCase(name), generate: (name: string, namespace?: string) => `import * as UI from '@objectstack/spec/ui'; /** @@ -287,12 +345,28 @@ export default ${toCamelCase(name)}Action; * The start node's `objectName` carries the namespace prefix: a trigger * bound to an object the stack does not define never fires, and * `validate-flow-trigger-readiness` reports it. + * + * It declares what it needs to run (#20215): {@link FLOW_SCAFFOLD_REQUIRES}. + * `defineStack` refuses a record-change flow in a stack whose `requires` + * lacks `triggers`, and a stack that has `triggers` but not `automation` + * loads it and never runs it — measured on `os serve`: "1 flow(s) declared + * but the automation engine is not enabled — they will never run", each + * trigger plugin "NOT installed". So both tokens are declared here, `os + * init` declares the union, `os g flow` names any the stack is missing, and + * the emitted file says so in its own header. */ namesObject: true, + itemName: (name: string) => `${toSnakeCase(name)}_flow`, + requires: FLOW_SCAFFOLD_REQUIRES, generate: (name: string, namespace?: string) => `import * as Automation from '@objectstack/spec/automation'; /** * ${toTitleCase(name)} Flow + * + * Starts when a record changes, so the stack that carries it must declare + * requires: [${FLOW_SCAFFOLD_REQUIRES.map((t) => `'${t}'`).join(', ')}]. The 'triggers' capability + * fires the flow and 'automation' runs it: without 'triggers' the config does + * not load, and without 'automation' the server loads the flow and never runs it. */ const ${toCamelCase(name)}Flow: Automation.Flow = { name: '${toSnakeCase(name)}_flow', @@ -334,6 +408,7 @@ export default ${toCamelCase(name)}Flow; description: 'Analytics dashboard', defaultDir: 'src/dashboards', namesObject: false, + itemName: (name: string) => `${toSnakeCase(name)}_dashboard`, generate: (name: string) => `import * as UI from '@objectstack/spec/ui'; /** @@ -367,6 +442,7 @@ export default ${toCamelCase(name)}Dashboard; * `objectName` that names no declared object. */ namesObject: true, + itemName: (name: string) => `${toSnakeCase(name)}_app`, generate: (name: string, namespace?: string) => `import * as UI from '@objectstack/spec/ui'; /** @@ -413,6 +489,7 @@ export default ${toCamelCase(name)}App; * harness default. */ namesObject: false, + itemName: (name: string) => toSnakeCase(name), generate: (name: string) => `import { defineSkill } from '@objectstack/spec/ai'; /** @@ -473,20 +550,29 @@ export default ${toCamelCase(name)}Skill; * Exported for `generate-file-name-registry-parity.test.ts` (which reads * `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which * reads `generate` to materialize each scaffold and put it through the schema - * `os validate` parses it with). Derived on purpose: each pin's job is to hold - * for the NEXT generator somebody adds, and a hand-kept list would leave that - * one unmeasured while still reading green. + * `os validate` parses it with), and `init.ts`, whose templates wire every + * `defaultDir` barrel under its `stackKey` and declare the union of `requires` + * (#20215). Derived on purpose: each pin's job is to hold for the NEXT + * generator somebody adds, and a hand-kept list would leave that one + * unmeasured while still reading green. */ export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string; + /** The `defineStack` key this type is collected under: `singularToPlural(type)`. */ + stackKey: string; namesObject: boolean; + itemName: (name: string, namespace?: string) => string; + requires: readonly string[]; generate: (name: string, namespace?: string) => string; }[] = Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir, + stackKey: singularToPlural(type), namesObject: gen.namesObject, + itemName: gen.itemName, + requires: gen.requires ?? [], generate: gen.generate, })); @@ -1064,16 +1150,25 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?: process.exit(1); } - // The project's `manifest.namespace`, read only for a generator that - // writes an object machine name (#20197) — see `objectNameFor`. + // The project's config, loaded once before anything is written. + // + // Its `manifest.namespace` is applied only by a generator that writes an + // object machine name (#20197) — see `objectNameFor` — and only such a + // generator refuses when the config does not load. Every generator reads + // it (#20215), because whether the config loaded BEFORE this command wrote + // anything is what the reach check below needs: a config that loaded then + // and does not load once the scaffold is in place was broken by this + // command, and the write is taken back out. A generator that names no + // object still generates into a project whose config does not load, as it + // always has. // // BELOW the charset gate, because the prefix is a derivation and the // #16726 position puts every derivation after that gate. ABOVE the render, // the parse check and the dry-run branch, so a preview shows the object // name that would land. + const project = await readProjectNamespace(); let namespace: string | undefined; if (generator.namesObject) { - const project = await readProjectNamespace(); if (project.kind === 'load-failed') { // ⛔ REFUSE rather than write the name as typed. An unreadable // manifest is not a manifest with no namespace: guessing "none" is @@ -1352,42 +1447,184 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?: process.exit(1); } + // Everything this run writes is recorded, so that a write the reach check + // below refuses can be taken back out byte-for-byte: the scaffold (new — + // its absence was just checked), the directory if this run created it, and + // the barrel as it was before (`null`: it did not exist). + const fullDir = path.dirname(filePath); + const indexPath = path.join(fullDir, 'index.ts'); + let createdDir: string | undefined; + let barrelBefore: string | null = null; + let barrelWritten = false; + // The success lines are held until the reach verdict, so a refused write + // never prints a `Created` line for a file that is no longer there. + const written: string[] = []; try { - // Create directory - const fullDir = path.dirname(filePath); - if (!fs.existsSync(fullDir)) { - fs.mkdirSync(fullDir, { recursive: true }); - } + // `mkdirSync` answers the first directory it created, or `undefined` + // when the whole path already existed. + createdDir = fs.mkdirSync(fullDir, { recursive: true }) ?? undefined; // Write file — the same `content` the parse check above accepted, ⛔ not // a re-render: a second call to `generator.generate` would make the // bytes that were checked and the bytes that land two different things. fs.writeFileSync(filePath, content); - printSuccess(`Created ${path.join(dir, fileName)}`); + written.push(`Created ${path.join(dir, fileName)}`); - // Check for barrel index - const indexPath = path.join(process.cwd(), dir, 'index.ts'); if (fs.existsSync(indexPath)) { - const indexContent = fs.readFileSync(indexPath, 'utf-8'); - - if (!indexContent.includes(toCamelCase(name))) { + barrelBefore = fs.readFileSync(indexPath, 'utf-8'); + // Asked of the compiler, never `includes` (#20215): see + // `barrelExportsBinding` for the names a substring test dropped. + if (!(await barrelExportsBinding(barrelBefore, barrelAlias))) { fs.appendFileSync(indexPath, exportLine + '\n'); - printSuccess(`Updated ${dir}/index.ts with export`); + barrelWritten = true; + written.push(`Updated ${dir}/index.ts with export`); } } else { - // Create barrel index fs.writeFileSync(indexPath, exportLine + '\n'); - printSuccess(`Created ${dir}/index.ts`); + barrelWritten = true; + written.push(`Created ${dir}/index.ts`); + } + } catch (error: any) { + for (const line of written) printSuccess(line); + printError(error.message || String(error)); + process.exit(1); + } + + // ── Does it reach the stack? (#20215) ────────────────────────────── + // + // The one wrong answer is silence: a scaffold nothing imports passed + // `os validate` at a count of 0. So the project's config is loaded again, + // now with the scaffold in place, and asked whether its stack carries the + // item — the same loader and the same fold `os validate` counts with (see + // `utils/scaffold-wiring.ts` for why the loaded stack, not the config's + // text, is asked). ⛔ The config is never edited: it is the author's file. + const stackKey = singularToPlural(type); + const itemName = generator.itemName(name, namespace); + const requires = generator.requires ?? []; + const reach = await measureStackReach({ stackKey, itemName, requires }); + const scaffoldLabel = path.join(dir, fileName); + + if (reach.kind === 'load-failed' && project.kind === 'loaded') { + // The config loaded before this run wrote anything and does not load + // now, so this write is what broke it: the barrel it reaches carries + // the scaffold into a stack that refuses it. ⛔ REFUSE, and leave the + // project as it was — the same "nothing written" every refusal above + // this line holds. + fs.rmSync(filePath, { force: true }); + if (barrelWritten) { + if (barrelBefore === null) fs.rmSync(indexPath, { force: true }); + else fs.writeFileSync(indexPath, barrelBefore); } + if (createdDir) fs.rmSync(createdDir, { recursive: true, force: true }); + const configName = path.basename(reach.configPath); + printError(`Refusing to generate — with ${scaffoldLabel} in place, ${configName} no longer loads`); console.log(''); - console.log(chalk.dim(` Tip: Run \`objectstack validate\` to check your config`)); + for (const line of reach.message.split('\n')) { + console.log(chalk.dim(` ${line}`)); + } + console.log(''); + console.log(chalk.dim( + ` ${configName} loaded before this command wrote anything, and it wires ${dir}/index.ts,`, + )); + console.log(chalk.dim( + ` so the ${type} became part of its stack, and the stack refuses it in the words above.`, + )); + if (requires.length > 0) { + console.log(chalk.dim( + ` A ${type} needs requires: [${requires.map((t) => `'${t}'`).join(', ')}] in ${configName} to load and to run.`, + )); + } + console.log(chalk.dim( + ' The scaffold and its barrel line were removed again, so nothing was written.', + )); console.log(''); - - } catch (error: any) { - printError(error.message || String(error)); process.exit(1); } + + for (const line of written) printSuccess(line); + reportStackReach(reach, { type, dir, scaffoldLabel, stackKey, itemName, requires, barrelDir: fullDir }); +} + +/** + * Say whether a scaffold `os generate` just wrote is part of the project's + * stack (#20215). Every branch that is not "yes, and it can run" is a + * warning with the exact lines that fix it, because each of them is a file + * `os validate` will not count or will not see run. + */ +function reportStackReach( + reach: StackReach, + info: { + type: string; + dir: string; + scaffoldLabel: string; + stackKey: string; + itemName: string; + requires: readonly string[]; + barrelDir: string; + }, +): void { + const { type, dir, scaffoldLabel, stackKey, itemName, requires, barrelDir } = info; + const quoted = (tokens: readonly string[]) => tokens.map((t) => `'${t}'`).join(', '); + const printWiring = (specifier: string, missingRequires: readonly string[]) => { + const { importLine, stackLines } = wiringLines({ specifier, stackKey, missingRequires }); + console.log(chalk.white(` ${importLine}`)); + console.log(chalk.dim(' and inside defineStack({ … }):')); + for (const line of stackLines) console.log(chalk.white(` ${line}`)); + }; + console.log(''); + + if (reach.kind === 'loaded' && reach.reached) { + const configName = path.basename(reach.configPath); + printSuccess(`Reaches the stack: ${configName} carries it in \`${stackKey}\` as '${itemName}'`); + if (reach.missingRequires.length > 0) { + printWarning(`It will not run yet: ${configName} does not require ${quoted(reach.missingRequires)}`); + console.log(chalk.dim( + ` A ${type} needs requires: [${quoted(requires)}] to run. The stack carries it, and the`, + )); + console.log(chalk.dim( + ` server loads it and never runs it until ${configName} also declares ${quoted(reach.missingRequires)}.`, + )); + } + console.log(''); + console.log(chalk.dim(` Tip: Run \`objectstack validate\` to check your config`)); + console.log(''); + return; + } + + if (reach.kind === 'loaded') { + const configName = path.basename(reach.configPath); + printWarning(`Not wired: ${scaffoldLabel} is not part of the stack ${configName} builds`); + console.log(chalk.dim( + ` ${configName} loads, and its \`${stackKey}\` has no '${itemName}'. Nothing loads this file,`, + )); + console.log(chalk.dim( + ' and `objectstack validate` neither counts it nor checks it.', + )); + console.log(chalk.dim(` To wire every ${type} in ${dir}, add to ${configName}:`)); + printWiring(barrelSpecifier(reach.configPath, barrelDir), reach.missingRequires); + console.log(''); + return; + } + + if (reach.kind === 'no-config') { + printWarning(`Not wired: there is no objectstack.config.{ts,js,mjs} here, so nothing loads ${scaffoldLabel}`); + console.log(chalk.dim( + ` Run \`${CLI_ALIAS} g\` where the project's config is, or wire ${dir}/index.ts into the config`, + )); + console.log(chalk.dim(' of the stack that should carry it, next to this directory:')); + printWiring(barrelSpecifier(path.join(process.cwd(), 'objectstack.config.ts'), barrelDir), requires); + console.log(''); + return; + } + + // The config did not load before this run either, so this run did not break + // it, and whether the scaffold reaches the stack is unknown. + printWarning( + `${path.basename(reach.configPath)} does not load, so whether ${scaffoldLabel} reaches its stack cannot be told`, + ); + console.log(chalk.dim(` \`${CLI_ALIAS} validate\` reports why it does not load.`)); + console.log(''); } async function runTypesGeneration(configPath: string | undefined, flags: { output: string; dryRun?: boolean }): Promise { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d0a70a8f718..bdce64d62c0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -20,6 +20,7 @@ import { } from '../utils/format.js'; import { validateScaffold } from '../utils/scaffold-validate.js'; import { summarizeTree, describeEntry } from 'create-objectstack/created-summary'; +import { GENERATOR_SCAFFOLD_TARGETS } from './generate.js'; // ─── Version resolution ────────────────────────────────────────────── // @@ -571,6 +572,117 @@ export function renderPnpmWorkspaceYaml( ].join('\n'); } +// ─── Wired barrels (#20215) ────────────────────────────────────────── + +/** + * Every directory `os generate` writes into, with the `defineStack` key its + * barrel is wired under — DERIVED from the generator roster, so `os init` and + * `os g` cannot disagree about where a type lives or what it is collected as, + * and a generator added later is wired by every template that wires the rest. + * + * ## The defect (#20215) + * + * The `app` and `plugin` configs imported `./src/objects` alone. `os g view`, + * `action`, `flow`, `dashboard`, `app` and `skill` each wrote a scaffold and a + * barrel that nothing imported, and `os validate` then exited 0 with + * `UI: 0 Apps` and `Logic: 0 Flows` — the road step "a scaffolded project + * validates" held only because nothing generated was ever looked at. + * + * ## Why the templates wire every barrel, and `os g` never edits a config + * + * The alternative was `os g` inserting an import and a key into the config it + * finds. That is a config EDITOR, and a config is the author's file: reordered, + * split across variables, `.js` / `.mjs`, fed from `packages[]`. Wiring every + * barrel here, once, changes no file the author has touched, and a project + * this command did not shape still hears whether a scaffold arrived — `os g` + * loads the config after writing and says so (see `utils/scaffold-wiring.ts`). + * The measurement behind the choice is recorded on the pull request that made + * it. + */ +export const SCAFFOLD_WIRED_BARRELS: readonly { type: string; dir: string; stackKey: string }[] = + GENERATOR_SCAFFOLD_TARGETS.map((t) => ({ type: t.type, dir: t.defaultDir, stackKey: t.stackKey })); + +/** + * The union of the capability tokens the scaffolds need to run — today the + * `flow` scaffold's pair. Declared by every template that wires the `flows` + * barrel: without `triggers` a record-change flow makes `defineStack` refuse + * the config, so the first `os g flow` would break the project, and without + * `automation` the server loads the flow and never runs it. + */ +export const SCAFFOLD_WIRED_REQUIRES: readonly string[] = [ + ...new Set(GENERATOR_SCAFFOLD_TARGETS.flatMap((t) => t.requires)), +]; + +/** The import lines, one per wired barrel, bound under its stack key. */ +function renderWiredImports(): string { + return SCAFFOLD_WIRED_BARRELS.map((b) => `import * as ${b.stackKey} from './${b.dir}';`).join('\n'); +} + +/** The `requires` entry and the collection keys inside `defineStack({ … })`. */ +function renderWiredStackKeys(): string { + const requires = SCAFFOLD_WIRED_REQUIRES.map((t) => `'${t}'`).join(', '); + return [ + ` // What the files \`objectstack generate\` writes need in order to run. A`, + ` // flow that starts on a record change is fired by 'triggers' and run by`, + ` // 'automation': without 'triggers' this config stops loading once it holds`, + ` // such a flow, and without 'automation' the server loads the flow and never`, + ` // runs it. Both can go if this project will never hold a flow.`, + ` requires: [${requires}],`, + '', + ` // Every directory \`objectstack generate\` writes into is wired here: its`, + ` // index.ts exports what the directory holds, and each list below hands`, + ` // those exports to the stack. \`objectstack generate view NAME\` adds a file`, + ` // and one export line, and the view is part of this stack with no edit to`, + ` // this file. A directory that is not wired here is never loaded, and`, + ` // \`objectstack validate\` neither counts nor checks what it holds.`, + ...SCAFFOLD_WIRED_BARRELS.map((b) => ` ${b.stackKey}: Object.values(${b.stackKey}),`), + ].join('\n'); +} + +/** + * The barrel `os init` writes for a wired directory its template puts nothing + * in. `export {}` makes it a module, so the config's `import * as` resolves to + * an empty namespace and `Object.values` to `[]` — a key `os validate` counts + * at zero rather than an import that fails. + */ +function renderEmptyWiredBarrel(stackKey: string, type: string): string { + return `// The ${stackKey} in this directory. \`objectstack generate ${type} NAME\` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a ${type} exported here is part of it, +// and a ${type} file this index does not export is never loaded. +export {}; +`; +} + +/** + * The renderers of the empty wired barrels, which {@link writeTemplateSrcFiles} + * writes only where no file exists. `os init` without a name scaffolds into + * the current directory, which may already hold a `src/views/index.ts` of the + * author's; the config wires whatever that file exports, so keeping it loses + * nothing, and overwriting it would. Keyed by renderer rather than by path, so + * a barrel a template writes WITH content (`src/objects/index.ts`) keeps the + * write it always had. + */ +const EMPTY_WIRED_BARRELS = new WeakSet<(name: string, namespace: string) => string>(); + +/** + * `srcFiles` plus an empty barrel for every wired directory the template does + * not already write an `index.ts` into. + */ +function withWiredBarrels( + srcFiles: Record string>, +): Record string> { + const out = { ...srcFiles }; + for (const b of SCAFFOLD_WIRED_BARRELS) { + const barrel = `${b.dir}/index.ts`; + if (barrel in out) continue; + const render = () => renderEmptyWiredBarrel(b.stackKey, b.type); + EMPTY_WIRED_BARRELS.add(render); + out[barrel] = render; + } + return out; +} + export const TEMPLATES: Record; @@ -614,7 +726,7 @@ export const TEMPLATES: Record `import { defineStack } from '@objectstack/spec'; -import * as objects from './src/objects'; +${renderWiredImports()} // This file is a MODULE, and the whole module is the stack: the default // export below is the base, and every NAMED export is merged onto it as a @@ -640,10 +752,10 @@ export default defineStack({ engines: { protocol: '^${PROTOCOL_MAJOR}' }, }, - objects: Object.values(objects), +${renderWiredStackKeys()} }); `, - srcFiles: { + srcFiles: withWiredBarrels({ 'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object'; `, 'src/objects/__name___item.object.ts': (_name, namespace) => `import { ObjectSchema } from '@objectstack/spec/data'; @@ -683,7 +795,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({ export default ${toCamelCase(namespace)}Item; `, - }, + }), }, plugin: { @@ -708,7 +820,7 @@ export default ${toCamelCase(namespace)}Item; typecheck: 'tsc --noEmit', }, configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec'; -import * as objects from './src/objects'; +${renderWiredImports()} // This file is a MODULE, and the whole module is the stack: the default // export below is the base, and every NAMED export is merged onto it as a @@ -735,10 +847,10 @@ export default defineStack({ engines: { protocol: '^${PROTOCOL_MAJOR}' }, }, - objects: Object.values(objects), +${renderWiredStackKeys()} }); `, - srcFiles: { + srcFiles: withWiredBarrels({ 'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object'; `, 'src/objects/__name___item.object.ts': (_name, namespace) => `import { ObjectSchema } from '@objectstack/spec/data'; @@ -764,7 +876,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({ export default ${toCamelCase(namespace)}Item; `, - }, + }), }, empty: { @@ -891,6 +1003,10 @@ export function writeTemplateSrcFiles( const fullPath = path.join(targetDir, resolvedPath); const dir = path.dirname(fullPath); + // An empty wired barrel is written only where none exists (#20215): see + // EMPTY_WIRED_BARRELS for the author's file this would overwrite. + if (EMPTY_WIRED_BARRELS.has(contentFn) && fs.existsSync(fullPath)) continue; + if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } diff --git a/packages/cli/src/utils/scaffold-wiring.ts b/packages/cli/src/utils/scaffold-wiring.ts new file mode 100644 index 00000000000..7f12a709487 --- /dev/null +++ b/packages/cli/src/utils/scaffold-wiring.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import path from 'node:path'; +import type { ts as TS } from 'ts-morph'; +import { findConfigPath, loadConfig } from './config.js'; +import { authoringRuleUnionStack } from './stack-collections.js'; + +/** + * Whether what `os generate` just wrote REACHES the stack the project's config + * builds, asked right after the write (#20215). + * + * ## The defect this answers + * + * `os g view|action|flow|dashboard|app|skill` wrote a scaffold and a barrel + * `index.ts`, and nothing imported the barrel: an `os init` config wired + * `./src/objects` alone. `os validate` then exited 0 with `UI: 0 Apps` and + * `Logic: 0 Flows` — a green that judged nothing the command had just written, + * with no line anywhere saying so. + * + * `os init` now wires every generator's barrel (see `init.ts`), which is the + * fix for the project it scaffolds. This module is the other half: every + * project that is NOT shaped that way — a config written before the fix, one + * the author reorganised, a `.js` / `.mjs` config, `create-objectstack`'s + * starter, a directory with no config at all — hears the truth from the + * command that wrote the file instead of from a count that silently stayed 0. + * + * ## Why the loaded stack and not the config's source text + * + * The question is read off the stack the config EVALUATES to, through the same + * {@link loadConfig} `os validate` uses, and folded with the same + * {@link authoringRuleUnionStack} its counter uses. So it is exact for every + * config shape at once — keys reordered, extra imports, `defineStack` fed from + * variables, `packages[]` — without this command understanding any of them, + * and it can never disagree with the count `os validate` then prints. Nothing + * here reads or edits the config's text: a config is the author's file, and + * the only thing this module does with it is load it. + */ + +/** What the project's config says about one item `os generate` wrote. */ +export type StackReach = + /** No `objectstack.config.{ts,js,mjs}` in the working directory. */ + | { kind: 'no-config' } + /** A config exists and did not load (or its `packages[]` did not fold). */ + | { kind: 'load-failed'; configPath: string; message: string } + /** + * The config loaded. `reached` says whether its stack carries the item; + * `missingRequires` lists the capability tokens the item needs to RUN that + * the stack's top-level `requires` does not declare. + */ + | { kind: 'loaded'; configPath: string; reached: boolean; missingRequires: string[] }; + +/** The item one scaffold writes, and where in a stack it has to land. */ +export interface ScaffoldStackTarget { + /** The `defineStack` key the type is collected under (`views`, `flows`, …). */ + stackKey: string; + /** The metadata `name` the scaffold writes. */ + itemName: string; + /** Capability tokens a stack must declare in `requires` for the item to run. */ + requires: readonly string[]; +} + +type Bag = Record; + +/** + * Whether the stack a loaded config evaluates to carries an item named + * `itemName` under `stackKey`. Both collection spellings are read: the array + * form every scaffold config uses, and the name-keyed map form + * `normalizeStackInput` also accepts. + */ +export function stackCarries(config: unknown, stackKey: string, itemName: string): boolean { + const stack = authoringRuleUnionStack((config ?? {}) as Bag) as Bag; + const collection = stack[stackKey]; + if (Array.isArray(collection)) { + return collection.some((item) => (item as { name?: unknown } | null)?.name === itemName); + } + if (collection && typeof collection === 'object') { + return Object.entries(collection as Bag).some( + ([key, item]) => key === itemName || (item as { name?: unknown } | null)?.name === itemName, + ); + } + return false; +} + +/** + * The tokens in `requires` that the config's top-level `requires` does not + * declare. Top-level on purpose: it is the list `defineStack`'s trigger + * capability rule reads and the list `os serve` mounts capabilities from. + */ +export function missingCapabilities(config: unknown, requires: readonly string[]): string[] { + const declared = (config as { requires?: unknown } | null)?.requires; + const tokens = Array.isArray(declared) ? declared : []; + return requires.filter((token) => !tokens.includes(token)); +} + +/** Load the project's config and ask it about `target`. Never throws. */ +export async function measureStackReach( + target: ScaffoldStackTarget, + cwd: string = process.cwd(), +): Promise { + const configPath = findConfigPath(cwd); + if (!configPath) return { kind: 'no-config' }; + try { + const { config } = await loadConfig(configPath); + return { + kind: 'loaded', + configPath, + reached: stackCarries(config, target.stackKey, target.itemName), + missingRequires: missingCapabilities(config, target.requires), + }; + } catch (error) { + return { + kind: 'load-failed', + configPath, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * The module specifier a config at `configPath` imports the barrel directory + * `barrelDir` (absolute) through, in the extensionless form `os init` writes. + * `loadConfig` bundles the config, so the directory form resolves to its + * `index.ts` for a `.ts`, `.js` and `.mjs` config alike. + */ +export function barrelSpecifier(configPath: string, barrelDir: string): string { + const rel = path.relative(path.dirname(configPath), barrelDir).split(path.sep).join('/'); + if (rel === '') return './index'; + return rel.startsWith('../') || rel === '..' ? rel : `./${rel}`; +} + +/** + * The lines that wire one barrel into a config: the import, the `defineStack` + * key, and — when the item needs capabilities the stack does not declare — the + * `requires` entry. The binding is named after the stack key, the name + * `os init`'s own config gives it. + */ +export function wiringLines(args: { + specifier: string; + stackKey: string; + missingRequires: readonly string[]; +}): { importLine: string; stackLines: string[] } { + const { specifier, stackKey, missingRequires } = args; + const stackLines = [`${stackKey}: Object.values(${stackKey}),`]; + if (missingRequires.length > 0) { + stackLines.push(`requires: [${missingRequires.map((t) => `'${t}'`).join(', ')}],`); + } + return { importLine: `import * as ${stackKey} from '${specifier}';`, stackLines }; +} + +// ─── Barrel membership ───────────────────────────────────────────────── + +/** + * Every name a barrel source exports by name: `export { a, b as c }` (with or + * without `from`), and exported `const` / `let` / `var` / `function` / `class` + * declarations. `export *` contributes nothing, because its names are not in + * this file. + * + * Exported so a pin reaches the instrument the command uses, not a copy. + */ +export function barrelExportNames(ts: typeof TS, source: string): Set { + const file = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + const names = new Set(); + const exported = (node: TS.Node) => + ts.canHaveModifiers(node) + && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword); + for (const statement of file.statements) { + if (ts.isExportDeclaration(statement)) { + const clause = statement.exportClause; + if (clause && ts.isNamedExports(clause)) { + for (const element of clause.elements) names.add(element.name.text); + } + } else if (ts.isVariableStatement(statement) && exported(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text); + } + } else if ( + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) + && exported(statement) + && statement.name + ) { + names.add(statement.name.text); + } + } + return names; +} + +/** + * Whether the barrel `source` already exports `binding` by name. + * + * ⛔ Not a substring test. The barrel step used to ask + * `indexContent.includes(binding)`, so a binding that merely APPEARED in the + * file was read as exported: after `os g view order_line` (binding + * `orderLine`), `os g view order` found `order` inside `orderLine` and + * appended nothing, and the view it had just written was never exported. The + * empty barrels `os init` now writes (`export {};`) would have made that bite + * on real names too — `port`, `ex`. The compiler is asked which names the file + * exports instead, so a comment, a module path or a longer identifier never + * counts. + */ +export async function barrelExportsBinding(source: string, binding: string): Promise { + const { ts } = await import('ts-morph'); + return barrelExportNames(ts, source).has(binding); +} From c21f964600748e9f486c70c06a7d2144d36d8a7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:35:10 +0000 Subject: [PATCH 2/8] fix(cli): print the whole requires list in the wiring advice Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/src/commands/generate.ts | 16 ++++++++---- packages/cli/src/utils/scaffold-wiring.ts | 32 ++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 195959a93ad..3a92cfa433d 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -1566,8 +1566,12 @@ function reportStackReach( ): void { const { type, dir, scaffoldLabel, stackKey, itemName, requires, barrelDir } = info; const quoted = (tokens: readonly string[]) => tokens.map((t) => `'${t}'`).join(', '); - const printWiring = (specifier: string, missingRequires: readonly string[]) => { - const { importLine, stackLines } = wiringLines({ specifier, stackKey, missingRequires }); + const printWiring = ( + specifier: string, + missingRequires: readonly string[], + declaredRequires: readonly string[] | null, + ) => { + const { importLine, stackLines } = wiringLines({ specifier, stackKey, missingRequires, declaredRequires }); console.log(chalk.white(` ${importLine}`)); console.log(chalk.dim(' and inside defineStack({ … }):')); for (const line of stackLines) console.log(chalk.white(` ${line}`)); @@ -1583,8 +1587,10 @@ function reportStackReach( ` A ${type} needs requires: [${quoted(requires)}] to run. The stack carries it, and the`, )); console.log(chalk.dim( - ` server loads it and never runs it until ${configName} also declares ${quoted(reach.missingRequires)}.`, + ` server loads it and never runs it until ${configName} also declares ${quoted(reach.missingRequires)}:`, )); + const all = [...(reach.declaredRequires ?? []), ...reach.missingRequires]; + console.log(chalk.white(` requires: [${quoted(all)}],`)); } console.log(''); console.log(chalk.dim(` Tip: Run \`objectstack validate\` to check your config`)); @@ -1602,7 +1608,7 @@ function reportStackReach( ' and `objectstack validate` neither counts it nor checks it.', )); console.log(chalk.dim(` To wire every ${type} in ${dir}, add to ${configName}:`)); - printWiring(barrelSpecifier(reach.configPath, barrelDir), reach.missingRequires); + printWiring(barrelSpecifier(reach.configPath, barrelDir), reach.missingRequires, reach.declaredRequires); console.log(''); return; } @@ -1613,7 +1619,7 @@ function reportStackReach( ` Run \`${CLI_ALIAS} g\` where the project's config is, or wire ${dir}/index.ts into the config`, )); console.log(chalk.dim(' of the stack that should carry it, next to this directory:')); - printWiring(barrelSpecifier(path.join(process.cwd(), 'objectstack.config.ts'), barrelDir), requires); + printWiring(barrelSpecifier(path.join(process.cwd(), 'objectstack.config.ts'), barrelDir), requires, null); console.log(''); return; } diff --git a/packages/cli/src/utils/scaffold-wiring.ts b/packages/cli/src/utils/scaffold-wiring.ts index 7f12a709487..4faec4b4c50 100644 --- a/packages/cli/src/utils/scaffold-wiring.ts +++ b/packages/cli/src/utils/scaffold-wiring.ts @@ -45,9 +45,16 @@ export type StackReach = /** * The config loaded. `reached` says whether its stack carries the item; * `missingRequires` lists the capability tokens the item needs to RUN that - * the stack's top-level `requires` does not declare. + * the stack's top-level `requires` does not declare, and `declaredRequires` + * is that list as declared (`null`: the stack declares no `requires`). */ - | { kind: 'loaded'; configPath: string; reached: boolean; missingRequires: string[] }; + | { + kind: 'loaded'; + configPath: string; + reached: boolean; + missingRequires: string[]; + declaredRequires: string[] | null; + }; /** The item one scaffold writes, and where in a stack it has to land. */ export interface ScaffoldStackTarget { @@ -87,11 +94,16 @@ export function stackCarries(config: unknown, stackKey: string, itemName: string * capability rule reads and the list `os serve` mounts capabilities from. */ export function missingCapabilities(config: unknown, requires: readonly string[]): string[] { - const declared = (config as { requires?: unknown } | null)?.requires; - const tokens = Array.isArray(declared) ? declared : []; + const tokens = declaredCapabilities(config) ?? []; return requires.filter((token) => !tokens.includes(token)); } +/** The config's top-level `requires` tokens, or `null` when it declares none. */ +export function declaredCapabilities(config: unknown): string[] | null { + const declared = (config as { requires?: unknown } | null)?.requires; + return Array.isArray(declared) ? declared.filter((t): t is string => typeof t === 'string') : null; +} + /** Load the project's config and ask it about `target`. Never throws. */ export async function measureStackReach( target: ScaffoldStackTarget, @@ -106,6 +118,7 @@ export async function measureStackReach( configPath, reached: stackCarries(config, target.stackKey, target.itemName), missingRequires: missingCapabilities(config, target.requires), + declaredRequires: declaredCapabilities(config), }; } catch (error) { return { @@ -133,16 +146,23 @@ export function barrelSpecifier(configPath: string, barrelDir: string): string { * key, and — when the item needs capabilities the stack does not declare — the * `requires` entry. The binding is named after the stack key, the name * `os init`'s own config gives it. + * + * A `requires` line is the WHOLE list: what the stack already declares, then + * the missing tokens. Printing the missing tokens alone would read as a second + * `requires` key to add beside the first, which replaces it. */ export function wiringLines(args: { specifier: string; stackKey: string; missingRequires: readonly string[]; + declaredRequires: readonly string[] | null; }): { importLine: string; stackLines: string[] } { - const { specifier, stackKey, missingRequires } = args; + const { specifier, stackKey, missingRequires, declaredRequires } = args; const stackLines = [`${stackKey}: Object.values(${stackKey}),`]; if (missingRequires.length > 0) { - stackLines.push(`requires: [${missingRequires.map((t) => `'${t}'`).join(', ')}],`); + const all = [...(declaredRequires ?? []), ...missingRequires]; + const replaces = declaredRequires !== null ? ' // replaces the requires already there' : ''; + stackLines.push(`requires: [${all.map((t) => `'${t}'`).join(', ')}],${replaces}`); } return { importLine: `import * as ${stackKey} from '${specifier}';`, stackLines }; } From 4173b2067856e15b7df9e92ed3dd67aaec931626 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:46:39 +0000 Subject: [PATCH 3/8] test(cli): pin scaffold wiring, the stack-reach report, and the init-to-validate chain The init templates read barrels through a typed exportsOf helper: with an empty barrel, Object.values took its element type from defineStack's map branch and a fresh project failed its own tsc. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/src/commands/init.ts | 33 +- .../generate-object-namespace-prefix.test.ts | 23 +- .../generate-refuses-namespace-prefix.test.ts | 4 +- .../cli/test/generate-scaffold-wiring.test.ts | 284 ++++++++++++++++++ ...generate-scaffolds-reach-stack.e2e.test.ts | 139 +++++++++ .../cli/test/generate-stack-reach.test.ts | 234 +++++++++++++++ 6 files changed, 704 insertions(+), 13 deletions(-) create mode 100644 packages/cli/test/generate-scaffold-wiring.test.ts create mode 100644 packages/cli/test/generate-scaffolds-reach-stack.e2e.test.ts create mode 100644 packages/cli/test/generate-stack-reach.test.ts diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index bdce64d62c0..3770e16c691 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -613,9 +613,32 @@ export const SCAFFOLD_WIRED_REQUIRES: readonly string[] = [ ...new Set(GENERATOR_SCAFFOLD_TARGETS.flatMap((t) => t.requires)), ]; -/** The import lines, one per wired barrel, bound under its stack key. */ +/** + * The import lines, one per wired barrel, bound under its stack key, and the + * one helper the collection keys read them through. + * + * ## Why `exportsOf` and not `Object.values` + * + * `Object.values(barrel)` is the idiom the example apps use, and it is right + * for a barrel that exports something. For an EMPTY barrel it does not + * type-check: with no export to infer from, TypeScript takes the element type + * from `defineStack`'s own collection type, whose name-keyed map branch makes + * `name` optional, and the list it then infers is assignable to neither + * branch — measured, `tsc --noEmit` refused the `actions`, `flows`, + * `dashboards` and `apps` keys of a fresh project (TS2322), while `views` and + * `skills`, which have no map form, passed. `exportsOf` takes its element type + * from the barrel alone: `never[]` while the barrel exports nothing, and the + * exported type once it does, so a fresh project passes its own `typecheck` + * and a filled one is checked exactly as strictly as before. + */ function renderWiredImports(): string { - return SCAFFOLD_WIRED_BARRELS.map((b) => `import * as ${b.stackKey} from './${b.dir}';`).join('\n'); + return [ + ...SCAFFOLD_WIRED_BARRELS.map((b) => `import * as ${b.stackKey} from './${b.dir}';`), + '', + '// Every value a barrel exports, as the list a stack key takes: typed by what', + '// the barrel exports, and an empty list while it exports nothing yet.', + 'const exportsOf = (barrel: M): M[keyof M][] => Object.values(barrel);', + ].join('\n'); } /** The `requires` entry and the collection keys inside `defineStack({ … })`. */ @@ -635,15 +658,15 @@ function renderWiredStackKeys(): string { ` // and one export line, and the view is part of this stack with no edit to`, ` // this file. A directory that is not wired here is never loaded, and`, ` // \`objectstack validate\` neither counts nor checks what it holds.`, - ...SCAFFOLD_WIRED_BARRELS.map((b) => ` ${b.stackKey}: Object.values(${b.stackKey}),`), + ...SCAFFOLD_WIRED_BARRELS.map((b) => ` ${b.stackKey}: exportsOf(${b.stackKey}),`), ].join('\n'); } /** * The barrel `os init` writes for a wired directory its template puts nothing * in. `export {}` makes it a module, so the config's `import * as` resolves to - * an empty namespace and `Object.values` to `[]` — a key `os validate` counts - * at zero rather than an import that fails. + * an empty namespace and `exportsOf` to `[]` — a key `os validate` counts at + * zero rather than an import that fails. */ function renderEmptyWiredBarrel(stackKey: string, type: string): string { return `// The ${stackKey} in this directory. \`objectstack generate ${type} NAME\` writes one diff --git a/packages/cli/test/generate-object-namespace-prefix.test.ts b/packages/cli/test/generate-object-namespace-prefix.test.ts index 9c0637408ea..ca71e78d3f9 100644 --- a/packages/cli/test/generate-object-namespace-prefix.test.ts +++ b/packages/cli/test/generate-object-namespace-prefix.test.ts @@ -25,9 +25,14 @@ * flow start `objectName` advisory `flow-trigger-unknown-object`: the flow never fires * view `object` no finding at all: a binding to nothing, silently * - * No gate judges a view's, action's, flow's, dashboard's, app's or skill's OWN - * `name` against the namespace. So those stay as typed, and every OBJECT name - * gains the prefix through one derivation (`objectNameFor`). + * No `os validate` gate judges a view's, action's, flow's, dashboard's, app's + * or skill's OWN `name` against the namespace. So those stay as typed, and + * every OBJECT name gains the prefix through one derivation (`objectNameFor`). + * + * One exception the census above could not see, because it stops at + * `os validate` (#20215): the RUNTIME registers a views container under the + * object it binds to and refuses, at boot, one whose own `name` disagrees — + * so a view's `name` is an object name, and is prefixed with its `object`. * * ## Why `defineStack` and not the per-artifact parse * @@ -150,6 +155,7 @@ function objectNamesWritten(a: Record>) { return { 'object.name': a.object.name, 'view.object': a.view.object, + 'view.name (its object key)': a.view.name, 'action.objectName': a.action.objectName, 'flow start.config.objectName': flowStart?.config?.objectName, 'app navigation[0].objectName': nav?.objectName, @@ -183,11 +189,15 @@ describe('[#20197] under a manifest namespace, the generated set passes the gate 'action.objectName': PREFIXED, 'flow start.config.objectName': PREFIXED, 'app navigation[0].objectName': PREFIXED, + // [#20215] A views container's own `name` is its object key: the + // runtime registers the container under the object it binds to and + // refuses one whose `name` disagrees (`registerMetadataCollections`), + // so it carries the prefix like the binding beside it. + 'view.name (its object key)': PREFIXED, }); // The census: no gate judges these against the namespace, so they are // written exactly as they were before this change. expect({ - view: a.view.name, action: a.action.name, 'action target (a flow)': a.action.target, flow: a.flow.name, @@ -195,7 +205,6 @@ describe('[#20197] under a manifest namespace, the generated set passes the gate app: a.app.name, skill: a.skill.name, }).toEqual({ - view: STEM, action: STEM, 'action target (a flow)': `${STEM}_flow`, flow: `${STEM}_flow`, @@ -229,13 +238,13 @@ describe('[#20197] under a manifest namespace, the generated set passes the gate describe('[#20197] the name cases around the prefix', () => { it('a project with no namespace gets no prefix, and its set passes too', async () => { const a = await generateAll(STEM); - expect(Object.values(objectNamesWritten(a))).toEqual(Array(5).fill(STEM)); + expect(Object.values(objectNamesWritten(a))).toEqual(Array(6).fill(STEM)); expect(defineStackRefusal(composedStack(a))).toBeNull(); }); it('a name that already carries the prefix is used as written, never doubled', async () => { const a = await generateAll(PREFIXED, NS); - expect(Object.values(objectNamesWritten(a))).toEqual(Array(5).fill(PREFIXED)); + expect(Object.values(objectNamesWritten(a))).toEqual(Array(6).fill(PREFIXED)); expect(defineStackRefusal(composedStack(a, NS))).toBeNull(); }); diff --git a/packages/cli/test/generate-refuses-namespace-prefix.test.ts b/packages/cli/test/generate-refuses-namespace-prefix.test.ts index 4bb3ce0a42d..fb071f01b11 100644 --- a/packages/cli/test/generate-refuses-namespace-prefix.test.ts +++ b/packages/cli/test/generate-refuses-namespace-prefix.test.ts @@ -24,7 +24,9 @@ * refusals from being satisfied by a command that refuses everything: in the * SAME namespaced project `os g object order_line` generates, prefixed; and in * the SAME broken project `os g dashboard sales` generates, because a - * dashboard names no object and never reads the config. + * dashboard names no object, so it does not refuse on a config that does not + * load (it reads the config since #20215, to say whether the scaffold reached + * the stack, and here it says that cannot be told). * * The residual refusal's rule is compared against what * `validateObjectNamespacePrefix` itself says about the prefixed name, so the diff --git a/packages/cli/test/generate-scaffold-wiring.test.ts b/packages/cli/test/generate-scaffold-wiring.test.ts new file mode 100644 index 00000000000..ac17e991cef --- /dev/null +++ b/packages/cli/test/generate-scaffold-wiring.test.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#20215) — what `os generate` writes can reach the stack, and the + * instruments that say whether it did. + * + * ## The defect + * + * `os init -t app` wrote a config that imported `./src/objects` alone. Every + * other generator wrote a scaffold and a barrel nothing imported, and + * `os validate` exited 0 with `UI: 0 Apps` / `Logic: 0 Flows`. Once the flows + * barrel was wired by hand, the flow scaffold was refused for a `requires` + * without `triggers`; once the views barrel was, `os serve` refused the view + * scaffold at boot for a container `name` that disagreed with its object key. + * + * ## What this file holds, in-process (the per-PR half) + * + * 1. The generator roster: every type's stack key is a key the stack schema + * declares, and `itemName` is the `name` the scaffold really writes. + * 2. The `app` and `plugin` templates wire every generator's barrel under + * its stack key, write a barrel for each, and declare what the scaffolds + * need to run; the materialized template loads, with every wired key a + * list. A fresh template project was also measured to fail its own + * `tsc` with `Object.values` on an empty barrel — that half is + * `scaffold-emission-typechecks.test.ts`'s, which types every template. + * 3. The pure instruments `os g` reports with: barrel membership asked of + * the compiler (a substring test dropped names), the stack reach reader, + * and the wiring lines it prints. + * 4. `os init` never overwrites a barrel of the author's with an empty one. + * + * The command's own behaviour — the reach report, the refusal that takes a + * write back out — is spawned in `generate-stack-reach.test.ts`, and the whole + * `os init` → `os g` every type → `os validate` chain runs nightly in + * `generate-scaffolds-reach-stack.e2e.test.ts`. + * + * Sandboxes live under this package's `node_modules` so a scaffold's + * `@objectstack/spec` import resolves to the workspace copy. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bundleRequire } from 'bundle-require'; +import { ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { ts } from 'ts-morph'; +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; +import { + TEMPLATES, + SCAFFOLD_WIRED_BARRELS, + SCAFFOLD_WIRED_REQUIRES, + sanitizeNamespace, + writeTemplateSrcFiles, +} from '../src/commands/init.js'; +import { BUNDLE_REQUIRE_EXTERNALS, loadConfig } from '../src/utils/config.js'; +import { + barrelExportNames, + barrelExportsBinding, + barrelSpecifier, + declaredCapabilities, + measureStackReach, + missingCapabilities, + stackCarries, + wiringLines, +} from '../src/utils/scaffold-wiring.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const TMP_ROOT = fs.mkdtempSync(path.join(HERE, '..', 'node_modules', '.scaffold-wiring-')); + +afterAll(() => { + fs.rmSync(TMP_ROOT, { recursive: true, force: true }); +}); + +const PROJECT = 'my-app'; +const NS = sanitizeNamespace(PROJECT); +const STEM = 'order_line'; + +let seq = 0; + +/** Load one scaffold the way `os validate` loads authored TypeScript. */ +async function materialize(source: string): Promise> { + const file = path.join(TMP_ROOT, `scaffold-${seq++}.ts`); + fs.writeFileSync(file, source, 'utf8'); + const { mod } = await bundleRequire({ filepath: file, external: BUNDLE_REQUIRE_EXTERNALS }); + return ((mod as { default?: unknown }).default ?? mod) as Record; +} + +/** An `os init -t ` project, written through the command's own emitters. */ +function emitTemplate(key: string): string { + const root = fs.mkdtempSync(path.join(TMP_ROOT, `init-${key}-`)); + const template = TEMPLATES[key]; + fs.writeFileSync(path.join(root, 'objectstack.config.ts'), template.configContent(PROJECT, NS)); + writeTemplateSrcFiles(template.srcFiles, root, PROJECT, NS); + return root; +} + +// ── 1. The roster ───────────────────────────────────────────────────────── + +describe('[#20215] every generator names where its items land', () => { + const declaredKeys = new Set(Object.keys((ObjectStackDefinitionSchema as unknown as { shape: object }).shape)); + + it('has generators to measure', () => { + expect(GENERATOR_SCAFFOLD_TARGETS.length).toBeGreaterThan(0); + }); + + it.each(GENERATOR_SCAFFOLD_TARGETS.map((t) => [t.type, t] as const))( + '`%s` is collected under a key the stack schema declares', + (_type, target) => { + expect(declaredKeys.has(target.stackKey), `${target.type} → ${target.stackKey}`).toBe(true); + }, + ); + + it.each(GENERATOR_SCAFFOLD_TARGETS.map((t) => [t.type, t] as const))( + '`%s`: itemName is the name the scaffold writes, with and without a namespace', + async (_type, target) => { + for (const namespace of [NS, undefined]) { + const artifact = await materialize(target.generate(STEM, namespace)); + expect(artifact.name).toBe(target.itemName(STEM, namespace)); + // …and the reach reader finds it by exactly that name under that key. + expect(stackCarries({ [target.stackKey]: [artifact] }, target.stackKey, target.itemName(STEM, namespace))) + .toBe(true); + } + }, + ); + + it('the view container is named by its object key, which the runtime registers it under', async () => { + const view = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'view')!; + const artifact = await materialize(view.generate(STEM, NS)); + expect(artifact.name).toBe(artifact.object); + expect(artifact.object).toBe(`${NS}_${STEM}`); + }); + + it('the flow scaffold declares the capabilities it runs on, in its own header too', () => { + const flow = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'flow')!; + expect([...flow.requires].sort()).toEqual(['automation', 'triggers']); + const source = flow.generate(STEM, NS); + for (const token of flow.requires) expect(source).toContain(`'${token}'`); + }); +}); + +// ── 2. The templates ────────────────────────────────────────────────────── + +const WIRING_TEMPLATES = ['app', 'plugin'] as const; + +describe('[#20215] the `app` and `plugin` templates wire every generator barrel', () => { + it('the roster the templates wire IS the generator roster', () => { + expect(SCAFFOLD_WIRED_BARRELS.map((b) => [b.type, b.dir, b.stackKey])).toEqual( + GENERATOR_SCAFFOLD_TARGETS.map((t) => [t.type, t.defaultDir, t.stackKey]), + ); + expect([...SCAFFOLD_WIRED_REQUIRES].sort()).toEqual( + [...new Set(GENERATOR_SCAFFOLD_TARGETS.flatMap((t) => t.requires))].sort(), + ); + }); + + it.each(WIRING_TEMPLATES)('`%s`: imports each barrel, wires it under its key, and writes it', (key) => { + const template = TEMPLATES[key]; + const config = template.configContent(PROJECT, NS); + for (const b of SCAFFOLD_WIRED_BARRELS) { + expect(config).toContain(`import * as ${b.stackKey} from './${b.dir}';`); + expect(config).toContain(` ${b.stackKey}: exportsOf(${b.stackKey}),`); + expect(Object.keys(template.srcFiles)).toContain(`${b.dir}/index.ts`); + } + expect(config).toContain(`requires: [${SCAFFOLD_WIRED_REQUIRES.map((t) => `'${t}'`).join(', ')}],`); + }); + + it.each(WIRING_TEMPLATES)('`%s`: the emitted project loads, and every wired key is a list', async (key) => { + const root = emitTemplate(key); + const { config } = await loadConfig(path.join(root, 'objectstack.config.ts')); + const stack = config as Record; + for (const b of SCAFFOLD_WIRED_BARRELS) { + expect(Array.isArray(stack[b.stackKey]), b.stackKey).toBe(true); + } + // The template's own object is carried, through the reader `os g` uses. + const reach = await measureStackReach({ stackKey: 'objects', itemName: `${NS}_item`, requires: SCAFFOLD_WIRED_REQUIRES }, root); + expect(reach).toMatchObject({ kind: 'loaded', reached: true, missingRequires: [] }); + // Nothing else is there yet: an empty barrel is a key counted at zero. + for (const b of SCAFFOLD_WIRED_BARRELS.filter((x) => x.type !== 'object')) { + expect(stack[b.stackKey], b.stackKey).toEqual([]); + } + }); + + it('`empty` stays a bare config: it writes no directory to wire', () => { + expect(Object.keys(TEMPLATES.empty.srcFiles)).toEqual([]); + expect(TEMPLATES.empty.configContent(PROJECT, NS)).not.toContain('import * as'); + }); +}); + +// ── 3. The instruments ──────────────────────────────────────────────────── + +describe('[#20215] barrel membership is asked of the compiler, not of a substring', () => { + it('an empty barrel exports no name, whatever its text contains', () => { + const empty = TEMPLATES.app.srcFiles['src/views/index.ts'](PROJECT, NS); + expect([...barrelExportNames(ts, empty)]).toEqual([]); + }); + + it('`port` is not exported by `export {};` — the substring test said it was', async () => { + const empty = TEMPLATES.app.srcFiles['src/views/index.ts'](PROJECT, NS); + expect(empty.includes('port')).toBe(true); // the old test's verdict: "already there" + expect(await barrelExportsBinding(empty, 'port')).toBe(false); + }); + + it('`order` is not exported by a barrel that exports `orderLine`', async () => { + const barrel = "export { default as orderLine } from './order_line.view';\n"; + expect(barrel.includes('order')).toBe(true); + expect(await barrelExportsBinding(barrel, 'order')).toBe(false); + expect(await barrelExportsBinding(barrel, 'orderLine')).toBe(true); + }); + + it('reads every by-name export form, and nothing from `export *`', () => { + const names = barrelExportNames(ts, [ + "export { default as a, b } from './x';", + 'const c = 1; export { c as d };', + 'export const e = 1, f = 2;', + 'export function g() {}', + 'export class H {}', + "export * from './y';", + '// export { z }', + ].join('\n')); + expect([...names].sort()).toEqual(['H', 'a', 'b', 'd', 'e', 'f', 'g']); + }); +}); + +describe('[#20215] the reach reader and the lines it prints', () => { + it('finds an item by name in the list form, the map form and a folded package', () => { + expect(stackCarries({ views: [{ name: 'a' }] }, 'views', 'a')).toBe(true); + expect(stackCarries({ views: [{ name: 'a' }] }, 'views', 'b')).toBe(false); + expect(stackCarries({ flows: { a: { label: 'A' } } }, 'flows', 'a')).toBe(true); + expect(stackCarries({ packages: [{ manifest: { id: 'p' }, apps: [{ name: 'a' }] }] }, 'apps', 'a')).toBe(true); + expect(stackCarries({}, 'apps', 'a')).toBe(false); + }); + + it('names the capability tokens a stack does not declare', () => { + expect(declaredCapabilities({})).toBeNull(); + expect(missingCapabilities({}, ['automation', 'triggers'])).toEqual(['automation', 'triggers']); + expect(missingCapabilities({ requires: ['triggers'] }, ['automation', 'triggers'])).toEqual(['automation']); + expect(missingCapabilities({ requires: ['automation', 'triggers'] }, ['automation', 'triggers'])).toEqual([]); + }); + + it('prints the import, the key, and the WHOLE requires list when one is missing', () => { + expect(wiringLines({ specifier: './src/views', stackKey: 'views', missingRequires: [], declaredRequires: null })) + .toEqual({ importLine: "import * as views from './src/views';", stackLines: ['views: Object.values(views),'] }); + const flows = wiringLines({ specifier: './src/flows', stackKey: 'flows', missingRequires: ['triggers'], declaredRequires: ['automation'] }); + expect(flows.stackLines[1]).toMatch(/^requires: \['automation', 'triggers'\],/); + }); + + it('spells the barrel import relative to the config, extensionless', () => { + expect(barrelSpecifier('/p/objectstack.config.ts', '/p/src/views')).toBe('./src/views'); + expect(barrelSpecifier('/p/app/objectstack.config.mjs', '/p/lib/views')).toBe('../lib/views'); + expect(barrelSpecifier('/p/objectstack.config.ts', '/p')).toBe('./index'); + }); + + it('reports a directory with no config, and a config that does not load', async () => { + const bare = fs.mkdtempSync(path.join(TMP_ROOT, 'bare-')); + expect(await measureStackReach({ stackKey: 'views', itemName: 'a', requires: [] }, bare)).toEqual({ kind: 'no-config' }); + fs.writeFileSync(path.join(bare, 'objectstack.config.ts'), "throw new Error('broken on purpose');\n"); + const reach = await measureStackReach({ stackKey: 'views', itemName: 'a', requires: [] }, bare); + expect(reach.kind).toBe('load-failed'); + }); +}); + +// ── 4. `os init` keeps an author's barrel ───────────────────────────────── + +describe('[#20215] an empty barrel never overwrites a file that is already there', () => { + it('keeps an existing views barrel byte-identical, and still writes the objects barrel', () => { + const root = fs.mkdtempSync(path.join(TMP_ROOT, 'existing-')); + const kept = "export { default as mine } from './mine.view';\n"; + const replaced = "export { default as old } from './old.object';\n"; + fs.mkdirSync(path.join(root, 'src', 'views'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src', 'objects'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'views', 'index.ts'), kept); + fs.writeFileSync(path.join(root, 'src', 'objects', 'index.ts'), replaced); + + const written = writeTemplateSrcFiles(TEMPLATES.app.srcFiles, root, PROJECT, NS); + + expect(fs.readFileSync(path.join(root, 'src', 'views', 'index.ts'), 'utf-8')).toBe(kept); + expect(written).not.toContain('src/views/index.ts'); + // Control: the objects barrel carries the template's own object, and is + // written as it always was. + expect(fs.readFileSync(path.join(root, 'src', 'objects', 'index.ts'), 'utf-8')).not.toBe(replaced); + expect(written).toContain('src/objects/index.ts'); + // …and a wired directory with no barrel yet gets one. + expect(written).toContain('src/flows/index.ts'); + }); +}); diff --git a/packages/cli/test/generate-scaffolds-reach-stack.e2e.test.ts b/packages/cli/test/generate-scaffolds-reach-stack.e2e.test.ts new file mode 100644 index 00000000000..181d8a62f26 --- /dev/null +++ b/packages/cli/test/generate-scaffolds-reach-stack.e2e.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#20215), end to end: in a fresh `os init -t app` project, generate + * every type, and `os validate` exits 0 with each generated item counted. + * + * Measured before the fix, on this chain: `os validate` exited 0 printing + * `UI: 0 Apps` and `Logic: 0 Flows` — the config imported `./src/objects` + * alone, so nothing else `os g` wrote was ever loaded. Wiring the barrels by + * hand then surfaced two scaffolds the platform refuses once loaded: the flow + * (a `record_change` trigger in a stack whose `requires` lacks `triggers`, + * refused by `defineStack`) and the view (a container `name` that disagreed + * with its object key, refused by `os serve` at boot). + * + * The chain, through the real commands, in the order the docs' workflow uses: + * the object first, so every scaffold that binds to it (view, action, flow, + * app) binds to something declared. + * + * os init my-app -t app --no-install + * os g object|view|action|flow|dashboard|app|skill order_line + * os validate → exit 0, every generated item counted + * os compile → the artifact carries every generated item + * + * `os validate`'s summary has no row for skills, so the skill is held by the + * compiled artifact instead: it is the same stack, emitted. + * + * Every `os g` must also not print the wiring lines it prints for a scaffold + * that did not reach the stack — the per-PR guard for that report is + * `generate-stack-reach.test.ts`, and the template and instruments are + * `generate-scaffold-wiring.test.ts`. + * + * Nightly (`.e2e`): nine cold CLI starts. Projects live under this package's + * `node_modules`, so a config's `@objectstack/spec` import resolves to the + * workspace copy without an install. Commands print through `utils/format.ts`, + * which writes to stdout, so stdout is what is read. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold starts, ten of them, sequential. */ +const RUN_TIMEOUT_MS = 480_000; + +const NS = 'my_app'; +const STEM = 'order_line'; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +let root: string; +let project: string; +let init: Run; +const generated: Record = {}; +let validate: Run; +let compile: Run; + +/** Object first: the view, action, flow and app scaffolds bind to it. */ +const ORDER = ['object', ...GENERATOR_SCAFFOLD_TARGETS.map((t) => t.type).filter((t) => t !== 'object')]; + +beforeAll(async () => { + root = mkdtempSync(join(HERE, '..', 'node_modules', '.generate-reach-e2e-')); + init = await runCli(['init', 'my-app', '-t', 'app', '--no-install'], root); + project = join(root, 'my-app'); + for (const type of ORDER) { + generated[type] = await runCli(['g', type, STEM], project); + } + validate = await runCli(['validate'], project); + compile = await runCli(['compile'], project); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('[#20215] `os init -t app` → `os g` every type → `os validate`', () => { + it('the project was scaffolded with the namespace this file assumes', () => { + expect(init.code, init.stdout + init.stderr).toBe(0); + expect(readFileSync(join(project, 'objectstack.config.ts'), 'utf-8')).toContain(`namespace: '${NS}'`); + }); + + it.each(ORDER)('`os g %s` exits 0 and reports no wiring to add', (type) => { + const run = generated[type]; + const target = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === type)!; + expect(run.code, run.stdout + run.stderr).toBe(0); + expect(run.stdout).toContain(`'${target.itemName(STEM, NS)}'`); + expect(run.stdout).not.toContain(`import * as ${target.stackKey}`); + }); + + it('`os validate` exits 0 and counts every generated item', () => { + expect(validate.code, validate.stdout + validate.stderr).toBe(0); + // The template's own object plus the generated one. + expect(validate.stdout).toMatch(/Data: 2 Objects/); + for (const [label, n] of [['Apps', 1], ['Views', 1], ['Dashboards', 1], ['Actions', 1], ['Flows', 1]] as const) { + expect(validate.stdout, label).toMatch(new RegExp(`\\b${n} ${label}\\b`)); + } + }); + + it('`os compile` carries every generated item into the artifact, the skill included', () => { + expect(compile.code, compile.stdout + compile.stderr).toBe(0); + const artifact = JSON.parse(readFileSync(join(project, 'dist', 'objectstack.json'), 'utf-8')) as Record; + for (const target of GENERATOR_SCAFFOLD_TARGETS) { + const names = ((artifact[target.stackKey] ?? []) as { name?: unknown }[]).map((i) => i.name); + expect(names, target.stackKey).toContain(target.itemName(STEM, NS)); + } + }); +}); diff --git a/packages/cli/test/generate-stack-reach.test.ts b/packages/cli/test/generate-stack-reach.test.ts new file mode 100644 index 00000000000..44b4097f90a --- /dev/null +++ b/packages/cli/test/generate-stack-reach.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#20215) — after writing, `os generate` says whether the scaffold + * reached the stack, and never leaves a config that loaded unable to load. + * + * `os g` loads the project's config once more with the scaffold in place and + * asks the stack it evaluates to (`utils/scaffold-wiring.ts`). Four answers, + * each held here through the real command: + * + * reached a project `os init` shaped: the barrel is wired, nothing + * to add — the wiring lines are NOT printed. + * not wired a config that imports `./src/objects` alone (every + * `os init` project before this change) or no config at all: + * exit 0, the config byte-identical, and the exact import and + * key lines that wire it printed. + * cannot run reached, but the stack's `requires` lacks a token the + * scaffold runs on: the WHOLE requires list printed. + * refused a config that loaded before the write and does not load + * after it — an action bound to an object nobody declared, a + * flow in a stack without `triggers`: exit 1, and the project + * tree byte-identical, because the write is taken back out. + * + * A control keeps the refusal from being a command that refuses everything: + * in the same project, once the object exists, the same action generates. + * + * Prose is not pinned. What is asserted is the exit status, the bytes on + * disk, the named subject, and the lines an author pastes (the wiring lines + * are code, consumed verbatim). + * + * ## Why a child process, and why this file is NOT named `.e2e` + * + * An exit status and bytes on disk are the contract, `process.exit` inside a + * vitest worker is not an exit status, and `printError` writes to stdout. + * Spawning puts the file in the `integration` project; the name keeps it in + * the per-PR run. The whole `os init` → `os g` every type → `os validate` + * chain is `generate-scaffolds-reach-stack.e2e.test.ts`, nightly. + * + * Projects are written through the `os init` template's own emitters and live + * under this package's `node_modules`, so a config's `@objectstack/spec` + * import resolves to the workspace copy without an install. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold starts, eight of them, sequential. */ +const RUN_TIMEOUT_MS = 300_000; + +const PROJECT = 'my-app'; +const NS = sanitizeNamespace(PROJECT); + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; a signalled child has none and + // is reported as 1, never as 0. + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +/** Every file under `dir`, path → sha1, so "nothing was written" is a byte fact. */ +function tree(dir: string): Record { + const out: Record = {}; + const walk = (d: string) => { + for (const entry of readdirSync(d)) { + const p = join(d, entry); + if (statSync(p).isDirectory()) walk(p); + else out[relative(dir, p)] = createHash('sha1').update(readFileSync(p)).digest('hex'); + } + }; + walk(dir); + return out; +} + +const CONFIG = 'objectstack.config.ts'; + +/** An `os init -t app` project, optionally with its config rewritten. */ +function initProject(dir: string, rewrite?: (config: string) => string): void { + mkdirSync(dir, { recursive: true }); + const config = TEMPLATES.app.configContent(PROJECT, NS); + const next = rewrite ? rewrite(config) : config; + if (rewrite && next === config) throw new Error(`fixture rewrite matched nothing for ${dir}`); + writeFileSync(join(dir, CONFIG), next); + writeTemplateSrcFiles(TEMPLATES.app.srcFiles, dir, PROJECT, NS); +} + +/** The config every `os init` project had before this change: objects only. */ +const PRE_FIX_CONFIG = `import { defineStack } from '@objectstack/spec'; +import * as objects from './src/objects'; + +export default defineStack({ + manifest: { + id: 'com.example.my-app', + namespace: '${NS}', + version: '0.1.0', + type: 'app', + name: 'My App', + }, + objects: Object.values(objects), +}); +`; + +let root: string; +const dirs = { wired: '', noRequires: '', triggersOnly: '', preFix: '', bare: '' }; +const before: Record> = {}; +const runs: Record = {}; +let wiredAfterRefusal: Record; +let noRequiresAfterRefusal: Record; + +beforeAll(async () => { + root = mkdtempSync(join(HERE, '..', 'node_modules', '.generate-stack-reach-')); + dirs.wired = join(root, 'wired'); + dirs.noRequires = join(root, 'no-requires'); + dirs.triggersOnly = join(root, 'triggers-only'); + dirs.preFix = join(root, 'pre-fix'); + dirs.bare = join(root, 'bare'); + + initProject(dirs.wired); + initProject(dirs.noRequires, (c) => c.replace(/^ {2}requires: \[.*\],\n/m, '')); + initProject(dirs.triggersOnly, (c) => c.replace(/^ {2}requires: \[.*\],$/m, " requires: ['triggers'],")); + initProject(dirs.preFix); + writeFileSync(join(dirs.preFix, CONFIG), PRE_FIX_CONFIG); + mkdirSync(dirs.bare, { recursive: true }); + + before.wired = tree(dirs.wired); + before.noRequires = tree(dirs.noRequires); + before.preFixConfig = { [CONFIG]: readFileSync(join(dirs.preFix, CONFIG), 'utf-8') }; + + // Sequential on purpose: cold tsx starts in a container several agents share. + // Each refusal runs BEFORE its project's control, so the snapshots above are + // what each refusal was measured against. + runs.actionNoObject = await runCli(['g', 'action', 'approve'], dirs.wired); + wiredAfterRefusal = tree(dirs.wired); + runs.flowNoRequires = await runCli(['g', 'flow', 'order_line'], dirs.noRequires); + noRequiresAfterRefusal = tree(dirs.noRequires); + + runs.objectControl = await runCli(['g', 'object', 'approve'], dirs.wired); + runs.actionControl = await runCli(['g', 'action', 'approve'], dirs.wired); + + runs.flowTriggersOnly = await runCli(['g', 'flow', 'order_line'], dirs.triggersOnly); + runs.viewPreFix = await runCli(['g', 'view', 'order_line'], dirs.preFix); + runs.viewBare = await runCli(['g', 'view', 'order_line'], dirs.bare); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +const out = (r: Run) => r.stdout + r.stderr; + +describe('[#20215] refused: the write would stop a loading config from loading', () => { + it('an action bound to an object nobody declared: exit 1, the tree byte-identical', () => { + expect(runs.actionNoObject.code, out(runs.actionNoObject)).toBe(1); + expect(wiredAfterRefusal).toEqual(before.wired); + expect(existsSync(join(dirs.wired, 'src', 'actions', 'approve.action.ts'))).toBe(false); + // The subject is named: the object the action binds to. + expect(runs.actionNoObject.stdout).toContain(`${NS}_approve`); + expect(runs.actionNoObject.stdout).not.toContain('Created'); + }); + + it('a flow in a stack whose `requires` lacks `triggers`: exit 1, the tree byte-identical', () => { + expect(runs.flowNoRequires.code, out(runs.flowNoRequires)).toBe(1); + expect(noRequiresAfterRefusal).toEqual(before.noRequires); + expect(runs.flowNoRequires.stdout).toContain("requires: ['automation', 'triggers']"); + }); + + it('CONTROL: in the same project, once the object exists, the same action generates and reaches', () => { + expect(runs.objectControl.code, out(runs.objectControl)).toBe(0); + expect(runs.actionControl.code, out(runs.actionControl)).toBe(0); + expect(readFileSync(join(dirs.wired, 'src', 'actions', 'index.ts'), 'utf-8')) + .toContain("export { default as approve } from './approve.action';"); + expect(runs.actionControl.stdout).toContain("'approve'"); + // Reached: no wiring lines to add. + expect(runs.actionControl.stdout).not.toContain('import * as actions'); + }); +}); + +describe('[#20215] not wired: exit 0, the config untouched, and the lines that wire it', () => { + it('a config that wires `./src/objects` alone', () => { + expect(runs.viewPreFix.code, out(runs.viewPreFix)).toBe(0); + expect(existsSync(join(dirs.preFix, 'src', 'views', 'order_line.view.ts'))).toBe(true); + expect(readFileSync(join(dirs.preFix, CONFIG), 'utf-8')).toBe(before.preFixConfig[CONFIG]); + expect(runs.viewPreFix.stdout).toContain("import * as views from './src/views';"); + expect(runs.viewPreFix.stdout).toContain('views: Object.values(views),'); + expect(runs.viewPreFix.stdout).toContain(`'${NS}_order_line'`); + }); + + it('a directory with no config', () => { + expect(runs.viewBare.code, out(runs.viewBare)).toBe(0); + expect(existsSync(join(dirs.bare, 'src', 'views', 'order_line.view.ts'))).toBe(true); + expect(runs.viewBare.stdout).toContain("import * as views from './src/views';"); + }); +}); + +describe('[#20215] cannot run: reached, and a capability it runs on is missing', () => { + it('a flow in a stack that requires `triggers` but not `automation`', () => { + expect(runs.flowTriggersOnly.code, out(runs.flowTriggersOnly)).toBe(0); + expect(runs.flowTriggersOnly.stdout).toContain("'order_line_flow'"); + // The whole list, never a second `requires` key beside the first. + expect(runs.flowTriggersOnly.stdout).toContain("requires: ['triggers', 'automation'],"); + expect(runs.flowTriggersOnly.stdout).not.toContain('import * as flows'); + }); +}); From d3db85888f3ad16c79984fcd5062563cc877daa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:50:32 +0000 Subject: [PATCH 4/8] test(cli): fix the packaged-stack fixture and read the refused file before the control Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/test/generate-scaffold-wiring.test.ts | 4 +++- packages/cli/test/generate-stack-reach.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/generate-scaffold-wiring.test.ts b/packages/cli/test/generate-scaffold-wiring.test.ts index ac17e991cef..c18c77979f9 100644 --- a/packages/cli/test/generate-scaffold-wiring.test.ts +++ b/packages/cli/test/generate-scaffold-wiring.test.ts @@ -225,7 +225,9 @@ describe('[#20215] the reach reader and the lines it prints', () => { expect(stackCarries({ views: [{ name: 'a' }] }, 'views', 'a')).toBe(true); expect(stackCarries({ views: [{ name: 'a' }] }, 'views', 'b')).toBe(false); expect(stackCarries({ flows: { a: { label: 'A' } } }, 'flows', 'a')).toBe(true); - expect(stackCarries({ packages: [{ manifest: { id: 'p' }, apps: [{ name: 'a' }] }] }, 'apps', 'a')).toBe(true); + const app = { name: 'crm_app', label: 'CRM' }; + const pkg = { manifest: { id: 'com.example.p', version: '1.0.0', type: 'app', name: 'p', apps: [app] } }; + expect(stackCarries({ packages: [pkg] }, 'apps', 'crm_app')).toBe(true); expect(stackCarries({}, 'apps', 'a')).toBe(false); }); diff --git a/packages/cli/test/generate-stack-reach.test.ts b/packages/cli/test/generate-stack-reach.test.ts index 44b4097f90a..5bf350b3221 100644 --- a/packages/cli/test/generate-stack-reach.test.ts +++ b/packages/cli/test/generate-stack-reach.test.ts @@ -136,6 +136,7 @@ const dirs = { wired: '', noRequires: '', triggersOnly: '', preFix: '', bare: '' const before: Record> = {}; const runs: Record = {}; let wiredAfterRefusal: Record; +let actionFileAfterRefusal: boolean; let noRequiresAfterRefusal: Record; beforeAll(async () => { @@ -162,6 +163,7 @@ beforeAll(async () => { // what each refusal was measured against. runs.actionNoObject = await runCli(['g', 'action', 'approve'], dirs.wired); wiredAfterRefusal = tree(dirs.wired); + actionFileAfterRefusal = existsSync(join(dirs.wired, 'src', 'actions', 'approve.action.ts')); runs.flowNoRequires = await runCli(['g', 'flow', 'order_line'], dirs.noRequires); noRequiresAfterRefusal = tree(dirs.noRequires); @@ -183,7 +185,7 @@ describe('[#20215] refused: the write would stop a loading config from loading', it('an action bound to an object nobody declared: exit 1, the tree byte-identical', () => { expect(runs.actionNoObject.code, out(runs.actionNoObject)).toBe(1); expect(wiredAfterRefusal).toEqual(before.wired); - expect(existsSync(join(dirs.wired, 'src', 'actions', 'approve.action.ts'))).toBe(false); + expect(actionFileAfterRefusal).toBe(false); // The subject is named: the object the action binds to. expect(runs.actionNoObject.stdout).toContain(`${NS}_approve`); expect(runs.actionNoObject.stdout).not.toContain('Created'); From 0960e6c7a5c1f270f9790971c44e5ff502e67640 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:55:39 +0000 Subject: [PATCH 5/8] docs(cli): os generate's stack-reach report, and a Typical Workflow that validates what it generated Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .../20215-generate-scaffolds-reach-stack.md | 25 +++++++ content/docs/deployment/cli.mdx | 66 ++++++++++++++----- 2 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 .changeset/20215-generate-scaffolds-reach-stack.md diff --git a/.changeset/20215-generate-scaffolds-reach-stack.md b/.changeset/20215-generate-scaffolds-reach-stack.md new file mode 100644 index 00000000000..0a0486d336a --- /dev/null +++ b/.changeset/20215-generate-scaffolds-reach-stack.md @@ -0,0 +1,25 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): what `os generate` writes now reaches the stack, or the command says it does not + +`os init my-app -t app` wrote a config that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `UI: 0 Apps` and `Logic: 0 Flows`: a green that had judged nothing the command just wrote. + +**What `os init` now writes (`app` and `plugin` templates):** + +- `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. +- An `index.ts` containing only `export {};` for each directory the template puts nothing in. An `index.ts` that already exists is kept as it is and never overwritten. +- `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. Without `triggers`, `defineStack` refuses the config as soon as it holds such a flow. Without `automation`, the server loads the flow and never runs it. + +**What `os generate` now does:** + +- After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add), or it **cannot run** (a flow in a stack whose `requires` lacks `automation`: the command prints the whole `requires` list to use). It never edits the config. +- It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load. +- A view's own `name` is now the object it binds to, prefix included (`my_app_order_line`, not `order_line`). The server registers a view under its object and refused, at boot, a scaffold whose `name` disagreed. That never showed while the views barrel was not loaded. +- The barrel step asks the compiler whether the barrel already exports the name, instead of searching the file's text. `os g view order` after `os g view order_line` had found `order` inside `orderLine` and exported nothing. +- The `flow` scaffold's header states the `requires` it needs. + +These two statements in this release's namespace-prefix entry no longer hold: "`dashboard` and `skill` scaffolds name no object and never read the config", and a view's own `name` being "written as before". + +**Projects scaffolded by an earlier release** keep their config. `os g` now tells you when a file it wrote is not wired, and prints the lines to add. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 044fab07a35..7ee77942c46 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1402,16 +1402,48 @@ either. A name in the legacy `__` form (`order__line`) is refused, because no prefix makes it one `os validate` accepts. The namespace is read from the loaded config, the same place `os validate` reads it from. If a config exists but does not load, the command refuses and writes nothing. -Without a `namespace` (or without a config), nothing is prefixed. Nothing else a -scaffold names (a view's, flow's or app's own `name`) is prefixed. +Without a `namespace` (or without a config), nothing is prefixed. A view's own +`name` is the object it binds to, prefix included, because the server registers +a view under that object and refuses one whose `name` says otherwise. Nothing +else a scaffold names (an action's, flow's or app's own `name`) is prefixed. + +**Every scaffold reaches the stack, or the command says it does not.** The +`objectstack.config.ts` that `os init` writes for the `app` and `plugin` +templates wires every directory in the table below: it imports each +`src//index.ts` barrel and hands its exports to `defineStack` under the key +in the **Collected as** column, so a file `os g` writes there is part of the +stack with no edit to the config. It also declares +`requires: ['automation', 'triggers']`, which a flow needs to load and to run. +After writing, `os g` loads the config again and says which of these holds: + +- **Reached**: the stack carries the new item, so `os validate` counts and + checks it. +- **Not wired**: the config loads and its stack does not carry the item, or + there is no config. This is what happens with a config that imports + `./src/objects` alone, as `os init` projects from earlier releases and the + `npm create objectstack` starter do. The file is written, the config is left + as it was, and the command prints the import and the `defineStack` key that + wire the directory. +- **Cannot run**: the stack carries a flow, and its `requires` lacks + `automation`. The config loads and the server never runs the flow. The + command prints the whole `requires` list to use. +- **Refused**: the config loaded before the command wrote anything and no + longer loads with the new file in place, because the stack refuses it. Two + examples are an action or app bound to an object nobody declared, and a flow + in a stack without `triggers`. The command removes what it wrote, so the + project is as it was, and exits 1 with the stack's own reason. Generate the + object first (`os g object customer`), then what binds to it. + +`os g` never edits `objectstack.config.ts`: the config is yours, and the +command only loads it. ```bash os g object customer # Generate a Customer object os g view customer # Generate a Customer list view -os g action approve # Generate an action -os g flow customer # Generate an automation flow +os g action customer # Generate an action on Customer records +os g flow customer # Generate a flow that runs when a Customer changes os g dashboard sales # Generate a dashboard -os g app crm # Generate an app definition +os g app customer # Generate an app whose navigation opens Customer os g skill lead_qual # Generate an AI skill os g object task -d lib/ # Override target directory @@ -1420,15 +1452,15 @@ os g object task --dry-run # Preview without writing **Available types:** -| Type | Default Directory | Written as | Description | -|------|------------------|------------|-------------| -| `object` | `src/objects/` | `NAME.object.ts` | Business data object with fields | -| `view` | `src/views/` | `NAME.view.ts` | List or form view definition | -| `action` | `src/actions/` | `NAME.action.ts` | Button or batch action | -| `flow` | `src/flows/` | `NAME.flow.ts` | Automation flow | -| `dashboard` | `src/dashboards/` | `NAME.dashboard.ts` | Analytics dashboard | -| `app` | `src/apps/` | `NAME.app.ts` | Application navigation | -| `skill` | `src/skills/` | `NAME.skill.ts` | AI skill — the ADR-0063 extension primitive | +| Type | Default Directory | Written as | Collected as | Description | +|------|------------------|------------|--------------|-------------| +| `object` | `src/objects/` | `NAME.object.ts` | `objects` | Business data object with fields | +| `view` | `src/views/` | `NAME.view.ts` | `views` | List or form view definition | +| `action` | `src/actions/` | `NAME.action.ts` | `actions` | Button or batch action | +| `flow` | `src/flows/` | `NAME.flow.ts` | `flows` | Automation flow | +| `dashboard` | `src/dashboards/` | `NAME.dashboard.ts` | `dashboards` | Analytics dashboard | +| `app` | `src/apps/` | `NAME.app.ts` | `apps` | Application navigation | +| `skill` | `src/skills/` | `NAME.skill.ts` | `skills` | AI skill — the ADR-0063 extension primitive | Every scaffold is written as `NAME.TYPE.ts`, and the infix is read from the @@ -2164,10 +2196,10 @@ os g object account os g object contact os g object opportunity -# 3. Add business logic -os g flow lead_qualification +# 3. Add business logic: a flow that runs when an opportunity changes +os g flow opportunity -# 4. Validate everything +# 4. Validate everything: each file `os g` wrote is counted and checked os validate # 5. Start development with Console UI From e33889d77f87657552efa8b6d60929a9f8c8e091 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:43:14 +0000 Subject: [PATCH 6/8] test(cli): pin the barrel step against a name inside the empty barrel's text Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/cli/test/generate-stack-reach.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/generate-stack-reach.test.ts b/packages/cli/test/generate-stack-reach.test.ts index 5bf350b3221..0373db489a4 100644 --- a/packages/cli/test/generate-stack-reach.test.ts +++ b/packages/cli/test/generate-stack-reach.test.ts @@ -54,7 +54,7 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); -/** oclif + tsx cold starts, eight of them, sequential. */ +/** oclif + tsx cold starts, nine of them, sequential. */ const RUN_TIMEOUT_MS = 300_000; const PROJECT = 'my-app'; @@ -170,6 +170,10 @@ beforeAll(async () => { runs.objectControl = await runCli(['g', 'object', 'approve'], dirs.wired); runs.actionControl = await runCli(['g', 'action', 'approve'], dirs.wired); + // `port` is inside the `export {};` of the empty barrel `os init` writes: + // the substring test this replaced read it as already exported. + runs.dashboardPort = await runCli(['g', 'dashboard', 'port'], dirs.wired); + runs.flowTriggersOnly = await runCli(['g', 'flow', 'order_line'], dirs.triggersOnly); runs.viewPreFix = await runCli(['g', 'view', 'order_line'], dirs.preFix); runs.viewBare = await runCli(['g', 'view', 'order_line'], dirs.bare); @@ -208,6 +212,15 @@ describe('[#20215] refused: the write would stop a loading config from loading', }); }); +describe('[#20215] the barrel step asks which names the barrel exports, not what its text contains', () => { + it('`os g dashboard port` into the empty `export {};` barrel exports `port`, and reaches', () => { + expect(runs.dashboardPort.code, out(runs.dashboardPort)).toBe(0); + expect(readFileSync(join(dirs.wired, 'src', 'dashboards', 'index.ts'), 'utf-8')) + .toContain("export { default as port } from './port.dashboard';"); + expect(runs.dashboardPort.stdout).not.toContain('import * as dashboards'); + }); +}); + describe('[#20215] not wired: exit 0, the config untouched, and the lines that wire it', () => { it('a config that wires `./src/objects` alone', () => { expect(runs.viewPreFix.code, out(runs.viewPreFix)).toBe(0); From a03756d5e358078b9858c1c840abbab940145fbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:32:53 +0000 Subject: [PATCH 7/8] docs(changeset): correct the two sentences of the pending namespace-prefix note this PR falsifies The pending note said dashboard and skill scaffolds never read the config, and that a view's own name is written as before. With this PR os g loads the config after every write to report whether the scaffold reaches the stack, and a view's name equals the object key it binds to. Corrected in place (the DELIBERATE CORRECTION class of check-empty-changeset.mjs), both notes compiling into the same release. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/20197-generate-object-namespace-prefix.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/20197-generate-object-namespace-prefix.md b/.changeset/20197-generate-object-namespace-prefix.md index 3c33cafc79c..69f2b6c273f 100644 --- a/.changeset/20197-generate-object-namespace-prefix.md +++ b/.changeset/20197-generate-object-namespace-prefix.md @@ -8,6 +8,6 @@ fix(cli): `os generate` gives object names the project's namespace prefix, so `o - **Object names are prefixed.** In a project whose manifest declares a `namespace`, every object name a scaffold writes now starts with `_`. That covers the `object` scaffold's `name`, a `view`'s `object`, an `action`'s `objectName`, a `flow` start node's `objectName` and an `app` navigation item's `objectName`. Generated scaffolds now also point at each other: `os g view order_line` binds the object `os g object order_line` wrote. The file name and the exported binding still come from the name you typed (`src/objects/order_line.object.ts`, `orderLine`), and the command prints the object name it wrote. - **No double prefix.** A name that already carries the prefix (`os g object my_app_order_line`) is written as typed. The "already compliant?" check is the namespace-prefix gate's own `validateObjectNamespacePrefix`, so a `sys_*` name, which the gate exempts, is not prefixed either. A name the gate would still refuse after prefixing (the legacy `NS__SHORT` form) is refused before anything is written. -- **One namespace source.** The namespace is `manifest.namespace` of the config as loaded, the value `os validate` checks against. It is never re-derived from the directory or the `package.json` name. With no config, or a manifest without a `namespace`, nothing is prefixed, as before. If a config exists but does not load, a type that names an object is refused and nothing is written, because the namespace is unknown. `dashboard` and `skill` scaffolds name no object and never read the config. -- **Unchanged:** the names the gate does not check against the namespace. A view's, action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before. +- **One namespace source.** The namespace is `manifest.namespace` of the config as loaded, the value `os validate` checks against. It is never re-derived from the directory or the `package.json` name. With no config, or a manifest without a `namespace`, nothing is prefixed, as before. If a config exists but does not load, a type that names an object is refused and nothing is written, because the namespace is unknown. `dashboard` and `skill` scaffolds name no object, so a config that does not load does not stop them, but `os g` loads the config after every write, theirs included, to report whether the scaffold reaches the stack. +- **Unchanged:** the names the gate does not check against the namespace. An action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before; a view's own `name` now equals the object key it binds to, prefix included. - `os generate --help` now lists all seven metadata types in the `TYPE` argument. It had omitted `skill`, and the list now comes from the generator table. From da4aca64164de4ec34a7887eb56482f5dacaab36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:08:40 +0000 Subject: [PATCH 8/8] docs(changeset): drop the supersession paragraph now that the namespace-prefix note is corrected in place Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/20215-generate-scaffolds-reach-stack.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/20215-generate-scaffolds-reach-stack.md b/.changeset/20215-generate-scaffolds-reach-stack.md index 0a0486d336a..984d9b48b90 100644 --- a/.changeset/20215-generate-scaffolds-reach-stack.md +++ b/.changeset/20215-generate-scaffolds-reach-stack.md @@ -20,6 +20,4 @@ fix(cli): what `os generate` writes now reaches the stack, or the command says i - The barrel step asks the compiler whether the barrel already exports the name, instead of searching the file's text. `os g view order` after `os g view order_line` had found `order` inside `orderLine` and exported nothing. - The `flow` scaffold's header states the `requires` it needs. -These two statements in this release's namespace-prefix entry no longer hold: "`dashboard` and `skill` scaffolds name no object and never read the config", and a view's own `name` being "written as before". - **Projects scaffolded by an earlier release** keep their config. `os g` now tells you when a file it wrote is not wired, and prints the lines to add.