From 8679418942c91827751803a3eda0ff92c724165b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:15:55 +0000 Subject: [PATCH] fix(create-objectstack): a stale name carries the old prefix and not the new one findStaleNamespacePrefixes tested each object name against the template's prefix alone. A project namespace that itself begins with the template namespace plus an underscore (blank-probe -> blank_probe) makes every correctly rewritten name (blank_probe_note) carry the old prefix too, so a valid project was refused as a scaffolder bug. The check now takes the new namespace and reports a literal only when it carries the old prefix and not the new one, judged per literal. One predicate for every template namespace. The one caller passes the namespace it just wrote. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 --- .../22911-blank-prefixed-project-name.md | 14 ++++ packages/create-objectstack/src/index.ts | 2 +- .../src/rewrite-identity.test.ts | 38 ++++++++- .../src/rewrite-identity.ts | 23 +++-- ...caffold-namespace-extends-template.test.ts | 84 +++++++++++++++++++ 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 .changeset/22911-blank-prefixed-project-name.md create mode 100644 packages/create-objectstack/src/scaffold-namespace-extends-template.test.ts 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); +});