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. diff --git a/.changeset/20215-generate-scaffolds-reach-stack.md b/.changeset/20215-generate-scaffolds-reach-stack.md new file mode 100644 index 00000000000..984d9b48b90 --- /dev/null +++ b/.changeset/20215-generate-scaffolds-reach-stack.md @@ -0,0 +1,23 @@ +--- +"@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. + +**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 8a31c689243..247bfeadca4 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1403,16 +1403,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 @@ -1421,15 +1453,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 @@ -2165,10 +2197,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 diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 62cb7ec8b8e..3a92cfa433d 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,190 @@ 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[], + 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}`)); + }; + 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)}:`, + )); + 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`)); + 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, reach.declaredRequires); + 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, null); + 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..3770e16c691 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,140 @@ 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, 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}';`), + '', + '// 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({ … })`. */ +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}: 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 `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 +// 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 +749,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 +775,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 +818,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({ export default ${toCamelCase(namespace)}Item; `, - }, + }), }, plugin: { @@ -708,7 +843,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 +870,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 +899,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({ export default ${toCamelCase(namespace)}Item; `, - }, + }), }, empty: { @@ -891,6 +1026,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..4faec4b4c50 --- /dev/null +++ b/packages/cli/src/utils/scaffold-wiring.ts @@ -0,0 +1,223 @@ +// 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, and `declaredRequires` + * is that list as declared (`null`: the stack declares no `requires`). + */ + | { + 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 { + /** 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 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, + 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), + declaredRequires: declaredCapabilities(config), + }; + } 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. + * + * 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, declaredRequires } = args; + const stackLines = [`${stackKey}: Object.values(${stackKey}),`]; + if (missingRequires.length > 0) { + 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 }; +} + +// ─── 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); +} 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..c18c77979f9 --- /dev/null +++ b/packages/cli/test/generate-scaffold-wiring.test.ts @@ -0,0 +1,286 @@ +// 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); + 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); + }); + + 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..0373db489a4 --- /dev/null +++ b/packages/cli/test/generate-stack-reach.test.ts @@ -0,0 +1,249 @@ +// 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, nine 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 actionFileAfterRefusal: boolean; +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); + actionFileAfterRefusal = existsSync(join(dirs.wired, 'src', 'actions', 'approve.action.ts')); + 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); + + // `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); +}, 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(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'); + }); + + 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] 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); + 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'); + }); +});