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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/init-scaffold-object-file-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/cli": patch
---

fix(cli): `os init` scaffolds its starter object as `<ns>_item.object.ts`, the spelling the registry declares (#11598)

`objectstack init` wrote its starter object to `src/objects/<namespace>_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/<ns>_item.ts` to
`src/objects/<ns>_item.object.ts` (and the matching `from './<ns>_item'` →
`from './<ns>_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.
6 changes: 4 additions & 2 deletions content/docs/getting-started/examples.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -381,8 +381,10 @@ my-app/
<Callout type="info">
`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.
</Callout>

**Naming conventions:**
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/commands/init.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand DownExpand Up@@ -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
Expand Down
187 changes: 187 additions & 0 deletions packages/cli/test/init-scaffold-file-name-registry-parity.test.ts
Original file line numberDiff line numberDiff line change
@@ -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/<namespace>_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 `<ns>_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 <stem>` 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();
}
},
);
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<ns>_item.ts` to `<ns>_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);
});

Expand DownExpand Up@@ -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) => {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/test/init.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand Down
Loading