Skip to content

feat(cli,lint)!: os validate and os build refuse a field picklist that names no picklist, and lint R8 counts picklist as an options source - #21003

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20825-cli-picklist-kind
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20825-cli-picklist-kind

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20825
Clause-②: no (narrowing)

This PR lands items 2 and 3 of the direction on the card (triage 5908852656). It measures item 1's ingest half. The os generate picklist half of item 1 is NOT here: it needs files outside this card's claimed surface (see "Held" below). #20825 stays open for that half.

What changes

os validate and os build refuse a field whose picklist names no picklist in the stack (item 2). Ruling 5755653853 item 3 asks for this loud refusal. FieldSchema checks the spelling of the name and nothing else, so before this change a typo parsed. os validate exited 0, and os build wrote the artifact with the typo in it. Now both doors exit 1 with picklist-reference-unknown. The error names the field (where), the list it names (message), the lists the stack does declare, and the path.

  • The walk is the one the load path registers, the same reading findViewContainerNameRefusals makes. A one-package stack is judged on its top-level objects and objectExtensions. A packages[] stack is judged on each body's own. A reference resolves against every picklist the stack declares, including one that a sibling package in the same artifact owns.
  • Declared dependencies outside the stack (dispatch assumption 2): reported, never refused. manifest.dependencies maps package ids to version ranges. Nothing maps such an id to a file the CLI could open, so these doors cannot read another package's picklists. Take a reference that resolves nowhere in the stack. If the package declaring the field depends on a package the stack does not carry, the reference gets an info notice (picklist-reference-unverified). The notice names the field, the list and the dependencies. It rides warnings and the console, and it never gates, not even under --strict. If the package declaring the field has no outside dependency, the reference is refused. That package's own dependencies decides, not a sibling's.
  • One judge, packages/cli/src/utils/picklist-references.ts, is called by both doors right after the parse: validate.ts step 2d and compile.ts step 3a-bis. validate-build-gate-parity.test.ts classifies it as a shared non-registry gate. It is not an @objectstack/lint registry rule because the verdict depends on WHICH package declares the field, and the union run hands a registry rule the flattened top level, which carries no package provenance.

Lint R8 field/select-missing-options counts picklist as an options source (item 3). A select, multiselect or radio field that names a picklist is no longer reported. A select with neither source still warns (the control), and its fix now names options and picklist as alternatives. Before this change the warning pointed at options, the key the schema refuses beside picklist.

Item 1, ingest: already true on origin/main, measured, no change. os build writes picklists and picklistExtensions into the artifact as authored, and the field keeps picklist. Both doors parse through ObjectStackDefinitionSchema, which declares both collections. metadataFileName('picklist', 'industry') already answers industry.picklist.ts, from the registry's **/*.picklist.ts.

Held: the os generate picklist row

The generator row was written (6ef78d3f29) and taken back out (e16359a9fa). A new generator re-derives os init's wired barrels. create-objectstack-wiring-parity.test.ts then requires the npm create objectstack blank starter to wire src/picklists too. Measured: with the row in place, 4 tests in that file were red, and the other 239 files of the cli unit tier were green. Landing the row needs these files, none of them in this card's claim:

  • packages/create-objectstack/src/templates/blank/objectstack.config.ts, plus a new src/picklists/index.ts and a create-objectstack changeset;
  • the generator table in content/docs/deployment/cli.mdx and the type list in packages/cli/README.md;
  • skills/objectstack-platform/SKILL.md, whose "seven generator barrels" line names the starter's barrels. That path is Tier H.

The PM decides on that wider surface. The report on #20825 sets out the options.

Before / after (real os processes on scratch fixtures, run-dev + tsx)

reading before (4957ee5ef0) after (a044a7f500)
os validate, field picklist: 'industy', stack declares industry exit 0, "Validation passed" exit 1, picklist-reference-unknown naming pick_account.industry and 'industy'
os build, same stack exit 0, artifact written with industy exit 1, no artifact
os validate, same typo, manifest.dependencies names an outside package exit 0, silent exit 0, one picklist-reference-unverified info line
os validate / os build / os lint, stack with picklist industry and a field naming it exit 0 / 0 / 0 exit 0 / 0 / 0
os lint R8 on that field "select field ... has no options" not reported (2 warnings left of 3)
os generate picklist industry exit 1, "Unknown type: picklist" unchanged (held, see above)

Tests

  • packages/cli/src/utils/picklist-references.test.ts (unit, 9): the walk, the two verdicts, sibling-package resolution, and the declaring package's own dependencies.
  • packages/cli/test/picklist-reference-doors.test.ts (integration: it spawns the CLI, so the PR tiers run it, not the nightly one; 6): all three doors pass a correct stack, the artifact carries the list, os validate refuses the typo naming the field and the list (JSON and text), and os build refuses it and writes no artifact.
  • packages/lint/src/data-model-rules.picklist-options-source.test.ts (6): R8 for select, multiselect and radio, the neither control, the fix naming both sources, and the empty-string control.
  • Ablations, run on committed fixes through scripts/ablation-replace.mjs; each restore was proven (git diff HEAD empty, blob equal to HEAD):
    • validate.ts refusal disabled: 2 red (the two os validate pins), 4 green.
    • compile.ts refusal disabled: 1 red (the os build pin), 5 green.
    • R8's picklist limb removed: 3 red (select, multiselect, radio), 3 green (the controls).
  • At a044a7f500: @objectstack/cli unit tier 240 files / 3401 tests green; the door file 6/6; @objectstack/cli typecheck exit 0; @objectstack/lint typecheck exit 0; @objectstack/lint tests 118 files / 5450 tests green. The cli integration tier beyond the new file is declared to CI.
  • Gates: the dispatch-gates --commands union and pnpm lint. The report on picklist kind: the os CLI compile / validate / lint path accepts *.picklist.ts, picklists and picklistExtensions, and os validate refuses a picklist that names no picklist (Scope 6 of #19518) #20825 gives each reading against its head.

Acceptance notes


Generated by Claude Code

os validate and os build refuse a field whose picklist names no picklist
the stack declares (an info notice when the declaring package depends on
packages outside the stack), os generate picklist writes NAME.picklist.ts,
and lint R8 counts picklist as an options source.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…side this card's surface

Adding a generator re-derives the os init wiring, and the create-objectstack
wiring-parity pin then demands the blank starter wire src/picklists too
(packages/create-objectstack, outside the claimed surface). The row is kept
in the history of this branch for the follow-up.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/lint, touching 17 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 315888d660ad3e65a3e32eec47d3c5b6293547ec.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 53 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 315888d660ad3e65a3e32eec47d3c5b6293547ec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 80ea3a0e97eef2d24e0ad26015be2b49b79499ef — the merge of head a044a7f500f213ad8c980eb3a14b2976b20e9b07 into base 315888d660ad3e65a3e32eec47d3c5b6293547ec, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 80ea3a0e97eef2d24e0ad26015be2b49b79499ef && git checkout 80ea3a0e97eef2d24e0ad26015be2b49b79499ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 315888d660ad3e65a3e32eec47d3c5b6293547ec a044a7f500f213ad8c980eb3a14b2976b20e9b07 && git checkout -B drift-repro 315888d660ad3e65a3e32eec47d3c5b6293547ec && git merge --no-ff a044a7f500f213ad8c980eb3a14b2976b20e9b07

node scripts/docs-audit/affected-docs.mjs --json 315888d660ad3e65a3e32eec47d3c5b6293547ec

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 315888d660ad3e65a3e32eec47d3c5b6293547ec → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a044a7f500f213ad8c980eb3a14b2976b20e9b07
Local-runs: none

Inputs read: card #20825 (body; triage 5908852656; claim 5921605707; os-dev-report 5922744843; retriage ask 5922773172), #19518 (Scope item 6), design of record 5715762696 and ruling 5755653853 on #18164, #19519 (body and its three pointer comments), PR #21003 (body, 9-file list, five commits), the net diff 4957ee5ef0..refs/review/pr-21003 (+795), the reverted hunk git show 6ef78d3f29 -- packages/cli/src/commands/generate.ts, the revert e16359a9fa, and the head's check-runs. Source context was read by sha only (git show refs/review/pr-21003:path), never from the working tree.

① Derived judgments

(a) The shared judge packages/cli/src/utils/picklist-references.ts.

  • The walk is the load path's. bodiesOf branches on parsed.packages !== undefined, the same test view-container-names.ts makes and the one resolveArtifactPackageOrder (@objectstack/core) makes: no packages[] means the top-level objects / objectExtensions; packages[] means each packages[i].manifest body's own, through the same artifactPackages reader os build and os validate already walk with, and not the top level. Measured against the spec: picklist is an authorable key on FieldSchema only (field.zod.ts:1360), and FieldSchema is embedded in ObjectSchema.fields and ObjectExtensionSchema.fields (object.zod.ts:1962, :3204); no view, page or form schema carries it (FormFieldSchema is a presentation shape). The stack declares no standalone fields collection. So the two collections walked are every stack-resident place a field can carry picklist. Map-form objects cannot reach the judge un-normalised: defineStack runs normalizeStackInput before the parse, composeStacks refuses a non-array collection, and ObjectStackDefinitionSchema declares objects: z.array(ObjectSchema), so the Array.isArray bound is exact on the parsed stack. Bound shared with the precedent and not stated in the new header: a nested plugins[] entry's metadata (typed unknown[]) is not walked by either judge. Not a defect; worth one sentence in the header on the follow-up.
  • Resolution matches the runtime's reading. The reference resolves against every picklist the stack declares, sibling bodies included, which is the ADR-0130 co-ownership boundary packageBodyAsStack already encodes for object names. picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519 items 1 and 2 say the runtime will register picklists from every package and "an unknown picklist name is a loud load-time error naming the field and the package" — the judge's picklist-reference-unknown is that verdict at author time, one door earlier. Pinned by the unit file (sibling resolution; own-dependencies reading) and by the door file over a real os process.
  • picklistExtensions is not judged — stated in the header and in the PR's Acceptance notes ("a picklistExtensions entry whose extend names no picklist is a different reference"). Triage's item 2 names a field's picklist only, so this is outside the direction as written. It is, however, the same trap one key over: picklistExtensions: [{ extend: 'industy', options }] parses, validates and builds at the head exactly as the field typo did at base, and picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519's text does not name a dangling extend either (it names duplicate values). See ③.
  • The unresolved-with-outside-dependency arm. Measured: ManifestSchema.dependencies is z.record(z.string(), z.string()) (ids to version ranges; its own example is an npm plugin id), @objectstack/core artifact-packages.ts reads it only as optionalDependencies for the sort and says the artifact is not the resolution scope for an external id, and nothing in packages/cli, core, runtime, metadata or metadata-protocol maps such an id to a file. So "names no picklist in the stack or its dependencies" (triage item 2) cannot be judged at author time for a dependency outside the artifact; refusing there would misrefuse a correct stack, which is the one thing the clause forbids. The arm takes the only faithful reading: a package whose declared dependencies are all inside the artifact gets the refusal; one that names an outside id gets picklist-reference-unverified at info, in the --json warnings list and on the console, naming the field, the list and the ids. It never gates, not under --strict either: validate.ts builds the --strict list from the capability, unknown-key, conversion, rule-advisory, docs and structural warnings only, and the nav-group, permission-set and JSX-gate notices already sit outside it by the [finding] os validate / compile / lint validate JSX pages at parse level only when no SDUI manifest resolves, and say nothing: the author-time JSX gate silently degrades #20113 reading, so this is the existing class, not a new exemption. Against ruling 5755653853 item 3 ("a wrong reference is a loud refusal"): the refusal holds wherever the CLI can know the reference is wrong, and the skipped judgement is printed rather than read as a pass, which is the "Absence must be loud" shape. One reservation, flagged in ③: the trigger is coarse — any outside id, an npm plugin id included, downgrades every unresolved reference in that package — and in this repo's corpus the only manifest.dependencies user (examples/app-multi-package, orders on core) is inside its artifact, so the arm has no in-repo positive case; its only pins are unit-level.

(b) os build refuses as well as os validate. Inside the direction. #19518 Scope item 6, the design text the card cites first, names "the os CLI compile / validate path"; the claim's file surface lists compile.ts ("the path os build shares"); and the repo's own parity contract (validate-build-gate-parity.test.ts: os validate is the read-only superset of os build's gates, VALIDATE_ONLY_GATES is a ledger of gaps the build carries, "EMPTY is this ledger's steady state", and the precedent row findViewContainerNameRefusals moved from validate-only to shared when compile.ts gained the call) makes a validate-only refusal the exception that needs a written reason, not the default. The report's own before-row ("os build exit 0, artifact written with industy") is the ruling's silent shape on the command that ships, so the build half is what makes item 2's refusal loud where it matters. The parity roster edit that this forces is read in ③.

(c) R8 (packages/lint/src/data-model-rules.ts). picklist is counted for every member of OPTION_FIELD_TYPES = select, multiselect, radio, enum; the new limb requires a non-empty string, which is stricter than the spec's FIELD_CHOICE_WITHOUT_OPTIONS (typeof def.picklist === 'string') and unreachable post-parse anyway (SnakeCaseIdentifierSchema refuses the empty string). The control holds (neither source still warns at .options), and the fix names the two sources as alternatives and says why both is refused, matching field.zod.ts:2146-2155. Not left out: checkboxes and tags accept picklist per the schema (MULTI_OPTION_TYPES) but R8 has never covered them (pre-existing; the spec's functional-completeness rule warns on checkboxes), and the claim names select, multiselect and radio. Not wrongly included: enum is in R8's set but is not a spec FieldType (only type-compat.ts knows it), a dead member that predates this diff. Measured through the pin file (6) and the door file (os lint --json on the good fixture).

(d) Item 1's ingest half, measured at base. Sound. addbbf02ab (#19518's PR #20823) is an ancestor of base 4957ee5ef0; at base stack.zod.ts:346 / :360 declare picklists and picklistExtensions as arrays with concat compose dispositions, and compile.ts:933 at base writes finalBundle = { ...result.data }, the parsed stack, with no key filter between parse and write. So both collections ride the artifact as authored without any change here; the door file pins artifact.picklists = ['industry'] and the field's picklist through a real os build. metadataFileName('picklist', 'industry') derives industry.picklist.ts from the registry row's **/*.picklist.ts (metadata-plugin.zod.ts:825, RECURSIVE_TS_PATTERN). Nothing in item 1's ingest clause is undelivered.

(e) The changeset .changeset/20825-cli-picklist-kind.md. @objectstack/cli: minor with a BREAKING banner is the launch-window convention this repo's open changesets spell the same way (20280, 20546, 20671, 20901: "ships as minor under the launch-window convention for accept-set narrowings; check-changeset-no-major refuses major until GA"). @objectstack/lint: patch is right: the lint change removes a false-positive warning and adds a fix string, a fix in a released package, no export change. Clause-②: no (narrowing) is a well-formed pair under AGENTS.md's closed set (no (widening) would be malformed; (narrowing) is BREAKING) and the dispatch allowed exactly this re-reading. The ADR-0087 marker not-required (no-migration-prescription) is the honest arm: no authorable spelling or type moves, packages/spec is untouched, and the refused shape carries no mechanically derivable rewrite (which list the author meant is not in the metadata). The migration text is present: the FROM state, the TO state and the one-line fix. Every sentence is delivered at the head: the revert e16359a9fa removed the generator paragraph and rewrote the title and the Clause-② line from yes to no; nothing for objectstack generate picklist or objectstack init wiring survives. The title's (#20825) matches sibling changesets' form.

Pins (triage's three), read off the head: a stack with a picklist and a field naming it validates, builds (artifact carries the list) and lints clean — door file, three tests; a dangling reference is refused by os validate naming the field and the list (JSON where / message / path, and the text face), and os build refuses it and writes no artifact — door file, three tests; R8's control still warns — lint pin file. The parity roster edit classifies judgePicklistReferences as a shared non-registry gate and printPicklistReferenceNotices as presentation, which the closed roster (#18491) requires for any new bare call in either command.

② Semver level

Right. @objectstack/cli minor carrying BREAKING: os validate and os build newly refuse an input they accepted, an accept-set narrowing on two published commands, which AGENTS.md's changeset rules grade BREAKING and the launch-window convention ships as minor (check-changeset-no-major). @objectstack/lint patch: a false-positive removal plus a fix string, no API change. Clause-②: no (narrowing) is the correct pair for this diff: nothing it accepts is wider than before (the R8 relaxation widens no accept set of a door; os lint --strict stops failing a field the schema already admits, a fix), and the one narrowing is declared with its banner, migration line and ADR-0087 disposition.

③ Boundary flags

  1. The open question (A widen / B hold). The governing texts do not decide it, and that is the finding. os generate picklist appears in none of the design texts: the design of record 5715762696 item 7 cuts phase 1 at "spec + engine + CLI compile + translation + hotcrm migration"; ruling 5755653853 carries that design unchanged; picklist metadata kind — spec: picklist collection, Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518 Scope item 6 names the "compile / validate path" and the two-or-neither refusal. The generator clause entered through the card body (the picklist metadata kind — spec: picklist collection, Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518 dev's measurement) and triage adopted it "as written", with the claim scoping it to metadata-file-name.ts "and the generate command if it needs a kind row". The seat's measurement is real and reproducible from source: GENERATOR_SCAFFOLD_TARGETS is derived from GENERATORS, SCAFFOLD_WIRED_BARRELS is derived from it, and create-objectstack-wiring-parity.test.ts reads the blank starter against os init's render, so one new generator row necessarily reaches packages/create-objectstack and, through the "seven generator barrels" sentence at skills/objectstack-platform/SKILL.md:194, a Tier H path. So A is the path inside triage's direction as written and outside the claim; B is consistent with the design of record's phase-1 cut and with Prime Directive 10's corollary (no scaffold for a kind whose reader has not landed; field.picklist is planned + authorWarn in the liveness ledger and scripts/check-stack-collection-maps.mjs carries PENDING waivers on picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519), but B amends triage's item 1, which only triage or the PM can do. Routing the fork to triage is the correct act, and the record does not pre-empt it. Part of #20825, not Fixes, is right for this head whichever letter triage picks: under A the card stays open for the row; under B the card closes by a release act after the clause moves, and the Part-of PR must not also close its card check on this head is green.
  2. Every deviations entry. (i) Part of instead of the dispatched Fixes: right, above. (ii) The new shared file utils/picklist-references.ts and its unit test: inside the claim's surface, which names "the stack-collection readers in packages/cli/src/utils/" for item 1 and compile.ts; a new file no other claim can hold, in the precedent shape of view-container-names.ts, and the only way to make the two doors share one verdict. (iii) The validate-build-gate-parity.test.ts edit: forced, not elective — the roster is closed, every new bare identifier either command calls must be ledgered, and the two rows added are the truthful classification. (iv) Clause-② re-read to no (narrowing): within the dispatch's own instruction. (v) No labels written; the labeler's four are the platform's. (vi) Three stray files in the container root (/cli-unit-final.pid, /gates-final.err, /gates-final.txt): outside the repo and the diff, declared for the maintainer to remove, no bearing on the head. (vii) The cli integration tier beyond the new file and the nightly e2e source pins were declared to CI, not run locally: acceptable because the head's required checks converged green (below).
  3. The two out-of-scope notes. (i) collectMetadataStats has no picklist row, carrier "none": defensible under Prime Directive 10 (a missing summary line is not a defect, a contract violation or an authoring trap), but "none" is the weaker choice. scripts/check-stack-collection-maps.mjs does not track MetadataStats, so nothing will fail stale when the kind goes live, and the [finding] the npm create objectstack blank template's config imports only ./src/objects, so every os g view|action|flow|dashboard|app|skill it scaffolds is never loaded (os validate counts 0) #20333 lesson behind the wiring-parity pin is that the stats block is how an author learns a scaffold reached the stack. The natural carrier is whichever card lands the generator/scaffold half (B's new card, or picklist kind: the os CLI compile / validate / lint path accepts *.picklist.ts, picklists and picklistExtensions, and os validate refuses a picklist that names no picklist (Scope 6 of #19518) #20825 under A), stated as a line item there. (ii) liveness-planned-property on a picklist-bound field until picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519, carrier picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519: right — picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519's pointer 5912707861 says the ledger row field.picklist is planned + authorWarn and flips live with that card's reader, so the advisory is the spec-owned ledger doing its job and this PR correctly leaves it alone.
  4. Not in the report's findings and worth a carrier: a picklistExtensions[].extend naming no picklist passes both doors at the head, the same silent shape the card exists to close, outside item 2 as written and unnamed by picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519's text. Under Prime Directive 10 this is a metadata-authoring trap to file (or to point at picklist metadata kind — runtime: resolve picklist → options when serving field metadata, validate writes against the resolved set, apply package-level extensions (phase 1 of objectstack#18164) #19519 item 1 explicitly), not an Acceptance-notes sentence. Also the arm's coarse trigger in ①(a): worth a line on the same follow-up, since the fix (reading an installed dependency's picklists, or a declared metadata-package versus plugin distinction on dependencies) is not in this card.

Check-runs on the head (a044a7f500, collapsed latest-per-name, read after convergence at 01:33Z): 34 names, 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) — path-filtered or opt-in, none owed by a cli/lint diff), 0 failures, 0 in progress. All seven required contexts success: Lint & Repo Gates (01:29:23Z, which carries check:adr-0087-registration), TypeScript Type Check (01:27:55Z), Test Core (01:33:18Z; shards 1/6–6/6 all success), Dogfood Regression Gate (01:22:55Z; 1/3–3/3 all success), Build Core (01:19:11Z), Temporal Conformance (live PG + MySQL) (01:24:39Z), Governed Surface Queue Guard (01:14:45Z). Also success: Check Changeset, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Check PR Size, the three Type Check · sub-jobs and Dogfood Verify CLI.
Mergeability: origin/main (315888d660) is 12 commits past the base 4957ee5ef0; none of main's changed paths overlaps the PR's nine files (closest: packages/cli/src/utils/i18n-extract.ts, which the PR does not touch). git merge-tree --write-tree 315888d660 a044a7f500 from a driver-free bare scratch clone (AGENTS.md §11's probe shape, so GitHub's mergeability is what was asked; a git read, nothing built or run) produced tree d59b7151ba with no conflicted paths, exit 0: clean. Governed surfaces in the file list: none (Governed Surface Queue Guard green). Head repo = base repo; 795 changed lines, under the 5,000 threshold; draft, not armed.

Implemented-by: claude/issue-20825-cli-picklist-kind
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 01:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 01:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit b84b240 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20825-cli-picklist-kind branch October 1, 2026 02:09
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…y whose extend names no declared picklist (objectstack-ai#21049)

Fixes objectstack-ai#20825
Clause-②: no (narrowing)

This PR is the item-2 sibling that triage folded into the card (answer
`5923151807`): a `picklistExtensions[].extend` that names no picklist is
refused by the same judge on the same two doors as a field's dangling
`picklist`. It is one more walk, over a second collection, in the file
PR objectstack-ai#21003 landed (`b84b240b2a`). Nothing else is on the card: the
generator and scaffold half went to objectstack-ai#21018.

## What changes

**`os validate` and `os build` refuse an extension whose `extend` names
no picklist the stack declares.** `PicklistExtensionSchema` checks the
spelling of `extend` and nothing else, so `extend: 'industy'` beside
`picklists: [{ name: 'industry', ... }]` parsed. `os validate` exited 0
and `os build` wrote the artifact carrying the extension. Now both exit
1 with `picklist-reference-unknown`; the finding names the extension
(`where`), the list it names (`message`), the lists the stack does
declare, and the `path` (`picklistExtensions[N].extend`, prefixed with
`packages[I].manifest.` for a package body).

- **Same walk as the field reference, one collection added.** A
one-package stack is judged on its top-level `picklistExtensions`. A
`packages[]` stack is judged on each body's own `picklistExtensions`,
and a top-level `picklistExtensions` beside `packages[]` is not judged,
because the load path does not register from it (pinned). An `extend`
resolves against every picklist the stack declares, including one a
sibling package in the same artifact owns.
- **Same outside-dependency arm.** When the package declaring the
extension lists a `manifest.dependencies` entry the stack does not
carry, an unresolved `extend` is an `info` notice
(`picklist-reference-unverified`) in `warnings` and on the console,
naming the extension, the list and the dependencies. It never gates, not
even under `--strict`. That package's own `dependencies` decide, not a
sibling's. The coarse trigger itself (any outside id downgrades every
unresolved reference in that package) is unchanged: it stays the
recorded line triage kept.

**Rule id: reuse `picklist-reference-unknown` and
`picklist-reference-unverified`, no new sibling id.** What is judged is
one fact, a name that resolves to no picklist the stack declares, with
one cure class (declare the list or correct the name) and the same two
verdicts. The finding's `where`, `path` and message already say which
key carried it, and the sentences that differ (`extend: '...'` instead
of `picklist: '...'`, "the options it adds have no list to join", a hint
that offers removing the entry instead of inline `options`) are chosen
per site inside the one judge. A second id would split one verdict in
two for everything keyed on the rule: the `--json` `errors[].rule`
consumers, the door pins, and the already-pending changeset that names
`picklist-reference-unknown`. Nothing in the repo registers these ids in
a ledger.

**The two doors' refusal heading no longer says "A field".**
`validate.ts` (step 2d) and `compile.ts` (step 3a-bis) printed "A field
names a picklist this stack does not declare (N references)" above the
refusals, which would be false for an `extend` refusal. It now reads "A
picklist reference names a picklist this stack does not declare (N
references)". That literal and the two step comments are the only edits
in those files; the call and the notice printing are unchanged, so
`validate-build-gate-parity.test.ts` asked for no registration (no new
identifier is called from either command; the whole cli unit tier is
green).

`packages/spec/**`, `packages/lint/**`, the generator and scaffold, and
`collectMetadataStats` are untouched.

## Before / after (real `os` processes on scratch fixtures, run-dev +
tsx)

Stack: `picklists: [{ name: 'industry', ... }]`, a field `picklist:
'industry'`, and one `picklistExtensions` entry. Before is `origin/main`
at `2f2fa11d75`; after is `69f413fb76`.

| reading | before | after |
|:--|:--|:--|
| `os validate`, `extend: 'industy'` | exit 0, "Validation passed" |
exit 1, `picklist-reference-unknown` naming `picklist extension
"industy"` and `'industy'`, at `picklistExtensions[0].extend` |
| `os build`, same stack | exit 0, artifact written carrying `industy` |
exit 1, no artifact |
| `os validate` / `os build`, `extend: 'industry'` (the control) | exit
0 / 0 | exit 0 / 0, artifact written |

After text from `os validate`:

```
  ✗ A picklist reference names a picklist this stack does not declare (1 reference)
  • picklist extension "industy": `extend: 'industy'` names no picklist this stack declares (declared: 'industry'), so the options it adds have no list to join.
      Correct `extend` to the picklist this entry adds options to, or declare that list — `picklists: [{ name: 'industy', label, options }]` in the package that owns it, or a `*.picklist.ts` file the stack imports. Or remove the entry: it adds options to nothing.
      rule: picklist-reference-unknown  at picklistExtensions[0].extend
```

## Tests

- `packages/cli/src/utils/picklist-references.test.ts` (unit, 19 now, 10
new): the control (a resolving `extend`), the refusal naming extension,
list, declared lists and path, a stack that declares no picklist, a
field and an extension judged in one pass without hiding each other, the
outside-dependency notice, sibling-package resolution, the declaring
package's own dependencies, and a top-level `picklistExtensions` beside
`packages[]` not judged.
- `packages/cli/test/picklist-reference-doors.test.ts` (integration: it
spawns the CLI, so the PR tiers run it; 11 now, 5 new): a resolving
`extend` validates and builds with the artifact carrying it as authored
(the control); a dangling `extend` is refused by `os validate` (JSON and
text face) and by `os build`, which writes no artifact.
- Ablations on the committed fix, through `scripts/ablation-replace.mjs`
under the verify lock. Each mutation landed (anchor 1 to 0, blob
changed) and each restore was proven (blob equal to HEAD, `git diff
HEAD` empty). The suites read `src` (vitest imports the module;
`bin/run-dev.js` runs `src` through tsx), so there is no build leg.
- New walk disabled (`if (false && Array.isArray(extensions))`): unit 7
red and 12 green; doors 3 red (JSON, text, `os build`) and 8 green (both
control pins and the six field pins).
- Package branch disabled (`if (false && parsed.packages !==
undefined)`): the new top-level-not-judged pin goes red, along with the
two per-package pins that depend on the branch (5 red, 14 green).
- At `69f413fb76`: `os-verify-lock` runs of the door file 11/11 and
`@objectstack/cli` typecheck exit 0 (including `check:test-typecheck`);
the unit pin 19/19; `pnpm lint` exit 0 (full repo, `eslint .
--no-inline-config`). The whole `@objectstack/cli` unit tier, 240 files
/ 3411 tests, was green at `ec1d965497`, one commit earlier; the delta
to the head is one comment sentence in `picklist-references.ts`.
- Gates: `dispatch-gates --commands` derived 63 families at
`69f413fb76`; all 63 were run, each exit code recorded before any pipe,
and `--ran` reconciles 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. Four
of them (`check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`,
`check:dual-build-cjs-loads`) first exited 3 (prerequisite not met,
nothing measured: no built CLI or workspace dist); after building their
prerequisites they were re-run and exited 0 on a final sweep.

## Acceptance notes

- A picklist-bound field still raises the `liveness-planned-property`
advisory until the runtime reader (objectstack-ai#19519) lands; unchanged.
- Not judged by this change: the runtime metadata write path (Studio or
REST `/meta`).
- The metadata summary row for picklists (`collectMetadataStats`) and
the coarse outside-dependency trigger stay as triage ruled: the row is
objectstack-ai#21018's, the trigger a recorded line with no positive case in the repo.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants