Skip to content

Commit 56fdd56

Browse files
feat(lint): warn when a package namespace is too long to have a paired customization namespace (#28775)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Sb8nicvrXvYFwDvb6LLSn4)_ Fixes #28473 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:新增一条只告警、不拒收的作者期规则 `namespace-no-customization-pair`:包的 `manifest.namespace` 超过 13 个字符时,`os validate` / `os build` / `os lint` 提示"这个包没有配对的 `_custom` 定制命名空间",并给出 `os explain` 说明。 - 为什么改:按 #28404 的裁决(「同意」),命名空间超长的基础包拿不到配对定制包,确保定制包的入口会直接拒绝,安装方永远无法定制它;以前作者要等客户第一次定制被拒才知道,那时改命名空间等于改所有对象名和表名。 - 风险与回滚:只多一条 warning,不拒收任何东西,也不进运行时发布门;仓库内已发布的包和示例最长命名空间是 13 字符,没有一个会触发。回滚即撤销本 PR。 - 先看:`packages/lint/src/validate-customization-namespace.ts`(判定只读 spec 的 `customizationNamespace`,不另写数字)、`packages/lint/src/authoring-rules.ts` 末尾的注册项、`packages/lint/src/rule-explanations.ts` 的新条目。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-customization-namespace.test.ts`,预期 13 个用例全过(13 字符静默,14 字符告警,`.custom` 配对包本身静默)。 - 需要你做的:无,席位落地。 ## What changes - **New advisory rule** `validateCustomizationNamespace` (`packages/lint/src/validate-customization-namespace.ts`), rule id `namespace-no-customization-pair`, severity `warning`. For each package one install delivers (`packages[].manifest` when the stack carries `packages[]`, the top-level `manifest` otherwise; an artifact's own wrapper manifest is not judged), it asks `customizationNamespace(manifest.namespace)` from `@objectstack/spec/kernel` and reports exactly its `base_namespace_too_long` refusal, at `manifest.namespace` (or `packages[N].manifest.namespace`). - **One reader.** The verdict is the helper the ensure door creates the pair with. The sentence is built from `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` and `CUSTOMIZATION_NAMESPACE_SUFFIX`; the rule spells neither 13 nor `_custom` itself. The grammar maximum it prints is `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH + CUSTOMIZATION_NAMESPACE_SUFFIX.length`, the identity `customization-package.ts` defines the limit by. - **Not judged:** a package that is itself a pair (`customizationPackageId` answers `base_is_customization_package`: a pair is never paired again); the helper's `base_namespace_missing` and `base_namespace_invalid` refusals (the second is `os validate`'s grammar refusal). - **Registry entry** in `AUTHORING_RULES`: `tier: 'advisory'`, `input: 'normalized'`, all three commands, `surfaces: CLI_ONLY` with a `surfaceReason` (a package manifest is not a metadata item, so the runtime publish gate has nothing to judge; the ensure door already refuses at 409). - **`os explain namespace-no-customization-pair`**: a new entry in `packages/lint/src/rule-explanations.ts` (the table `os explain` reads; `packages/cli` is unchanged). - **Barrel**: `validateCustomizationNamespace`, `NAMESPACE_NO_CUSTOMIZATION_PAIR` and the `CustomizationNamespaceFinding` type from `@objectstack/lint`'s root entry (the rule-id barrel test requires the id there). - **Changeset**: `.changeset/28473-namespace-no-customization-pair.md`, `@objectstack/lint` patch. - **Docs, forced**: four CLI transcripts print the author-time rule count, which the registry derives (`authoringRulesFor`), 58 → 59: `content/docs/deployment/cli.mdx`, `content/docs/deployment/validating-metadata.mdx`, `content/docs/getting-started/build-with-claude-code.mdx`, `content/docs/ui/react-pages.mdx` (`check:docs-transcript-drift` reds without it). ## Why ADR-0146 D3 names a base package's paired customization package's namespace `BASE_NS_custom`. The manifest namespace grammar admits 20 characters and the suffix takes 7, so a base namespace over 13 characters has no pair namespace. The ruling on #28404 (comment 6549129352, 「同意」) answers that with a named refusal and no truncation or hashing, and the ensure door refuses such a base with `409`. Its installers can never customize it, and its author would learn that only when a customer first tried. This is follow-up (a) of that ruling. ## Tests - `packages/lint/src/validate-customization-namespace.test.ts` (new, 13 cases): the fixtures sit on the helper's own boundary; 13 characters silent; 14 characters warns at `manifest.namespace` with severity `warning`; the message names `warehouse_mgmt_custom`; a `.custom` pair with a 20-character namespace is silent while the same namespace on a base warns; missing, empty and grammar-invalid namespaces are silent; no `manifest.id` still judged; artifact `packages[]` judged per body and the wrapper never; the CLI per-package view of each body yields the union run's findings byte for byte (so the CLI's per-package de-duplication adds no second copy); registry entry shape; all three commands report it; barrel and explain entry present. - `packages/lint/src/rule-explanations.test.ts`: one added case holds the explanation's written numbers and suffix (the module imports nothing) to the spec constants. - Forced test edits: none. Forced docs edits: the four transcript counts above. ## Acceptance notes - The dispatch expected the `os explain` catalogue in `packages/cli`. It lives in `packages/lint/src/rule-explanations.ts` (published as `@objectstack/lint/rule-explanations`); the CLI's `os explain` and `rule:` pointer read it, so `packages/cli` needs no edit. `packages/cli/test/explain-rule-id.test.ts` iterates every entry through the command and covers the new one. - Public surface, measured: `@objectstack/lint`'s root entry gains one function, one rule-id constant and one type, and `RULE_EXPLANATIONS` gains one key (its type is unchanged). No accept set changes: the rule never emits `error`. Other new lint rule ids landed under `Clause-②: no` (for example `.changeset/26719-list-view-chart-foreign-dataset.md`). - Boundary, not filed: the ensure door also refuses a base that declares no namespace (`base_namespace_missing`, 409). This rule does not judge that case; the ruling names the long one. ## Verification Final head `365377b2` (main merged at `5493b884`). The lint tree is byte-identical between the merge commit `0780c708` and the final head (the last commit touches `content/docs` only), so the lint and CLI readings taken on `0780c708` hold for it. <details><summary>Tests</summary> - `pnpm --filter @objectstack/lint test` on `0780c708`: Test Files 188 passed (188), Tests 7777 passed (7777). `pnpm --filter @objectstack/lint typecheck`: exit 0. - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated` on `0780c708`: exit 0 (spec moved on main's side; nothing to regenerate here). - `pnpm --filter @objectstack/cli exec vitest run --project unit` on `0780c708`: 364/365 files, 5174/5175 tests. The one red, `src/utils/override-declarations.test.ts`, arrived with the merge (main's flow-override change) and read a stale `metadata-protocol` dist; after rebuilding the packages the merge moved (`metadata-protocol`, `objectql`, `service-automation`) it passed 7/7. Before the merge, on `15f94cb9`: 363/365, the two reds being `published-subpath-*.pin.test.ts` needing the CLI's own dist; after `pnpm --filter @objectstack/cli build`, 29/29. - CLI explain and per-package seams: `test/explain-rule-id.test.ts`, `test/union-fold-command-parity.test.ts`, `test/lint-per-package-authoring-seam.test.ts`, `test/validate-per-package-authoring-seam.test.ts`, `test/per-package-dedup-positional-echo.test.ts`: 5 files, 36 tests passed. - eslint over the six changed `packages/lint/src` files: exit 0. </details> <details><summary>End to end, through the public door</summary> A throwaway `defineStack` config with `manifest.namespace: 'warehouse_mgmt'` (14 characters), run with the built CLI: - `os validate`: exit 0, and prints the warning `package "com.acme.warehouse": namespace "warehouse_mgmt" is 14 characters, so this package has no paired customization namespace: "warehouse_mgmt_custom" would be 21, past the 20 a namespace may have, and installers cannot customize the package`, then `fix: shorten manifest.namespace to 13 characters or fewer, and rename the object names it prefixes to match`, then `rule: namespace-no-customization-pair at manifest.namespace — os explain namespace-no-customization-pair for why a long namespace leaves a package uncustomizable`. - `os lint`: exit 0, the same warning. - With `warehouse_ops` (13 characters): `os validate` prints the rule id 0 times. - `os explain namespace-no-customization-pair` prints the five paragraphs. </details> <details><summary>Ablation (rule source, read from src by the suite)</summary> Through `scripts/ablation-replace.mjs`, each leg restored to the HEAD blob (`51500056606b`, `git diff HEAD` empty): - The verdict guard replaced so the rule never reports: `validate-customization-namespace.test.ts` 6 failed / 7 passed (the 14-character, message, pair-control, no-id, artifact and all-commands cases). - The pair-self skip removed: 1 failed / 12 passed (the `.custom` pair case). </details> <details><summary>Dispatch gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `365377b2` listed 88 commands (58 before the docs edit added `content/docs` paths). All 88 run, each with stdin from /dev/null and its exit captured before any pipe: 87 exit 0 on the first pass. `pnpm check:dts-closure` first read exit 1 (organizations' declaration files absent mid-run while another gate's build touched that package); after `pnpm --filter @objectstack/organizations build` it read exit 0 (241/241 declaration files present). Before running, 13 packages without a `dist/` were built, so `check:dual-build-cjs-loads` measured instead of answering exit 3; `check-plugin-teardown-shape --self-test` read its pinned commit after `git fetch --depth=1`. Reconciliation: `dispatch-gates --ran`: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). CI check-runs on this PR: not read yet (in progress at the time of writing). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No schema touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the six changed files: exit 0; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 section is Chinese by the repo's maintainer rule.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmT6n95wTYF66NNtWi3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a00f335 commit 56fdd56

11 files changed

Lines changed: 380 additions & 5 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
feat(lint): `os validate`, `os build` and `os lint` warn when a package's `manifest.namespace` is too long to have a paired customization namespace (`namespace-no-customization-pair`)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
ADR-0146 gives every installed base package one paired customization package, `<id>.custom`, whose namespace is the base namespace followed by `_custom`. A manifest namespace admits at most 20 characters, so a base namespace of 14 characters or more has no pair namespace: the ensure door refuses to create the pair, and the package's installers can never customize it. Until now its author learned this only when a customer first tried.
11+
12+
- **Now.** A new advisory rule reports such a package at `manifest.namespace` (or `packages[N].manifest.namespace` in a multi-package artifact) with severity `warning`, naming the namespace and the `_custom` pair namespace it cannot have. `os explain namespace-no-customization-pair` prints the reasoning.
13+
- **One reader.** The verdict is `customizationNamespace` from `@objectstack/spec/kernel`, the function the ensure door creates the pair with, and the sentence is built from `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` and `CUSTOMIZATION_NAMESPACE_SUFFIX`. Nothing restates the limit.
14+
- **Not judged.** A package that is itself a `.custom` pair (it is never paired again), a package that declares no namespace, and a namespace the manifest grammar refuses for another reason, which `os validate` reports.
15+
- **Who sees it.** No shipped package or example in this repository: the longest namespace among them is `feature_flags`, 13 characters.
16+
17+
Nothing is refused: the rule never emits an error, and it does not run at the runtime publish door, because a package manifest is not a metadata item.
18+
19+
**The remedy.** Shorten `manifest.namespace` to 13 characters or fewer, and rename the object names it prefixes to match.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines
771771
→ Normalizing stack definition...
772772
→ Lowering inline handlers...
773773
→ Validating protocol compliance...
774-
→ Running author-time rules (58)...
774+
→ Running author-time rules (59)...
775775
→ Checking that every required capability has a provider installable in this edition...
776776
→ Collecting package docs (ADR-0046)... 0 collected
777777
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ A clean run walks the registry and reports timing:
759759
Config: /path/to/support-desk/objectstack.config.ts
760760
Load time: 21ms
761761
→ Validating against ObjectStack Protocol...
762-
→ Running author-time rules (58)...
762+
→ Running author-time rules (59)...
763763
→ Checking that every required capability has a provider installable in this edition...
764764
→ Checking package docs (ADR-0046)...
765765

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ visible: 'status != "resolved"'
305305
◆ Validate
306306
────────────────────────────────────────
307307
→ Validating against ObjectStack Protocol...
308-
→ Running author-time rules (58)...
308+
→ Running author-time rules (59)...
309309
310310
✗ Author-time rules failed (1 issue)
311311
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ objectstack validate
404404
────────────────────────────────────────
405405
→ Loading configuration...
406406
→ Validating against ObjectStack Protocol...
407-
→ Running author-time rules (58)...
407+
→ Running author-time rules (59)...
408408
→ Checking that every required capability has a provider installable in this edition...
409409
→ Checking package docs (ADR-0046)...
410410

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import { lintViewRefs } from './lint-view-refs.js';
156156
import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js';
157157
import { validateEmailTemplateHolePlacements } from './validate-email-template-hole-placements.js';
158158
import { validateEmailTemplateHoleGrammar } from './validate-email-template-hole-grammar.js';
159+
import { validateCustomizationNamespace } from './validate-customization-namespace.js';
159160
import {
160161
lintUniqueDeclarations,
161162
lintUnscopedDeclaredIndexes,
@@ -2579,6 +2580,27 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
25792580
runtimeTypes: ['email_template'],
25802581
run: (stack) => validateEmailTemplateHoleGrammar(stack),
25812582
},
2583+
// [#28473] ADR-0146 D3: a package whose `manifest.namespace` is too long to
2584+
// have a paired customization namespace (`customizationNamespace` answers
2585+
// `base_namespace_too_long`), so the ensure door will refuse to create its
2586+
// `.custom` pair and its installers can never customize it. Advisory: the
2587+
// package builds, installs and runs, and only its customization is refused.
2588+
// `normalized` because it reads one string off each delivered manifest and
2589+
// needs no parsed stack, so `os lint` judges it too.
2590+
{
2591+
name: 'validateCustomizationNamespace',
2592+
tier: 'advisory',
2593+
input: 'normalized',
2594+
commands: ALL,
2595+
source: 'packages/lint/src/validate-customization-namespace.ts',
2596+
surfaces: CLI_ONLY,
2597+
surfaceReason:
2598+
'Not a metadata write: the runtime publish gate judges metadata items, and a package manifest is not '
2599+
+ 'one (no metadata type carries it). A package reaches the runtime through the install doors, where '
2600+
+ 'the ensure door that creates a pair already refuses a base whose namespace has none (409) with its '
2601+
+ 'own sentence; this rule exists to give the author that answer before the package ships.',
2602+
run: (stack) => validateCustomizationNamespace(stack),
2603+
},
25822604
];
25832605

25842606
// ─── Runner ─────────────────────────────────────────────────────────

‎packages/lint/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,14 @@ export {
714714
} from './validate-report-date-range.js';
715715
export type { ReportDateRangeFinding } from './validate-report-date-range.js';
716716

717+
// [#28473] ADR-0146 D3: a package namespace too long to have a paired
718+
// customization namespace, judged with `customizationNamespace` itself.
719+
export {
720+
validateCustomizationNamespace,
721+
NAMESPACE_NO_CUSTOMIZATION_PAIR,
722+
} from './validate-customization-namespace.js';
723+
export type { CustomizationNamespaceFinding } from './validate-customization-namespace.js';
724+
717725
// [#14105] The layer BELOW the chart/widget binding rules above: a dataset's
718726
// own `include[]`, `dimensions[].field`, `measures[].field` and filter KEYS,
719727
// resolved against the object graph. Its base-object half is

‎packages/lint/src/rule-explanations.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ import { fileURLToPath } from 'node:url';
1111
import { describe, expect, it } from 'vitest';
1212

1313
import { UNCONSUMED_WIDGET_OPTION } from '@objectstack/sdui-parser';
14-
import { FUNCTIONAL_COMPLETENESS_RULES } from '@objectstack/spec/kernel';
14+
import {
15+
CUSTOMIZATION_NAMESPACE_SUFFIX,
16+
FUNCTIONAL_COMPLETENESS_RULES,
17+
MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH,
18+
} from '@objectstack/spec/kernel';
1519

1620
import * as indexBarrel from './index.js';
1721
import { RULE_EXPLANATIONS, explainRule } from './rule-explanations.js';
1822
import { CARRIER_ROOTS, CONSUMER_ROOTS, FIELD_NO_CONSUMERS } from './validate-field-consumers.js';
23+
import { NAMESPACE_NO_CUSTOMIZATION_PAIR } from './validate-customization-namespace.js';
1924

2025
const HERE = dirname(fileURLToPath(import.meta.url));
2126

@@ -94,6 +99,19 @@ describe('rule explanations (#22161)', () => {
9499
);
95100
});
96101

102+
// [#28473] The module imports nothing, so the pairing rule's numbers and
103+
// suffix are written out in the text; held here to the spec constants.
104+
it('the namespace-no-customization-pair text states the pairing rule\'s own limit and suffix', () => {
105+
const text = explainRule(NAMESPACE_NO_CUSTOMIZATION_PAIR)!.paragraphs.join('\n');
106+
const suffix = CUSTOMIZATION_NAMESPACE_SUFFIX;
107+
const max = MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH;
108+
expect(text).toContain(
109+
`A manifest namespace is at most ${max + suffix.length} characters and \`${suffix}\` takes ${suffix.length}, ` +
110+
`so a namespace of ${max + 1} characters or more has no pair namespace`,
111+
);
112+
expect(text).toContain(`to ${max} characters or fewer`);
113+
});
114+
97115
it('explainRule is an exact-id lookup: no prefix, case or prototype match', () => {
98116
expect(explainRule(FIELD_NO_CONSUMERS)?.rule).toBe(FIELD_NO_CONSUMERS);
99117
expect(explainRule('field-no-consumer')).toBeUndefined();

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5339,6 +5339,40 @@ const REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION: RuleExplanation = {
53395339
paragraphs: REPORT_DATE_RANGE_SHARED_PARAGRAPHS,
53405340
};
53415341

5342+
// The pairing rule lives in `@objectstack/spec/kernel`'s `customization-package.ts`
5343+
// (`customizationNamespace`, `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH`,
5344+
// `CUSTOMIZATION_NAMESPACE_SUFFIX`); `rule-explanations.test.ts` holds the
5345+
// numbers and the suffix written below to those constants.
5346+
const NAMESPACE_NO_CUSTOMIZATION_PAIR_EXPLANATION: RuleExplanation = {
5347+
rule: 'namespace-no-customization-pair',
5348+
covers: 'why a long namespace leaves a package uncustomizable',
5349+
paragraphs: [
5350+
'A package\'s paired customization package holds its installers\' changes to it (ADR-0146). The ' +
5351+
'first time an installer customizes an installed package, the platform creates `<id>.custom` ' +
5352+
'beside it, with the package\'s namespace followed by `_custom` as its namespace. A manifest ' +
5353+
'namespace is at most 20 characters and `_custom` takes 7, so a namespace of 14 characters or more ' +
5354+
'has no pair namespace: the door that creates the pair refuses the package, and its installers can ' +
5355+
'never customize it. (A package an installer authored in Studio is writable and is customized ' +
5356+
'directly; the pair exists for a package provided by code or installed as an app.)',
5357+
'The pair namespace is never shortened to fit. Truncating or hashing it would be a second spelling ' +
5358+
'of the pair\'s namespace that nothing could check the pair\'s declarations against, so the only ' +
5359+
'remedy is the package\'s own namespace.',
5360+
'Nothing fails when such a package is built or installed: the refusal arrives on an installer\'s ' +
5361+
'first customization, long after the namespace has shipped. By then shortening it is no small ' +
5362+
'edit, because the namespace is the prefix of every object name, so every table name and REST path ' +
5363+
'of the package carries it. The warning is printed while renaming is still a source edit.',
5364+
'Judged: the namespace of each package one install delivers, which is each `packages[]` body of a ' +
5365+
'multi-package artifact, or the top-level `manifest` of a single package (an artifact\'s own ' +
5366+
'wrapper manifest is not a package). Not judged: a package that is itself a pair (an id ending in ' +
5367+
'`.custom`), whose namespace is a pair namespace and is never paired again; a package that declares ' +
5368+
'no namespace; and a namespace the manifest grammar refuses for another reason (an uppercase ' +
5369+
'letter, a leading digit), which `os validate` refuses with the grammar\'s own sentence.',
5370+
'It stays a warning: the package builds, installs and runs, and only its customization is refused, ' +
5371+
'so a package nobody is meant to customize may keep its namespace. To fix it, shorten ' +
5372+
'`manifest.namespace` to 13 characters or fewer and rename the object names it prefixes to match.',
5373+
],
5374+
};
5375+
53425376
export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Object.freeze({
53435377
[FIELD_NO_CONSUMERS_EXPLANATION.rule]: FIELD_NO_CONSUMERS_EXPLANATION,
53445378
[SECURITY_OWD_UNSET_EXPLANATION.rule]: SECURITY_OWD_UNSET_EXPLANATION,
@@ -5547,6 +5581,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
55475581
[REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_NOT_DATE_EXPLANATION,
55485582
[REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_AMBIGUOUS_EXPLANATION,
55495583
[REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION.rule]: REPORT_DATE_RANGE_DIMENSION_NONE_EXPLANATION,
5584+
[NAMESPACE_NO_CUSTOMIZATION_PAIR_EXPLANATION.rule]: NAMESPACE_NO_CUSTOMIZATION_PAIR_EXPLANATION,
55505585
});
55515586

55525587
/** The explanation for `rule`, or `undefined` when the rule has none. Exact id match. */
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [#28473] A package namespace too long to have a paired customization
4+
// namespace (ADR-0146 D3) warns at author time. The verdict is
5+
// `customizationNamespace`'s own (`@objectstack/spec/kernel`), so the boundary
6+
// cases below are the helper's boundary: 13 characters is silent, 14 warns.
7+
8+
import { describe, expect, it } from 'vitest';
9+
10+
import {
11+
CUSTOMIZATION_NAMESPACE_SUFFIX,
12+
MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH,
13+
customizationNamespace,
14+
} from '@objectstack/spec/kernel';
15+
16+
import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js';
17+
import * as indexBarrel from './index.js';
18+
import { RULE_EXPLANATIONS } from './rule-explanations.js';
19+
import {
20+
NAMESPACE_NO_CUSTOMIZATION_PAIR,
21+
validateCustomizationNamespace,
22+
} from './validate-customization-namespace.js';
23+
24+
/** 13 characters: the longest base namespace that has a pair namespace. */
25+
const AT_LIMIT = 'warehouse_ops';
26+
/** 14 characters: one past it. */
27+
const PAST_LIMIT = 'warehouse_mgmt';
28+
29+
const single = (namespace: unknown, id: unknown = 'com.acme.warehouse') => ({
30+
manifest: { id, version: '1.0.0', type: 'app', namespace },
31+
});
32+
33+
describe('validateCustomizationNamespace', () => {
34+
it('the boundary fixtures sit on the helper\'s own boundary', () => {
35+
expect(AT_LIMIT).toHaveLength(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH);
36+
expect(PAST_LIMIT).toHaveLength(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH + 1);
37+
expect(customizationNamespace(AT_LIMIT)).toEqual({ ok: true, value: `${AT_LIMIT}${CUSTOMIZATION_NAMESPACE_SUFFIX}` });
38+
expect(customizationNamespace(PAST_LIMIT)).toEqual({ ok: false, reason: 'base_namespace_too_long' });
39+
});
40+
41+
it('is silent on a namespace of 13 characters', () => {
42+
expect(validateCustomizationNamespace(single(AT_LIMIT))).toEqual([]);
43+
});
44+
45+
it('warns on a namespace of 14 characters, at manifest.namespace', () => {
46+
const findings = validateCustomizationNamespace(single(PAST_LIMIT));
47+
expect(findings).toHaveLength(1);
48+
const [finding] = findings;
49+
expect(finding.severity).toBe('warning');
50+
expect(finding.rule).toBe(NAMESPACE_NO_CUSTOMIZATION_PAIR);
51+
expect(finding.path).toBe('manifest.namespace');
52+
expect(finding.where).toBe('package "com.acme.warehouse"');
53+
});
54+
55+
it('the message names the pair namespace the package cannot have, and the hint the limit', () => {
56+
const [finding] = validateCustomizationNamespace(single(PAST_LIMIT));
57+
expect(finding.message).toContain(`"${PAST_LIMIT}${CUSTOMIZATION_NAMESPACE_SUFFIX}"`);
58+
expect(finding.message).toContain(`"${PAST_LIMIT}"`);
59+
expect(finding.hint).toContain(`${MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH} characters or fewer`);
60+
});
61+
62+
it('is silent when the package is itself a pair: a pair is never paired again', () => {
63+
const pairNamespace = `${AT_LIMIT}${CUSTOMIZATION_NAMESPACE_SUFFIX}`;
64+
expect(validateCustomizationNamespace(single(pairNamespace, 'com.acme.warehouse.custom'))).toEqual([]);
65+
// Control: the same namespace on a base package does warn.
66+
expect(validateCustomizationNamespace(single(pairNamespace))).toHaveLength(1);
67+
});
68+
69+
it('judges only the helper\'s too-long refusal: no namespace, or one the grammar refuses otherwise, is silent', () => {
70+
expect(validateCustomizationNamespace(single(undefined))).toEqual([]);
71+
expect(validateCustomizationNamespace(single(''))).toEqual([]);
72+
// `Warehouse` fails the manifest grammar (uppercase): `os validate`'s refusal, not this rule's.
73+
expect(customizationNamespace('Warehouse')).toEqual({ ok: false, reason: 'base_namespace_invalid' });
74+
expect(validateCustomizationNamespace(single('Warehouse'))).toEqual([]);
75+
});
76+
77+
it('still judges the namespace when the manifest carries no id', () => {
78+
const findings = validateCustomizationNamespace({ manifest: { namespace: PAST_LIMIT } });
79+
expect(findings.map((f) => [f.where, f.path])).toEqual([['package manifest', 'manifest.namespace']]);
80+
});
81+
82+
it('is silent on a stack with no manifest', () => {
83+
expect(validateCustomizationNamespace({ objects: [] })).toEqual([]);
84+
});
85+
86+
describe('an ADR-0130 artifact: the packages one install delivers', () => {
87+
const artifact = {
88+
// The artifact's own wrapper manifest is not a package and is not judged.
89+
manifest: { id: 'com.acme.bundle', version: '1.0.0', type: 'app', namespace: 'acme_bundle_wrapper' },
90+
packages: [
91+
{ manifest: { id: 'com.acme.core', version: '1.0.0', type: 'app', namespace: AT_LIMIT } },
92+
{ manifest: { id: 'com.acme.orders', version: '1.0.0', type: 'app', namespace: PAST_LIMIT } },
93+
],
94+
};
95+
96+
it('judges each packages[] body, at its own path, and never the wrapper manifest', () => {
97+
const findings = validateCustomizationNamespace(artifact);
98+
expect(findings.map((f) => [f.where, f.path])).toEqual([
99+
['package "com.acme.orders"', 'packages[1].manifest.namespace'],
100+
]);
101+
});
102+
103+
// The CLI's per-package pass (`runPerPackageAuthoringRules`) hands each body
104+
// back as `{ ...body, manifest: body, packages }` and drops any finding the
105+
// union run already reported (same rule, where, message and path). This
106+
// rule reads `packages[]` whenever it is present, so the per-package view
107+
// yields exactly the union's findings and adds no second copy of one.
108+
it('the per-package view of each body yields the union run\'s findings, byte for byte', () => {
109+
const union = validateCustomizationNamespace(artifact);
110+
for (const entry of artifact.packages) {
111+
const asStack = { ...entry.manifest, manifest: entry.manifest, packages: artifact.packages };
112+
expect(validateCustomizationNamespace(asStack)).toEqual(union);
113+
}
114+
});
115+
});
116+
});
117+
118+
describe('validateCustomizationNamespace in the authoring registry', () => {
119+
it('is registered as an advisory rule on all three commands, CLI surface only', () => {
120+
const entry = AUTHORING_RULES.find((r) => r.name === 'validateCustomizationNamespace');
121+
expect(entry).toBeDefined();
122+
expect(entry!.tier).toBe('advisory');
123+
expect([...entry!.commands].sort()).toEqual([...AUTHORING_COMMANDS].sort());
124+
expect(entry!.surfaces).toEqual(['cli']);
125+
});
126+
127+
it('reaches every authoring command\'s findings as a warning', () => {
128+
const stack = single(PAST_LIMIT);
129+
for (const command of AUTHORING_COMMANDS) {
130+
const findings = runAuthoringRules(command, { normalized: stack, parsed: stack })
131+
.filter((f) => f.rule === NAMESPACE_NO_CUSTOMIZATION_PAIR);
132+
expect(findings.map((f) => [f.severity, f.path]), command).toEqual([['warning', 'manifest.namespace']]);
133+
}
134+
});
135+
136+
it('exports its rule id from the root barrel and has an os explain entry', () => {
137+
expect(indexBarrel.NAMESPACE_NO_CUSTOMIZATION_PAIR).toBe(NAMESPACE_NO_CUSTOMIZATION_PAIR);
138+
expect(RULE_EXPLANATIONS[NAMESPACE_NO_CUSTOMIZATION_PAIR]?.rule).toBe(NAMESPACE_NO_CUSTOMIZATION_PAIR);
139+
});
140+
});

0 commit comments

Comments
 (0)