Skip to content

Commit 3fd4d6b

Browse files
fix(cli)!: os validate and os build refuse a composed stack's top-level picklist that the boot refuses (#28274)
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL ## 维护者速读 - 问题:PR #28231 之后,组合栈(带 `packages[]`)顶层声明、无包体声明且无引擎注册字段引用的 picklist 会在启动时被拒绝,但 `os validate` / `os build` 照常通过,作者到启动才发现。 - 修复:两道作者期入口复用残余规则(`unclaimedTopLevel`)并按启动的同一判定拒绝它,规则 id `picklist-residual-unreferenced`,消息与处方逐字等同启动的拒绝文字。 - "被引用"的集合与启动相同:包体的 objects / objectExtensions / 嵌套 plugins[] 字段,加上残余对象的字段;顶层 objectExtensions、包体对象的顶层副本、顶层 plugins[] 不算。 - 共享方式:引擎未导出该判定,且本卡文件面不含 `packages/objectql`,所以在 cli 中重述,并以启动对拍测试(真实内核 + `ObjectQLPlugin`)逐字节锁住两边一致。 - 文案:`packages/metadata` 残余规则头注释与 `packages/spec/src/stack.zod.ts` 的一个 docblock 补上 picklist 例外,无 schema 变更,`check:generated` 通过。 - 破坏性:作者期收窄(`Clause-②: no (narrowing)`),只拒绝启动本已拒绝的栈;patch 级 changeset,ADR-0087 not-required。 Clause-②: no (narrowing) — `os validate` and `os build` refuse a composed stack whose top level declares a picklist that no package body declares and no object field the engine registers names, which they accepted before; the boot already refuses every such stack, so nothing that boots is newly refused, and nothing is widened Metadata-protocol: none ## What changed - **The judgement** (`packages/cli/src/utils/picklist-references.ts`). `judgePicklistReferences` gains a third refusal, `picklist-residual-unreferenced`. It reads the residual rule's own answer (`unclaimedTopLevel` from `@objectstack/metadata`, the copy both boot doors and `view-container-names.ts` read) and takes its picklists. Each one that no object field the engine registers names is refused, located at its top-level index (`picklists[i]`). "Named" is the boot's set (`unreferencedResidualPicklistRefusal` in `@objectstack/objectql`): a field of a body's `objects` / `objectExtensions` / nested `plugins[]`, or of a residual object. A top-level `objectExtensions` entry, a top-level copy of a body-declared object, and a top-level `plugins[]` entry name nothing. Both doors call this function (`os validate` step 2d, `os build` step 3a-bis), so both refuse. - **The doors' text face** (`validate.ts`, `compile.ts`). The heading over the refusals now comes from one shared `picklistRefusalHeading`. It names a residual refusal as the boot's refusal, not as "a picklist reference names a picklist this stack does not declare". With only reference refusals, the heading is byte-identical to before. `--json` is unchanged in shape: the new refusal rides `errors` like the others. - **The prose.** The residual-rule header in `packages/metadata/src/plugin.ts` and the permission-set owner gate's docblock in `packages/spec/src/stack.zod.ts` now state the picklist exception. A residual picklist is metadata only: when a residual object's field names it, the object is listed and not served; when no registered field names it, the stack is refused at boot and by `os validate` / `os build` first. This is the one docblock in `stack.zod.ts`. It is not a `.describe()`, so nothing generated moves, and `pnpm --filter @objectstack/spec check:generated` reports all 14 artifacts up to date. - **The gate-parity ledger** (`packages/cli/test/validate-build-gate-parity.test.ts`) classifies `picklistRefusalHeading` as presentation, beside `printPicklistReferenceNotices`. ### Judgement-sharing route: restated, held by a boot-parity pin The judgement is restated in the CLI rather than imported. The engine's `unreferencedResidualPicklistRefusal` and `collectManifestPicklistReferences` are module-private in `@objectstack/objectql`. Exporting them is an edit to `packages/objectql`, which this card's claim excludes. What is shared is the residual rule itself (`unclaimedTopLevel`), so which items are residual is never re-derived. What is restated is the "named" walk and the wording. `picklist-references.boot-parity.test.ts` keeps the restatement honest. It judges each stack with the door, then boots it on a real `ObjectKernel` with the real `ObjectQLPlugin`, handed over the way `AppPlugin` hands a config stack. Where the door refuses one list, the boot's `INVALID_METADATA` message must equal the door's `message + ' ' + hint` byte for byte. On every control, neither side raises this refusal. A follow-up could export the engine's judgement from `@objectstack/objectql` and drop the restatement, the way `viewContainerNameRefusal` is shared. ## Measurement The doors were measured with the real CLI (`os validate --json`, `os build`) on main's `picklist-references.ts` (`f40b777d`) and on this branch. The boot was measured by the parity test's kernel boot on the same engine (merged PR #28231). | Stack | Boot | `main`: validate / build | This branch: validate / build | |:---|:---|:---|:---| | Composed, top-level picklist no body declares and no field names | refused, `INVALID_METADATA` | exit 0, no finding / exit 0, artifact written | exit 1, `picklist-residual-unreferenced` at `picklists[0]` / exit 1, no artifact | | CONTROL: same list, named by a residual object's field | boots (object listed, not served) | exit 0 / exit 0 | unchanged | | CONTROL: body-declared list, repeated on the top level | boots | exit 0 / exit 0 | unchanged | | CONTROL: no `packages[]`, unnamed top-level list | boots | exit 0 / exit 0 | unchanged | ## Pins - **New unit pins** (`picklist-references.test.ts`), 9 refusal or heading pins: - the refusal (rule, `where`, `path`, the list, the stack, all three remedies); - each list as its own finding; - a list named only by a top-level `objectExtensions` entry; - a list named only by a diverging top-level copy of a body object; - a list named only by a top-level `plugins[]` entry; - a list only a body `picklistExtensions` entry extends; - the three heading pins: references alone keep the old heading, residual alone, and both together. - **New unit CONTROLs**, 4: a residual object's field names the list; a body object's field names it (still `picklist-reference-unknown` alone); a body-declared list, repeated on the top level or not; a stack without `packages[]`. - **Boot-parity pins**: 4 refused stacks and 4 controls. - **Against main's `picklist-references.ts`**: 13 fail and 27 pass. The 13 are all 6 unit refusal pins, all 3 heading pins (the function is new), and all 4 parity refusal pins. Every control passes. - **On this branch**: 40/40 pass. Ablations, each restored from HEAD afterwards: | Ablation | Fails | |:---|:---| | B1: never raise the refusal | 10: every refusal pin, unit and parity | | B2: drop residual objects from the named set | 2: the residual-object control, unit and parity | | B3: drop bodies from the named set | 1: the body-field control | | B4: also count the whole top level as named | 5: the `objectExtensions`, diverging-copy and `plugins[]` pins, unit and parity | | B5: take every top-level picklist, not only the residual's | 1: the parity body-declared control | | B6: one word of the wording drifts from the boot's | 4: every parity refusal pin | ## Gates Final head `e1913ec6`, which merges `main` at `361ea242` (it brings PR #28244's other region of `stack.zod.ts`, merged cleanly). Run after a full rebuild: - **Derived gates.** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 85 families. All 85 were run with exit codes recorded, and all exited 0. `--ran` reports 85 run, 0 NOT-MEASURED and 0 UNRUN, and exits 0. - **Not measured locally.** The derivation names work outside the 85 that cannot run locally: 4 families that take a value from the workflow, 5 CI jobs these paths schedule, and 5 type-check lanes. CI runs them. - **Generated output.** `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date. - **Lint and typecheck.** `pnpm lint`: exit 0. `pnpm --filter @objectstack/cli run typecheck` and `pnpm --filter @objectstack/metadata run typecheck`: exit 0 (on `1fa87152`; the later commits change only the changeset and merge `main`). - **Suites on `e1913ec6`.** The four picklist/door files: 80/80 (`picklist-references.test.ts`, the boot-parity file, `validate-build-gate-parity.test.ts`, `picklist-reference-doors.test.ts`). - **Suites on `36aea717`, the previous merge head.** - `@objectstack/cli`: 537 files pass. One file, `migrate/data-commands.absent-database.integration.test.ts`, hit a `beforeAll` timeout under box load; re-run alone, it passes 51/51. - `@objectstack/metadata`: 906/906. - Dogfood battery under the verify lock: `VERDICT command-exit 0`, 360 files pass and 3 skipped, 2896 tests pass and 20 skipped. Fixes #28248 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5bf56b commit 3fd4d6b

9 files changed

Lines changed: 406 additions & 6 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/metadata': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(cli)!: `os validate` and `os build` refuse a composed stack's top-level picklist that the boot refuses (#28248)
8+
9+
Clause-②: no (narrowing) — `os validate` and `os build` refuse a composed stack whose top level declares a picklist that no package body declares and no object field the engine registers names, which they accepted before; the boot already refuses every such stack, so nothing that boots is newly refused, and nothing is widened
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and the stack schema accepts what it accepted. The two authoring doors now give, ahead of the boot, a refusal the boot already gives, with the same remedy; there is no metadata for a migration to rewrite. -->
13+
14+
**BREAKING** for `os validate` / `os build` on a composed stack only (one that carries `packages[]`), and only for a stack the boot already refuses.
15+
16+
A composed stack (one that carries `packages[]`) whose top level declares a picklist that no package body declares, and that no object field the engine registers names, is refused at boot (`INVALID_METADATA`). `os validate` and `os build` used to pass the same stack, so the author learned of it only at boot.
17+
18+
- **Now.** Both doors refuse it as `picklist-residual-unreferenced`, located at the picklist's top-level index. The message and prescription are the boot's own words: declare it inside the `packages[]` entry of the package whose objects use it; or name it from a field of the object meant to use it; or remove it from the top level.
19+
- **What counts as naming it** is the boot's set: a field of a body's objects or `objectExtensions`, or of a top-level object no body declares. A top-level `objectExtensions` entry, a top-level copy of an object a body declares, and a top-level `plugins[]` entry name nothing, because no door registers them in a composed stack.
20+
- **Unchanged.** A residual picklist that a top-level object's field names is not refused (the boot lists that object and does not serve it). A body field naming a list only the top level declares is `picklist-reference-unknown`, as before. A body-declared picklist and a stack without `packages[]` are not affected.
21+
- **Prose.** The residual-rule header in `@objectstack/metadata` and the permission-set owner gate's docblock in `@objectstack/spec` now state the picklist exception. No schema changes.
22+
23+
No stack that boots is newly refused: every stack this refuses, the boot already refuses.

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s
7474
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
7575
// A field `picklist` that names no picklist the stack declares — the walk
7676
// `os validate` step 2d runs, over the same judge.
77-
import { judgePicklistReferences, printPicklistReferenceNotices } from '../utils/picklist-references.js';
77+
import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js';
7878

7979
export default class Compile extends Command {
8080
static override description = 'Compile ObjectStack configuration to JSON artifact';
@@ -509,9 +509,8 @@ export default class Compile extends Command {
509509
await emitJson({ success: false, errors: picklistJudgement.refusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
510510
this.exit(1);
511511
}
512-
const n = picklistJudgement.refusals.length;
513512
console.log('');
514-
printError(`A picklist reference names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`);
513+
printError(picklistRefusalHeading(picklistJudgement.refusals));
515514
printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY });
516515
this.exit(1);
517516
}

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js'
7676
// A field `picklist` that names no picklist the stack declares — refused, or
7777
// reported when the declaring package depends on packages outside the stack.
7878
// Walked the way the load path registers, like the refusal above.
79-
import { judgePicklistReferences, printPicklistReferenceNotices } from '../utils/picklist-references.js';
79+
import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js';
8080

8181
export default class Validate extends Command {
8282
static override description =
@@ -479,9 +479,8 @@ export default class Validate extends Command {
479479
});
480480
this.exit(1);
481481
}
482-
const n = picklistJudgement.refusals.length;
483482
console.log('');
484-
printError(`A picklist reference names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`);
483+
printError(picklistRefusalHeading(picklistJudgement.refusals));
485484
printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY });
486485
this.exit(1);
487486
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0130 D4] `os validate` / `os build` foretell the boot: a composed
5+
* stack's top-level picklist that no package body declares and no object field
6+
* the engine registers names is refused by the boot (the engine's `manifest`
7+
* service, `INVALID_METADATA`), and `judgePicklistReferences` refuses the same
8+
* stacks as `picklist-residual-unreferenced`, in the same words.
9+
*
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.
17+
*/
18+
19+
import { afterEach, describe, expect, it } from 'vitest';
20+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
21+
import { ObjectQLPlugin } from '@objectstack/objectql';
22+
23+
import { judgePicklistReferences, PICKLIST_RESIDUAL_UNREFERENCED } from './picklist-references.js';
24+
25+
type AnyRec = Record<string, unknown>;
26+
27+
const STACK_ID = 'com.example.parity.release';
28+
const manifest = (id: string) => ({ id, name: id.split('.').pop(), version: '1.0.0', type: 'app' });
29+
const status = { name: 'parity_status', label: 'Status', options: [{ label: 'Open', value: 'open' }] };
30+
const unused = { name: 'parity_unused', label: 'Unused', options: [{ label: 'A', value: 'a' }] };
31+
const statusField = { status: { type: 'select', label: 'Status', picklist: 'parity_status' } };
32+
const caseObj = (fields: AnyRec = { subject: { type: 'text', label: 'Subject' } }) =>
33+
({ 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 });
36+
37+
/** Two bodies, a manifest naming neither, and `top` on the top level. */
38+
const composed = (top: AnyRec, coreExtra: AnyRec = {}): AnyRec => ({
39+
manifest: manifest(STACK_ID),
40+
...top,
41+
packages: [
42+
{ manifest: { ...manifest('com.example.parity.core'), objects: [caseObj()], ...coreExtra } },
43+
{ manifest: { ...manifest('com.example.parity.app'), picklists: [status] } },
44+
],
45+
});
46+
47+
/** Every stack the door's pins name, the refused and the controls alike. */
48+
const STACKS: Record<string, AnyRec> = {
49+
'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] }),
66+
'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()] },
68+
};
69+
70+
const kernels: ObjectKernel[] = [];
71+
afterEach(async () => {
72+
while (kernels.length) {
73+
const kernel = kernels.pop()!;
74+
if (kernel.getState() === 'running') await kernel.shutdown();
75+
}
76+
});
77+
78+
/** Boot the stack through the `manifest` service; the boot's refusal, or `undefined`. */
79+
async function bootRefusal(stack: AnyRec): Promise<(Error & { code?: string }) | undefined> {
80+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
81+
kernels.push(kernel);
82+
await kernel.use(new ObjectQLPlugin());
83+
await kernel.use({
84+
name: 'test.parity.app',
85+
dependencies: ['com.objectstack.engine.objectql'],
86+
init: async (ctx: PluginContext) => {
87+
await ctx.getService<{ register(m: unknown): unknown }>('manifest')
88+
.register({ ...(stack.manifest as AnyRec), ...stack });
89+
},
90+
} as Plugin);
91+
return kernel.bootstrap().then(() => undefined, (e: Error & { code?: string }) => e);
92+
}
93+
94+
describe('picklist-residual-unreferenced — the author-time door and the boot agree', () => {
95+
it.each(Object.keys(STACKS))('%s', async (label) => {
96+
const stack = STACKS[label];
97+
const door = judgePicklistReferences(stack).refusals.filter((r) => r.rule === PICKLIST_RESIDUAL_UNREFERENCED);
98+
const boot = await bootRefusal(stack);
99+
if (label.startsWith('CONTROL')) {
100+
expect(door).toEqual([]);
101+
expect(boot?.message ?? '').not.toContain('is declared on the top level of stack');
102+
return;
103+
}
104+
expect(door).toHaveLength(1);
105+
expect(boot?.code).toBe('INVALID_METADATA');
106+
expect(boot?.message).toBe(`${door[0].message} ${door[0].hint}`);
107+
});
108+
});

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
import { describe, expect, it } from 'vitest';
1919
import {
2020
judgePicklistReferences,
21+
picklistRefusalHeading,
2122
PICKLIST_REFERENCE_UNKNOWN,
2223
PICKLIST_REFERENCE_UNVERIFIED,
24+
PICKLIST_RESIDUAL_UNREFERENCED,
2325
} from './picklist-references.js';
2426

2527
const manifest = (id: string, extra: Record<string, unknown> = {}) => ({
@@ -329,3 +331,116 @@ describe('judgePicklistReferences — a `picklistExtensions[].extend` in a `pack
329331
})).toEqual({ refusals: [], notices: [] });
330332
});
331333
});
334+
335+
/**
336+
* [ADR-0130 D4] A composed stack's top-level picklist that no package body
337+
* declares and no object field the boot registers names: the boot refuses the
338+
* 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+
*/
342+
describe('judgePicklistReferences — a composed stack\'s residual picklist that nothing names', () => {
343+
const STACK_ID = 'com.example.release';
344+
const status = { name: 'status_list', label: 'Status', options: [{ label: 'Open', value: 'open' }] };
345+
const unused = { name: 'unused_list', label: 'Unused', options: [{ label: 'A', value: 'a' }] };
346+
const statusField = { status: { type: 'select', label: 'Status', picklist: 'status_list' } };
347+
const caseObj = (fields: Record<string, unknown> = { subject: { type: 'text', label: 'Subject' } }) =>
348+
({ name: 'pk_case', label: 'Case', fields });
349+
const noteObj = (fields: Record<string, unknown> = { title: { type: 'text', label: 'Title' } }) =>
350+
({ name: 'pk_note', label: 'Note', fields });
351+
/** Two bodies, a manifest naming neither, and `top` on the top level. */
352+
const composed = (top: Record<string, unknown>, coreExtra: Record<string, unknown> = {}) => ({
353+
manifest: manifest(STACK_ID),
354+
...top,
355+
packages: [
356+
{ manifest: { ...manifest('com.example.core'), objects: [caseObj()], ...coreExtra } },
357+
{ manifest: { ...manifest('com.example.app'), objects: [account('industry')], picklists: [industry] } },
358+
],
359+
});
360+
const residualRefusals = (parsed: Record<string, unknown>) =>
361+
judgePicklistReferences(parsed).refusals.filter((r) => r.rule === PICKLIST_RESIDUAL_UNREFERENCED);
362+
363+
it('refuses one, naming the list, the stack and the boot\'s prescription, located at its top-level index', () => {
364+
const { refusals, notices } = judgePicklistReferences(composed({ picklists: [industry, unused] }));
365+
expect(notices).toEqual([]);
366+
expect(refusals).toHaveLength(1);
367+
expect(refusals[0]).toMatchObject({
368+
severity: 'error',
369+
rule: PICKLIST_RESIDUAL_UNREFERENCED,
370+
where: 'picklist "unused_list"',
371+
path: 'picklists[1]',
372+
});
373+
expect(refusals[0].message).toContain(`Picklist 'unused_list' is declared on the top level of stack '${STACK_ID}'`);
374+
expect(refusals[0].message).toContain('no package body declares it and no object field the engine registers names it');
375+
expect(refusals[0].hint).toContain('Declare it inside the `packages[]` entry of the package whose objects use it');
376+
expect(refusals[0].hint).toContain('name it from a field of the object meant to use it');
377+
expect(refusals[0].hint).toContain('remove it from the top level');
378+
});
379+
380+
it('refuses each such list as its own finding', () => {
381+
expect(residualRefusals(composed({ picklists: [unused, { ...unused, name: 'spare_list' }] })).map((r) => r.where))
382+
.toEqual(['picklist "unused_list"', 'picklist "spare_list"']);
383+
});
384+
385+
it('refuses one that only a top-level `objectExtensions` entry names — no door registers it in a composed stack', () => {
386+
expect(residualRefusals(composed({ picklists: [status], objectExtensions: [{ extend: 'pk_case', fields: statusField }] })))
387+
.toHaveLength(1);
388+
});
389+
390+
it('refuses one that only a top-level copy of a body-declared object names, its fields diverging from the body\'s', () => {
391+
expect(residualRefusals(composed({ picklists: [status], objects: [caseObj(statusField)] }))).toHaveLength(1);
392+
});
393+
394+
it('refuses one that only a top-level `plugins[]` entry\'s object names', () => {
395+
expect(residualRefusals(composed({ picklists: [status], plugins: [{ name: 'p', objects: [noteObj(statusField)] }] })))
396+
.toHaveLength(1);
397+
});
398+
399+
it('refuses one that only a body\'s `picklistExtensions` entry extends', () => {
400+
expect(residualRefusals(composed({ picklists: [status] }, {
401+
picklistExtensions: [{ extend: 'status_list', options: [{ label: 'Closed', value: 'closed' }] }],
402+
}))).toHaveLength(1);
403+
});
404+
405+
it('CONTROL: one a residual object\'s field names is not this refusal (the boot lists that object and does not serve it)', () => {
406+
expect(judgePicklistReferences(composed({ picklists: [status], objects: [noteObj(statusField)] })))
407+
.toEqual({ refusals: [], notices: [] });
408+
});
409+
410+
it('CONTROL: one a body object\'s field names is not this refusal — that field is `picklist-reference-unknown`, as before', () => {
411+
const { refusals } = judgePicklistReferences(composed({ picklists: [status] }, { objects: [caseObj(statusField)] }));
412+
expect(refusals.map((r) => r.rule)).toEqual([PICKLIST_REFERENCE_UNKNOWN]);
413+
});
414+
415+
it('CONTROL: a body-declared picklist, repeated on the top level or not, is not residual', () => {
416+
for (const top of [{ picklists: [industry] }, {}]) {
417+
expect(judgePicklistReferences(composed(top))).toEqual({ refusals: [], notices: [] });
418+
}
419+
});
420+
421+
it('CONTROL: a stack without `packages[]` has no residual — its unnamed picklist is not refused', () => {
422+
expect(judgePicklistReferences({ manifest: manifest('com.example.pk'), picklists: [industry, unused], objects: [account('industry')] }))
423+
.toEqual({ refusals: [], notices: [] });
424+
});
425+
});
426+
427+
describe('picklistRefusalHeading — the text face\'s heading names what each rule refused', () => {
428+
const finding = (rule: string) => ({ severity: 'error' as const, rule, where: 'w', path: 'p', message: 'm', hint: 'h' });
429+
430+
it('CONTROL: reference refusals alone keep the heading they always had', () => {
431+
expect(picklistRefusalHeading([finding(PICKLIST_REFERENCE_UNKNOWN), finding(PICKLIST_REFERENCE_UNKNOWN)]))
432+
.toBe('A picklist reference names a picklist this stack does not declare (2 references)');
433+
});
434+
435+
it('a residual refusal is headed as the boot\'s refusal, not as a reference', () => {
436+
const heading = picklistRefusalHeading([finding(PICKLIST_RESIDUAL_UNREFERENCED)]);
437+
expect(heading).toBe('A top-level picklist of this composed stack is one the boot refuses: no package body declares it '
438+
+ 'and no object field the engine registers names it (1 picklist)');
439+
});
440+
441+
it('both kinds together are both named', () => {
442+
const heading = picklistRefusalHeading([finding(PICKLIST_REFERENCE_UNKNOWN), finding(PICKLIST_RESIDUAL_UNREFERENCED)]);
443+
expect(heading).toContain('(1 reference)');
444+
expect(heading).toContain('(1 picklist)');
445+
});
446+
});

0 commit comments

Comments
 (0)