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
14 changes: 14 additions & 0 deletions .changeset/22911-blank-prefixed-project-name.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/create-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 36 additions & 2 deletions packages/create-objectstack/src/rewrite-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,21 +117,55 @@ 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'),
path.join('objects', 'todo_task.object.ts'),
]);
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: `<old>_note` is not under `<new>_`, 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 ─────────────
Expand Down
23 changes: 18 additions & 5 deletions packages/create-objectstack/src/rewrite-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,33 @@ export function rewriteObjectNamePrefix(
}

/**
* Any `name: '<oldNs>_…'` 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: '<oldNs>_…'` literal still present after a rewrite that does NOT
* also carry the `<newNs>_` 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 `<oldNs>_` (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 `<newNs>_` 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
Loading