From da48e05887cf7b61a7a66cb7a9a8109c29804df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 05:59:57 +0000 Subject: [PATCH 1/2] fix(cli): scaffold the starter object as `_item.object.ts` (#11598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os init` wrote `src/objects/_item.ts` while the registry declares the `object` type as `**/*.object.ts` — zero glob matches, against exactly one for the type-infixed name. Both `srcFiles` tables and the barrel specifier they emit now carry the infix, converging `os init` on the same spelling `os g object` writes after #11071, `create-objectstack`'s blank starter already ships, and the examples use. Measured, not assumed: a scaffolded project loads the file through the barrel's module specifier (the config declares `objects: Object.values(objects)`) and boots from the compiled artifact, so the old name was a naming inconsistency rather than breakage — an unbarrelled `*.object.ts` does not reach the artifact either, which is what puts the registry glob off this load path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../init-scaffold-object-file-spelling.md | 43 ++++ content/docs/getting-started/examples.mdx | 6 +- packages/cli/src/commands/init.ts | 10 +- ...scaffold-file-name-registry-parity.test.ts | 187 ++++++++++++++++++ packages/cli/test/init.test.ts | 8 +- 5 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 .changeset/init-scaffold-object-file-spelling.md create mode 100644 packages/cli/test/init-scaffold-file-name-registry-parity.test.ts diff --git a/.changeset/init-scaffold-object-file-spelling.md b/.changeset/init-scaffold-object-file-spelling.md new file mode 100644 index 0000000000..e68ba91295 --- /dev/null +++ b/.changeset/init-scaffold-object-file-spelling.md @@ -0,0 +1,43 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os init` scaffolds its starter object as `_item.object.ts`, the spelling the registry declares (#11598) + +`objectstack init` wrote its starter object to `src/objects/_item.ts` +while `DEFAULT_METADATA_TYPE_REGISTRY` declares the `object` type as +`**/*.object.ts` / `.yml` / `.json`. Measured with `node:path`'s `matchesGlob` +against the registry read at runtime: `src/objects/my_app_item.ts` matched +**zero** of the three patterns, `src/objects/my_app_item.object.ts` matches +exactly one. Both `srcFiles` tables (the `app` and `plugin` templates) and the +barrel specifier they emit now carry the type infix. + +**This was a naming inconsistency, not breakage — measured, not assumed.** A +scaffolded project declares its objects in code (`import * as objects from +'./src/objects'` → `objects: Object.values(objects)`), so the object reaches the +stack through the barrel's *module specifier*, and `os dev` / `os serve` then +boot from the compiled `dist/objectstack.json` rather than by globbing source. +Three real scaffolds were compiled with the real `os compile` to establish it: +the old-spelled file **did** land in the artifact (so nothing was ever silently +skipped — this is not the #10359 silent-strip shape), a `*.object.ts` file +dropped into `src/objects/` but *not* re-exported from the barrel did **not** +land in it (so the registry glob was never on this load path), and the new +spelling lands identically. + +What it *was*: one CLI teaching two spellings for one metadata type. After +#11071 an author who runs `os init` and then `os g object customer` gets +`src/objects/my_app_item.ts` beside `src/objects/customer.object.ts` in the same +directory, from the same CLI. The registry spelling is the authority — the same +convergence #11071 settled — and it is already what `create-objectstack`'s blank +starter ships (`note.object.ts`), what the examples use +(`app-crm/src/objects/account.object.ts`), and what the getting-started docs +list as the house convention two lines under the callout that described the old +name. + +**Existing scaffolded projects need to do nothing.** The old filename still +loads exactly as it did — the barrel imports it by specifier and the filename is +not consulted. Renaming `src/objects/_item.ts` to +`src/objects/_item.object.ts` (and the matching `from './_item'` → +`from './_item.object'` in `src/objects/index.ts`) is an optional +consistency cleanup, not a migration: it changes nothing about how the project +builds, boots or behaves. Only newly scaffolded projects get the new name. diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index a985f97284..464dd9f732 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -381,8 +381,10 @@ my-app/ `os init` scaffolds a minimal starter, not this full tree. It emits `objectstack.config.ts` plus a single object under - `src/objects/{namespace}_item.ts` (e.g. `my_app_item.ts`) with a matching - barrel `src/objects/index.ts`. Add the other directories above as your app grows. + `src/objects/{namespace}_item.object.ts` (e.g. `my_app_item.object.ts`) with a + matching barrel `src/objects/index.ts` — the same `{name}.object.ts` spelling + the convention list below states. Add the other directories above as your app + grows. **Naming conventions:** diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 655f4f13f6..31da0d09d9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -419,9 +419,9 @@ export default defineStack({ }); `, srcFiles: { - 'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item'; + 'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object'; `, - 'src/objects/__name___item.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data'; + 'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data'; const ${toCamelCase(namespace)}Item: Data.Object = { name: '${namespace}_item', @@ -507,9 +507,9 @@ export default defineStack({ }); `, srcFiles: { - 'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item'; + 'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object'; `, - 'src/objects/__name___item.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data'; + 'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data'; const ${toCamelCase(namespace)}Item: Data.Object = { name: '${namespace}_item', @@ -633,7 +633,7 @@ function printCreatedFilesSummary(targetDir: string, wasEmpty: boolean) { * * File paths use `__name__` as a placeholder for the NAMESPACE (not the npm * name) so generated identifiers stay snake_case even when the project name - * contains hyphens (`my-app` → namespace `my_app` → `src/objects/my_app_item.ts`). + * contains hyphens (`my-app` → namespace `my_app` → `src/objects/my_app_item.object.ts`). * * Exported so the scaffold pin test generates projects through the real * emitter instead of a copy of it. A test that re-implemented this loop could diff --git a/packages/cli/test/init-scaffold-file-name-registry-parity.test.ts b/packages/cli/test/init-scaffold-file-name-registry-parity.test.ts new file mode 100644 index 0000000000..4faf9d22a9 --- /dev/null +++ b/packages/cli/test/init-scaffold-file-name-registry-parity.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#11598) — every object `objectstack init` scaffolds is written under a + * name the registry's `object` entry actually declares. + * + * ## The defect + * + * `init` scaffolded `src/objects/_item.ts` while + * `DEFAULT_METADATA_TYPE_REGISTRY` declares `*.object.ts` (plus `.yml` / + * `.json`) for the `object` type. Measured on `origin/main` with `node:path`'s + * `matchesGlob`: `src/objects/my_app_item.ts` matched ZERO of the three, + * `src/objects/my_app_item.object.ts` matches exactly one. + * + * ## What is and is NOT claimed here — the measured load path + * + * This is deliberately NOT the silent-strip shape (#10359, and #11071's case + * for `os generate`). A scaffolded project declares its objects in CODE: + * + * import * as objects from './src/objects'; + * export default defineStack({ …, objects: Object.values(objects) }); + * + * `os compile` bundle-requires that config, so the object arrives through the + * barrel's MODULE SPECIFIER, and `os dev` / `os serve` then boot from the + * compiled `dist/objectstack.json` — `standalone-stack.ts` hands + * `MetadataPlugin` an `artifactSource`, which routes bootstrap to + * `_loadFromLocalFile` and leaves `_loadFromFileSystem` (the glob pass) off a + * scaffolded project's path entirely. Measured end-to-end, not inferred: a + * `*.object.ts` file dropped into `src/objects/` and NOT re-exported from the + * barrel does not reach the compiled artifact. + * + * So the scaffold WORKED under the old name and nothing was invisible. What + * it was, is one CLI teaching two spellings for one metadata type: `os init` + * wrote `_item.ts`, `os g object customer` (after #11071) writes + * `customer.object.ts`, `create-objectstack`'s own blank starter already + * shipped `note.object.ts`, and the examples (`app-crm/src/objects/ + * account.object.ts`) plus the registry's own glob keys speak the same shape. + * The scaffold is the first thing a new author reads as the house convention. + * + * That is why the assertion below is `matchesGlob` against the REGISTRY and + * not `toBe('my_app_item.object.ts')`. A string equality here would be a pin + * on this edit; the property is that whatever `init` emits stays inside the + * set the platform declares discoverable — which is what makes the two + * commands converge, and what keeps a future template from drifting back out. + * + * ## Why no `dist/` is on this file's measured path + * + * `TEMPLATES` / `writeTemplateSrcFiles` are imported from `../src/commands/ + * init.js` — an in-package RELATIVE specifier, so vitest loads the source + * this PR edits, never `packages/cli/dist`. Nothing here spawns a child + * process (the CLI e2e suites that do, drive `bin/run-dev.js`, which is the + * tsx source entry point by its own header). An ablation of `init.ts` + * therefore reaches this file without a rebuild — stated because the opposite + * case is the one that comes back green while measuring nothing. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { matchesGlob } from 'node:path'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js'; +import { metadataFileName } from '../src/utils/metadata-file-name.js'; + +const PROJECT_NAME = 'my-app'; +const OBJECT_DIR = 'src/objects'; + +/** The registry's own entry for the `object` type — the authority this pins to. */ +const OBJECT_ENTRY = DEFAULT_METADATA_TYPE_REGISTRY.find((entry) => entry.type === 'object'); + +const roots: string[] = []; + +afterAll(() => { + for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** + * Emit one template through `init`'s OWN emitter and return the relative paths + * of the object sources it wrote (the barrel excluded — it is a re-export, not + * a metadata file, and the registry declares nothing about it). + */ +function emitObjectSources(templateKey: string): { written: string[]; objects: string[]; root: string } { + const template = TEMPLATES[templateKey]; + const namespace = sanitizeNamespace(PROJECT_NAME); + const root = fs.mkdtempSync(path.join(os.tmpdir(), `os-init-11598-${templateKey}-`)); + roots.push(root); + const written = writeTemplateSrcFiles(template.srcFiles, root, PROJECT_NAME, namespace); + const objects = written.filter( + (rel) => rel.startsWith(`${OBJECT_DIR}/`) && path.basename(rel) !== 'index.ts', + ); + return { written, objects, root }; +} + +/** Templates that actually emit an object source — the corpus this pin measures. */ +const TEMPLATES_WITH_OBJECTS = Object.keys(TEMPLATES).filter( + (key) => emitObjectSources(key).objects.length > 0, +); + +describe('[#11598] the init scaffold writes object files the registry declares', () => { + it('the registry still declares an `object` type with file patterns to check against', () => { + // Without this the sweep below would pass by having no contract to fail. + expect(OBJECT_ENTRY, 'DEFAULT_METADATA_TYPE_REGISTRY has no `object` entry').toBeDefined(); + expect(OBJECT_ENTRY!.filePatterns.length).toBeGreaterThan(0); + }); + + it('at least one built-in template emits an object source at all', () => { + // The green-because-nothing-ran direction: an `it.each` over an empty + // roster is a passing test that measured nothing. + expect( + TEMPLATES_WITH_OBJECTS, + 'no built-in template emits a src/objects source — this pin would sweep nothing', + ).not.toHaveLength(0); + }); + + it.each(TEMPLATES_WITH_OBJECTS)( + 'template "%s" writes every object under a name the `object` patterns match', + (templateKey) => { + const { objects } = emitObjectSources(templateKey); + expect(objects.length, `template "${templateKey}" emitted no object source`).toBeGreaterThan(0); + + for (const rel of objects) { + const matched = OBJECT_ENTRY!.filePatterns.filter((pattern) => matchesGlob(rel, pattern)); + expect( + matched.length, + `template "${templateKey}" scaffolds "${rel}", which matches none of ` + + `${JSON.stringify(OBJECT_ENTRY!.filePatterns)} — the CLI would be teaching a ` + + 'spelling the platform does not declare for this type', + ).toBeGreaterThan(0); + } + }, + ); + + it.each(TEMPLATES_WITH_OBJECTS)( + 'template "%s" writes the same filename `os g object` would, for the same stem', + (templateKey) => { + // The convergence half (#11071 direction, inherited): one CLI, one + // spelling. `metadataFileName` is the derivation `os generate` uses — + // reading the infix out of the pattern rather than interpolating the + // type key — so this compares the two commands' OUTPUTS, not two + // literals someone kept in sync by hand. + const { objects } = emitObjectSources(templateKey); + for (const rel of objects) { + const base = path.basename(rel); + // The stem `os g object ` would be handed to produce this file. + const stem = base.replace(/\.[^.]+\.ts$/, '').replace(/\.ts$/, ''); + expect( + base, + `\`os init -t ${templateKey}\` writes "${base}" but \`os g object ${stem}\` writes ` + + `"${metadataFileName('object', stem)}" — one CLI, two spellings for one type`, + ).toBe(metadataFileName('object', stem)); + } + }, + ); + + it.each(TEMPLATES_WITH_OBJECTS)( + 'template "%s" barrel re-exports a specifier that resolves to a file it actually wrote', + (templateKey) => { + // The rename has to move BOTH `srcFiles` keys and the barrel that + // imports them. A scaffold whose barrel points at a filename nobody + // emitted does not compile at all — `os compile` bundle-requires the + // config, which imports this barrel, so this is the behaviour the two + // edits jointly have to preserve. + const { written, root } = emitObjectSources(templateKey); + const barrel = written.find((rel) => rel === `${OBJECT_DIR}/index.ts`); + expect(barrel, `template "${templateKey}" emits objects but no barrel`).toBeDefined(); + + const src = fs.readFileSync(path.join(root, barrel!), 'utf8'); + const specifiers = [...src.matchAll(/from\s+'(\.\/[^']+)'/g)].map((m) => m[1]); + expect(specifiers.length, 'barrel re-exports nothing').toBeGreaterThan(0); + + for (const spec of specifiers) { + const stem = spec.replace(/^\.\//, '').replace(/\.js$/, ''); + const candidates = [`${stem}.ts`, `${stem}.js`, stem]; + const resolved = candidates.find((candidate) => + written.includes(`${OBJECT_DIR}/${candidate}`), + ); + expect( + resolved, + `template "${templateKey}" barrel imports '${spec}' but the template wrote ` + + `${JSON.stringify(written.filter((rel) => rel.startsWith(`${OBJECT_DIR}/`)))} — ` + + 'the scaffold would fail to compile on the user\'s next command', + ).toBeDefined(); + } + }, + ); +}); diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 9c952b6c79..cc139d8721 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -479,15 +479,17 @@ describe('scaffold rendering — round-trip', () => { for (const rel of written) { expect(rel).not.toMatch(/-/); } - // Object file is namespace-prefixed. - const objFile = path.join(tmpRoot, 'src', 'objects', 'my_app_item.ts'); + // Object file is namespace-prefixed AND carries the `object` type infix + // the registry declares (#11598) — see + // `init-scaffold-file-name-registry-parity.test.ts` for the derived contract. + const objFile = path.join(tmpRoot, 'src', 'objects', 'my_app_item.object.ts'); expect(fs.existsSync(objFile)).toBe(true); const objSrc = fs.readFileSync(objFile, 'utf8'); // Rendered object name must satisfy `${namespace}_${shortName}`. expect(objSrc).toMatch(/name: 'my_app_item'/); // Index re-exports the canonical identifier. const indexSrc = fs.readFileSync(path.join(tmpRoot, 'src', 'objects', 'index.ts'), 'utf8'); - expect(indexSrc).toMatch(/from '\.\/my_app_item'/); + expect(indexSrc).toMatch(/from '\.\/my_app_item\.object'/); expect(indexSrc).toMatch(/myAppItem/); // Rendered config embeds the sanitized namespace. const cfg = fs.readFileSync(path.join(tmpRoot, 'objectstack.config.ts'), 'utf8'); From 8a8aac728dfbf171b8c6ebf3c3ddcda222c8bec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:34:56 +0000 Subject: [PATCH 2/2] test(cli): select scaffold object sources structurally, not by spelling (#11598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `init-template-comments-self-contained.test.ts` picked object sources with `/src\/objects\/.*_item\.ts$/`, so the rename turned its two vacuity guards red — the guard working exactly as designed, but a re-edit the property it defends never needed. It now selects anything under `src/objects/` that is not the barrel. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../test/init-template-comments-self-contained.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/init-template-comments-self-contained.test.ts b/packages/cli/test/init-template-comments-self-contained.test.ts index 844b7253b9..2f880b7c9a 100644 --- a/packages/cli/test/init-template-comments-self-contained.test.ts +++ b/packages/cli/test/init-template-comments-self-contained.test.ts @@ -137,7 +137,11 @@ describe('rendered init templates are followable by a stranger', () => { } // The two templates that emit an object (app, plugin) must have reached // the OWD comment's file, or assertion 2 below would vacuously pass. - const objectFiles = rendered.filter((r) => /src\/objects\/.*_item\.ts$/.test(r.file)); + // Selected STRUCTURALLY (anything under src/objects/ that is not the + // barrel) rather than by filename spelling: this guard went red on the + // #11598 rename from `_item.ts` to `_item.object.ts`, which is a + // correct catch but a re-edit the property never needed. + const objectFiles = rendered.filter((r) => /^src\/objects\/(?!index\.ts$)[^/]+\.ts$/.test(r.file)); expect(objectFiles.length).toBeGreaterThan(0); }); @@ -173,7 +177,7 @@ describe('rendered init templates are followable by a stranger', () => { }, ); - const objectFiles = rendered.filter((r) => /src\/objects\/.*_item\.ts$/.test(r.file)); + const objectFiles = rendered.filter((r) => /^src\/objects\/(?!index\.ts$)[^/]+\.ts$/.test(r.file)); it.each(objectFiles.map((r) => [`${r.templateKey}/${r.file}`, r] as const))( '%s still explains the org-wide default', (_label, r) => {