diff --git a/.changeset/21370-starter-field-groups.md b/.changeset/21370-starter-field-groups.md new file mode 100644 index 00000000000..516899f0a86 --- /dev/null +++ b/.changeset/21370-starter-field-groups.md @@ -0,0 +1,12 @@ +--- +'create-objectstack': patch +'@objectstack/cli': patch +--- + +fix: a fresh project no longer warns about its own starter fields after the first `objectstack generate` + +Clause-②: no + +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. + +**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. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 80f2d44e709..7467eec7516 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -786,15 +786,28 @@ ${renderWiredStackKeys()} const ${toCamelCase(namespace)}Item = ObjectSchema.create({ name: '${namespace}_item', label: '${toTitleCase(namespace)} Item', + // Field groups: the sections an item's form and detail page draw, top to + // bottom in this order. A field joins one by naming its \`key\` in \`group\`. + // That placement is what displays \`description\` and \`status\`. + // \`objectstack validate\` and \`objectstack lint\` report a field that + // nothing displays or reads (\`field-no-consumers\`) once the project holds + // a view, flow, dashboard or anything else that could read it, so give each + // field you add a \`group\` as well. Field groups versus a view's own form + // sections: https://objectstack.ai/docs/ui/field-grouping-and-order + fieldGroups: [ + { key: 'details', label: 'Details' }, + ], fields: { name: { type: 'text', label: 'Name', required: true, + group: 'details', }, description: { type: 'textarea', label: 'Description', + group: 'details', }, status: { type: 'select', @@ -805,6 +818,7 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({ { label: 'Archived', value: 'archived' }, ], defaultValue: 'draft', + group: 'details', }, }, // Org-wide default (OWD): who can see records they don't own. 'private' is diff --git a/packages/cli/test/generate-scaffold-gates.e2e.test.ts b/packages/cli/test/generate-scaffold-gates.e2e.test.ts index 19eeab85bcf..03b7fa2999c 100644 --- a/packages/cli/test/generate-scaffold-gates.e2e.test.ts +++ b/packages/cli/test/generate-scaffold-gates.e2e.test.ts @@ -36,17 +36,25 @@ * A CONTROL leg runs the three commands on the bare starter: zero findings, * which is what makes a finding in any other leg one that `os g` caused. * - * ## The one exemption, and why it is a ledger and not a filter + * ## The starter ledger — EMPTY since #21370, and shrink-only * - * The bare starter is clean, but not because it has nothing to report: its - * own `note` object declares a `body` field that nothing reads, and - * `field-no-consumers` stays silent only while the stack has no consumer root - * at all (a stack of objects is judged to be another stack's object library). - * The first view, flow, action, app, dashboard or skill in the project — any - * of them, generated or hand-written — wakes that finding on the STARTER's - * object. It is the starter template's to fix (`os g` cannot give someone - * else's field a consumer), so it is recorded below, ⛔ never filtered by rule - * or by path pattern. The ledger is SHRINK-ONLY and holds itself honest: + * A bare starter can be clean without being clean: `field-no-consumers` stays + * silent while the stack has no consumer root at all (a stack of objects is + * judged to be another stack's object library), so a starter field nothing + * reads stays latent until the first view, flow, action, app, dashboard or + * skill in the project, generated or hand-written, wakes it on the STARTER's + * object. `os g` cannot give someone else's field a consumer, so such a + * finding is the starter template's to fix, and this file recorded the one it + * met in a ledger below, ⛔ never in a filter by rule or by path pattern. That + * entry was the starter note's `body`. #21370 placed every starter field in a + * keyed field group (`fieldGroups` + `group`), which the rule credits as + * displayed, and deleted it; `starter-field-consumers.test.ts` and its `.e2e` + * sibling hold every starter to that. + * + * The ledger is SHRINK-ONLY, so EMPTY is where it stays: a starter finding a + * leg meets is a template to fix, ⛔ never a line to add. Its floor is pinned + * below. The two checks that held each entry honest stay with it, in case a + * maintainer ever lifts that floor: * * - an entry must name a finding on an object the bare starter declares, * so it can never cover what a scaffold wrote; @@ -90,14 +98,10 @@ const PREREQUISITE_STEM: Record = { object: 'gate_target', flow: /** * Findings the bare starter carries latently, by `rule@path`. SHRINK-ONLY — * see the header: each must sit on an object the starter declares, and each - * must still fire somewhere, or this file is red. + * must still fire somewhere, or this file is red. EMPTY since #21370; keep it + * that way. */ -const STARTER_LATENT_FINDINGS: Record = { - 'field-no-consumers@objects[0].fields.body': - "The starter's own `note` object declares `body` and nothing in the starter reads it. " - + 'Silent while the stack has no consumer root; the first view, flow, action, app, ' - + 'dashboard or skill wakes it. The starter template owns the fix.', -}; +const STARTER_LATENT_FINDINGS: Record = {}; const GATES = ['validate', 'build', 'lint'] as const; type Gate = (typeof GATES)[number]; @@ -248,6 +252,13 @@ describe('[#21325] every generator: fresh starter + `os g ` → zero findi }); describe('[#21325] the starter ledger cannot outlive its defect or cover a scaffold', () => { + it('[#21370] is at its floor: EMPTY — a starter finding is a template to fix, never a line to add', () => { + expect( + Object.keys(STARTER_LATENT_FINDINGS), + 'STARTER_LATENT_FINDINGS is shrink-only and was emptied by #21370: fix the starter template instead', + ).toEqual([]); + }); + const fired = (key: string) => Object.entries(legs).flatMap(([leg, l]) => GATES.filter((gate) => l.gates[gate].findings?.includes(key)).map((gate) => ({ leg, gate }))); diff --git a/packages/cli/test/starter-field-consumers.e2e.test.ts b/packages/cli/test/starter-field-consumers.e2e.test.ts new file mode 100644 index 00000000000..d9485739f3a --- /dev/null +++ b/packages/cli/test/starter-field-consumers.e2e.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#21370), end to end — every starter a fresh project can begin from, + * followed by `os g dashboard`, `os g view` and `os g flow`, reports ZERO + * `field-no-consumers` findings under both `os validate` and `os lint`. With + * the starter's `group` placements stripped, the same commands report the + * finding again: that is the control. + * + * ## The defect, measured through these commands + * + * On `origin/main` 69a12a0952, each starter followed by ONE of + * `os g dashboard probe`, `os g object gate_target` + `os g view gate_target`, + * or `os g object gate_target` + `os g flow gate_probe --object gate_target`: + * + * npm create objectstack `os validate` and `os lint` exit 0 with one + * `field-no-consumers` warning: the starter note's + * `body` + * os init -t app the same, with two: the item's `description` and + * `status` + * os init -t plugin clean: its one field is the record's title + * + * Every bare starter reported nothing, because the rule stays silent while a + * stack holds no consumer root. The author's first view, flow, dashboard or + * anything else that could read a field woke a warning about a field the + * author never wrote. The fix places each starter field in a keyed field group + * (`fieldGroups` + `group`, ADR-0085 §5), which the rule credits as displayed. + * + * ## One leg per starter, all three wakers in it + * + * Each leg is its own fresh project: the on-ramp's `bin/` for the blank + * starter, `os init -t