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
13 changes: 13 additions & 0 deletions .changeset/28992-scaffold-namespace-pair-cap.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 14 additions & 6 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
22 changes: 21 additions & 1 deletion packages/cli/test/init.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
18 changes: 1 addition & 17 deletions packages/create-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { syncObjectStackDeps } from './pkg-utils.js';
import { copyDir } from './template-copy.js';
import {
deriveManifestId,
sanitizeNamespace,
readTemplateNamespace,
rewriteObjectNamePrefix,
findStaleNamespacePrefixes,
Expand Down Expand Up @@ -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');
Expand Down
37 changes: 37 additions & 0 deletions packages/create-objectstack/src/rewrite-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<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;
Expand Down
64 changes: 64 additions & 0 deletions packages/create-objectstack/src/scaffold-namespace-cap.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
Loading