From ec1d965497bfead9e31f7233f807988bf48e47e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:27:10 +0000 Subject: [PATCH 1/2] feat(cli)!: os validate and os build refuse a picklistExtensions entry whose extend names no declared picklist The picklist-reference judge walked a field's `picklist` only. A `picklistExtensions[].extend` is the same reference one key over, and `PicklistExtensionSchema` checks only its spelling, so `extend: 'industy'` beside `picklists: [{ name: 'industry', ... }]` parsed, validated and built. The judge now walks the second collection on the load path's reading (the top-level entries of a one-package stack, each `packages[]` body's own otherwise) and gives an unresolved `extend` the same two verdicts as a field: refused with `picklist-reference-unknown`, or an info notice `picklist-reference-unverified` when the declaring package depends on a package outside the stack. Both doors' refusal heading no longer says "A field". Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20825-cli-picklist-extend.md | 32 ++++ packages/cli/src/commands/compile.ts | 13 +- packages/cli/src/commands/validate.ts | 19 +- .../cli/src/utils/picklist-references.test.ts | 167 +++++++++++++++++- packages/cli/src/utils/picklist-references.ts | 156 ++++++++++++---- .../cli/test/picklist-reference-doors.test.ts | 82 ++++++++- 6 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 .changeset/20825-cli-picklist-extend.md diff --git a/.changeset/20825-cli-picklist-extend.md b/.changeset/20825-cli-picklist-extend.md new file mode 100644 index 00000000000..26849b5db08 --- /dev/null +++ b/.changeset/20825-cli-picklist-extend.md @@ -0,0 +1,32 @@ +--- +'@objectstack/cli': minor +--- + +feat(cli)!: `objectstack validate` and `objectstack build` refuse a `picklistExtensions` entry whose `extend` names no picklist the stack declares (#20825) + +Clause-②: no (narrowing — `objectstack validate` / `objectstack build` newly refuse a `picklistExtensions[].extend` that names no picklist the stack declares; nothing is accepted that was refused before) + + + +**BREAKING** — an accept-set narrowing on two authoring commands, shipped as +`minor` under the launch-window convention. A stack with a `picklistExtensions` +entry whose `extend` names no picklist the stack declares — `extend: 'industy'` +beside a `picklists: [{ name: 'industry', … }]` — used to pass `objectstack +validate` and `objectstack build` (which wrote the artifact). Both now exit 1 and +name the extension and the list it names (`picklist-reference-unknown`, the rule a +field's dangling `picklist` already gets). +**One-line fix:** correct `extend` to the picklist the entry adds options to, declare +the list it names (`picklists: [{ name, label, options }]`, or a `*.picklist.ts` file +the stack imports), or remove the entry. + +**Which extensions are judged.** The ones the load path registers: the top-level +`picklistExtensions` of a one-package stack, or each `packages[]` entry's own. An +`extend` resolves against every picklist the stack declares, including one a sibling +package in the same artifact owns. + +**A list from a package outside the stack is reported, not refused.** When the +package declaring the extension lists a `manifest.dependencies` entry the stack does +not carry, the list may live there, and these commands cannot read it. That +extension is an `info` notice (`picklist-reference-unverified`) in `warnings` and on +the console, naming the extension, the list and the dependencies — never a failure, +not even under `--strict`. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 0a9726d0b3c..d090650c29d 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -459,11 +459,12 @@ export default class Compile extends Command { this.exit(1); } - // 3a-bis. A field `picklist` that names no picklist the stack declares is - // REFUSED — the SAME call `os validate` makes at its step 2d, so the - // two doors cannot disagree about which references resolve. Without - // it this door wrote the artifact carrying the misspelt reference, and - // the command that ships shipped a choice with nothing to choose. + // 3a-bis. A field `picklist`, or a `picklistExtensions` entry's `extend`, + // that names no picklist the stack declares is REFUSED — the SAME call + // `os validate` makes at its step 2d, so the two doors cannot disagree + // about which references resolve. Without it this door wrote the + // artifact carrying the misspelt reference, and the command that ships + // shipped a choice with nothing to choose. // // A reference the stack cannot resolve while the declaring package // depends on packages outside the stack is an `info` notice instead @@ -484,7 +485,7 @@ export default class Compile extends Command { } const n = picklistJudgement.refusals.length; console.log(''); - printError(`A field names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`); + printError(`A picklist reference names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`); printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index dc34ed730d8..adb3cb831d8 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -420,15 +420,18 @@ export default class Validate extends Command { this.exit(1); } - // 2d. A field `picklist` that names no picklist the stack declares is - // REFUSED, naming the field and the list. `FieldSchema` judges the - // name's spelling only, so a misspelt reference parsed, passed this - // door at exit 0 and reached the runtime as a choice with nothing to - // choose — the silence a NAMED list exists to remove. + // 2d. A field `picklist`, or a `picklistExtensions` entry's `extend`, that + // names no picklist the stack declares is REFUSED, naming the field + // (or the extension) and the list. `FieldSchema` and + // `PicklistExtensionSchema` judge the name's spelling only, so a + // misspelt reference parsed, passed this door at exit 0 and reached + // the runtime as a choice with nothing to choose — the silence a NAMED + // list exists to remove. // // The walk is the load path's (see `utils/picklist-references.ts`): - // each `packages[]` body's fields, or the top level's when there is - // no `packages[]`, resolved against every picklist the stack declares. + // each `packages[]` body's fields and extensions, or the top level's + // when there is no `packages[]`, resolved against every picklist the + // stack declares. // A reference that resolves nowhere is refused only when the // declaring package depends on no package outside the stack; when it // does, the list may live there, and this command cannot read it — so @@ -455,7 +458,7 @@ export default class Validate extends Command { } const n = picklistJudgement.refusals.length; console.log(''); - printError(`A field names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`); + printError(`A picklist reference names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`); printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } diff --git a/packages/cli/src/utils/picklist-references.test.ts b/packages/cli/src/utils/picklist-references.test.ts index 6fe26142b91..a6732b68304 100644 --- a/packages/cli/src/utils/picklist-references.test.ts +++ b/packages/cli/src/utils/picklist-references.test.ts @@ -2,15 +2,17 @@ /** * `judgePicklistReferences` — the walk `os validate` (step 2d) and `os build` - * (step 3a-bis) run over a field's `picklist` reference. + * (step 3a-bis) run over a field's `picklist` reference and a + * `picklistExtensions` entry's `extend`. * * Pinned here: which references are judged (the load path's reading — the top * level when there is no `packages[]`, each body's own otherwise), what they * resolve against (every picklist the stack declares), and which of the two * verdicts an unresolved one gets (refused when the declaring package depends - * on nothing outside the stack; an `info` notice when it does). Asserted by - * rule id, severity, the field named in `where`, the list named in `message` - * and the `path` — never by the sentence around them. + * on nothing outside the stack; an `info` notice when it does) — the same two + * verdicts for a field and for an extension. Asserted by rule id, severity, + * the field or extension named in `where`, the list named in `message` and the + * `path` — never by the sentence around them. */ import { describe, expect, it } from 'vitest'; @@ -30,6 +32,12 @@ const industry = { options: [{ label: 'Technology', value: 'technology' }], }; +/** An extension adding one option to the list it names. */ +const extension = (extend: string) => ({ + extend, + options: [{ label: 'Healthcare', value: 'healthcare' }], +}); + const account = (picklist: string) => ({ name: 'pk_account', label: 'Account', @@ -170,3 +178,154 @@ describe('judgePicklistReferences — a `packages[]` stack', () => { expect(notices.map((n) => n.path)).toEqual(['packages[2].manifest.objects[0].fields.industry.picklist']); }); }); + +describe('judgePicklistReferences — a `picklistExtensions[].extend` in a one-package stack', () => { + it('CONTROL: an extension naming a picklist the stack declares is neither refused nor reported', () => { + expect(judgePicklistReferences({ + manifest: manifest('com.example.pk'), + picklists: [industry], + picklistExtensions: [extension('industry')], + })).toEqual({ refusals: [], notices: [] }); + }); + + it('refuses an `extend` that names no picklist in the stack, naming the extension and the list', () => { + const { refusals, notices } = judgePicklistReferences({ + manifest: manifest('com.example.pk'), + picklists: [industry], + picklistExtensions: [extension('industry'), extension('industy')], + }); + expect(notices).toEqual([]); + // Only the second entry: the first resolves. + expect(refusals).toHaveLength(1); + expect(refusals[0]).toMatchObject({ + severity: 'error', + rule: PICKLIST_REFERENCE_UNKNOWN, + where: 'picklist extension "industy"', + path: 'picklistExtensions[1].extend', + }); + expect(refusals[0].message).toContain("'industy'"); + // The lists it could have meant are named, so the typo is visible. + expect(refusals[0].message).toContain("'industry'"); + }); + + it('refuses a dangling `extend` in a stack that declares no picklist at all', () => { + const { refusals } = judgePicklistReferences({ + manifest: manifest('com.example.pk'), + picklistExtensions: [extension('industry')], + }); + expect(refusals.map((r) => r.rule)).toEqual([PICKLIST_REFERENCE_UNKNOWN]); + expect(refusals[0].path).toBe('picklistExtensions[0].extend'); + }); + + it('judges a field and an extension in one pass: each dangling reference is its own refusal', () => { + const { refusals } = judgePicklistReferences({ + manifest: manifest('com.example.pk'), + picklists: [industry], + objects: [account('industy')], + picklistExtensions: [extension('regoin')], + }); + expect(refusals.map((r) => r.path)).toEqual([ + 'objects[0].fields.industry.picklist', + 'picklistExtensions[0].extend', + ]); + expect(new Set(refusals.map((r) => r.rule))).toEqual(new Set([PICKLIST_REFERENCE_UNKNOWN])); + }); + + it('a resolving extension does not hide a dangling field reference, nor the reverse', () => { + const fieldOnly = judgePicklistReferences({ + manifest: manifest('com.example.pk'), + picklists: [industry], + objects: [account('industy')], + picklistExtensions: [extension('industry')], + }); + expect(fieldOnly.refusals.map((r) => r.where)).toEqual(['field "pk_account.industry"']); + + const extensionOnly = judgePicklistReferences({ + manifest: manifest('com.example.pk'), + picklists: [industry], + objects: [account('industry')], + picklistExtensions: [extension('industy')], + }); + expect(extensionOnly.refusals.map((r) => r.where)).toEqual(['picklist extension "industy"']); + }); + + it('REPORTS, never refuses, when the stack depends on a package it does not carry', () => { + const { refusals, notices } = judgePicklistReferences({ + manifest: manifest('com.example.pk', { dependencies: { 'com.acme.crm': '^1.0.0' } }), + picklistExtensions: [extension('industry')], + }); + expect(refusals).toEqual([]); + expect(notices).toHaveLength(1); + expect(notices[0]).toMatchObject({ + severity: 'info', + rule: PICKLIST_REFERENCE_UNVERIFIED, + path: 'picklistExtensions[0].extend', + }); + // The notice is printed without its `where`, so it names the extension, the + // list and the dependency it could not read. + expect(notices[0].message).toContain('picklist extension "industry"'); + expect(notices[0].message).toContain("'industry'"); + expect(notices[0].message).toContain("'com.acme.crm'"); + }); +}); + +describe('judgePicklistReferences — a `picklistExtensions[].extend` in a `packages[]` stack', () => { + it('resolves an extension against a picklist a SIBLING package in the same artifact declares', () => { + expect(judgePicklistReferences({ + packages: [ + { manifest: { ...manifest('com.example.core'), picklists: [industry] } }, + { + manifest: { + ...manifest('com.example.orders', { dependencies: { 'com.example.core': '^1.0.0' } }), + picklistExtensions: [extension('industry')], + }, + }, + ], + })).toEqual({ refusals: [], notices: [] }); + }); + + it('refuses when every declared dependency is inside the artifact — none of them declares the list', () => { + const { refusals, notices } = judgePicklistReferences({ + packages: [ + { manifest: { ...manifest('com.example.core'), picklists: [industry] } }, + { + manifest: { + ...manifest('com.example.orders', { dependencies: { 'com.example.core': '^1.0.0' } }), + picklistExtensions: [extension('region')], + }, + }, + ], + }); + expect(notices).toEqual([]); + expect(refusals).toHaveLength(1); + expect(refusals[0]).toMatchObject({ + rule: PICKLIST_REFERENCE_UNKNOWN, + where: 'picklist extension "region"', + path: 'packages[1].manifest.picklistExtensions[0].extend', + }); + }); + + it('reads the declaring package\'s OWN dependencies: an outside dependency of a sibling does not soften it', () => { + const { refusals, notices } = judgePicklistReferences({ + packages: [ + { manifest: { ...manifest('com.example.core', { dependencies: { 'com.acme.crm': '^1.0.0' } }) } }, + { manifest: { ...manifest('com.example.orders'), picklistExtensions: [extension('region')] } }, + { + manifest: { + ...manifest('com.example.billing', { dependencies: { 'com.acme.crm': '^1.0.0' } }), + picklistExtensions: [extension('segment')], + }, + }, + ], + }); + expect(refusals.map((r) => r.path)).toEqual(['packages[1].manifest.picklistExtensions[0].extend']); + expect(notices.map((n) => n.path)).toEqual(['packages[2].manifest.picklistExtensions[0].extend']); + }); + + it('does not judge a TOP-LEVEL `picklistExtensions` once `packages[]` carries the bodies: the load path does not register from it', () => { + expect(judgePicklistReferences({ + packages: [{ manifest: { ...manifest('com.example.core'), picklists: [industry] } }], + picklistExtensions: [extension('industy')], + })).toEqual({ refusals: [], notices: [] }); + }); +}); diff --git a/packages/cli/src/utils/picklist-references.ts b/packages/cli/src/utils/picklist-references.ts index dd2d16dfb46..b4ac10b9283 100644 --- a/packages/cli/src/utils/picklist-references.ts +++ b/packages/cli/src/utils/picklist-references.ts @@ -1,9 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The author-time judge of a field's `picklist` reference: a field that names - * a shared option list no package in the stack declares is refused, loudly, - * naming the field and the list it names. + * The author-time judge of a picklist REFERENCE: a field's `picklist`, and a + * `picklistExtensions` entry's `extend`. One that names a shared option list no + * package in the stack declares is refused, loudly, naming the field (or the + * extension) and the list it names. * * ## The defect * @@ -16,16 +17,24 @@ * choose. A named list exists precisely so a wrong reference is a refusal * instead of a silently empty control. * + * A `picklistExtensions` entry is the same reference one key over: its + * `extend` names the list it adds options to, and `PicklistExtensionSchema` + * checks that name's spelling and nothing else. An `extend: 'industy'` beside + * `picklists: [{ name: 'industry', … }]` parsed, validated and built the same + * way, and the options it carried were added to a list nobody declared — the + * silence this module exists to remove, for a second collection. + * * ## The walk is the boot path's * * The same reading `view-container-names.ts` makes, for the same reason — * what is judged is what boot registers: * - * - no `packages[]` → the top-level `objects` / `objectExtensions`, bounded - * by the top-level `manifest.dependencies`; - * - `packages[]` → each body's own `objects` / `objectExtensions`, - * bounded by that body's own `dependencies`, and ⛔ NOT the top level, - * which the load path does not register from. + * - no `packages[]` → the top-level `objects` / `objectExtensions` fields + * and `picklistExtensions`, bounded by the top-level + * `manifest.dependencies`; + * - `packages[]` → each body's own `objects` / `objectExtensions` / + * `picklistExtensions`, bounded by that body's own `dependencies`, and + * ⛔ NOT the top level, which the load path does not register from. * * A reference RESOLVES against every picklist the stack declares — every * `packages[]` body's `picklists`, or the top-level `picklists` when there is @@ -50,9 +59,15 @@ * reject a stack that is correct. The notice names the dependencies, so the * skipped judgement is said out loud rather than read as a pass. * + * A field and an extension take the SAME verdict and the same two rule ids: + * what is judged is one fact — a name that resolves to no picklist the stack + * declares — and the finding's `where`, `path` and message say which key + * carried it. The extension is where the outside-dependency arm is most at + * home, since an extension is how a package adds to a list it does not own. + * * ## Why this is not an `@objectstack/lint` registry rule * - * The verdict depends on WHICH package declares the field: that package's + * The verdict depends on WHICH package declares the reference: that package's * declared dependencies decide between a refusal and a notice. A registry rule * is handed one stack, and on the union run that stack is the flattened top * level, which carries no package provenance. The walk above is the artifact's @@ -60,9 +75,7 @@ * doors call it (`validate.ts` step 2d, `compile.ts` step 3a-bis). * * ⚠️ Bound, stated rather than hidden: the runtime metadata write path (a field - * saved through Studio or REST `/meta`) is not judged here. And a - * `picklistExtensions` entry whose `extend` names no picklist is a different - * reference, which this module does not judge. + * saved through Studio or REST `/meta`) is not judged here. * * Reads the PARSED stack — what `defineStack()` hands the boot wrap, and what * `os build` serializes. @@ -74,7 +87,10 @@ import type { AuthoringFinding } from '@objectstack/lint'; import { artifactPackages } from './artifact-packages.js'; import { printInfo } from './format.js'; -/** A field's `picklist` names no picklist the stack declares, and nothing outside it could. */ +/** + * A field's `picklist`, or a picklist extension's `extend`, names no picklist + * the stack declares, and nothing outside it could. + */ export const PICKLIST_REFERENCE_UNKNOWN = 'picklist-reference-unknown'; /** Same, but the declaring package names dependencies outside the stack, which this command cannot read. */ @@ -140,10 +156,23 @@ function picklistNamesOf(body: AnyRec): string[] { .filter((name): name is string => typeof name === 'string' && name !== ''); } -/** Every `picklist` a body's fields name, located. */ -function referencesOf(judged: JudgedBody): Array<{ where: string; path: string; picklist: string }> { - const out: Array<{ where: string; path: string; picklist: string }> = []; - const walk = (collection: 'objects' | 'objectExtensions', ownerKey: 'name' | 'extend') => { +/** One place a body names a picklist, located. */ +interface PicklistReference { + /** The key that carries the name: a field's `picklist`, or an extension's `extend`. */ + site: 'field' | 'extension'; + where: string; + path: string; + picklist: string; +} + +/** + * Every picklist a body names, located: the `picklist` of each field its + * `objects` / `objectExtensions` declare, then the `extend` of each + * `picklistExtensions` entry. + */ +function referencesOf(judged: JudgedBody): PicklistReference[] { + const out: PicklistReference[] = []; + const walkFields = (collection: 'objects' | 'objectExtensions', ownerKey: 'name' | 'extend') => { const entries = judged.body[collection]; if (!Array.isArray(entries)) return; entries.forEach((entry, index) => { @@ -155,6 +184,7 @@ function referencesOf(judged: JudgedBody): Array<{ where: string; path: string; const picklist = asRec(def)?.picklist; if (typeof picklist !== 'string' || picklist === '') continue; out.push({ + site: 'field', where: `field "${owner}.${fieldName}"`, path: `${judged.prefix}${collection}[${index}].fields.${fieldName}.picklist`, picklist, @@ -162,11 +192,68 @@ function referencesOf(judged: JudgedBody): Array<{ where: string; path: string; } }); }; - walk('objects', 'name'); - walk('objectExtensions', 'extend'); + walkFields('objects', 'name'); + walkFields('objectExtensions', 'extend'); + + // An extension has no name of its own: the list it extends is its identity, + // as an `objectExtensions` entry's `extend` is the owner its fields are + // quoted under above. + const extensions = judged.body.picklistExtensions; + if (Array.isArray(extensions)) { + extensions.forEach((entry, index) => { + const extend = asRec(entry)?.extend; + if (typeof extend !== 'string' || extend === '') return; + out.push({ + site: 'extension', + where: `picklist extension "${extend}"`, + path: `${judged.prefix}picklistExtensions[${index}].extend`, + picklist: extend, + }); + }); + } return out; } +/** The sentences that differ between a field's reference and an extension's. */ +function wordingOf(ref: PicklistReference, declared: readonly string[]): { + names: string; + /** What the notice calls the thing the declaring package declares. */ + declarer: string; + consequence: string; + refusalHint: string; + noticeHint: string; +} { + const key = ref.site === 'field' ? 'picklist' : 'extend'; + const names = `\`${key}: '${ref.picklist}'\` names no picklist this stack declares`; + const declare = `\`picklists: [{ name: '${ref.picklist}', label, options }]\``; + if (ref.site === 'field') { + return { + names, + declarer: 'the field', + consequence: `${names} (${quoteNames(declared)}), so the field has no list to take its options from.`, + refusalHint: + `Declare the list — ${declare} in the stack, or a ` + + '`*.picklist.ts` file the stack imports — or correct `picklist` to a list the stack declares. ' + + 'Or drop `picklist` and give the field inline `options` of its own.', + noticeHint: + `If one of those packages declares '${ref.picklist}', nothing is wrong. Otherwise declare the list ` + + 'in this stack or correct the name.', + }; + } + return { + names, + declarer: 'the extension', + consequence: `${names} (${quoteNames(declared)}), so the options it adds have no list to join.`, + refusalHint: + 'Correct `extend` to the picklist this entry adds options to, or declare that list — ' + + `${declare} in the package that owns it, or a \`*.picklist.ts\` file the stack imports. ` + + 'Or remove the entry: it adds options to nothing.', + noticeHint: + `If one of those packages declares '${ref.picklist}', nothing is wrong. Otherwise declare the list ` + + 'in this stack or correct `extend`.', + }; +} + function quoteNames(names: readonly string[]): string { if (names.length === 0) return 'this stack declares no picklist'; const quoted = names.slice(0, MAX_QUOTED_NAMES).map((n) => `'${n}'`).join(', '); @@ -175,10 +262,10 @@ function quoteNames(names: readonly string[]): string { } /** - * Every field `picklist` reference in this stack that resolves to no picklist - * the stack declares — refused, or reported when the declaring package names - * dependencies outside the stack. See the module header for the walk and the - * two verdicts. + * Every picklist reference in this stack — a field's `picklist`, an + * extension's `extend` — that resolves to no picklist the stack declares: + * refused, or reported when the declaring package names dependencies outside + * the stack. See the module header for the walk and the two verdicts. * * Returns two empty lists for a stack whose every reference resolves. */ @@ -192,22 +279,17 @@ export function judgePicklistReferences(parsed: AnyRec): PicklistReferenceJudgem for (const judged of bodies) { for (const ref of referencesOf(judged)) { if (known.has(ref.picklist)) continue; - const names = `\`picklist: '${ref.picklist}'\` names no picklist this stack declares`; + const wording = wordingOf(ref, declared); if (judged.outside.length === 0) { - // `where` names the field and every printer leads the line with it, so - // the message does not repeat it. + // `where` names the field (or the extension) and every printer leads + // the line with it, so the message does not repeat it. refusals.push({ severity: 'error', rule: PICKLIST_REFERENCE_UNKNOWN, where: ref.where, path: ref.path, - message: - `${names} (${quoteNames(declared)}), so the field has no list to take its ` + - 'options from.', - hint: - `Declare the list — \`picklists: [{ name: '${ref.picklist}', label, options }]\` in the stack, or a ` + - '`*.picklist.ts` file the stack imports — or correct `picklist` to a list the stack declares. ' + - 'Or drop `picklist` and give the field inline `options` of its own.', + message: wording.consequence, + hint: wording.refusalHint, }); } else { notices.push({ @@ -215,14 +297,12 @@ export function judgePicklistReferences(parsed: AnyRec): PicklistReferenceJudgem rule: PICKLIST_REFERENCE_UNVERIFIED, where: ref.where, path: ref.path, - // The notice printer shows no `where`, so this message names the field. + // The notice printer shows no `where`, so this message names the field or the extension. message: - `${ref.where}: ${names} — not judged: the package declaring the field depends on ` + + `${ref.where}: ${wording.names} — not judged: the package declaring ${wording.declarer} depends on ` + `${judged.outside.map((id) => `'${id}'`).join(', ')}, which this stack does not carry, ` + 'and whose picklists this command cannot read.', - hint: - `If one of those packages declares '${ref.picklist}', nothing is wrong. Otherwise declare the list ` + - 'in this stack or correct the name.', + hint: wording.noticeHint, }); } } diff --git a/packages/cli/test/picklist-reference-doors.test.ts b/packages/cli/test/picklist-reference-doors.test.ts index e6c2adb5f9a..b451356a102 100644 --- a/packages/cli/test/picklist-reference-doors.test.ts +++ b/packages/cli/test/picklist-reference-doors.test.ts @@ -3,7 +3,7 @@ /** * The `picklist` kind through the three authoring doors, over the real CLI. * - * Three facts, each read off a real `os` process: + * Five facts, each read off a real `os` process: * * 1. A stack declaring a picklist and a select field that names it validates, * builds and lints with no refusal — and the artifact `os build` writes @@ -14,6 +14,12 @@ * exited 0 and the misspelt reference shipped. * 3. `os lint`'s R8 (`field/select-missing-options`) does not report the * picklist-bound field — it names its options source. + * 4. A `picklistExtensions` entry whose `extend` names a picklist the stack + * declares validates and builds, the artifact carrying it as authored. + * 5. One whose `extend` names no picklist the stack declares is REFUSED by + * `os validate`, naming the extension and the list it names, and `os + * build` refuses the same stack and writes no artifact. Before this, both + * doors exited 0 and the options were added to a list nobody declared. * * The walk and both verdicts are pinned in `src/utils/picklist-references.test.ts` * and R8 in `@objectstack/lint`; this file holds the DOORS to them. @@ -92,18 +98,36 @@ const stack = (picklist: string) => ({ apps: [{ name: 'pickdoor_app', label: 'Pick Door' }], }); +/** + * The same stack with a correct field reference and one `picklistExtensions` + * entry adding an option to the list `extend` names — the only thing that + * varies between the control and the refusal below. + */ +const extensionStack = (extend: string) => ({ + ...stack('industry'), + picklistExtensions: [{ extend, options: [{ label: 'Healthcare', value: 'healthcare' }] }], +}); + let okDir: string; let danglingDir: string; +let extensionOkDir: string; +let extensionDanglingDir: string; beforeAll(() => { okDir = mkdtempSync(join(tmpdir(), 'os-picklist-ok-')); writeDefineStackConfig(okDir, stack('industry')); danglingDir = mkdtempSync(join(tmpdir(), 'os-picklist-dangling-')); writeDefineStackConfig(danglingDir, stack('industy')); + extensionOkDir = mkdtempSync(join(tmpdir(), 'os-picklist-ext-ok-')); + writeDefineStackConfig(extensionOkDir, extensionStack('industry')); + extensionDanglingDir = mkdtempSync(join(tmpdir(), 'os-picklist-ext-dangling-')); + writeDefineStackConfig(extensionDanglingDir, extensionStack('industy')); }); afterAll(() => { - for (const dir of [okDir, danglingDir]) if (dir) rmSync(dir, { recursive: true, force: true }); + for (const dir of [okDir, danglingDir, extensionOkDir, extensionDanglingDir]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } }); const picklistRules = (list: unknown): string[] => @@ -173,3 +197,57 @@ describe('a field naming a picklist the stack does not declare is refused', () = expect(existsSync(out)).toBe(false); }, RUN_TIMEOUT_MS); }); + +describe('a picklist extension naming a picklist the stack declares passes both doors', () => { + it('os validate accepts it, with no picklist-reference finding', async () => { + const run = await runCli(['validate', '--json'], extensionOkDir); + expect(run.code, run.stdout + run.stderr).toBe(0); + const doc = jsonOf(run); + expect(doc.valid).toBe(true); + expect(picklistRules(doc.warnings)).toEqual([]); + }, RUN_TIMEOUT_MS); + + it('os build writes the artifact, carrying the extension as authored', async () => { + const out = join(extensionOkDir, 'dist', 'objectstack.json'); + const run = await runCli(['build', '--json', '-o', out], extensionOkDir); + expect(run.code, run.stdout + run.stderr).toBe(0); + expect(picklistRules(jsonOf(run).warnings)).toEqual([]); + const artifact = JSON.parse(readFileSync(out, 'utf8')) as { + picklistExtensions?: Array<{ extend?: string }>; + }; + expect(artifact.picklistExtensions?.map((e) => e.extend)).toEqual(['industry']); + }, RUN_TIMEOUT_MS); +}); + +describe('a picklist extension naming no picklist the stack declares is refused', () => { + it('os validate refuses it, naming the extension and the missing list', async () => { + const run = await runCli(['validate', '--json'], extensionDanglingDir); + expect(run.code, run.stdout + run.stderr).toBe(1); + const doc = jsonOf(run); + expect(doc.valid).toBe(false); + const errors = doc.errors as Array<{ rule?: string; where?: string; message?: string; path?: string }>; + expect(errors).toHaveLength(1); + expect(errors[0].rule).toBe('picklist-reference-unknown'); + expect(errors[0].where).toContain('industy'); + expect(errors[0].message).toContain("'industy'"); + expect(errors[0].path).toBe('picklistExtensions[0].extend'); + }, RUN_TIMEOUT_MS); + + it('the text face says so too, with the same exit status', async () => { + const run = await runCli(['validate'], extensionDanglingDir); + expect(run.code).toBe(1); + expect(run.stdout).toContain('picklist extension "industy"'); + expect(run.stdout).toContain("'industy'"); + expect(run.stdout).toContain('picklistExtensions[0].extend'); + }, RUN_TIMEOUT_MS); + + it('os build refuses the same stack and writes no artifact', async () => { + const out = join(extensionDanglingDir, 'dist', 'objectstack.json'); + const run = await runCli(['build', '--json', '-o', out], extensionDanglingDir); + expect(run.code, run.stdout + run.stderr).toBe(1); + const errors = jsonOf(run).errors as Array<{ rule?: string; path?: string }>; + expect(errors.map((e) => e.rule)).toEqual(['picklist-reference-unknown']); + expect(errors[0].path).toBe('picklistExtensions[0].extend'); + expect(existsSync(out)).toBe(false); + }, RUN_TIMEOUT_MS); +}); From 69f413fb76e2dca9f395ba0116aef4d85d6182c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:08:08 +0000 Subject: [PATCH 2/2] docs(cli): state what a dangling picklist extension shipped as, without claiming a runtime merge The judge's header said the options of an extension with a dangling `extend` were added to a list nobody declared. No runtime reader merges extensions yet, so nothing was added; the artifact shipped an extension of an undeclared list. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/utils/picklist-references.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/picklist-references.ts b/packages/cli/src/utils/picklist-references.ts index b4ac10b9283..95393e11fc9 100644 --- a/packages/cli/src/utils/picklist-references.ts +++ b/packages/cli/src/utils/picklist-references.ts @@ -21,7 +21,7 @@ * `extend` names the list it adds options to, and `PicklistExtensionSchema` * checks that name's spelling and nothing else. An `extend: 'industy'` beside * `picklists: [{ name: 'industry', … }]` parsed, validated and built the same - * way, and the options it carried were added to a list nobody declared — the + * way, and the artifact shipped an extension of a list nobody declares — the * silence this module exists to remove, for a second collection. * * ## The walk is the boot path's