Skip to content

Commit 96451ec

Browse files
fix(spec): manifest.namespace refusal and TSDoc name the leading-letter rule (#19869)
Fixes #19831 Clause-②: no `manifest.namespace` is enforced by `^[a-z][a-z0-9_]{1,19}$`, so its first character must be a lowercase letter. The refusal sentence and the TSDoc `Rules:` line only stated the length and the charset. `1leave` and `_leave` satisfied every clause an author was shown, and the regex still refused them. This PR makes both texts state what the regex enforces. The regex is byte-identical, so the accept set does not move. ## What changes | surface | before | after | |:--|:--|:--| | `ManifestSchema.namespace` refusal (`packages/spec/src/kernel/manifest.zod.ts`) | `Namespace must be 2-20 chars, lowercase alphanumeric + underscore` | `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores` | | same key, TSDoc `Rules:` line | `2-20 characters, lowercase letters, digits, and underscores only.` | `2-20 characters, starting with a lowercase letter; lowercase letters, digits, and underscores only (1leave and _leave are refused).` | | `PackageSchema.namespace` refusal (`packages/spec/src/marketplace/package.zod.ts`). Through it, `CreatePackageRequestSchema.namespace` and the scaffold-only `TemplateManifestSchema.namespace` too | byte-identical copy of the old sentence | the new sentence, byte-identical to the manifest's | The `package.zod.ts` copy is the second author-facing restatement. The step-1 sweep below found it in `packages/spec`, triage's grading named it, and the dispatch authorised fixing it here. If only one copy changed, the manifest and the publish payload would prescribe two different rules for one vocabulary. Its in-code comment now says that the sentence is kept identical as well as the pattern. The new sentence has no trailing period on purpose. `duplicatePackage` in `metadata-protocol` appends `. ` after the declaration's message (`protocol.ts`, the explicit-`targetNamespace` and derived-namespace refusals). ## Measured, before and after Each value was parsed through `ManifestSchema.shape.namespace` from source: `origin/main` `0e90a8d1c5` before, this branch `27704f4cc2` after. | value | before | after | |:--|:--|:--| | `1leave` | refused, old sentence | refused, new sentence | | `_leave` | refused, old sentence | refused, new sentence | | `a` (1 char) | refused, old sentence | refused, new sentence | | 21-char name | refused, old sentence | refused, new sentence | | `Leave` | refused, old sentence | refused, new sentence | | `leave`, `ab`, 20-char name | accepted | accepted | All refusals are one issue with code `invalid_format`. The verdicts are identical on both sides. Only the text changed. ## Restatement census (step 1) This is `git grep` over the whole tree at `0e90a8d1c5`, for the regex literal, the sentence, `2-20 chars`, `2-20 characters` and `alphanumeric + underscore`. CHANGELOGs and release pages are excluded. | site | states the rule completely? | action | |:--|:--|:--| | `packages/spec/src/kernel/manifest.zod.ts` refusal + TSDoc | no (the leading letter is missing) | fixed here | | `packages/spec/src/marketplace/package.zod.ts` refusal | no (same sentence) | fixed here | | `packages/spec/src/api/endpoint-publish-gate.ts` refusal (`apis:` without a legal namespace) | yes, it quotes the regex | none | | `packages/spec/src/kernel/namespace-prefix.ts` code comment `(2-20 chars)` | partial, but it is an internal comment directly above the regex literal and not author-facing | none | | `packages/cli/src/commands/package/publish.ts` refusal + docblock | yes ("a lowercase letter followed by lowercase letters, digits or underscores") | outside `packages/spec`, none | | `packages/cli/src/commands/init.ts` docblock | yes, it quotes the regex | outside `packages/spec`, none | | `packages/create-objectstack/src/index.ts` docblock | yes, it quotes the regex | outside `packages/spec`, none | | `content/docs/api/declarative-endpoints.mdx` | yes, it quotes the regex | outside `packages/spec`, none | | `docs/adr/0048-…`, `docs/adr/0121-…` | yes, they quote the regex | outside `packages/spec`, none | | `.changeset/19577-duplicate-package-explicit-namespace.md` (unreleased) | it quotes the OLD sentence verbatim as `duplicatePackage`'s refusal text | outside `packages/spec`, not edited. See Acceptance notes | | `skills/**` | no restatement of the charset rule | none | | `content/docs/references/**` (generated) | renders the `.describe()` text only, which states no rule | none | ## Pins of the old sentence (step 4) This is `git grep` over all of `packages/` (and `examples/`, `apps/`, `scripts/`) for the sentence and its fragments. - `packages/spec/src/marketplace/package-namespace.test.ts` pinned it verbatim twice (`PackageSchema` and `TemplateManifestSchema` refusals). Both now pin the new text. - `packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts` reads the sentence from `ManifestSchema.shape.namespace` at run time and never retypes it. No edit was needed, and it passes against the rebuilt spec. - Zero other hits. The sibling `objectui` (`origin/main` `544ecba`) has zero hits too, with the control word `namespace` hitting. ## Generated artifacts (step 5) `pnpm --filter @objectstack/spec build` then `check:generated`: all 15 generated artifacts are up to date, and nothing was regenerated or committed. The `.describe()` text is unchanged, so `content/docs/references/kernel/manifest.mdx` does not move. The built `dist/` carries the new sentence in 18 files and the old one in 0. ## Changeset (step 6) `.changeset/manifest-namespace-leading-letter-refusal.md` is `"@objectstack/spec": patch`. The reading: - AGENTS.md Post-Task Checklist step 3 says a bug fix in a released package takes a `patch`. - The shipped text of `@objectstack/spec` changes: the built `dist/` carries the new sentence, so `skip-changeset` does not apply. - `Clause-②: no`: no authorable key is added, removed, widened or narrowed, and the regex and accept set are byte-identical. - `@objectstack/metadata-protocol` ships no changed bytes, because it reads the message at run time. So it has no changeset entry. ## Tests All of these ran at `27704f4cc2` unless another commit is noted. - New pins in `packages/spec/src/kernel/manifest.test.ts`: `1leave` and `_leave` are refused as exactly one `invalid_format` issue, and the message matches `start with a lowercase letter`. The same sentence still names `2-20 chars` and the charset. As a control, `leave`, `crm`, `my_app_2`, `a1` and a 20-char name still parse, both on the field and in a whole manifest. - New pin in `package-namespace.test.ts` ("two gates, one vocabulary"): all four namespace fields refuse `1leave` with the manifest's exact sentence. The existing verdict table could not see the prose, and this pin closes that class of drift. - `pnpm --filter @objectstack/spec test`: 525 files passed, 15503 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck` (tsc + scripts + `check:test-typecheck`): exit 0, and the debt ledger is unchanged. - `@objectstack/metadata-protocol`: built its dependency closure, then `pnpm --filter @objectstack/metadata-protocol test` ran 188 files passed and 3 skipped, and 2673 tests passed and 19 skipped. The verbose run of `protocol.duplicate-package-target-namespace.test.ts` passed 13/13. - **Firing control**, at `48ea3d9960` (same `packages/spec` tree): `scripts/ablation-replace.mjs` put today's sentence back through an anchor that must hit, with a shell trap restoring from `HEAD`. - Leg 1 changed `manifest.zod.ts` only. It went red, 4 failed / 85 passed: both leading-letter pins, the charset pin, and the four-field sentence pin. - Leg 2 changed `package.zod.ts` only (drift between the two declarations). It went red, 3 failed / 86 passed: the four-field sentence pin and both verbatim pins. - Restored, it went back to green, 89/89. The restore was proven by blob hash equal to `HEAD` and an empty `git diff HEAD` for both files. Nothing was left in the tree. - Lint was narrowed to the 4 touched `.ts` files: `eslint --no-inline-config --format json`, 4 files reported, 0 errors, 0 warnings. The population comes from eslint's own `isPathIgnored` (false for all 4). The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot change a verdict on an untouched file. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `27704f4cc2` derived 83 commands. All 83 ran, and `--ran` reconciles them as 83 run, 0 NOT-MEASURED, 0 UNRUN. Three gates first exited 3 (PREREQUISITE NOT MET, workspace `dist/` absent): `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` all three exit 0. Six roster gates in directories this diff touches also exit 0: `check-changeset-fixed`, `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. The spec `repo` vitest project and the CI-only jobs are left to CI. This branch is based on `0e90a8d1c5`. `origin/main` is 4 commits ahead (`2bbb462335`). None of them touches a file in this diff, and their diff has zero hits for the sentence. ## Acceptance notes - **Sibling key, same class, not fixed here.** The dispatch rules out every other `ManifestSchema` key. `manifest.id` / `PackageSchema.manifestId` (`manifestIdRefusal`, same file) say "lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not.", but the pattern also requires each segment to open with a letter. Measured at `0e90a8d1c5`: `com.example.2app` and `com.2example.app` are refused with exactly that sentence and no suggestion. They satisfy every clause it states. This is reported for the seat to file. - **The unreleased changeset `.changeset/19577-duplicate-package-explicit-namespace.md` (line 15)** quotes the old sentence as the text `duplicatePackage` returns. After this PR that quote describes text the runtime no longer produces. It is outside `packages/spec`, so it is not edited here. This PR's own changeset states the new sentence and names `duplicatePackage`, so the same release carries the correction. The replacement for the quoted fragment is `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores`. - PR #19637 edits the `version` key and the imports of `manifest.zod.ts`, and PR #19789 regenerates `manifest.mdx`. This PR touches neither region and moves no generated file. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd920ca commit 96451ec

5 files changed

Lines changed: 85 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the `manifest.namespace` refusal now names the leading-letter rule its pattern enforces
6+
7+
Clause-②: no
8+
9+
`manifest.namespace` is enforced by `^[a-z][a-z0-9_]{1,19}$`, so its FIRST character must be a lowercase letter. Its refusal sentence and its TSDoc `Rules:` line stated only the length and the charset, so `1leave` and `_leave` satisfied every clause an author was shown and were still refused, by a sentence that could not say why.
10+
11+
- **The refusal sentence** is now `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores`. It was `Namespace must be 2-20 chars, lowercase alphanumeric + underscore`.
12+
- **The same sentence on the publish payload.** `PackageSchema.namespace` and `CreatePackageRequestSchema.namespace` (`marketplace/package.zod.ts`, and through them the scaffold-only `TemplateManifestSchema.namespace`) carried a byte-identical copy of the old sentence and carry the new one. A new pin holds all four fields to `manifest.namespace`'s sentence, not only to its verdicts.
13+
- **The TSDoc `Rules:` line** now reads `2-20 characters, starting with a lowercase letter; lowercase letters, digits, and underscores only`.
14+
- **Doors that surface the sentence verbatim** carry the new text with no change of their own. For example, `duplicatePackage`'s refusal of an explicit `targetNamespace` reads the declaration's message, so its rule clause now names the leading letter too.
15+
16+
The accept set is unchanged: the pattern is byte-identical, and every value that parsed before still parses. Only the text shown on a refusal changes. A consumer that matched the old sentence verbatim needs to match the new one.

‎packages/spec/src/kernel/manifest.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,3 +675,44 @@ describe('manifest.id — reverse-domain identifier', () => {
675675
}
676676
});
677677
});
678+
679+
describe('manifest.namespace — the refusal states the rule the regex enforces', () => {
680+
// The pattern is `^[a-z][a-z0-9_]{1,19}$`: the FIRST character must be a
681+
// lowercase letter. A refusal sentence that only says "2-20 chars, lowercase
682+
// alphanumeric + underscore" is satisfied by `1leave` and `_leave`, so the
683+
// author it refuses is told a rule they already follow. The sentence is also
684+
// surfaced verbatim by other doors (e.g. `duplicatePackage`'s explicit
685+
// `targetNamespace`), so it has to carry the whole rule on its own.
686+
const namespaceRefusal = (value: string) => {
687+
const r = ManifestSchema.shape.namespace.safeParse(value);
688+
expect(r.success, `'${value}' must be refused`).toBe(false);
689+
const issues = r.success ? [] : r.error.issues;
690+
expect(issues).toHaveLength(1);
691+
expect(issues[0].code).toBe('invalid_format');
692+
return issues[0].message;
693+
};
694+
695+
it.each([
696+
['1leave', 'a leading digit'],
697+
['_leave', 'a leading underscore'],
698+
])('refuses %s (%s) with a sentence naming the leading-letter rule', (value) => {
699+
expect(namespaceRefusal(value)).toMatch(/start with a lowercase letter/);
700+
});
701+
702+
it('the same sentence still names the length and the charset', () => {
703+
const msg = namespaceRefusal('1leave');
704+
expect(msg).toMatch(/2-20 chars/);
705+
expect(msg).toMatch(/lowercase letters, digits and underscores/);
706+
});
707+
708+
it.each(['leave', 'crm', 'my_app_2', 'a1', 'abcdefghijklmnopqrst'])(
709+
'lit control — %s still parses',
710+
(value) => {
711+
expect(ManifestSchema.shape.namespace.safeParse(value).success).toBe(true);
712+
expect(
713+
ManifestSchema.safeParse({ id: 'com.example.leave', version: '1.0.0', type: 'app', name: 'X', namespace: value })
714+
.success,
715+
).toBe(true);
716+
},
717+
);
718+
});

‎packages/spec/src/kernel/manifest.zod.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,15 +379,16 @@ export const ManifestSchema = strictObject({
379379
* multiple packages installed in the same database cannot collide.
380380
*
381381
* Rules:
382-
* - 2-20 characters, lowercase letters, digits, and underscores only.
382+
* - 2-20 characters, starting with a lowercase letter; lowercase letters,
383+
* digits, and underscores only (`1leave` and `_leave` are refused).
383384
* - Must be unique within a running instance.
384385
* - Platform-reserved namespaces: "base", "system", "sys".
385386
* - Object names starting with `sys_` are reserved for the platform
386387
* and exempt from the namespace-prefix check (apps may reference
387388
* them but never define them).
388389
*/
389390
namespace: z.string()
390-
.regex(/^[a-z][a-z0-9_]{1,19}$/, 'Namespace must be 2-20 chars, lowercase alphanumeric + underscore')
391+
.regex(/^[a-z][a-z0-9_]{1,19}$/, 'Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores')
391392
.optional()
392393
.describe('Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project")'),
393394

‎packages/spec/src/marketplace/package-namespace.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('PackageSchema.namespace (ADR-0048 addendum Phase A1)', () => {
9797
expect(issues[0].code).toBe('invalid_format');
9898
expect(issues[0].path).toEqual(['namespace']);
9999
expect(issues[0].message).toBe(
100-
'Namespace must be 2-20 chars, lowercase alphanumeric + underscore',
100+
'Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores',
101101
);
102102
});
103103

@@ -157,6 +157,23 @@ describe('two gates, one vocabulary (§A.7)', () => {
157157
)).toEqual([]);
158158
});
159159

160+
it('every field refuses with the SAME sentence, so both sides prescribe the same rule', () => {
161+
// The verdict table above cannot see the prose: two declarations can agree
162+
// on every value and still tell a refused author two different rules. Read
163+
// the sentence off each field for a value only the leading-letter clause
164+
// refuses, and hold all four to manifest.namespace's.
165+
const sentenceOf = (field: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } }) => {
166+
const r = field.safeParse('1leave');
167+
expect(r.success).toBe(false);
168+
return r.error?.issues[0]?.message;
169+
};
170+
const manifestSentence = sentenceOf(ManifestSchema.shape.namespace);
171+
expect(manifestSentence).toMatch(/start with a lowercase letter/);
172+
expect(sentenceOf(PackageSchema.shape.namespace)).toBe(manifestSentence);
173+
expect(sentenceOf(CreatePackageRequestSchema.shape.namespace)).toBe(manifestSentence);
174+
expect(sentenceOf(TemplateManifestSchema.shape.namespace)).toBe(manifestSentence);
175+
});
176+
160177
it('every field is optional, so "absent" means the same thing on all sides', () => {
161178
expect(ManifestSchema.shape.namespace.safeParse(undefined).success).toBe(true);
162179
expect(PackageSchema.shape.namespace.safeParse(undefined).success).toBe(true);
@@ -208,7 +225,7 @@ describe('TemplateManifestSchema declares namespace as a scaffold-only extra (#6
208225
expect(issues[0].code).toBe('invalid_format');
209226
expect(issues[0].path).toEqual(['namespace']);
210227
expect(issues[0].message).toBe(
211-
'Namespace must be 2-20 chars, lowercase alphanumeric + underscore',
228+
'Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores',
212229
);
213230
});
214231

‎packages/spec/src/marketplace/package.zod.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,12 @@ export const PackageSchema = lazySchema(() => z.object({
195195
*/
196196
namespace: z
197197
.string()
198-
// Kept byte-identical to `ManifestSchema.shape.namespace`'s pattern; the
199-
// two are pinned against each other by `package-namespace.test.ts` so the
200-
// publish payload and the manifest cannot drift into disagreeing about
201-
// what a namespace is (addendum §A.7 "two gates, one vocabulary").
202-
.regex(/^[a-z][a-z0-9_]{1,19}$/, 'Namespace must be 2-20 chars, lowercase alphanumeric + underscore')
198+
// Kept byte-identical to `ManifestSchema.shape.namespace`'s pattern and
199+
// refusal sentence; the two are pinned against each other by
200+
// `package-namespace.test.ts` so the publish payload and the manifest
201+
// cannot drift into disagreeing about what a namespace is, or into
202+
// prescribing it differently (addendum §A.7 "two gates, one vocabulary").
203+
.regex(/^[a-z][a-z0-9_]{1,19}$/, 'Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores')
203204
.optional()
204205
.describe('Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account")'),
205206

0 commit comments

Comments
 (0)