Skip to content

Commit bcd68a2

Browse files
feat(cli,create-objectstack): os generate picklist, the src/picklists starter barrel, and a Picklists count in the metadata summary (#21167)
Part of #21018 Clause-②: yes (widening) This PR is the code half of #21018. It covers scope items 1, 2, 3 and 5 of the card body. Item 4, the "seven generator barrels" line in `skills/objectstack-platform/SKILL.md`, is on a Tier H governed path. It follows in its own PR under a second claim, so #21018 remains open for it. Dispatched by the `domain:cli` seat's PM under claim 5929373213. Dev session `session_01VvcEokUG1tvVxkceYfR5XB`. ## Premise, checked on `origin/main` The card waited on the runtime reader. That reader is on `main` as 88b484e (#21047), and these are the parts this PR relies on: - `picklists` and `picklistExtensions` are in `METADATA_ARRAY_KEYS` (`packages/objectql/src/engine.ts`). - The registry fold resolves a field's `picklist` into served `options` (`picklist-resolution.ts`, `resolvePicklistFieldsOnto`). - The write door judges a write against the resolved options. - The boot refuses an unresolved name (`packages/objectql/src/plugin.ts`). The repro below measures all of this end to end on a scaffolded project. ## What changed **Item 1: `os generate picklist NAME`** (`packages/cli/src/commands/generate.ts`) - This is the row written and reverted on #20825's branch (`6ef78d3f29`, reverted in `e16359a9fa`), re-read against the landed reader. - It writes `src/picklists/NAME.picklist.ts` through `definePicklist`. The file name comes from `metadataFileName`, with no override. - The list is collected under `picklists` (`singularToPlural`). `namesObject: false`, and `requires` is empty. - The emitted header states the next rule an author meets: a field that names the list declares no `options` of its own. - The row's comment now names the reader: the authoring doors, the boot refusal, the served options and the write judgement. - Two existing comments counted the roster ("an app or a skill", "14 emission sites across all 7 generators"). Both are reworded so the new row does not make them false. **Item 2: scaffold wiring** - `os init` derives its wiring from the roster (`SCAFFOLD_WIRED_BARRELS`), so the `app` and `plugin` templates wire `src/picklists` with no edit to `init.ts`. - The blank starter in `create-objectstack` gains `src/picklists/index.ts`, byte-identical to the empty barrel `os init` writes. Its `objectstack.config.ts` gains `import * as picklists from './src/picklists';` and `picklists: exportsOf(picklists),`. - `create-objectstack-wiring-parity.test.ts` holds the two scaffolders equal. **Item 3: docs** - `content/docs/deployment/cli.mdx` gets the example line, the table row (`picklist`, `src/picklists/`, `NAME.picklist.ts`, `picklists`) and the "What it does" clause. - `packages/cli/README.md` gets the type list and a short paragraph. **Item 5: the metadata summary** (`packages/cli/src/utils/format.ts`) - `MetadataStats` gains `picklists`, and `collectMetadataStats` counts it through the existing `authoringRuleUnionStack` fold, so both ADR-0130 D4 shapes are covered. - `printMetadataStats` renders it in the `Data:` row, the kind's own domain (`domain: 'data'` in the registry). - A stack with no picklists prints the row it printed before. A `picklistExtensions` entry is not counted as a list. - The two existing test fixtures that spell out every `MetadataStats` member gain `picklists: 0`: `format.metadata-stats-package-fold.test.ts` and `print-metadata-stats-zero-row.test.ts`. **Changesets** - `@objectstack/cli`: `minor`. A new generator kind, and `stats.picklists` is added to the `--json` output of `os validate`, `os build` and `os info`. - `create-objectstack`: `minor`. The starter gains a wired barrel. - Both carry `Clause-②: yes (widening)`. Nothing is narrowed and nothing is renamed, so there is no ADR-0087 marker. ## Repro, before and after Before, on `origin/main` 58a77db: ```text os generate picklist industry exit 1 ✗ Unknown type: picklist (roster lists 7 types) npm create objectstack (blank) src/ = actions apps dashboards flows objects skills views hand-wired picklist + select field os validate exit 0 Data: 1 Objects 3 Fields ``` After, on this branch: ```text npm create objectstack (blank) src/ = actions apps dashboards flows objects picklists skills views os generate picklist industry exit 0 ✓ Reaches the stack: objectstack.config.ts carries it in `picklists` as 'industry' note.object.ts gains industry: Field.select({ picklist: 'industry', label: 'Industry' }) os validate exit 0 Data: 1 Objects 3 Fields 1 Picklists os build exit 0 Data: 1 Objects 3 Fields 1 Picklists (artifact carries `picklists` and the field's `picklist`) os info exit 0 Data: 1 Objects 3 Fields 1 Picklists os info --json stats.picklists = 1 os dev --fresh -p RANDOM GET /api/v1/meta/object/my_app_note 200 fields.industry = { picklist: 'industry', options: [option_a, option_b], … } GET /api/v1/meta/picklist/industry 200 POST /api/v1/data/my_app_note { industry: 'option_a' } 201 POST /api/v1/data/my_app_note { industry: 'option_z' } 400 VALIDATION_FAILED · invalid_option · names picklist "industry" ``` On a project scaffolded before this change (its config wires no `src/picklists`), `os g picklist region` exits 0. It reports `Not wired` and prints the import line and the `defineStack` key to add. ## Tests The pins are: - `packages/cli/src/commands/generate-picklist.pin.test.ts`: - the roster row (`src/picklists`, stack key `picklists`, `namesObject: false`, no `requires`) and the `NAME.picklist.ts` file name; - the scaffold loaded through the loader `os validate` uses (`bundle-require`, `BUNDLE_REQUIRE_EXTERNALS`), parsed by `PicklistSchema`, under the item name `os g` reports; - that scaffold registered through `ObjectQL.registerApp` under `picklists`, serving its options on a `Field.select({ picklist })` field, and passing `PicklistServedFieldSchema`; - a control: the same field with no list serves no options. It constructs `ObjectQL`, so it lands in the `integration` tier. - `packages/cli/src/utils/format.metadata-stats-picklists.test.ts`: - the count, top level and option-B; - that an extension is not a list; - the zero case; - the rendered row `Data: 1 Objects 2 Fields 1 Picklists`; - a control: a stack with no picklists prints `Data: 1 Objects 2 Fields`. The runs, all on HEAD 5a53515 except where noted: | Run | Files | Tests | Result | |:---|---:|---:|:---| | `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` | 241 + 2 | 3427 + 29 | All passed, 29 skipped. The 2 files first failed with `packages/cli is not built` and passed after `pnpm --filter @objectstack/cli build`. | | `--project integration`, the 8 integration files among the related tests | 8 | 66 | Passed: `generate-picklist.pin`, `create-objectstack-stack-reach`, `generate-stack-reach`, `info-detail-package-fold` and 4 more | | `OS_TEST_TIERS=nightly`, the related `*.e2e` files | 6 | 42 | Passed. Includes `generate-scaffolds-reach-stack.e2e`: "`os g picklist` exits 0 and reports no wiring to add" | | `pnpm --filter create-objectstack exec vitest run` | 16 | 249 | Passed | | `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit && check:test-typecheck`) | | | Exit 0. The test-typecheck ledger is unchanged (3 files / 28 errors) | | `pnpm --filter create-objectstack typecheck` | | | Exit 0 | ## Ablation The ablations ran on the committed fix (e63b1db). Each mutation went through `scripts/ablation-replace.mjs` with the anchor counted on disk. Each restore was proven: the blob equals HEAD and `git diff HEAD` is empty. | Leg | Mutation | Red | Control (green) | |:---|:---|:---|:---| | generator | The `picklist` row deleted from `GENERATORS` (anchor x1 → x0, 53 lines) | `generate-picklist.pin` 3 of 5. `wiring-parity`: imports and stack keys (2) | `generate-scaffold-validates` 19/19; the pin's file-name case and its no-list control | | template | `picklists: exportsOf(picklists),` deleted from the blank config | `wiring-parity`: "hands every wired barrel to its stack key" (1 of 22) | the other 21 | | collect | `picklists: count(stack.picklists),` deleted | `metadata-stats-picklists` 5 and `package-fold` 4 (its `ZEROES` comparisons) | the pin's no-picklists row; `print-metadata-stats-zero-row` | | print | `['Picklists', stats.picklists],` deleted | `metadata-stats-picklists` "Data: row", and `print-metadata-stats-zero-row` "every metric collectMetadataStats counts is rendered" | the other 27 | ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run with no paths, derived 96 families. All 96 were run, each with its exit code recorded before any pipe. - Five of them first refused with `PREREQUISITE NOT MET` (exit 3) because their build inputs were missing: `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`. All five passed once those inputs were built. - `--ran` reconciliation: `✓ dispatch-gates --ran: 96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED`. **`pnpm lint`, as a proven narrowing** (on HEAD 5a53515): 1. **Population, read from eslint's own config** (`isPathIgnored` / `calculateConfigForFile`): all 8 touched `.ts` files are linted. The 4 touched `.md` / `.mdx` files are outside its population. 2. **Count, from `--format json`**: 8 files, 0 errors, 0 warnings, with `--no-inline-config`. 3. **Invariance**: no resolved config for these files is type-aware (no `parserOptions.project`, no `projectService`, as `eslint.config.mjs` states). This diff touches no lint config or baseline. So it cannot move the verdict on any file it does not touch. ## Not in this PR - **Item 4** (Tier H). On `db48028f1a`, lines 194–195 of `skills/objectstack-platform/SKILL.md` read: ```text generic connector executors in `plugins:`; and the seven generator barrels (`objects`, `views`, `actions`, `flows`, `dashboards`, `apps`, `skills`), ``` The proposed text, net zero lines: ```text generic connector executors in `plugins:`; and the eight generator barrels (`objects`, `views`, `actions`, `flows`, `dashboards`, `apps`, `skills`, `picklists`), ``` - Not carried here, as the card says: the `picklist-reference-unverified` trigger, and a dangling `picklistExtensions[].extend` (folded into #20825). ## Acceptance notes - **The two starter README listings now name `src/picklists`** (commit `90d55b1e13`, added on contract review `5930680827`): - the `Layout` bullet in `packages/create-objectstack/src/templates/blank/README.md`, which ships into every new project; - the tree in `packages/create-objectstack/README.md`. They ride this PR, not the Tier H item-4 PR, so two non-governed lines do not wait on a human approval. - **`Scaffold with repo dist` is red on this PR, and the PR is held until the next release publishes.** This note was added by the seat. - **Cause:** the job scaffolds with the repo-built `create-objectstack`, then installs the project's framework from the npm registry. The template's `^17.0.0` range resolves to the published `@objectstack/spec` 17.5.0, which predates the `picklists` stack key (`addbbf0`, `.changeset/19518-picklist-kind.md`, still unreleased). So the generated project's `defineStack` refuses the key as unrecognized at the validate step. - **Scope of the measurement:** the repro table's `npm create objectstack` rows and the end-to-end chain were measured on the repo build, not against the registry. - **It is not this PR's code to fix.** Its gate checks the template against the real registry, and the template now runs one release ahead of it. - The check is not required. It runs only on `pull_request` under its path filter (`packages/create-objectstack/**`, `docker/**` and its own workflow file), and it is not red on `main`. - Landing now would leave it red on every later `create-objectstack` PR until that release. - **The hold:** the release that carries the key is the open Version Packages PR #20639; its `@objectstack/spec` changelog already lists `addbbf0`. Once it merges and publishes, a re-run of this job reads the new spec and the template is coherent with it. - Until then, the PR stays draft and its card is `pm:blocked` on #20639. - On a project scaffolded by an earlier release, the not-wired hint prints `picklists: Object.values(picklists),`, not the starter's `exportsOf(...)` spelling. Both type-check once the barrel exports a list, which is the only state the hint is printed in. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6091136 commit bcd68a2

14 files changed

Lines changed: 336 additions & 11 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `objectstack generate picklist NAME` scaffolds a shared option list, and the metadata summary counts picklists
6+
7+
Clause-②: yes (widening)
8+
9+
- **`objectstack generate picklist NAME`** (alias `os g picklist`) writes `src/picklists/NAME.picklist.ts`, a list declared with `definePicklist({ name, label, options })`, and adds its export line to `src/picklists/index.ts`. The list is collected under the `picklists` stack key. A select field takes its options from the list by naming it, `Field.select({ picklist: 'NAME' })`, in place of options of its own. The server serves that field with the list's options resolved onto it, together with any options other packages add through `picklistExtensions`, and judges writes against them. `objectstack validate` and `objectstack build` refuse a field whose `picklist` names no list the stack declares, and so does the boot.
10+
- **`objectstack init`** wires the new `src/picklists` barrel in the `app` and `plugin` templates, the same way it wires every other directory `objectstack generate` writes into: an empty `src/picklists/index.ts` and a `picklists: exportsOf(picklists)` key in `objectstack.config.ts`. A project scaffolded by an earlier release keeps its config. `objectstack generate picklist` then reports the list as not wired and prints the import line and the `defineStack` key to add.
11+
- **The metadata summary** that `objectstack validate`, `objectstack build` and `objectstack info` print counts the picklists a stack declares, in the `Data:` row: `Data: 1 Objects 3 Fields 1 Picklists`. A stack that declares none prints the row it printed before. The `stats` object in the `--json` output of the same three commands gains a `picklists` count. A `picklistExtensions` entry is not counted as a list.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'create-objectstack': minor
3+
---
4+
5+
feat(create-objectstack): the blank starter wires a `src/picklists` barrel for `objectstack generate picklist`
6+
7+
Clause-②: yes (widening)
8+
9+
A new blank project ships an empty `src/picklists/index.ts`, and its `objectstack.config.ts` imports it and hands its exports to `defineStack` under `picklists`, as it already does for every other directory `objectstack generate` writes into. `objectstack generate picklist NAME` then writes `src/picklists/NAME.picklist.ts` and its export line, and the list is part of the stack with no edit to the config. A select field takes its options from the list with `Field.select({ picklist: 'NAME' })`, and the server serves that field with the list's options.
10+
11+
A project scaffolded by an earlier release keeps its config. There, `objectstack generate picklist NAME` writes the list, reports that it does not reach the stack, and prints the import line and the `defineStack` key that wire `src/picklists`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,7 @@ os g flow customer # Generate a flow that runs when a Customer changes
14831483
os g dashboard sales # Generate a dashboard
14841484
os g app customer # Generate an app whose navigation opens Customer
14851485
os g skill lead_qual # Generate an AI skill
1486+
os g picklist industry # Generate a shared option list select fields name
14861487
14871488
os g object task -d lib/ # Override target directory
14881489
os g object task --dry-run # Preview without writing
@@ -1499,6 +1500,7 @@ os g object task --dry-run # Preview without writing
14991500
| `dashboard` | `src/dashboards/` | `NAME.dashboard.ts` | `dashboards` | Analytics dashboard |
15001501
| `app` | `src/apps/` | `NAME.app.ts` | `apps` | Application navigation |
15011502
| `skill` | `src/skills/` | `NAME.skill.ts` | `skills` | AI skill — the ADR-0063 extension primitive |
1503+
| `picklist` | `src/picklists/` | `NAME.picklist.ts` | `picklists` | Shared option list that select fields reference by name |
15021504
15031505
<Callout type="info" title="Why generated files carry a type infix">
15041506
Every scaffold is written as `NAME.TYPE.ts`, and the infix is read from the
@@ -1555,7 +1557,7 @@ the rules a JSON Schema can express and names the rest under
15551557
15561558
**What it does:**
15571559
1. For a type that names an object (`object`, `view`, `action`, `flow`, `app`), reads `manifest.namespace` from the project config (`objectstack.config.ts`, `.js` or `.mjs`) and prefixes the object name with it (see above)
1558-
2. Creates the TypeScript file — an `object` declared with `ObjectSchema.create({ … })`, the same shape the `os init` templates write; a `skill` declared with `defineSkill({ … })`; the other types as typed literals (`UI.View`, `UI.Action`, `Automation.Flow`, `UI.Dashboard`, `UI.App`)
1560+
2. Creates the TypeScript file — an `object` declared with `ObjectSchema.create({ … })`, the same shape the `os init` templates write; a `skill` declared with `defineSkill({ … })`; a `picklist` declared with `definePicklist({ … })`, which a select field names with `Field.select({ picklist: 'NAME' })` in place of its own `options`, and which the server resolves into that field's `options`; the other types as typed literals (`UI.View`, `UI.Action`, `Automation.Flow`, `UI.Dashboard`, `UI.App`)
15591561
3. Creates or updates the barrel `index.ts` in the target directory
15601562
4. Shows a hint to run `objectstack validate`
15611563

‎packages/cli/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ os compile
5858
| `os generate <type> <name>` | Generate metadata files (alias: `os g`) |
5959
| `os create <type> [name]` | Scaffold a standalone **kernel code** plugin project (the `Plugin` contract, built by `tsc`) from a built-in template |
6060

61-
Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`
61+
Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`, `picklist`
62+
63+
`picklist` writes a shared option list (`src/picklists/<name>.picklist.ts`, with
64+
`definePicklist`). A select field names it with `Field.select({ picklist: '<name>' })`
65+
in place of its own `options`, and the server serves that field with the list's options.
6266

6367
`agent` is **retired** (ADR-0063 §2): agents are platform-internal, so a scaffolded
6468
`src/agents/*.ts` validated, published and was then filtered out of the runtime
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN: `os generate picklist NAME` writes a shared option list that the
5+
* runtime SERVES, not one it ignores.
6+
*
7+
* ## What was measured before the row existed
8+
*
9+
* On `origin/main` 58a77dbde2, `os generate picklist industry` printed
10+
* `Unknown type: picklist` and exited 1, and the blank starter wired no
11+
* `src/picklists` barrel. A list could be authored only by hand, and the
12+
* command an author reaches for first did not know the kind.
13+
*
14+
* ## Why the row waited, and what this file holds against
15+
*
16+
* A scaffold of a kind with no runtime reader validates, builds, and then
17+
* serves nothing: the list registers and no field ever carries its options.
18+
* The row was held until the engine resolved `picklist` into `options`
19+
* (`@objectstack/objectql`, `picklist-resolution.ts`). So the obligation here
20+
* is end to end, not a template string: the file the generator writes,
21+
* loaded the way `os validate` loads authored TypeScript and collected under
22+
* the stack key `os init` and the blank starter wire its barrel into, is what
23+
* the engine resolves onto a field that names it.
24+
*
25+
* Every fact about the row is READ off the roster (`GENERATOR_SCAFFOLD_TARGETS`)
26+
* and the registry (`metadataFileName`), never restated, except the three the
27+
* card names: the type `picklist`, the directory `src/picklists`, and the
28+
* stack key `picklists` the engine reads.
29+
*
30+
* The control: the same object with no list registered serves no options, so
31+
* a green here cannot come from a field that carried options already.
32+
*/
33+
34+
import { afterAll, describe, expect, it } from 'vitest';
35+
import fs from 'node:fs';
36+
import path from 'node:path';
37+
import { fileURLToPath } from 'node:url';
38+
import { bundleRequire } from 'bundle-require';
39+
import { Field, ObjectSchema, PicklistSchema, PicklistServedFieldSchema } from '@objectstack/spec/data';
40+
import { ObjectQL } from '@objectstack/objectql';
41+
import { GENERATOR_SCAFFOLD_TARGETS } from './generate.js';
42+
import { metadataFileName } from '../utils/metadata-file-name.js';
43+
import { BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
44+
45+
const STEM = 'industry';
46+
47+
const PICKLIST = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'picklist');
48+
49+
/**
50+
* Inside this package's own `node_modules`, for the reason
51+
* `test/generate-scaffold-validates.test.ts` gives: git-ignored, and the
52+
* scaffold's `@objectstack/spec/data` import resolves from here exactly as it
53+
* would in an author's project.
54+
*/
55+
const TMP_ROOT = fs.mkdtempSync(
56+
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'node_modules', '.generate-picklist-pin-'),
57+
);
58+
59+
afterAll(() => {
60+
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
61+
});
62+
63+
/** Materialize the scaffold through the loader `os validate` uses. */
64+
async function loadScaffold(): Promise<Record<string, unknown>> {
65+
if (!PICKLIST) throw new Error('no `picklist` generator on the roster');
66+
const file = path.join(TMP_ROOT, metadataFileName('picklist', STEM) ?? 'unnamed.ts');
67+
fs.writeFileSync(file, PICKLIST.generate(STEM), 'utf8');
68+
const { mod } = await bundleRequire({ filepath: file, external: BUNDLE_REQUIRE_EXTERNALS });
69+
return ((mod as { default?: unknown }).default ?? mod) as Record<string, unknown>;
70+
}
71+
72+
/** An object with one select field that names the list, as an author writes it. */
73+
const account = (listName: string) => ObjectSchema.create({
74+
name: 'pin_account',
75+
label: 'Account',
76+
sharingModel: 'private',
77+
fields: {
78+
industry: Field.select({ picklist: listName, label: 'Industry' }),
79+
},
80+
});
81+
82+
/** Register a package the way the boot does, through the stack keys it declares. */
83+
function serve(collections: Record<string, unknown[]>) {
84+
const engine = new ObjectQL();
85+
engine.registerApp({ id: 'com.example.picklist_pin', name: 'picklist_pin', ...collections } as never);
86+
return engine.registry.getObject('pin_account')?.fields.industry as Record<string, unknown> | undefined;
87+
}
88+
89+
describe('`os generate picklist` is on the roster, where the starter wires it', () => {
90+
it('scaffolds into src/picklists, collected under the `picklists` stack key', () => {
91+
expect(PICKLIST, 'the `picklist` generator').toBeDefined();
92+
expect(PICKLIST!.defaultDir).toBe('src/picklists');
93+
expect(PICKLIST!.stackKey).toBe('picklists');
94+
// A list names no object, so a namespaced project writes the name as typed,
95+
// and it needs no capability token to load.
96+
expect(PICKLIST!.namesObject).toBe(false);
97+
expect(PICKLIST!.requires).toEqual([]);
98+
});
99+
100+
it('writes NAME.picklist.ts, the registry\'s own pattern for the kind', () => {
101+
expect(metadataFileName('picklist', STEM)).toBe(`${STEM}.picklist.ts`);
102+
});
103+
});
104+
105+
describe('the scaffold the generator writes is a list the engine serves', () => {
106+
it('loads as a picklist named what `os g` reports it reached', async () => {
107+
const list = await loadScaffold();
108+
expect(PicklistSchema.safeParse(list).success).toBe(true);
109+
expect(list.name).toBe(PICKLIST!.itemName(STEM));
110+
});
111+
112+
it('resolves onto a select field that names it: the served field carries the list\'s options', async () => {
113+
const list = await loadScaffold();
114+
const field = serve({ [PICKLIST!.stackKey]: [list], objects: [account(PICKLIST!.itemName(STEM))] });
115+
116+
expect(field?.picklist).toBe(STEM);
117+
expect(field?.options).toEqual(list.options);
118+
// The served contract the object read exits owe a client.
119+
expect(PicklistServedFieldSchema.safeParse(field).success).toBe(true);
120+
});
121+
122+
it('control: the same field with no list registered serves no options', () => {
123+
const field = serve({ objects: [account(STEM)] });
124+
expect(field?.picklist).toBe(STEM);
125+
expect(field?.options).toBeUndefined();
126+
});
127+
});

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ const FLOW_SCAFFOLD_REQUIRES = ['automation', 'triggers'] as const;
105105
* flag against the templates.
106106
*
107107
* Only object names are prefixed. The scaffold's own `name` on an action, a
108-
* flow, a dashboard, an app or a skill is not judged against the namespace by
109-
* any gate `os validate` runs, so it stays the name the author typed. A view
108+
* flow, a dashboard, an app, a skill or a picklist is not judged against the
109+
* namespace by any gate `os validate` runs, so it stays the name the author
110+
* typed. A view
110111
* container's own `name` IS an object name — the container is registered under
111112
* the object it binds to — so it is prefixed with it (#20215).
112113
*
@@ -539,6 +540,59 @@ const ${toCamelCase(name)}Skill = defineSkill({
539540
});
540541
541542
export default ${toCamelCase(name)}Skill;
543+
`,
544+
},
545+
546+
picklist: {
547+
description: 'Shared option list that select fields reference by name',
548+
defaultDir: 'src/picklists',
549+
/**
550+
* A shared option list (`data/picklist.zod.ts`): one `options` array that
551+
* select fields on any object take their options from by NAMING the list,
552+
* `Field.select({ picklist: 'NAME' })`, instead of each carrying a copy.
553+
*
554+
* Written as `NAME.picklist.ts`, the registry's own pattern for the kind,
555+
* through {@link metadataFileName} like every other type here, with no
556+
* override. Declared through `definePicklist`, so the list is parsed the
557+
* moment this module loads: an unknown key or an empty `options` is a
558+
* startup error naming it, not a list that goes missing later.
559+
*
560+
* Every door a referencing field passes resolves the name, so the list
561+
* this writes is one the runtime serves, not a declaration it ignores:
562+
*
563+
* - `os validate` and `os build` refuse a field whose `picklist` names no
564+
* list the stack declares (`utils/picklist-references.ts`);
565+
* - the boot refuses the same unresolved name, and serves every field that
566+
* names a list with the list's options resolved onto it, together with
567+
* the options other packages add through `picklistExtensions`
568+
* (`@objectstack/objectql`, `picklist-resolution.ts`);
569+
* - a write to such a field is judged against that resolved set.
570+
*
571+
* The emitted header states the one rule an author meets next: a field
572+
* that names the list declares no `options` of its own, because
573+
* `FieldSchema` refuses the two together.
574+
*/
575+
namesObject: false,
576+
itemName: (name: string) => toSnakeCase(name),
577+
generate: (name: string) => `import { definePicklist } from '@objectstack/spec/data';
578+
579+
/**
580+
* ${toTitleCase(name)} Picklist
581+
*
582+
* A shared option list. A select field offers these options by naming the
583+
* list — Field.select({ picklist: '${toSnakeCase(name)}' }) — and declares no
584+
* \`options\` of its own: a field declaring both is refused.
585+
*/
586+
const ${toCamelCase(name)}Picklist = definePicklist({
587+
name: '${toSnakeCase(name)}',
588+
label: '${toTitleCase(name)}',
589+
options: [
590+
{ label: 'Option A', value: 'option_a' },
591+
{ label: 'Option B', value: 'option_b' },
592+
],
593+
});
594+
595+
export default ${toCamelCase(name)}Picklist;
542596
`,
543597
},
544598
};
@@ -1267,7 +1321,8 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
12671321
// them: the scaffold file, and the barrel re-export line. They are the two
12681322
// files one name reaches (#16541), and rendering them at the single point
12691323
// where the name has finished being derived is what lets one refusal cover
1270-
// all 14 emission sites across all 7 generators instead of 14 patches.
1324+
// every emission site of every generator (14 across 7 when it landed)
1325+
// instead of one patch per site.
12711326
const content = generator.generate(name, namespace);
12721327
const exportLine = `export { default as ${toCamelCase(name)} } from '${moduleSpecifier}';`;
12731328

‎packages/cli/src/utils/format.metadata-stats-package-fold.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const ZEROES: MetadataStats = {
7676
objects: 0,
7777
objectExtensions: 0,
7878
fields: 0,
79+
picklists: 0,
7980
views: 0,
8081
pages: 0,
8182
apps: 0,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN: the metadata summary `os validate`, `os build` and `os info` print
5+
* counts the picklists a stack declares.
6+
*
7+
* ## What was measured before the row existed
8+
*
9+
* On `origin/main` 58a77dbde2, a blank starter with one picklist wired under
10+
* `picklists` and one select field naming it printed, through `os validate`:
11+
*
12+
* Data: 1 Objects 3 Fields
13+
*
14+
* The list was declared, loaded and resolved, and the summary that answers
15+
* "what does this stack declare" said nothing about it. With `os generate
16+
* picklist` on the roster, an author who runs it and then `os validate` reads
17+
* that summary to see the scaffold arrive.
18+
*
19+
* ## What is pinned
20+
*
21+
* - the READER (`collectMetadataStats`) counts `picklists`, in both ADR-0130
22+
* D4 shapes, through the one fold every other member goes through;
23+
* - the PRINTER renders the count in the `Data:` row, the domain the kind
24+
* belongs to (`kernel/metadata-plugin.zod.ts` files it under `data`);
25+
* - a stack with no picklists prints the `Data:` row it printed before,
26+
* because a zero item is filtered like every other one;
27+
* - a `picklistExtensions` entry is not a list: it adds options to a list
28+
* another package owns, so it is not counted as one.
29+
*/
30+
31+
import { describe, expect, it } from 'vitest';
32+
import { collectMetadataStats, printMetadataStats, type MetadataStats } from './format.js';
33+
34+
/** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */
35+
const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m/g, '');
36+
37+
function dataRow(stats: MetadataStats): string | undefined {
38+
const captured: string[] = [];
39+
const original = console.log;
40+
console.log = (...args: unknown[]) => {
41+
captured.push(args.map(String).join(' '));
42+
};
43+
try {
44+
printMetadataStats(stats);
45+
} finally {
46+
console.log = original;
47+
}
48+
return stripAnsi(captured.join('\n')).split('\n').find((l) => l.trim().startsWith('Data:'))?.trim();
49+
}
50+
51+
const manifest = {
52+
id: 'com.example.pk',
53+
name: 'pk',
54+
version: '1.0.0',
55+
type: 'app',
56+
namespace: 'pk',
57+
};
58+
59+
const INDUSTRY = { name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] };
60+
const REGION = { name: 'region', label: 'Region', options: [{ label: 'EMEA', value: 'emea' }] };
61+
62+
const ACCOUNT = {
63+
name: 'pk_account',
64+
label: 'Account',
65+
sharingModel: 'private',
66+
fields: {
67+
name: { type: 'text', label: 'Name' },
68+
industry: { type: 'select', label: 'Industry', picklist: 'industry' },
69+
},
70+
};
71+
72+
describe('collectMetadataStats counts the picklists a stack declares', () => {
73+
it('a top-level stack', () => {
74+
expect(collectMetadataStats({ manifest, picklists: [INDUSTRY, REGION], objects: [ACCOUNT] }).picklists).toBe(2);
75+
});
76+
77+
it('an option-B stack, every definition inside `packages[]`', () => {
78+
const optionB = { manifest, packages: [{ manifest: { ...manifest, picklists: [INDUSTRY], objects: [ACCOUNT] } }] };
79+
expect(collectMetadataStats(optionB).picklists).toBe(1);
80+
});
81+
82+
it('a `picklistExtensions` entry is not a list', () => {
83+
const extending = { manifest, picklistExtensions: [{ extend: 'industry', options: [{ label: 'Bio', value: 'bio' }] }] };
84+
expect(collectMetadataStats(extending).picklists).toBe(0);
85+
});
86+
87+
it('a stack that declares none counts zero', () => {
88+
expect(collectMetadataStats({ manifest, objects: [ACCOUNT] }).picklists).toBe(0);
89+
});
90+
});
91+
92+
describe('printMetadataStats renders the count in the Data: row', () => {
93+
it('beside the objects and fields that name the lists', () => {
94+
const stats = collectMetadataStats({ manifest, picklists: [INDUSTRY], objects: [ACCOUNT] });
95+
expect(dataRow(stats)).toBe('Data: 1 Objects 2 Fields 1 Picklists');
96+
});
97+
98+
it('control: a stack with no picklists prints the row it printed before', () => {
99+
const stats = collectMetadataStats({ manifest, objects: [ACCOUNT] });
100+
expect(dataRow(stats)).toBe('Data: 1 Objects 2 Fields');
101+
});
102+
});

0 commit comments

Comments
 (0)