Skip to content

Commit fa7b565

Browse files
fix(create-objectstack,cli): starter objects place their fields in a keyed field group (#21407)
Fixes #21370 Clause-②: no ## What changes Each starter object now places the fields it declares in a keyed field group (`fieldGroups` on the object, `group` on each field, ADR-0085 §5), as triage ruled in comment 5948728426: - `packages/create-objectstack/src/templates/blank/src/objects/note.object.ts` (`npm create objectstack`): `fieldGroups: [{ key: 'details', label: 'Details' }]`, with `group: 'details'` on `title` and `body`. - `packages/cli/src/commands/init.ts`, `TEMPLATES.app` (`os init -t app`): the same group, on `name`, `description` and `status`. Each object carries a comment for the author. It says what the group does, that it is what displays the field, that `objectstack validate` / `objectstack lint` report a field nothing displays or reads once the project holds anything that could read it, and that a new field should name a group too. It links the public page on field groups. `field-no-consumers` itself is unchanged: there is no starter carve-out, no field was dropped, and nothing in `packages/lint` was edited. The rule already credits a field the synthesized layout places in a keyed section as displayed. Clause-② is `no`, measured against the diff: the templates gain placement keys the schema already accepts, and no accept set or public member moves. The changeset (`.changeset/21370-starter-field-groups.md`) is a `patch` for `create-objectstack` and `@objectstack/cli`, the two packages whose shipped templates change. ## Measured before and after, through the real commands Before, at `origin/main` 69a12a0: one fresh project per starter and per waker, then `os validate --json` and `os lint --json`. Each cell is the `field-no-consumers` count, identical under both commands, every command exit 0. | starter | bare | `os g dashboard probe` | `os g object gate_target` + `os g view gate_target` | `os g object gate_target` + `os g flow gate_probe --object gate_target` | |---|---|---|---|---| | `npm create objectstack` | 0 | 1: `body` | 1: `body` | 1: `body` | | `os init -t app` | 0 | 2: `description`, `status` | 2 | 2 | | `os init -t plugin` | 0 | 0 | 0 | 0 | `os init -t plugin` is clean because its only field is the record's title, which the rule exempts through the `nameField` ladder. `os init -t empty` declares no object. Neither template changes. After, at d94a0d6: the nightly pin below runs each starter, then all three wakers in one project. It reads 0 `field-no-consumers` under both commands for all three starters. With the starters' `group` lines stripped, both commands report the finding again, on the starters' own fields only. ## Pins - `packages/cli/test/starter-field-consumers.test.ts`: per-PR, unit tier, in-process. The roster is derived: every `*.object.ts` the blank starter ships and every one an `os init` template renders. Each source is loaded the way `os validate` loads it (`bundle-require` with `BUNDLE_REQUIRE_EXTERNALS`). Each starter is combined with the real `dashboard`, `view` and `flow` generator output, the latter two bound through `stackBindingCandidates` to an object generated beside the starter's. Each stack is run through `runAuthoringRules('validate')` and `lintConfig`, with zero `field-no-consumers` asserted (9 legs). CONTROL: every non-title starter field, with its `group` removed, is reported at exactly its own path by both commands (3 legs: `body`, `description`, `status`). The title is read through `resolveDisplayField`, not restated. Every leg also requires its stack to pass `defineStack` and parse, so a pipeline that stopped early cannot read as a zero. - `packages/cli/test/starter-field-consumers.e2e.test.ts`: nightly, real commands. Starters: the on-ramp's `bin/`, plus every `os init -t` template that renders an object. Each runs `os g dashboard`, `os g object`, `os g view` and `os g flow`, then `os validate --json` and `os lint --json`, and must report zero `field-no-consumers` (6 legs). CONTROL: every `group` line stripped from the scaffolded objects, then the same two commands must report the finding, only on fields the stripped files declare (4 legs). It runs only for starters whose sources place a field, which is decided when the file is collected. - `packages/cli/test/generate-scaffold-gates.e2e.test.ts`: the one `STARTER_LATENT_FINDINGS` entry (`field-no-consumers@objects[0].fields.body`) is deleted, so the ledger is empty. The header now records why. The ledger's `describe` gains a floor test pinning it empty. Without that test the block would hold no test at all once both `it.each` tables are empty, and vitest 4 fails such a block with "No test found in suite". ## Red before the fix, and the ablations All ablations ran from the committed fix (d94a0d6) through `scripts/ablation-replace.mjs`: the anchor hit once, the mutation was proven on disk, and the restore was proven as blob == HEAD with `git diff HEAD` empty. Dist-mediated legs were rebuilt and read with `scripts/ablation-dist-preflight.mjs`. - **Red before the fix.** The in-process pin ran against the 69a12a0 templates (`git restore --source=69a12a0952`, tree only). Result: 8 failed, 6 passed. The 3 blank and 3 app waker legs failed (`objects[0].fields.body`; `description` + `status`), and so did the 2 app controls (both fields reported, not one). Plugin and the blank control passed, as expected. Restored: blob == HEAD for both files. - **A1**, blank `body` loses its group: 3 failed, 11 passed. Exactly the 3 blank waker legs. - **A2**, app `description` loses its group: 4 failed, 10 passed. The 3 app waker legs, plus the `status` control. - **A3**, the rule credits the untitled bucket (`if (section.key === undefined) continue;` deleted, `@objectstack/lint` rebuilt; preflight `--absent` with `--source-marker` passed): 3 failed, 11 passed. Exactly the 3 control legs. Restore leg: lint rebuilt, and the marker `section.key === void 0` is present again in dist. - **E** (nightly pin), blank `body` and app `description` lose their groups. `create-objectstack` and `@objectstack/cli` were rebuilt, and the dist read `group: 'details'` 2 → 1 and 3 → 2, with the plant marker present 1 / 1. Result: 4 failed, 11 passed. Exactly the blank and app zero legs under `os validate` and `os lint`. Restore leg: both rebuilt, counts back to 2 / 3, markers 0 / 0. - **EC** (nightly control), the same A3 guard deletion: 15 passed, so it did **not** red the nightly control. That is expected on reading: the control strips every `group`, title included, so `deriveFieldGroupLayout` returns `null` and there is no untitled bucket to credit. The in-process control, which strips one field at a time, is the one that catches that class (A3). - **EC2** (nightly control), the rule's findings stop carrying the `field-no-consumers` id. This is plant `-ablated`, lint rebuilt, preflight present in 4 built files. Result: 4 failed, 11 passed, exactly the 4 control legs. Restore leg: lint rebuilt, marker absent, tree clean. ## Verification (at d94a0d6 unless noted) - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`, in 3 shards: 82 + 82 + 83 files. One file, `published-subpath-console.pin.test.ts`, first refused with "packages/cli is not built" (prerequisite, not a red). After `pnpm --filter @objectstack/cli build` it passed (14/14). All other files passed: 1315 + 1079 + 1095 tests, 14 skipped. - Integration tier, per-PR file the blank starter feeds: `create-objectstack-stack-reach.test.ts` passed (4/4). - Nightly, `OS_TEST_TIERS=nightly`: `starter-field-consumers.e2e.test.ts` passed (15/15), and `generate-scaffold-gates.e2e.test.ts` passed with the empty ledger (37/37). - `pnpm --filter @objectstack/cli typecheck`: exit 0. `check:test-typecheck` holds the ledgered 28 errors in 3 files. `tsc -p tsconfig.test.json --listFiles` includes all three touched test files, with 0 errors in them. `pnpm --filter create-objectstack typecheck` and `pnpm --filter create-objectstack test` (16 files, 249 tests) both exit 0. That suite includes the shipped-comment pins, which resolve the new docs link. - The gate union from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 68 commands, each run at d94a0d6 with its exit code captured before any pipe, all exit 0. `--ran` reconciliation: "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". On an earlier pass, three of them first answered as follows. `check:cross-package-test-inputs` was red: the pin's header named the lint rule's repo path in prose, which the gate reads as a read. It was reworded in d94a0d6. `check:dual-build-cjs-loads` and `check:i18n-coverage` refused with PREREQUISITE NOT MET (exit 3). After a workspace build both were green, and both are green in the final union. - Lint is a proven narrowing, not a `pnpm lint` run. ① The population comes from `eslint.config.mjs`: `pnpm lint` is `eslint . --no-inline-config`, minus the global `NEVER_LINTED` set (node_modules, dist, build, .next, .turbo), and none of the 5 touched TS files falls in it. ② `eslint --no-inline-config --format json` on those 5 files reports 5 file results, 0 errors and 0 warnings. No result is a "file ignored" warning. ③ The config never enables type-aware linting (no `parserOptions.project`, no `projectService`) and loads no cross-file rule plugin. Its only reads are two baseline JSON files this diff does not touch. So no untouched file's verdict can move. ## Acceptance notes - `main` moved 7 commits past 69a12a0 while this ran (to 1d0600b). None of them touches `init.ts`, the create-objectstack templates, `generate.ts`, `generate-scaffold-gates.e2e.test.ts` or `validate-field-consumers.ts`. - The before readings were one project per waker. The nightly pin uses one project per starter with all three wakers, to keep it at about 25 cold starts. The per-waker split is held per-PR by the in-process pin. - Measured on the way, not filed (no defect class): `os validate` on a bare `os init -t app` project prints one plain-string warning, "No apps or plugins defined — this stack may not do much". It is not a rule finding, and it is the same before and after. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96a9719 commit fa7b565

6 files changed

Lines changed: 697 additions & 17 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'create-objectstack': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix: a fresh project no longer warns about its own starter fields after the first `objectstack generate`
7+
8+
Clause-②: no
9+
10+
The blank starter's `note` object (`npm create objectstack`) and the item object of the `app` template (`objectstack init -t app`) now declare one field group, `fieldGroups: [{ key: 'details', label: 'Details' }]`, and place every field in it with `group: 'details'`. Before this, the first view, flow, dashboard or other metadata that can read a field made `objectstack validate` and `objectstack lint` report `field-no-consumers` on a field the author never wrote: the note's `body`, or the item's `description` and `status`. That held whether the author generated it or wrote it by hand. Both commands still exited 0. A field placed in a declared group is drawn by the object's form and detail page, and the rule counts that as displayed, so a fresh project now reports nothing. The `plugin` and `empty` templates are unchanged: the plugin's one field is the record's title, which the rule never reports, and the empty template declares no object.
11+
12+
**What changes for an author.** In a new project, the object's form and detail page show the starter fields in one section labelled Details instead of a flat list. A field you add joins a section the same way, by naming its `key` in `group`. A project scaffolded by an earlier release keeps its files. To clear the warning there, add the same `fieldGroups` entry to the object and `group: 'details'` to each field the warning names, or give each field another consumer, such as a view column.

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,15 +786,28 @@ ${renderWiredStackKeys()}
786786
const ${toCamelCase(namespace)}Item = ObjectSchema.create({
787787
name: '${namespace}_item',
788788
label: '${toTitleCase(namespace)} Item',
789+
// Field groups: the sections an item's form and detail page draw, top to
790+
// bottom in this order. A field joins one by naming its \`key\` in \`group\`.
791+
// That placement is what displays \`description\` and \`status\`.
792+
// \`objectstack validate\` and \`objectstack lint\` report a field that
793+
// nothing displays or reads (\`field-no-consumers\`) once the project holds
794+
// a view, flow, dashboard or anything else that could read it, so give each
795+
// field you add a \`group\` as well. Field groups versus a view's own form
796+
// sections: https://objectstack.ai/docs/ui/field-grouping-and-order
797+
fieldGroups: [
798+
{ key: 'details', label: 'Details' },
799+
],
789800
fields: {
790801
name: {
791802
type: 'text',
792803
label: 'Name',
793804
required: true,
805+
group: 'details',
794806
},
795807
description: {
796808
type: 'textarea',
797809
label: 'Description',
810+
group: 'details',
798811
},
799812
status: {
800813
type: 'select',
@@ -805,6 +818,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({
805818
{ label: 'Archived', value: 'archived' },
806819
],
807820
defaultValue: 'draft',
821+
group: 'details',
808822
},
809823
},
810824
// Org-wide default (OWD): who can see records they don't own. 'private' is

‎packages/cli/test/generate-scaffold-gates.e2e.test.ts‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,25 @@
3636
* A CONTROL leg runs the three commands on the bare starter: zero findings,
3737
* which is what makes a finding in any other leg one that `os g` caused.
3838
*
39-
* ## The one exemption, and why it is a ledger and not a filter
39+
* ## The starter ledger — EMPTY since #21370, and shrink-only
4040
*
41-
* The bare starter is clean, but not because it has nothing to report: its
42-
* own `note` object declares a `body` field that nothing reads, and
43-
* `field-no-consumers` stays silent only while the stack has no consumer root
44-
* at all (a stack of objects is judged to be another stack's object library).
45-
* The first view, flow, action, app, dashboard or skill in the project — any
46-
* of them, generated or hand-written — wakes that finding on the STARTER's
47-
* object. It is the starter template's to fix (`os g` cannot give someone
48-
* else's field a consumer), so it is recorded below, ⛔ never filtered by rule
49-
* or by path pattern. The ledger is SHRINK-ONLY and holds itself honest:
41+
* A bare starter can be clean without being clean: `field-no-consumers` stays
42+
* silent while the stack has no consumer root at all (a stack of objects is
43+
* judged to be another stack's object library), so a starter field nothing
44+
* reads stays latent until the first view, flow, action, app, dashboard or
45+
* skill in the project, generated or hand-written, wakes it on the STARTER's
46+
* object. `os g` cannot give someone else's field a consumer, so such a
47+
* finding is the starter template's to fix, and this file recorded the one it
48+
* met in a ledger below, ⛔ never in a filter by rule or by path pattern. That
49+
* entry was the starter note's `body`. #21370 placed every starter field in a
50+
* keyed field group (`fieldGroups` + `group`), which the rule credits as
51+
* displayed, and deleted it; `starter-field-consumers.test.ts` and its `.e2e`
52+
* sibling hold every starter to that.
53+
*
54+
* The ledger is SHRINK-ONLY, so EMPTY is where it stays: a starter finding a
55+
* leg meets is a template to fix, ⛔ never a line to add. Its floor is pinned
56+
* below. The two checks that held each entry honest stay with it, in case a
57+
* maintainer ever lifts that floor:
5058
*
5159
* - an entry must name a finding on an object the bare starter declares,
5260
* so it can never cover what a scaffold wrote;
@@ -90,14 +98,10 @@ const PREREQUISITE_STEM: Record<string, string> = { object: 'gate_target', flow:
9098
/**
9199
* Findings the bare starter carries latently, by `rule@path`. SHRINK-ONLY —
92100
* see the header: each must sit on an object the starter declares, and each
93-
* must still fire somewhere, or this file is red.
101+
* must still fire somewhere, or this file is red. EMPTY since #21370; keep it
102+
* that way.
94103
*/
95-
const STARTER_LATENT_FINDINGS: Record<string, string> = {
96-
'field-no-consumers@objects[0].fields.body':
97-
"The starter's own `note` object declares `body` and nothing in the starter reads it. "
98-
+ 'Silent while the stack has no consumer root; the first view, flow, action, app, '
99-
+ 'dashboard or skill wakes it. The starter template owns the fix.',
100-
};
104+
const STARTER_LATENT_FINDINGS: Record<string, string> = {};
101105

102106
const GATES = ['validate', 'build', 'lint'] as const;
103107
type Gate = (typeof GATES)[number];
@@ -248,6 +252,13 @@ describe('[#21325] every generator: fresh starter + `os g <kind>` → zero findi
248252
});
249253

250254
describe('[#21325] the starter ledger cannot outlive its defect or cover a scaffold', () => {
255+
it('[#21370] is at its floor: EMPTY — a starter finding is a template to fix, never a line to add', () => {
256+
expect(
257+
Object.keys(STARTER_LATENT_FINDINGS),
258+
'STARTER_LATENT_FINDINGS is shrink-only and was emptied by #21370: fix the starter template instead',
259+
).toEqual([]);
260+
});
261+
251262
const fired = (key: string) =>
252263
Object.entries(legs).flatMap(([leg, l]) =>
253264
GATES.filter((gate) => l.gates[gate].findings?.includes(key)).map((gate) => ({ leg, gate })));

0 commit comments

Comments
 (0)