Skip to content

Commit 1c61f48

Browse files
refactor(metadata,objectql,cli): one spelling of the residual-picklist refusal and of two registration refusal envelopes (#28531)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XNLFamdrWU3ohqRH6ptUBYs)_ Fixes #28275 Fixes #28160 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:三处「同一个拒绝写了两份」各收成一份。① 组合栈顶层「没人引用的 picklist」拒绝:判定(哪些列表、按哪些字段算「被引用」)和文案搬进 `@objectstack/metadata/picklist-references` 的 `unreferencedResidualPicklists`,引擎的 `manifest` 服务和 `os validate` / `os build` 都读它;引擎原来的 picklist 字段遍历也搬过去,objectql 的 `picklist-resolution.ts` 改为转出。② 权限集 `packageId` 归属拒绝的信封、③ `views:` 非容器条目的 422 文案:从 `engine.ts` 导出,顶层残余注册直接调用,不再各抄一份。 - **为什么改**:卡 #28275(分诊 6537443627)与 #28160(范围扩大 6526088897,来自合约复核 6526074825):两处拷贝只靠一个启动对拍测试或文字断言维持一致,改一边就会悄悄漂移。 - **风险与回滚**:纯搬移,不改任何启动结果、错误码、状态码和文案(逐字节);`--json` 发现形状不变。新增公开导出(metadata 新子路径 + objectql 根转出),故 `Clause-②: yes (widening)`,`patch`。回滚即 revert。 - **先看**:`packages/metadata/src/picklist-references.ts`(新判定与遍历);`packages/objectql/src/plugin.ts`(`unreferencedResidualPicklistRefusal` 与 `registerUnclaimedTopLevelItems` 的两处调用);`packages/cli/src/utils/picklist-references.ts`(`findUnreferencedResidualPicklists`)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/registration-refusal-one-spelling.test.ts` → 7 passed(引擎与两扇门的抛出值逐字段等于唯一导出函数的返回值)。 - **需要你做的**:无,席位落地(合约复核由席位做)。 ## What changes Measured on `origin/main` `23d1303d` before any edit: `unreferencedResidualPicklistRefusal` was module-private at `packages/objectql/src/plugin.ts:238`, composing its own text over `collectManifestPicklistReferences`; `packages/cli/src/utils/picklist-references.ts` restated the walk (`fieldPicklistNamesOf`) and the words (`residualPicklistWording`); `permissionSetOwnerRegistrationRefusal` was module-private at `packages/objectql/src/engine.ts:4793` and `plugin.ts` rebuilt its envelope inline; the `views:` container-shape 422 text was spelled in both `engine.ts` and `plugin.ts`. ### #28275 — one residual-picklist judgement - **New subpath `@objectstack/metadata/picklist-references`** (`packages/metadata/src/picklist-references.ts`, own `exports` entry and tsup entry, after the `./view-container-name` precedent). It holds: - the manifest picklist walk the engine already used, moved verbatim from `packages/objectql/src/picklist-resolution.ts`: `collectPicklistReferences`, `collectManifestPicklistReferences`, `collectManifestPicklistNames`, `collectManifestPicklistExtensions`, and the `PicklistReference` / `OrphanPicklistExtension` types; - `unreferencedResidualPicklists(residual, bodies)`: takes the residual answer (`unclaimedTopLevel`, read structurally so the entry never loads the package root) and the package bodies, and returns `{ owner, picklists: [{ name, field, index, wording }], wording }` or `undefined`. `wording` is `{ judgement, prescription, text }`; the top-level `wording` is the engine's one plural sentence, each list's `wording` is a door's finding. The sentence is byte for byte the text the engine composed before. - **Engine.** `unreferencedResidualPicklistRefusal` is now `picklistMetadataError(unreferencedResidualPicklists(residual, bodies).wording.text)`. `picklist-resolution.ts` re-exports the moved walk, so `registry.ts`, the boot audit and the install door import it from the same place as before, and there is one walk, not two. `@objectstack/objectql` re-exports `unreferencedResidualPicklists` and its four types from the root, as it does `viewContainerNameRefusal`. - **Doors.** `findUnreferencedResidualPicklists` (`os validate` step 2d, `os build` step 3a-bis) maps the judgement's lists to findings; `fieldPicklistNamesOf` and `residualPicklistWording` are gone. The finding keeps `severity`, `rule`, `where`, `path: picklists[i]`, `message`, `hint`. - **Boot-parity file** shrinks to a smoke check: one refused stack and one control, still byte-comparing the boot's message with the door's `message + ' ' + hint`. The seven stacks it dropped are pinned at least as close to the code: on the judgement (`packages/metadata/src/picklist-references.test.ts`), at the door (`picklist-references.test.ts`, whose residual pins are unchanged) and at the engine (`manifest-service-residual-objects.test.ts`, unchanged). ### #28160 — one envelope per registration refusal - `permissionSetOwnerRegistrationRefusal` is exported from `engine.ts` (module export only; not on any package entry). `registerUnclaimedTopLevelItems` calls it instead of rebuilding the `VALIDATION_ERROR` / 400 / `issues[]` envelope. - New `viewsEntryNotContainerRefusal(itemName, sourceLabel, ownerId)` in `engine.ts` holds the `views:` container-shape refusal's `INVALID_METADATA` / 422 envelope and text. The body seam (`registerMetadataCollections`) and the residual registration (with the `manifest` label) both throw it. - `plugin.ts` drops its now-unused `ASSEMBLED_VIEW_ITEMS_KEY` and `judgePermissionSetOwnerBinding` imports. Not changed: what the boot refuses or serves, every refusal's code, status and text, the `--json` finding shape, `packages/spec/**`. ## Tests - New `packages/objectql/src/registration-refusal-one-spelling.test.ts` (7 tests): the objectql re-export `toBe` the metadata function; the boot's residual-picklist refusal equals `unreferencedResidualPicklists(...).wording.text` and the envelope, for one and two lists; the residual registration's permission-set refusal and `views:` refusal each equal, field for field (`message`, `code`, `status`, `httpStatus`, `issues`), what `engine.ts`'s exported function returns, and so does the body seam's (`registerApp`) for the same input. - New `packages/metadata/src/picklist-references.test.ts` (12 tests): which residual lists are refused and which fields count as naming one (body object in both spellings, body `objectExtensions`, body nested `plugins[]`, residual object; a body `picklistExtensions` entry names nothing), the singular and plural words, the owner word `none` in angle brackets for a stack with no id. - `packages/cli/src/utils/picklist-references.test.ts`: one new pin, the door's findings equal the judgement's lists, locations and per-list words. - `packages/metadata/src/serializers/typescript-serializer-annotation.test.ts`: its export-entry count control moves 6 → 7 for the new entry (the loop it guards now visits it too). - Unchanged and green: the engine's residual pins (`manifest-service-residual-objects.test.ts`, including the #28153 permission-set and `views:` pins), `picklist-references.test.ts`'s existing pins. <details><summary>Ablation — each single-source pin goes red on a re-inlined copy with one changed word, and only it</summary> Run with `scripts/ablation-replace.mjs` (mutation landed and restore proven by blob hash and an empty `git diff HEAD`), on head `3ba35659` (the source files are byte-identical at the final head; only a test count moved after it): | Leg | Mutation | Red | Still green | | :-- | :-- | :-- | :-- | | A | `plugin.ts`: the engine composes the residual-picklist text inline again, "missing" → "absent" | `registration-refusal-one-spelling` × 2 (one list, two lists) | 65 of 67 in the two objectql files, including every residual-picklist phrase pin | | B | `cli/.../picklist-references.ts`: the door's `message` re-inlined, "missing" → "absent" | the new door pin + the boot-parity smoke check | 33 of 35 | | C1 | `plugin.ts`: the `views:` refusal rebuilt inline, "wrap it" → "enclose it" | `registration-refusal-one-spelling` residual `views:` pin | 66 of 67, including the #28153 `views:` pin (it quotes the first sentence only) | | C2 | `plugin.ts`: the permission envelope rebuilt inline around the same message, issue `code` `custom` → `invalid` | `registration-refusal-one-spelling` residual permission pin | 66 of 67, including the #28153 permission pin (it matches the message) | The "still green" column is why the pins are new: the behaviour pins quote phrases, so a second copy that drifts outside those phrases passed them. </details> ## Acceptance notes - **File surface.** The claim lists `packages/metadata/src/**`, `plugin.ts`, `engine.ts`, the objectql re-export, the CLI file and its tests, and changesets. This diff also touches `packages/metadata/package.json` and `tsup.config.ts` (the new subpath's entry, the precedent's shape) and `packages/objectql/src/picklist-resolution.ts` (it re-exports the moved walk, so the walk is not spelled twice). The `picklist-resolution.ts` amendment was asked of the seat before the edit. - **Clause-②** measured `yes (widening)`: a new subpath with new exports on `@objectstack/metadata`, and a root re-export on `@objectstack/objectql`. Nothing is newly refused or accepted, so no `(narrowing)` arm and `Metadata-protocol: none`; `patch` on all three packages per AGENTS.md Post-Task step 3. - The two `engine.ts` exports are module exports only (`@objectstack/objectql`'s entries are `.` and `./core`, neither re-exports them), so they add no published surface. - The door's walk is now the engine's. It differs from the old CLI copy only on shapes the stack parse refuses before the door runs (a name-keyed `objectExtensions`, an array inside `plugins[]`), so no parsed stack changes outcome. ## Verification All on head `b139a24c` (base `origin/main` `23d1303d`), in the card thread's worktree, after `pnpm --filter '@objectstack/cli^...' --filter @objectstack/cli build` (lock VERDICT command-exit 0). <details><summary>Package suites and typecheck</summary> | Command | Result | | :-- | :-- | | `pnpm --filter @objectstack/metadata test` | 65 files, 918 tests passed | | `pnpm --filter @objectstack/objectql test` | 510 files, 9868 tests passed | | `pnpm --filter @objectstack/cli exec vitest run --project unit` | 360 files, 5144 tests passed | | `pnpm --filter @objectstack/objectql exec vitest run --project repo` | 1 file, 5 tests passed | | `pnpm --filter @objectstack/core exec vitest run --project repo` | 6 files, 94 tests passed | | `pnpm --filter @objectstack/{metadata,objectql,cli} typecheck` | exit 0 each | | `eslint --no-inline-config` over the 11 changed `.ts` files | exit 0, no output | | `node scripts/check-platform-checklist.mjs --changed-from origin/main` | OK, 13 anchors judged, 0 lost | The first metadata run had one red, `typescript-serializer-annotation.test.ts`'s export-entry count (6, now 7 with the new subpath); fixed in `b139a24c` and re-run green above. The cli `integration` tier is left to CI: no integration-tier file or spawn entry is in the diff. </details> <details><summary>Derived gates — 79 derived, 78 run green, 1 not measured</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff derives 79 families; every one ran with its exit code captured before any pipe, and `--ran` reconciles: `79 derived famil(ies) accounted for — 78 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET (needs every package's `dist/`, i.e. a full `pnpm build`). CI runs it. - `pnpm check:i18n-coverage` first exited 3 (the showcase example's `@objectstack/connector-slack` and `@objectstack/service-print` had no `dist/`); after building the four examples' closures it exited 0. - `pnpm check:type-check-debt`: `OK — 0 ledger entr(ies) re-measured, 0 raw tsc error(s) total`. - The families that ran before the last commit (a test-count line) were re-run on `b139a24c`: all exit 0. </details> Serial check (order): no open pull request touches `packages/objectql/src/{engine,plugin,picklist-resolution,index}.ts`, `packages/metadata/**` or `packages/cli/src/utils/picklist-references*` (24 open PRs read, files listed per PR); `git merge-tree --write-tree HEAD origin/main` (`bff315cd`) is clean, and `origin/main` moved none of those files since the base. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oQR8KjnpATg1n2xCN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50646f0 commit 1c61f48

14 files changed

Lines changed: 661 additions & 282 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/cli': patch
5+
---
6+
7+
refactor(metadata, objectql, cli): one spelling of the residual-picklist refusal, and one envelope for two registration refusals
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
Nothing a stack can declare is refused or accepted differently, and every refusal keeps its code, status and words byte for byte. What moves is where each judgement is written, so the doors that give the same answer read it from one place.
13+
14+
- **New subpath `@objectstack/metadata/picklist-references`.** It exports `unreferencedResidualPicklists(residual, bodies)`: given a composed stack's residual (what `unclaimedTopLevel` answers) and its package bodies, it returns every top-level picklist that no package body declares and no object field the engine registers names, each with its location and words, plus the one plural sentence for all of them, or `undefined` when there is none. It also exports the manifest picklist walk the engine already used (`collectManifestPicklistReferences`, `collectPicklistReferences`, `collectManifestPicklistNames`, `collectManifestPicklistExtensions`) and the types `PicklistReference`, `OrphanPicklistExtension`, `ResidualPicklistInput`, `ResidualPicklistWording`, `UnreferencedResidualPicklist` and `UnreferencedResidualPicklists`.
15+
- **`@objectstack/objectql`** re-exports `unreferencedResidualPicklists` and its four types from the package root. The `manifest` service's `INVALID_METADATA` refusal of such a picklist is now that judgement's sentence, and the engine's picklist walk is the one in `@objectstack/metadata`. The residual registration of a composed stack now throws the body seam's own refusals for a permission set whose `packageId` names another package (`VALIDATION_ERROR` / 400 with `issues[]`) and for a `views:` entry that is not a view container (`INVALID_METADATA` / 422), instead of rebuilding each.
16+
- **`@objectstack/cli`**: the `picklist-residual-unreferenced` finding of `os validate` and `os build` is read from the same judgement. Its `--json` shape (`severity`, `rule`, `where`, `path`, `message`, `hint`) and its text are unchanged.

‎packages/cli/src/utils/picklist-references.boot-parity.test.ts‎

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
* stack's top-level picklist that no package body declares and no object field
66
* the engine registers names is refused by the boot (the engine's `manifest`
77
* service, `INVALID_METADATA`), and `judgePicklistReferences` refuses the same
8-
* stacks as `picklist-residual-unreferenced`, in the same words.
8+
* stack as `picklist-residual-unreferenced`, in the same words.
99
*
10-
* The engine does not export its judgement, so the author-time door restates
11-
* it. This file is what keeps the two from drifting. Each stack is judged by
12-
* the door and booted on a real kernel with the real `ObjectQLPlugin`, the way
13-
* `AppPlugin` hands a config boot's stack to the `manifest` service
14-
* (`{ ...manifest, ...stack }`). Where the door refuses one list, the boot
15-
* throws `${message} ${hint}` byte for byte; where the door raises no such
16-
* refusal, the boot does not throw this one.
10+
* Both read ONE judgement, `unreferencedResidualPicklists`
11+
* (`@objectstack/metadata/picklist-references`, re-exported by
12+
* `@objectstack/objectql`), so this file is a smoke check of the wiring, not
13+
* the guard against drift: one refused stack and one control, booted on a real
14+
* kernel with the real `ObjectQLPlugin`, the way `AppPlugin` hands a config
15+
* boot's stack to the `manifest` service (`{ ...manifest, ...stack }`). Which
16+
* lists are refused, judged off which fields, is pinned on the judgement and
17+
* at each door: `picklist-references.test.ts` here, and objectql's
18+
* `manifest-service-residual-objects.test.ts` and
19+
* `registration-refusal-one-spelling.test.ts`.
1720
*/
1821

1922
import { afterEach, describe, expect, it } from 'vitest';
@@ -31,8 +34,6 @@ const unused = { name: 'parity_unused', label: 'Unused', options: [{ label: 'A',
3134
const statusField = { status: { type: 'select', label: 'Status', picklist: 'parity_status' } };
3235
const caseObj = (fields: AnyRec = { subject: { type: 'text', label: 'Subject' } }) =>
3336
({ name: 'parity_case', label: 'Case', sharingModel: 'private', fields });
34-
const noteObj = (fields: AnyRec = { title: { type: 'text', label: 'Title' } }) =>
35-
({ name: 'parity_note', label: 'Note', sharingModel: 'private', fields });
3637

3738
/** Two bodies, a manifest naming neither, and `top` on the top level. */
3839
const composed = (top: AnyRec, coreExtra: AnyRec = {}): AnyRec => ({
@@ -44,27 +45,10 @@ const composed = (top: AnyRec, coreExtra: AnyRec = {}): AnyRec => ({
4445
],
4546
});
4647

47-
/** Every stack the door's pins name, the refused and the controls alike. */
48+
/** One refused stack and one control. */
4849
const STACKS: Record<string, AnyRec> = {
4950
'a list nothing names': composed({ picklists: [unused] }),
50-
'a list only a top-level `objectExtensions` entry names': composed({
51-
picklists: [unused], objectExtensions: [{ extend: 'parity_case', fields: { u: { type: 'select', label: 'U', picklist: 'parity_unused' } } }],
52-
}),
53-
'a list only a diverging top-level copy of a body object names': composed({
54-
picklists: [unused], objects: [caseObj({ u: { type: 'select', label: 'U', picklist: 'parity_unused' } })],
55-
}),
56-
'a list only a body `picklistExtensions` entry extends': composed({ picklists: [unused] }, {
57-
picklistExtensions: [{ extend: 'parity_unused', options: [{ label: 'Closed', value: 'closed' }] }],
58-
}),
59-
'a list only a top-level `plugins[]` entry\'s object names': composed({
60-
picklists: [unused], plugins: [{ name: 'parity_plugin', objects: [noteObj({ u: { type: 'select', label: 'U', picklist: 'parity_unused' } })] }],
61-
}),
62-
'CONTROL: a list a residual object\'s field names': composed({
63-
picklists: [unused], objects: [noteObj({ u: { type: 'select', label: 'U', picklist: 'parity_unused' } })],
64-
}),
65-
'CONTROL: a body-declared list repeated on the top level': composed({ picklists: [status] }),
6651
'CONTROL: a body-declared list a body field names': composed({}, { objects: [caseObj(statusField)] }),
67-
'CONTROL: a stack without `packages[]`': { manifest: manifest(STACK_ID), picklists: [unused], objects: [noteObj()] },
6852
};
6953

7054
const kernels: ObjectKernel[] = [];

‎packages/cli/src/utils/picklist-references.test.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
*/
1717

1818
import { describe, expect, it } from 'vitest';
19+
import { unclaimedTopLevel } from '@objectstack/metadata';
20+
import { unreferencedResidualPicklists } from '@objectstack/metadata/picklist-references';
1921
import {
2022
judgePicklistReferences,
2123
picklistRefusalHeading,
@@ -336,8 +338,9 @@ describe('judgePicklistReferences — a `picklistExtensions[].extend` in a `pack
336338
* [ADR-0130 D4] A composed stack's top-level picklist that no package body
337339
* declares and no object field the boot registers names: the boot refuses the
338340
* stack (`INVALID_METADATA`), so this door refuses it too, as
339-
* `picklist-residual-unreferenced`, in the boot's words. The boot's own text is
340-
* held equal in `picklist-references.boot-parity.test.ts`.
341+
* `picklist-residual-unreferenced`, in the boot's words. Both read the one
342+
* judgement, `unreferencedResidualPicklists`; the last pin below holds the door
343+
* to it, and `picklist-references.boot-parity.test.ts` smoke-checks the boot.
341344
*/
342345
describe('judgePicklistReferences — a composed stack\'s residual picklist that nothing names', () => {
343346
const STACK_ID = 'com.example.release';
@@ -422,6 +425,23 @@ describe('judgePicklistReferences — a composed stack\'s residual picklist that
422425
expect(judgePicklistReferences({ manifest: manifest('com.example.pk'), picklists: [industry, unused], objects: [account('industry')] }))
423426
.toEqual({ refusals: [], notices: [] });
424427
});
428+
429+
it('reports the one judgement the boot throws — its lists, their locations and its words, one finding per list', () => {
430+
const stack = composed({ picklists: [industry, unused, { ...unused, name: 'spare_list' }] });
431+
const judged = unreferencedResidualPicklists(
432+
unclaimedTopLevel(stack),
433+
stack.packages.map((entry) => entry.manifest),
434+
);
435+
expect(judged?.picklists).toHaveLength(2);
436+
expect(residualRefusals(stack)).toEqual(judged!.picklists.map((list) => ({
437+
severity: 'error',
438+
rule: PICKLIST_RESIDUAL_UNREFERENCED,
439+
where: `picklist "${list.name}"`,
440+
path: `${list.field}[${list.index}]`,
441+
message: list.wording.judgement,
442+
hint: list.wording.prescription,
443+
})));
444+
});
425445
});
426446

427447
describe('picklistRefusalHeading — the text face\'s heading names what each rule refused', () => {

‎packages/cli/src/utils/picklist-references.ts‎

Lines changed: 21 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
* The boot keeps a composed stack's residual picklist (a top-level one no
4848
* body declares) as metadata only, and refuses the stack when no object field
4949
* the engine registers names it (ADR-0130 D4). This door gives the same
50-
* refusal, in the boot's words, as {@link PICKLIST_RESIDUAL_UNREFERENCED}, so
51-
* `os validate` and `os build` foretell it. See
52-
* `findUnreferencedResidualPicklists`.
50+
* refusal as {@link PICKLIST_RESIDUAL_UNREFERENCED}, read from the boot's own
51+
* judgement (`unreferencedResidualPicklists`), so `os validate` and `os build`
52+
* foretell it in the boot's words. See `findUnreferencedResidualPicklists`.
5353
*
5454
* ## Declared dependencies outside the stack: reported, never refused
5555
*
@@ -93,6 +93,7 @@
9393
import chalk from 'chalk';
9494
import type { AuthoringFinding } from '@objectstack/lint';
9595
import { unclaimedTopLevel } from '@objectstack/metadata';
96+
import { unreferencedResidualPicklists } from '@objectstack/objectql';
9697

9798
import { artifactPackages } from './artifact-packages.js';
9899
import { printInfo } from './format.js';
@@ -335,60 +336,6 @@ export function judgePicklistReferences(parsed: AnyRec): PicklistReferenceJudgem
335336
return { refusals, notices };
336337
}
337338

338-
/**
339-
* The picklist names a manifest-shaped source's fields carry: its `objects`
340-
* and `objectExtensions`, in either field spelling (a name-keyed record or an
341-
* array), and the same of each nested `plugins[]` entry that is a plain
342-
* object. That is the reading the boot's `collectManifestPicklistReferences`
343-
* (`@objectstack/objectql`) makes of a source it registers. The stack parse
344-
* refuses a `plugins` key inside a `packages[]` body today; it is walked anyway
345-
* so this set stays the engine's.
346-
*/
347-
function fieldPicklistNamesOf(source: unknown): string[] {
348-
const names: string[] = [];
349-
const rec = asRec(source);
350-
if (!rec) return names;
351-
const nested = Array.isArray(rec.plugins) ? rec.plugins.map(asRec).filter((p): p is AnyRec => p !== undefined) : [];
352-
for (const s of [rec, ...nested]) {
353-
for (const collection of ['objects', 'objectExtensions'] as const) {
354-
const raw = s[collection];
355-
const entries = Array.isArray(raw) ? raw : (asRec(raw) ? Object.values(raw as AnyRec) : []);
356-
for (const entry of entries) {
357-
const fields = asRec(entry)?.fields;
358-
const defs = Array.isArray(fields) ? fields : (asRec(fields) ? Object.values(fields as AnyRec) : []);
359-
for (const def of defs) {
360-
const picklist = asRec(def)?.picklist;
361-
if (typeof picklist === 'string' && picklist !== '') names.push(picklist);
362-
}
363-
}
364-
}
365-
}
366-
return names;
367-
}
368-
369-
/**
370-
* The boot's judgement of an unreferenced residual picklist, for one list, in
371-
* the boot's words: what `ObjectQLPlugin`'s `manifest` service throws
372-
* (`unreferencedResidualPicklistRefusal`, `INVALID_METADATA`) is
373-
* `${judgement} ${prescription}`. Restated here because the engine does not
374-
* export it; `picklist-references.boot-parity.test.ts` boots the engine on the
375-
* same stacks and holds the two equal.
376-
*/
377-
function residualPicklistWording(name: string, owner: string): { judgement: string; prescription: string } {
378-
return {
379-
judgement:
380-
`Picklist '${name}' is declared on the top level of stack '${owner}', and no package body declares it and no `
381-
+ 'object field the engine registers names it (in a composed stack, a top-level `objectExtensions` entry, a '
382-
+ 'top-level copy of an object a package body declares and a top-level `plugins[]` entry are not registered). '
383-
+ 'The engine registers a top-level picklist only through a package body, so this list would be missing from '
384-
+ 'the engine with nothing said.',
385-
prescription:
386-
'Declare it inside the `packages[]` entry of the package whose objects use it; or name it from a field of '
387-
+ 'the object meant to use it (a top-level object that does is then listed and not served until both move '
388-
+ 'into a package body); or remove it from the top level.',
389-
};
390-
}
391-
392339
/**
393340
* [ADR-0130 D4] Every top-level picklist of a composed stack (one that carries
394341
* `packages[]`) that no package body declares and no object field the boot
@@ -397,40 +344,30 @@ function residualPicklistWording(name: string, owner: string): { judgement: stri
397344
* whose top level IS its one package.
398345
*
399346
* Which items are residual is the residual rule's answer (`unclaimedTopLevel`,
400-
* `@objectstack/metadata`), never re-derived here. "Named" is the boot's set:
401-
* a field of a body's `objects` / `objectExtensions` / nested `plugins[]`, or
402-
* of a residual object. A top-level `objectExtensions` entry, a top-level copy
403-
* of an object a body declares and a top-level `plugins[]` entry name nothing,
404-
* because no door registers them in a composed stack.
347+
* `@objectstack/metadata`); which of its lists nothing names, and the words,
348+
* are the boot's own judgement, `unreferencedResidualPicklists` (re-exported by
349+
* `@objectstack/objectql`), which the `manifest` service throws as one
350+
* sentence. This door only reports it, one finding per list, located at the
351+
* list's top-level index. ⛔ Never re-derived or re-worded here.
405352
*
406353
* A residual picklist that a field DOES name is not this judge's: a residual
407354
* object's field makes that object listed and not served at boot, and a
408355
* body's field is {@link PICKLIST_REFERENCE_UNKNOWN} above.
409356
*/
410357
function findUnreferencedResidualPicklists(parsed: AnyRec): AuthoringFinding[] {
411358
if (parsed.packages === undefined) return [];
412-
const residual = unclaimedTopLevel(parsed);
413-
const lists = residual?.items.filter((entry) => entry.type === 'picklist') ?? [];
414-
if (!residual || lists.length === 0) return [];
415-
const named = new Set<string>();
416-
for (const pkg of artifactPackages(parsed)) for (const name of fieldPicklistNamesOf(pkg.body)) named.add(name);
417-
const residualObjects = residual.items.filter((entry) => entry.type === 'object').map((entry) => entry.item);
418-
for (const name of fieldPicklistNamesOf({ objects: residualObjects })) named.add(name);
419-
const owner = residual.ownerId ?? '<none>';
420-
return lists
421-
.map((entry) => ({ entry, name: String(asRec(entry.item)?.name) }))
422-
.filter(({ name }) => !named.has(name))
423-
.map(({ entry, name }) => {
424-
const wording = residualPicklistWording(name, owner);
425-
return {
426-
severity: 'error' as const,
427-
rule: PICKLIST_RESIDUAL_UNREFERENCED,
428-
where: `picklist "${name}"`,
429-
path: `${entry.field}[${entry.index}]`,
430-
message: wording.judgement,
431-
hint: wording.prescription,
432-
};
433-
});
359+
const unreferenced = unreferencedResidualPicklists(
360+
unclaimedTopLevel(parsed),
361+
artifactPackages(parsed).map((pkg) => pkg.body),
362+
);
363+
return (unreferenced?.picklists ?? []).map((list) => ({
364+
severity: 'error' as const,
365+
rule: PICKLIST_RESIDUAL_UNREFERENCED,
366+
where: `picklist "${list.name}"`,
367+
path: `${list.field}[${list.index}]`,
368+
message: list.wording.judgement,
369+
hint: list.wording.prescription,
370+
}));
434371
}
435372

436373
/**

‎packages/metadata/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@
6767
"types": "./dist/view-container-name.d.cts",
6868
"default": "./dist/view-container-name.cjs"
6969
}
70+
},
71+
"./picklist-references": {
72+
"import": {
73+
"types": "./dist/picklist-references.d.ts",
74+
"default": "./dist/picklist-references.js"
75+
},
76+
"require": {
77+
"types": "./dist/picklist-references.d.cts",
78+
"default": "./dist/picklist-references.cjs"
79+
}
7080
}
7181
},
7282
"files": [

0 commit comments

Comments
 (0)