diff --git a/.changeset/22911-blank-prefixed-project-name.md b/.changeset/22911-blank-prefixed-project-name.md new file mode 100644 index 00000000000..4199774d73b --- /dev/null +++ b/.changeset/22911-blank-prefixed-project-name.md @@ -0,0 +1,14 @@ +--- +"create-objectstack": patch +--- + +fix(create-objectstack): a project name that starts with the template's namespace scaffolds instead of being refused + +Clause-②: no + +A project whose namespace begins with the template's own namespace plus `_` (for example `blank-probe`, namespace `blank_probe`, under the `blank` template) was refused with exit 1: "1 object name(s) still carry the template's 'blank_' prefix … This is a bug in the scaffolder". The rewrite was correct (`blank_note` became `blank_probe_note`), but the check that verifies it tested each name against the template prefix alone, and `blank_probe_note` starts with `blank_`. + +- The check now reports a name as stale only when it carries the template's prefix and not the new namespace's prefix, so `blank-probe` scaffolds with objects named `blank_probe_*`. +- A name the rewrite really missed (`blank_note` under namespace `blank_probe`) is still refused, as before. +- The same rule applies to every template namespace; no name is treated specially. +- No migration: a project that scaffolded before this release is unaffected, and a name that was refused before can be scaffolded again. diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index b68538942c9..7677081f619 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -242,7 +242,7 @@ function rewriteProjectIdentity( if (templateNamespace && namespace !== templateNamespace) { const srcDir = path.join(targetDir, 'src'); rewriteObjectNamePrefix(srcDir, templateNamespace, namespace); - const stale = findStaleNamespacePrefixes(srcDir, templateNamespace); + const stale = findStaleNamespacePrefixes(srcDir, templateNamespace, namespace); if (stale.length > 0) { const shown = stale .slice(0, 5) diff --git a/packages/create-objectstack/src/rewrite-identity.test.ts b/packages/create-objectstack/src/rewrite-identity.test.ts index 3b42075ca24..a272c6cb864 100644 --- a/packages/create-objectstack/src/rewrite-identity.test.ts +++ b/packages/create-objectstack/src/rewrite-identity.test.ts @@ -117,14 +117,14 @@ describe('findStaleNamespacePrefixes', () => { it('reports nothing once the rewrite has run', () => { writeObject('todo_task.object.ts', 'todo_task'); rewriteObjectNamePrefix(path.join(dir, 'src'), 'todo', 'my_app'); - expect(findStaleNamespacePrefixes(path.join(dir, 'src'), 'todo')).toEqual([]); + expect(findStaleNamespacePrefixes(path.join(dir, 'src'), 'todo', 'my_app')).toEqual([]); }); it('reports what a skipped rewrite leaves behind — the #4902 failure state', () => { writeObject('todo_task.object.ts', 'todo_task'); writeObject('todo_label.object.ts', 'todo_label'); // No rewrite at all: exactly what the old manifest-only guard produced. - const stale = findStaleNamespacePrefixes(path.join(dir, 'src'), 'todo'); + const stale = findStaleNamespacePrefixes(path.join(dir, 'src'), 'todo', 'my_app'); expect(stale).toHaveLength(2); expect(stale.map((s) => s.file).sort()).toEqual([ path.join('objects', 'todo_label.object.ts'), @@ -132,6 +132,40 @@ describe('findStaleNamespacePrefixes', () => { ]); expect(stale[0].line).toBeGreaterThan(0); }); + + // A new namespace that itself begins with the template namespace plus `_` + // (project `blank-probe` → namespace `blank_probe`): every CORRECT rewrite + // then carries the old prefix as well. The predicate is one rule for every + // template namespace, so the same pins run against more than one. + describe.each([ + ['blank', 'blank_probe'], + ['todo', 'todo_list'], + ])('when the new namespace extends the template namespace (%s → %s)', (oldNs, newNs) => { + it('reports nothing once the rewrite has run', () => { + writeObject('note.object.ts', `${oldNs}_note`); + expect(rewriteObjectNamePrefix(path.join(dir, 'src'), oldNs, newNs)).toBe(1); + expect( + fs.readFileSync(path.join(dir, 'src', 'objects', 'note.object.ts'), 'utf8'), + ).toContain(`name: '${newNs}_note'`); + expect(findStaleNamespacePrefixes(path.join(dir, 'src'), oldNs, newNs)).toEqual([]); + }); + + it('CONTROL: still reports a genuinely stale name', () => { + writeObject('note.object.ts', `${oldNs}_note`); + // No rewrite: `_note` is not under `_`, so the project is rejected. + const stale = findStaleNamespacePrefixes(path.join(dir, 'src'), oldNs, newNs); + expect(stale).toHaveLength(1); + expect(stale[0].text).toContain(`'${oldNs}_note'`); + }); + + it('judges each literal, so a correct name does not hide a stale one on its line', () => { + fs.writeFileSync( + path.join(dir, 'src', 'objects', 'pair.object.ts'), + `export const o = [{ name: '${newNs}_ok' }, { name: '${oldNs}_note' }];\n`, + ); + expect(findStaleNamespacePrefixes(path.join(dir, 'src'), oldNs, newNs)).toHaveLength(1); + }); + }); }); // ── deriveManifestId — the pin the scaffold's id is held against ───────────── diff --git a/packages/create-objectstack/src/rewrite-identity.ts b/packages/create-objectstack/src/rewrite-identity.ts index 0b0a7185e92..35eae3f4e75 100644 --- a/packages/create-objectstack/src/rewrite-identity.ts +++ b/packages/create-objectstack/src/rewrite-identity.ts @@ -159,20 +159,33 @@ export function rewriteObjectNamePrefix( } /** - * Any `name: '_…'` literal still present after a rewrite — i.e. metadata - * the scaffolded project will be rejected for. Each entry is `file:line` - * relative to `dir`, plus the offending text. + * Any `name: '_…'` literal still present after a rewrite that does NOT + * also carry the `_` prefix — i.e. metadata the scaffolded project will + * be rejected for. Each entry is `file:line` relative to `dir`, plus the + * offending text. + * + * Both halves of the predicate are needed. A new namespace may itself begin + * with `_` (project `blank-probe` → namespace `blank_probe` under the + * `blank` template), and then every CORRECTLY rewritten name (`blank_probe_note`) + * carries the old prefix too; testing the old prefix alone reports the rewrite + * as stale and refuses a valid project. A name under `_` satisfies the + * `${namespace}_${shortName}` rule whatever else it starts with, so it is + * never stale. One predicate for every template namespace — no name is special. + * Judged per literal, so a correct name cannot hide a stale one on its line. */ export function findStaleNamespacePrefixes( dir: string, oldNs: string, + newNs: string, ): { file: string; line: number; text: string }[] { - const re = namePrefixRe(oldNs, ''); + const oldRe = namePrefixRe(oldNs, 'g'); + const newRe = namePrefixRe(newNs, ''); const stale: { file: string; line: number; text: string }[] = []; for (const file of tsFiles(dir)) { const lines = fs.readFileSync(file, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { - if (re.test(lines[i])) { + const literals = [...lines[i].matchAll(oldRe)].map((m) => m[0]); + if (literals.some((literal) => !newRe.test(literal))) { stale.push({ file: path.relative(dir, file), line: i + 1, diff --git a/packages/create-objectstack/src/scaffold-namespace-extends-template.test.ts b/packages/create-objectstack/src/scaffold-namespace-extends-template.test.ts new file mode 100644 index 00000000000..f87f0443400 --- /dev/null +++ b/packages/create-objectstack/src/scaffold-namespace-extends-template.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A project whose namespace BEGINS with the template's own namespace plus `_` +// (`blank-probe` → `blank_probe` under the `blank` template) must scaffold. +// The scaffolder's stale-prefix self-check once tested each rewritten object +// name against the template prefix alone; `blank_probe_note` carries `blank_`, +// so a correct rewrite was refused with "This is a bug in the scaffolder". +// +// Drives the real CLI end to end via `tsx` — index.ts calls `program.parse()` +// on import, so `rewriteProjectIdentity` cannot be called directly (the same +// constraint and pattern as scaffold-description.test.ts). One case per +// bundled template, each probe named after THAT template's own namespace, so a +// second template is covered the day it is registered. + +import { describe, it, expect, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; +import { TEMPLATES } from './template-registry.js'; +import { readTemplateNamespace } from './rewrite-identity.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(HERE, '..'); +const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..'); +const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx'); +const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts'); + +/** Every `name: '…'` literal in the `*.ts` files under `dir`. */ +function nameLiterals(dir: string): string[] { + const names: string[] = []; + for (const rel of fs.readdirSync(dir, { recursive: true, encoding: 'utf8' })) { + if (!rel.endsWith('.ts')) continue; + const text = fs.readFileSync(path.join(dir, rel), 'utf8'); + for (const m of text.matchAll(/\bname:\s*(['"`])([a-z0-9_]+)\1/g)) names.push(m[2]); + } + return names.sort(); +} + +let tmp: string | undefined; +afterEach(() => { + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; +}); + +describe('a project namespace that extends the template namespace', () => { + it.each( + Object.entries(TEMPLATES).map(([template, info]) => { + const ns = readTemplateNamespace(path.join(HERE, 'templates', info.source.dir)); + if (!ns) throw new Error(`bundled template '${template}' declares no namespace`); + return [template, ns, `${ns.replace(/_/g, '-')}-probe`, info.source.dir] as const; + }), + )('%s (namespace %s): `%s` scaffolds, every object moved under the new namespace', (template, ns, project, dir) => { + const templateNames = nameLiterals(path.join(HERE, 'templates', dir, 'src')).filter( + (n) => n.startsWith(`${ns}_`), + ); + // Precondition: the template really ships prefixed names, or nothing is pinned. + expect(templateNames.length).toBeGreaterThan(0); + + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-ns-extends-')); + const run = spawnSync( + TSX, + [INDEX_TS, project, '--template', template, '--skip-install', '--skip-skills'], + { cwd: tmp, encoding: 'utf8' }, + ); + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + + const projectDir = path.join(tmp, project); + const newNs = `${ns}_probe`; + expect(readTemplateNamespace(projectDir)).toBe(newNs); + + const scaffolded = nameLiterals(path.join(projectDir, 'src')).filter((n) => + n.startsWith(`${ns}_`), + ); + expect(scaffolded).toEqual( + templateNames.map((n) => `${newNs}_${n.slice(ns.length + 1)}`).sort(), + ); + for (const name of scaffolded) { + expect(validateObjectNamespacePrefix(name, newNs), name).toBeNull(); + } + }, 30_000); +});