diff --git a/.changeset/28992-scaffold-namespace-pair-cap.md b/.changeset/28992-scaffold-namespace-pair-cap.md new file mode 100644 index 00000000000..079df59b57c --- /dev/null +++ b/.changeset/28992-scaffold-namespace-pair-cap.md @@ -0,0 +1,13 @@ +--- +'create-objectstack': patch +'@objectstack/cli': patch +--- + +fix(scaffold): a scaffolded namespace is at most 13 characters, so a fresh project from a long name validates with no `namespace-no-customization-pair` warning (#28992) + +Clause-②: no +Metadata-protocol: none + +`npx create-objectstack` and `os init` derive `manifest.namespace` from the project name. They truncated it at 20 characters, the manifest grammar's limit, while a namespace longer than 13 has no paired customization namespace (ADR-0146 D3). So `qa-console-first-paint` scaffolded the namespace `qa_console_first_pai`, and the project's first `os validate` warned about it and about the object names it prefixes, none of which the author wrote. + +Both scaffolders now cap the namespace at `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` (13): `qa-console-first-paint` gets `qa_console_fi`, and every object name and file the scaffold derives from the namespace follows it. A truncated namespace never ends in `_`. `os init` reads the constant from `@objectstack/spec/kernel`; `create-objectstack`, which does not depend on the spec at runtime, keeps its own copy, pinned equal to the spec constant by a test. Project names whose namespace already fit are scaffolded exactly as before. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 36e32addae0..ef45f5993e1 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; @@ -67,18 +67,26 @@ function pkgVersion(): string { * * npm names allow hyphens/dots/scopes (e.g. `@acme/my-app`); identifiers don't. * We strip the scope, replace separators with `_`, lowercase, prefix a leading - * digit, truncate to 20, and pad short names so the result always satisfies - * the regex. Reserved names get a `_app` suffix. + * digit, truncate, and pad short names so the result always satisfies the + * regex. Reserved names get a `_app` suffix. + * + * The cap is `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` (13), not the grammar's + * 20: a longer namespace has no paired customization namespace (ADR-0146 D3), + * so `os validate` on the fresh project would warn + * `namespace-no-customization-pair` about objects its author never wrote. + * `create-objectstack`'s `sanitizeNamespace()` restates the same rule, pinned + * to the same constant, so both scaffolders agree. */ export function sanitizeNamespace(name: string): string { + const max = MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH; let s = name.replace(/^@[^/]+\//, ''); // drop npm scope s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_'); // separators → _ s = s.replace(/^_+|_+$/g, ''); // trim underscores if (!s) s = 'app'; if (/^[0-9]/.test(s)) s = 'a' + s; // must start with a letter - if (s.length < 2) s = (s + '_app').slice(0, 20); - if (s.length > 20) s = s.slice(0, 20).replace(/_+$/, ''); - if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, 20); + if (s.length < 2) s = (s + '_app').slice(0, max); + if (s.length > max) s = s.slice(0, max).replace(/_+$/, ''); + if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, max); return s; } diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 45f40a7a9c3..47eaf3b2fcc 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { PROTOCOL_MAJOR, customizationNamespace } from '@objectstack/spec/kernel'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -404,6 +404,26 @@ describe('sanitizeNamespace', () => { expect(sanitizeNamespace(input)).toMatch(NS_RE); } }); + + // A namespace too long for a paired customization namespace (ADR-0146 D3) + // makes `os validate` warn `namespace-no-customization-pair` on the fresh + // scaffold, about objects its author never wrote. + it.each([ + ['qa-console-first-paint', 'qa_console_fi'], + ['abcdefghijkl-mnop', 'abcdefghijkl'], // the 13th character is `_`: dropped + ['@acme/very-long-project-name', 'very_long_pro'], + ['1234567890123456', 'a123456789012'], + ['abcdefghijklm', 'abcdefghijklm'], + ])('caps %s at the customization-pair limit → %s', (input, expected) => { + expect(sanitizeNamespace(input)).toBe(expected); + }); + + it('always produces a namespace that has a customization pair', () => { + for (const input of ['my-app', '123app', 'sys', 'a', '', 'qa-console-first-paint', 'really-long-name-truncated-here', 'abcdefghijkl-mnop']) { + const ns = sanitizeNamespace(input); + expect(customizationNamespace(ns), ns).toEqual({ ok: true, value: `${ns}_custom` }); + } + }); }); describe('scaffold rendering — round-trip', () => { diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index 625a2dc9c1c..461c63dc9e7 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -74,6 +74,7 @@ import { syncObjectStackDeps } from './pkg-utils.js'; import { copyDir } from './template-copy.js'; import { deriveManifestId, + sanitizeNamespace, readTemplateNamespace, rewriteObjectNamePrefix, findStaleNamespacePrefixes, @@ -107,23 +108,6 @@ function toTitleCase(str: string): string { return str.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } -/** - * Convert an npm package name into a valid ObjectStack namespace identifier - * (regex `^[a-z][a-z0-9_]{1,19}$`, reserved: base/system/sys). Mirrors the - * implementation in `@objectstack/cli` so both scaffolders agree. - */ -export function sanitizeNamespace(name: string): string { - let s = name.replace(/^@[^/]+\//, ''); - s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_'); - s = s.replace(/^_+|_+$/g, ''); - if (!s) s = 'app'; - if (/^[0-9]/.test(s)) s = 'a' + s; - if (s.length < 2) s = (s + '_app').slice(0, 20); - if (s.length > 20) s = s.slice(0, 20).replace(/_+$/, ''); - if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, 20); - return s; -} - function readCliVersion(): string { try { const pkgPath = path.resolve(__dirname, '..', 'package.json'); diff --git a/packages/create-objectstack/src/rewrite-identity.ts b/packages/create-objectstack/src/rewrite-identity.ts index 35eae3f4e75..8fb4bd565dd 100644 --- a/packages/create-objectstack/src/rewrite-identity.ts +++ b/packages/create-objectstack/src/rewrite-identity.ts @@ -116,6 +116,43 @@ export function deriveManifestId(projectName: string): string { return `${SCAFFOLD_ID_PREFIX}${s}`; } +/** + * The longest namespace a scaffold emits: the longest base namespace that + * still has a paired customization namespace (`_custom`, ADR-0146 + * D3). A longer one passes the manifest grammar (20 characters) but has no + * pair, so `os validate` on the fresh project warns + * `namespace-no-customization-pair` about objects its author never wrote. + * + * It is `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` from + * `@objectstack/spec/kernel`, restated because this package cannot import it: + * the spec is a devDependency here, so a value import would be bundled into + * the published scaffolder (`check:undeclared-dep-imports`), and making it a + * runtime dependency would put zod into a two-dependency `npx` entry point. + * `scaffold-namespace-cap.test.ts` pins the two equal, so they cannot drift. + */ +export const SCAFFOLD_NAMESPACE_MAX_LENGTH = 13; + +/** + * Convert an npm package name into a valid ObjectStack namespace identifier + * (regex `^[a-z][a-z0-9_]{1,19}$`, reserved: base/system/sys) that is at most + * {@link SCAFFOLD_NAMESPACE_MAX_LENGTH} characters, so it has a paired + * customization namespace. A longer name is truncated, with no trailing `_`. + * Mirrors `sanitizeNamespace()` in `@objectstack/cli`'s `init` command, so + * both scaffolders agree. + */ +export function sanitizeNamespace(name: string): string { + const max = SCAFFOLD_NAMESPACE_MAX_LENGTH; + let s = name.replace(/^@[^/]+\//, ''); + s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + s = s.replace(/^_+|_+$/g, ''); + if (!s) s = 'app'; + if (/^[0-9]/.test(s)) s = 'a' + s; + if (s.length < 2) s = (s + '_app').slice(0, max); + if (s.length > max) s = s.slice(0, max).replace(/_+$/, ''); + if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, max); + return s; +} + /** Every `*.ts` file under `dir`, recursively. Missing dir → empty. */ function tsFiles(dir: string, out: string[] = []): string[] { if (!fs.existsSync(dir)) return out; diff --git a/packages/create-objectstack/src/scaffold-namespace-cap.test.ts b/packages/create-objectstack/src/scaffold-namespace-cap.test.ts new file mode 100644 index 00000000000..744c2038f02 --- /dev/null +++ b/packages/create-objectstack/src/scaffold-namespace-cap.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// `sanitizeNamespace()` emits a namespace that has a paired customization +// namespace (ADR-0146 D3), judged by the spec's own `customizationNamespace`, +// and its cap is pinned equal to the spec constant it restates. + +import { describe, it, expect } from 'vitest'; +import { + MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, + ManifestSchema, + customizationNamespace, +} from '@objectstack/spec/kernel'; +import { SCAFFOLD_NAMESPACE_MAX_LENGTH, sanitizeNamespace } from './rewrite-identity.js'; + +/** Names a scaffolder is handed: short, exact-fit, long, scoped, odd. */ +const NAMES = [ + '', 'x', 'a', '1', 'my-app', 'crm', 'base', 'system', 'sys', 'Base', '@acme/sys', + 'abcdefghijklm', 'abcdefghijklmn', 'qa-console-first-paint', 'abcdefghijkl-mnop', + 'abcdefghijk--lmnop', '1234567890123456', '9-lives', '@acme/very-long-project-name', + 'My Cool App!!', '__leading', 'trailing__', 'a.b.c.d.e.f.g.h.i', 'ünïcödé-name', + 'abcdefghijklmnopqrstuvwxyz', 'twelve-chars', 'thirteen-char', +]; + +describe('the scaffold namespace cap', () => { + it('is the spec constant it restates', () => { + expect(SCAFFOLD_NAMESPACE_MAX_LENGTH).toBe(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH); + }); + + it.each(NAMES)('%j sanitizes to a namespace the manifest accepts and that has a customization pair', (name) => { + const ns = sanitizeNamespace(name); + expect(ManifestSchema.shape.namespace.safeParse(ns).success, ns).toBe(true); + expect(['base', 'system', 'sys']).not.toContain(ns); + expect(customizationNamespace(ns), ns).toEqual({ ok: true, value: `${ns}_custom` }); + }); +}); + +describe('sanitizeNamespace', () => { + it.each([ + ['qa-console-first-paint', 'qa_console_fi'], + // The 13th character is `_`: dropped, never left trailing. + ['abcdefghijkl-mnop', 'abcdefghijkl'], + ['@acme/very-long-project-name', 'very_long_pro'], + ['1234567890123456', 'a123456789012'], + ['abcdefghijklmn', 'abcdefghijklm'], + ])('truncates %j to %j', (name, expected) => { + expect(sanitizeNamespace(name)).toBe(expected); + }); + + // Names that already fit are scaffolded exactly as before. + it.each([ + ['my-app', 'my_app'], + ['crm', 'crm'], + ['abcdefghijklm', 'abcdefghijklm'], + ['thirteen-char', 'thirteen_char'], + ['', 'app'], + ['x', 'x_app'], + ['9-lives', 'a9_lives'], + ['sys', 'sys_app'], + ['system', 'system_app'], + ['@acme/my-app', 'my_app'], + ])('keeps %j as %j', (name, expected) => { + expect(sanitizeNamespace(name)).toBe(expected); + }); +}); diff --git a/packages/create-objectstack/src/scaffold-namespace-customization-pair.test.ts b/packages/create-objectstack/src/scaffold-namespace-customization-pair.test.ts new file mode 100644 index 00000000000..99ed187026f --- /dev/null +++ b/packages/create-objectstack/src/scaffold-namespace-customization-pair.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A scaffold's namespace has a paired customization namespace, so the first +// `os validate` on a fresh project prints no `namespace-no-customization-pair` +// warning about objects its author never wrote. +// +// The rule (ADR-0146 D3, `@objectstack/lint`'s `validateCustomizationNamespace`) +// warns when `customizationNamespace(manifest.namespace)` answers +// `base_namespace_too_long`. The verdict here is that same helper's, read from +// the spec, so the scaffold is judged by the function the ensure door creates +// the pair with, never by a number restated in this file. +// +// Drives the real CLI end to end via `tsx`: index.ts calls `program.parse()` on +// import (the pattern of scaffold-namespace-extends-template.test.ts). + +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 { customizationNamespace, validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; +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 scaffold from a long project name', () => { + it('`qa-console-first-paint` gets a namespace with a customization pair, and every object follows it', () => { + const project = 'qa-console-first-paint'; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-ns-pair-')); + const run = spawnSync(TSX, [INDEX_TS, project, '--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 namespace = readTemplateNamespace(projectDir); + expect(namespace).toBe('qa_console_fi'); + expect(customizationNamespace(namespace)).toEqual({ ok: true, value: 'qa_console_fi_custom' }); + + // The config's `namespace:` literal is the same value the manifest carries. + const config = fs.readFileSync(path.join(projectDir, 'objectstack.config.ts'), 'utf8'); + expect(/\bnamespace:\s*'([^']+)'/.exec(config)?.[1]).toBe(namespace); + + // Every object the scaffold ships sits under the shortened namespace. + const objects = nameLiterals(path.join(projectDir, 'src')).filter((n) => /_/.test(n)); + expect(objects.length).toBeGreaterThan(0); + for (const name of objects.filter((n) => n.startsWith('qa_'))) { + expect(validateObjectNamespacePrefix(name, namespace as string), name).toBeNull(); + } + expect(objects.some((n) => n.startsWith(`${namespace}_`))).toBe(true); + }, 30_000); +});