Skip to content

Commit a1ae469

Browse files
fix(scaffold): a scaffolded namespace fits the customization-pair cap, so a fresh project from a long name validates with no warning (#29103)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XWpxb51mia4Ts4VrhocktU6)_ Fixes #28992 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`npx create-objectstack` 和 `os init` 由项目名生成命名空间时,上限从 20 个字符改为 13 个(即 `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH`)。`qa-console-first-paint` 现在得到 `qa_console_fi`,脚手架里的对象名和文件名都跟着它走;截断后不留结尾的 `_`。13 个字符以内的项目名,生成结果与以前逐字节相同。 - 为什么改:超过 13 个字符的命名空间没有配对的定制命名空间(ADR-0146 D3),所以新人刚生成的项目第一次 `os validate` 就弹出 `namespace-no-customization-pair` 警告,让他去改不是他写的对象名。 - 风险与回滚:只影响新生成的项目,已有项目不变。`create-objectstack` 运行时不依赖 spec,所以保留一份本地常量,由测试钉死等于 spec 的常量;`os init` 直接读 spec 常量。回滚即 revert。 - 先看:`packages/create-objectstack/src/rewrite-identity.ts` 的 `SCAFFOLD_NAMESPACE_MAX_LENGTH` 与 `sanitizeNamespace()`(从 index.ts 原样搬来,只改上限);`packages/cli/src/commands/init.ts` 的 `sanitizeNamespace()`;`packages/create-objectstack/src/scaffold-namespace-cap.test.ts` 的常量相等钉。 - 两分钟验证:`pnpm --filter create-objectstack exec vitest run src/scaffold-namespace-cap.test.ts src/scaffold-namespace-customization-pair.test.ts`,预期 `Tests 44 passed (44)`。 - 需要你做的:无,席位落地。 ## What changes - **`packages/create-objectstack/src/rewrite-identity.ts`**: `sanitizeNamespace()` moves here from `index.ts`, beside its sibling `deriveManifestId()`, so it can be unit tested (`index.ts` calls `program.parse()` on import). The only behaviour change is the cap: `SCAFFOLD_NAMESPACE_MAX_LENGTH = 13` replaces the three `20`s. The existing trailing-`_` strip after truncation is kept, so a namespace whose 13th character is `_` ends one character earlier. - **Why a local constant in create-objectstack (measured).** The order asked to read `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` from one place if the scaffolder can depend on it cheaply. `@objectstack/spec` is only a devDependency of create-objectstack. A value import from `src/` would be bundled into the published scaffolder, and `check:undeclared-dep-imports` refuses exactly that (a devDependency inlined into published source). Making the spec a runtime dependency would add `zod` and `pg-connection-string` to a two-dependency `npx` entry point. Bundle size was not the obstacle: an esbuild probe from spec source gives 3,121 bytes and no zod. So the order's fallback applies: a local constant, pinned equal to the spec constant by `scaffold-namespace-cap.test.ts`, which resolves `@objectstack/spec/kernel` to source through the package's existing vitest alias. - **`packages/create-objectstack/src/index.ts`**: imports `sanitizeNamespace` from `./rewrite-identity.js`. The namespace is still computed once (`const namespace = sanitizeNamespace(projectName)`) and handed to the manifest, the config and the object-prefix rewrite, so every derived name uses the truncated value. - **`packages/cli/src/commands/init.ts`** (`os init`): the same function had the same 20 cap. It now reads `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` from `@objectstack/spec/kernel`, a runtime dependency of the CLI already. The seat widened the claim to cover it, so the two scaffolders keep agreeing. - **`.changeset/28992-scaffold-namespace-pair-cap.md`**: `create-objectstack` patch and `@objectstack/cli` patch, with both declaration lines. Nothing newly exported from `@objectstack/spec`. The `create-objectstack` `index.ts` export of `sanitizeNamespace` is not in its `exports` map (only `./created-summary` is), so no published surface moved. ## Tests - **Red first, unit (real scaffold).** New `scaffold-namespace-customization-pair.test.ts` drives the real CLI through `tsx` for `qa-console-first-paint`. On the unchanged code it failed with `Expected: "qa_console_fi"`, `Received: "qa_console_first_pai"`. Green, it asserts that the namespace is `qa_console_fi`, that `customizationNamespace()` (the helper the lint rule and the ensure door use) answers `{ ok: true, value: 'qa_console_fi_custom' }`, that the config literal matches, and that every scaffolded object name passes `validateObjectNamespacePrefix`. - **New `scaffold-namespace-cap.test.ts`** (43 cases): - the constant equals `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH`; - for 27 names (empty, single character, reserved, scoped, digit-leading, Unicode, exactly 13, 14, 26 characters), the result passes `ManifestSchema.shape.namespace`, is not reserved, and has a customization pair; - truncation cases, including the 13th character being `_` (`abcdefghijkl-mnop` → `abcdefghijkl`) and a digit-leading name (`1234567890123456` → `a123456789012`); - controls: names that already fit (`my-app`, `crm`, `abcdefghijklm`, `thirteen-char`, `''`, `x`, `9-lives`, `sys`, `system`, `@acme/my-app`) keep their previous value. - **cli `test/init.test.ts`**: five truncation cases and one customization-pair sweep added to its `sanitizeNamespace` block. - **Real `os validate` (red and green), on scaffolds under `packages/cli/node_modules/.probe-28992/`, run with the built `packages/cli/bin/run.js`:** - main's `index.ts` (from `0be247ce`): namespace `qa_console_first_pai`, `os validate` exit 0 with 1 `namespace-no-customization-pair` warning ("namespace "qa_console_first_pai" is 20 characters …"). - this branch: namespace `qa_console_fi`, `os validate` exit 0, 0 occurrences of the rule and 0 `⚠` lines. - `os init qa-console-first-paint --no-install` on this branch: `Namespace: qa_console_fi`, object file `qa_console_fi_item.object.ts`, `os validate` exit 0 with no `namespace-no-customization-pair`. (It prints the pre-existing `⚠ No apps or plugins defined` for the `app` template, which does not depend on the namespace.) - **Ablation** (`scripts/ablation-replace.mjs`, anchor hit once each; restore proved by blob == HEAD and an empty `git diff HEAD`): - create-objectstack, `SCAFFOLD_NAMESPACE_MAX_LENGTH = 13` → `20` (blob `8fb4bd56` → `3a388a6f`): `pnpm --filter create-objectstack test` reds 15 failed / 330 passed, in the 2 new files only, while the 19 existing files stay green. The red cases are the constant pin, the 8 long names in the pair sweep, the 5 truncation cases, and the real-scaffold case. - cli, `const max = MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` → `const max = 20` (blob `ef45f599` → `a44b2c1f`): the five init suites red 5 failed / 118 passed, exactly the 4 new truncation cases and the pair sweep. The exact-fit control stays green. - No build was on either path: both suites import the changed file as relative source. - **Package suites** at `c422fbc2` (the follow-up `51ac52ae` changes one comment line in a test): `pnpm --filter create-objectstack test` 21 files / 345 tests passed, and its `typecheck` (which includes `src/**/*.test.ts`) passed. `pnpm --filter @objectstack/cli typecheck` passed, and `vitest run --project unit` passed 368 files / 5,202 tests. The cli `integration` layer is left to CI: no integration-layer file or spawn entry is touched. ## Acceptance notes - No "namespace was shortened" line was added. `create-objectstack` and `os init` already print `Namespace: VALUE` before writing, and the value is now the truncated one. A separate notice would need the untruncated value threaded out of `sanitizeNamespace()`, which is more than the order's "only if trivial". - `docs/audits/2026-09-os-create-os-init-convergence-measurement.md` mentions `sanitizeNamespace()` in a dated audit table. Its claim (one call site, the config namespace) still holds, so it was left as is. ## Verification Head `51ac52ae`. - **Derived gate families** (`node scripts/pm/dispatch-gates.mjs --commands`, change set vs merge base `0be247ce8`): 62 derived. 60 ran green (exit 0). 2 NOT MEASURED, reason: prerequisite, because they read every package's built output and only the CLI closure was built here: `check:dual-build-cjs-loads` and `check:i18n-coverage` (exit 3, `PREREQUISITE NOT MET`). CI runs both. Reconciliation: `dispatch-gates --ran` → `62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED`. - One real red, fixed on this branch: `check:cross-package-test-inputs` read a repo path spelled in a test comment (`packages/lint/src/…`) as an undeclared input of the new real-scaffold test. The comment now names the rule by its export (`51ac52ae`), and the gate passes: `OK: 31 package(s) read outside themselves, all declared`. - `check-plugin-teardown-shape.mjs --self-test` first exited 3 because the shallow clone could not reach its pinned fixture commit. After `git fetch --depth=1` of that commit it passed (48 cases). - `test:repo` was not run locally (no file of that project is touched). CI's Test Core covers it. <details><summary>Gate record (60 + 2)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 3 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:scaffold-emission-policy :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oqaD6uoTrtmva9fde)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 104cd1a commit a1ae469

7 files changed

Lines changed: 225 additions & 24 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'create-objectstack': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
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)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
`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.
12+
13+
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.

‎packages/cli/src/commands/init.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Args, Command, Flags } from '@oclif/core';
4-
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
4+
import { MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
55
import chalk from 'chalk';
66
import fs from 'fs';
77
import path from 'path';
@@ -67,18 +67,26 @@ function pkgVersion(): string {
6767
*
6868
* npm names allow hyphens/dots/scopes (e.g. `@acme/my-app`); identifiers don't.
6969
* We strip the scope, replace separators with `_`, lowercase, prefix a leading
70-
* digit, truncate to 20, and pad short names so the result always satisfies
71-
* the regex. Reserved names get a `_app` suffix.
70+
* digit, truncate, and pad short names so the result always satisfies the
71+
* regex. Reserved names get a `_app` suffix.
72+
*
73+
* The cap is `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` (13), not the grammar's
74+
* 20: a longer namespace has no paired customization namespace (ADR-0146 D3),
75+
* so `os validate` on the fresh project would warn
76+
* `namespace-no-customization-pair` about objects its author never wrote.
77+
* `create-objectstack`'s `sanitizeNamespace()` restates the same rule, pinned
78+
* to the same constant, so both scaffolders agree.
7279
*/
7380
export function sanitizeNamespace(name: string): string {
81+
const max = MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH;
7482
let s = name.replace(/^@[^/]+\//, ''); // drop npm scope
7583
s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_'); // separators → _
7684
s = s.replace(/^_+|_+$/g, ''); // trim underscores
7785
if (!s) s = 'app';
7886
if (/^[0-9]/.test(s)) s = 'a' + s; // must start with a letter
79-
if (s.length < 2) s = (s + '_app').slice(0, 20);
80-
if (s.length > 20) s = s.slice(0, 20).replace(/_+$/, '');
81-
if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, 20);
87+
if (s.length < 2) s = (s + '_app').slice(0, max);
88+
if (s.length > max) s = s.slice(0, max).replace(/_+$/, '');
89+
if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, max);
8290
return s;
8391
}
8492

‎packages/cli/test/init.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
4+
import { PROTOCOL_MAJOR, customizationNamespace } from '@objectstack/spec/kernel';
55
import fs from 'fs';
66
import os from 'os';
77
import path from 'path';
@@ -404,6 +404,26 @@ describe('sanitizeNamespace', () => {
404404
expect(sanitizeNamespace(input)).toMatch(NS_RE);
405405
}
406406
});
407+
408+
// A namespace too long for a paired customization namespace (ADR-0146 D3)
409+
// makes `os validate` warn `namespace-no-customization-pair` on the fresh
410+
// scaffold, about objects its author never wrote.
411+
it.each([
412+
['qa-console-first-paint', 'qa_console_fi'],
413+
['abcdefghijkl-mnop', 'abcdefghijkl'], // the 13th character is `_`: dropped
414+
['@acme/very-long-project-name', 'very_long_pro'],
415+
['1234567890123456', 'a123456789012'],
416+
['abcdefghijklm', 'abcdefghijklm'],
417+
])('caps %s at the customization-pair limit → %s', (input, expected) => {
418+
expect(sanitizeNamespace(input)).toBe(expected);
419+
});
420+
421+
it('always produces a namespace that has a customization pair', () => {
422+
for (const input of ['my-app', '123app', 'sys', 'a', '', 'qa-console-first-paint', 'really-long-name-truncated-here', 'abcdefghijkl-mnop']) {
423+
const ns = sanitizeNamespace(input);
424+
expect(customizationNamespace(ns), ns).toEqual({ ok: true, value: `${ns}_custom` });
425+
}
426+
});
407427
});
408428

409429
describe('scaffold rendering — round-trip', () => {

‎packages/create-objectstack/src/index.ts‎

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import { syncObjectStackDeps } from './pkg-utils.js';
7474
import { copyDir } from './template-copy.js';
7575
import {
7676
deriveManifestId,
77+
sanitizeNamespace,
7778
readTemplateNamespace,
7879
rewriteObjectNamePrefix,
7980
findStaleNamespacePrefixes,
@@ -107,23 +108,6 @@ function toTitleCase(str: string): string {
107108
return str.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
108109
}
109110

110-
/**
111-
* Convert an npm package name into a valid ObjectStack namespace identifier
112-
* (regex `^[a-z][a-z0-9_]{1,19}$`, reserved: base/system/sys). Mirrors the
113-
* implementation in `@objectstack/cli` so both scaffolders agree.
114-
*/
115-
export function sanitizeNamespace(name: string): string {
116-
let s = name.replace(/^@[^/]+\//, '');
117-
s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_');
118-
s = s.replace(/^_+|_+$/g, '');
119-
if (!s) s = 'app';
120-
if (/^[0-9]/.test(s)) s = 'a' + s;
121-
if (s.length < 2) s = (s + '_app').slice(0, 20);
122-
if (s.length > 20) s = s.slice(0, 20).replace(/_+$/, '');
123-
if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, 20);
124-
return s;
125-
}
126-
127111
function readCliVersion(): string {
128112
try {
129113
const pkgPath = path.resolve(__dirname, '..', 'package.json');

‎packages/create-objectstack/src/rewrite-identity.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,43 @@ export function deriveManifestId(projectName: string): string {
116116
return `${SCAFFOLD_ID_PREFIX}${s}`;
117117
}
118118

119+
/**
120+
* The longest namespace a scaffold emits: the longest base namespace that
121+
* still has a paired customization namespace (`<namespace>_custom`, ADR-0146
122+
* D3). A longer one passes the manifest grammar (20 characters) but has no
123+
* pair, so `os validate` on the fresh project warns
124+
* `namespace-no-customization-pair` about objects its author never wrote.
125+
*
126+
* It is `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` from
127+
* `@objectstack/spec/kernel`, restated because this package cannot import it:
128+
* the spec is a devDependency here, so a value import would be bundled into
129+
* the published scaffolder (`check:undeclared-dep-imports`), and making it a
130+
* runtime dependency would put zod into a two-dependency `npx` entry point.
131+
* `scaffold-namespace-cap.test.ts` pins the two equal, so they cannot drift.
132+
*/
133+
export const SCAFFOLD_NAMESPACE_MAX_LENGTH = 13;
134+
135+
/**
136+
* Convert an npm package name into a valid ObjectStack namespace identifier
137+
* (regex `^[a-z][a-z0-9_]{1,19}$`, reserved: base/system/sys) that is at most
138+
* {@link SCAFFOLD_NAMESPACE_MAX_LENGTH} characters, so it has a paired
139+
* customization namespace. A longer name is truncated, with no trailing `_`.
140+
* Mirrors `sanitizeNamespace()` in `@objectstack/cli`'s `init` command, so
141+
* both scaffolders agree.
142+
*/
143+
export function sanitizeNamespace(name: string): string {
144+
const max = SCAFFOLD_NAMESPACE_MAX_LENGTH;
145+
let s = name.replace(/^@[^/]+\//, '');
146+
s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_');
147+
s = s.replace(/^_+|_+$/g, '');
148+
if (!s) s = 'app';
149+
if (/^[0-9]/.test(s)) s = 'a' + s;
150+
if (s.length < 2) s = (s + '_app').slice(0, max);
151+
if (s.length > max) s = s.slice(0, max).replace(/_+$/, '');
152+
if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, max);
153+
return s;
154+
}
155+
119156
/** Every `*.ts` file under `dir`, recursively. Missing dir → empty. */
120157
function tsFiles(dir: string, out: string[] = []): string[] {
121158
if (!fs.existsSync(dir)) return out;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// `sanitizeNamespace()` emits a namespace that has a paired customization
4+
// namespace (ADR-0146 D3), judged by the spec's own `customizationNamespace`,
5+
// and its cap is pinned equal to the spec constant it restates.
6+
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH,
10+
ManifestSchema,
11+
customizationNamespace,
12+
} from '@objectstack/spec/kernel';
13+
import { SCAFFOLD_NAMESPACE_MAX_LENGTH, sanitizeNamespace } from './rewrite-identity.js';
14+
15+
/** Names a scaffolder is handed: short, exact-fit, long, scoped, odd. */
16+
const NAMES = [
17+
'', 'x', 'a', '1', 'my-app', 'crm', 'base', 'system', 'sys', 'Base', '@acme/sys',
18+
'abcdefghijklm', 'abcdefghijklmn', 'qa-console-first-paint', 'abcdefghijkl-mnop',
19+
'abcdefghijk--lmnop', '1234567890123456', '9-lives', '@acme/very-long-project-name',
20+
'My Cool App!!', '__leading', 'trailing__', 'a.b.c.d.e.f.g.h.i', 'ünïcödé-name',
21+
'abcdefghijklmnopqrstuvwxyz', 'twelve-chars', 'thirteen-char',
22+
];
23+
24+
describe('the scaffold namespace cap', () => {
25+
it('is the spec constant it restates', () => {
26+
expect(SCAFFOLD_NAMESPACE_MAX_LENGTH).toBe(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH);
27+
});
28+
29+
it.each(NAMES)('%j sanitizes to a namespace the manifest accepts and that has a customization pair', (name) => {
30+
const ns = sanitizeNamespace(name);
31+
expect(ManifestSchema.shape.namespace.safeParse(ns).success, ns).toBe(true);
32+
expect(['base', 'system', 'sys']).not.toContain(ns);
33+
expect(customizationNamespace(ns), ns).toEqual({ ok: true, value: `${ns}_custom` });
34+
});
35+
});
36+
37+
describe('sanitizeNamespace', () => {
38+
it.each([
39+
['qa-console-first-paint', 'qa_console_fi'],
40+
// The 13th character is `_`: dropped, never left trailing.
41+
['abcdefghijkl-mnop', 'abcdefghijkl'],
42+
['@acme/very-long-project-name', 'very_long_pro'],
43+
['1234567890123456', 'a123456789012'],
44+
['abcdefghijklmn', 'abcdefghijklm'],
45+
])('truncates %j to %j', (name, expected) => {
46+
expect(sanitizeNamespace(name)).toBe(expected);
47+
});
48+
49+
// Names that already fit are scaffolded exactly as before.
50+
it.each([
51+
['my-app', 'my_app'],
52+
['crm', 'crm'],
53+
['abcdefghijklm', 'abcdefghijklm'],
54+
['thirteen-char', 'thirteen_char'],
55+
['', 'app'],
56+
['x', 'x_app'],
57+
['9-lives', 'a9_lives'],
58+
['sys', 'sys_app'],
59+
['system', 'system_app'],
60+
['@acme/my-app', 'my_app'],
61+
])('keeps %j as %j', (name, expected) => {
62+
expect(sanitizeNamespace(name)).toBe(expected);
63+
});
64+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A scaffold's namespace has a paired customization namespace, so the first
4+
// `os validate` on a fresh project prints no `namespace-no-customization-pair`
5+
// warning about objects its author never wrote.
6+
//
7+
// The rule (ADR-0146 D3, `@objectstack/lint`'s `validateCustomizationNamespace`)
8+
// warns when `customizationNamespace(manifest.namespace)` answers
9+
// `base_namespace_too_long`. The verdict here is that same helper's, read from
10+
// the spec, so the scaffold is judged by the function the ensure door creates
11+
// the pair with, never by a number restated in this file.
12+
//
13+
// Drives the real CLI end to end via `tsx`: index.ts calls `program.parse()` on
14+
// import (the pattern of scaffold-namespace-extends-template.test.ts).
15+
16+
import { describe, it, expect, afterEach } from 'vitest';
17+
import { spawnSync } from 'node:child_process';
18+
import fs from 'node:fs';
19+
import os from 'node:os';
20+
import path from 'node:path';
21+
import { fileURLToPath } from 'node:url';
22+
import { customizationNamespace, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
23+
import { readTemplateNamespace } from './rewrite-identity.js';
24+
25+
const HERE = path.dirname(fileURLToPath(import.meta.url));
26+
const PKG_ROOT = path.resolve(HERE, '..');
27+
const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..');
28+
const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
29+
const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts');
30+
31+
/** Every `name: '…'` literal in the `*.ts` files under `dir`. */
32+
function nameLiterals(dir: string): string[] {
33+
const names: string[] = [];
34+
for (const rel of fs.readdirSync(dir, { recursive: true, encoding: 'utf8' })) {
35+
if (!rel.endsWith('.ts')) continue;
36+
const text = fs.readFileSync(path.join(dir, rel), 'utf8');
37+
for (const m of text.matchAll(/\bname:\s*(['"`])([a-z0-9_]+)\1/g)) names.push(m[2]);
38+
}
39+
return names.sort();
40+
}
41+
42+
let tmp: string | undefined;
43+
afterEach(() => {
44+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
45+
tmp = undefined;
46+
});
47+
48+
describe('a scaffold from a long project name', () => {
49+
it('`qa-console-first-paint` gets a namespace with a customization pair, and every object follows it', () => {
50+
const project = 'qa-console-first-paint';
51+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-ns-pair-'));
52+
const run = spawnSync(TSX, [INDEX_TS, project, '--skip-install', '--skip-skills'], {
53+
cwd: tmp,
54+
encoding: 'utf8',
55+
});
56+
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
57+
58+
const projectDir = path.join(tmp, project);
59+
const namespace = readTemplateNamespace(projectDir);
60+
expect(namespace).toBe('qa_console_fi');
61+
expect(customizationNamespace(namespace)).toEqual({ ok: true, value: 'qa_console_fi_custom' });
62+
63+
// The config's `namespace:` literal is the same value the manifest carries.
64+
const config = fs.readFileSync(path.join(projectDir, 'objectstack.config.ts'), 'utf8');
65+
expect(/\bnamespace:\s*'([^']+)'/.exec(config)?.[1]).toBe(namespace);
66+
67+
// Every object the scaffold ships sits under the shortened namespace.
68+
const objects = nameLiterals(path.join(projectDir, 'src')).filter((n) => /_/.test(n));
69+
expect(objects.length).toBeGreaterThan(0);
70+
for (const name of objects.filter((n) => n.startsWith('qa_'))) {
71+
expect(validateObjectNamespacePrefix(name, namespace as string), name).toBeNull();
72+
}
73+
expect(objects.some((n) => n.startsWith(`${namespace}_`))).toBe(true);
74+
}, 30_000);
75+
});

0 commit comments

Comments
 (0)