From 1ab8a1c2092a02c81bf40607db1b191670c8a8e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:17:26 +0000 Subject: [PATCH 1/3] fix(create-objectstack,cli): starter objects place their fields in a keyed field group The blank starter's note and the os init app template's item now declare fieldGroups and put every field in it, so the first view, flow or dashboard in a fresh project no longer wakes field-no-consumers on a field the author never wrote. Adds the per-PR in-process pin. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/commands/init.ts | 14 + .../cli/test/starter-field-consumers.test.ts | 331 ++++++++++++++++++ .../blank/src/objects/note.object.ts | 14 + 3 files changed, 359 insertions(+) create mode 100644 packages/cli/test/starter-field-consumers.test.ts 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/starter-field-consumers.test.ts b/packages/cli/test/starter-field-consumers.test.ts new file mode 100644 index 00000000000..b680004493a --- /dev/null +++ b/packages/cli/test/starter-field-consumers.test.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#21370) — no starter ships a latent `field-no-consumers` warning. Every + * object a fresh project starts with stays silent under `os validate` and + * `os lint` once the project holds a dashboard, a view or a flow, and the + * silence comes from the starter's field groups, not from a rule that never ran. + * + * ## The defect, measured through the real 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 reports nothing. The rule stays silent while a stack holds + * no consumer root (a stack of objects is judged to be another stack's object + * library), so 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 this pins + * + * Each starter object places the fields it declares in a keyed field group: + * `fieldGroups` on the object and `group` on each field (ADR-0085 §5). The + * rule credits a field the synthesized layout places in a KEYED section as + * displayed (`packages/lint/src/validate-field-consumers.ts`, "The synthesized + * layout"), because that section is what the form, detail and drawer surfaces + * draw. The rule itself is unchanged: a starter is a stack like any other. + * + * ## Every non-title field is its own control + * + * A zero proves nothing by itself, because a pipeline that failed before the + * rule ran reads zero too. So every leg first requires its stack to load + * (`defineStack`) and parse, and every non-title starter field is a control: + * with its `group` removed, the same pipeline reports `field-no-consumers` at + * exactly that field, under both commands. The title field is no control. The + * rule exempts it through the platform's `nameField` ladder + * (`resolveDisplayField`), which this file reads rather than restates. + * + * ## The roster is derived + * + * The starters are every `*.object.ts` the blank starter ships and every + * `*.object.ts` an `os init` template renders (`TEMPLATES`), so a starter + * object added later is measured the day it lands. The wakers are the real + * generators' output (`GENERATOR_SCAFFOLD_TARGETS`), bound the way the + * command binds them (`stackBindingCandidates`). Every source is loaded the way + * `os validate` loads authored TypeScript (`bundle-require` with + * `BUNDLE_REQUIRE_EXTERNALS`), and the findings come from what the two commands + * run: `runAuthoringRules('validate')` over the parsed stack, and `lintConfig`. + * + * The real commands on real projects are the nightly sibling + * `starter-field-consumers.e2e.test.ts`. This file is the per-PR half. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bundleRequire } from 'bundle-require'; +import { ObjectStackDefinitionSchema, defineStack, normalizeStackInput } from '@objectstack/spec'; +import { resolveDisplayField, type DisplayNameObjectMeta } from '@objectstack/spec/data'; +import { singularToPlural } from '@objectstack/spec/shared'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { FIELD_NO_CONSUMERS, runAuthoringRules } from '@objectstack/lint'; +import { TEMPLATES, sanitizeNamespace } from '../src/commands/init.js'; +import { + GENERATOR_SCAFFOLD_TARGETS, + stackBindingCandidates, + type ScaffoldBindings, +} from '../src/commands/generate.js'; +import { lintConfig } from '../src/commands/lint.js'; +import { BUNDLE_REQUIRE_EXTERNALS } from '../src/utils/config.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// One `path.resolve(HERE, …)` call per line: `check:cross-package-test-inputs` +// reconstructs these reads by SOURCE SCAN. The blank starter's config and its +// `src/**` tree are declared inputs of this package +// (scripts/cross-package-test-inputs.mjs), so a template-only diff re-runs this file. +const BLANK_CONFIG = path.resolve(HERE, '../../create-objectstack/src/templates/blank/objectstack.config.ts'); +const BLANK_OBJECTS = path.resolve(HERE, '../../create-objectstack/src/templates/blank/src/objects'); + +/** + * Where sources are materialized for `bundle-require`: under this package's own + * `node_modules`, so `@objectstack/spec/…` resolves from there as it would for + * a file in an author's project, and git-ignored, so a killed run leaves inert + * litter rather than sources a repo-wide scan can trip over. The same choice, + * argued at length, as `generate-scaffold-validates.test.ts`. + */ +const TMP_ROOT = fs.mkdtempSync(path.join(HERE, '..', 'node_modules', '.starter-field-consumers-')); + +afterAll(() => { + fs.rmSync(TMP_ROOT, { recursive: true, force: true }); +}); + +/** The project name `os init` is measured with. */ +const INIT_PROJECT = 'my-app'; +/** The object the binding wakers bind: generated beside the starter, never the starter's own. */ +const TARGET_STEM = 'gate_target'; +/** The name each waker is invoked with. */ +const WAKER_ITEM = 'gate_probe'; +/** The generators measured as wakers, each in its own project: the three the ruling names. */ +const WAKERS = ['dashboard', 'view', 'flow'] as const; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +const target = (type: string) => { + const found = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === type); + if (!found) throw new Error(`no '${type}' generator in the roster`); + return found; +}; + +let fileSeq = 0; + +/** Load one TypeScript source through the loader `os validate` uses. */ +async function loadSource(label: string, source: string): Promise { + const file = path.join(TMP_ROOT, `${label.replace(/[^a-z0-9]+/gi, '-')}-${fileSeq++}.ts`); + fs.writeFileSync(file, source, 'utf8'); + const { mod } = await bundleRequire({ filepath: file, external: BUNDLE_REQUIRE_EXTERNALS }); + return mod as AnyRec; +} + +/** Every object definition a module exports, whatever its export names. */ +const objectsExportedBy = (mod: AnyRec): AnyRec[] => + [...new Set(Object.values(mod))].filter( + (v): v is AnyRec => isRec(v) && typeof v.name === 'string' && isRec(v.fields), + ); + +// ── The roster: every starter object, from both scaffolders ────────────── + +interface StarterDoor { + door: string; + namespace: string; + /** Each emitted `*.object.ts`, as the project receives it. */ + sources: string[]; +} + +const blankNamespace = /namespace: '([a-z][a-z0-9_]*)'/.exec(fs.readFileSync(BLANK_CONFIG, 'utf8'))?.[1]; +const initNamespace = sanitizeNamespace(INIT_PROJECT); + +const DOORS: StarterDoor[] = [ + { + door: 'npm create objectstack', + namespace: blankNamespace ?? '', + sources: fs + .readdirSync(BLANK_OBJECTS) + .filter((f) => f.endsWith('.object.ts')) + .sort() + .map((f) => fs.readFileSync(path.join(BLANK_OBJECTS, f), 'utf8')), + }, + ...Object.entries(TEMPLATES).map(([key, template]) => ({ + door: `os init -t ${key}`, + namespace: initNamespace, + sources: Object.entries(template.srcFiles) + .filter(([file]) => file.endsWith('.object.ts')) + .map(([, render]) => render(INIT_PROJECT, initNamespace)), + })), +].filter((d) => d.sources.length > 0); + +interface Starter { + door: string; + namespace: string; + objects: AnyRec[]; +} + +const STARTERS: Starter[] = await Promise.all( + DOORS.map(async ({ door, namespace, sources }) => ({ + door, + namespace, + objects: (await Promise.all(sources.map((s) => loadSource(door, s)))).flatMap(objectsExportedBy), + })), +); + +const starterOf = (door: string): Starter => { + const found = STARTERS.find((s) => s.door === door); + if (!found) throw new Error(`no starter '${door}'`); + return found; +}; + +/** The record's title field, by the ladder the rule exempts it through. */ +const titleOf = (obj: AnyRec): string | undefined => + resolveDisplayField({ + nameField: typeof obj.nameField === 'string' ? obj.nameField : undefined, + displayNameField: typeof obj.displayNameField === 'string' ? obj.displayNameField : undefined, + fields: obj.fields as DisplayNameObjectMeta['fields'], + }); + +/** Every starter field that is not its object's title: one control each. */ +const CONTROLS = STARTERS.flatMap((s) => + s.objects.flatMap((obj, objectIndex) => + Object.keys(obj.fields as AnyRec) + .filter((field) => field !== titleOf(obj)) + .map((field) => ({ door: s.door, object: String(obj.name), objectIndex, field })), + ), +); + +// ── A project: the starter's objects, then one waker ───────────────────── + +interface Item { + type: string; + artifact: AnyRec; +} + +/** The stack a project's config builds: a namespaced manifest, each item in its collection. */ +function stackOf(namespace: string, items: readonly Item[]): AnyRec { + const stack: AnyRec = { + // `engines.protocol` as every scaffolded config declares it. + manifest: { + id: 'com.example.starter', + name: 'starter', + version: '1.0.0', + type: 'app', + namespace, + engines: { protocol: `^${PROTOCOL_MAJOR}` }, + }, + }; + const requires = new Set(); + for (const { type, artifact } of items) { + const key = singularToPlural(type); + stack[key] = [...((stack[key] as unknown[] | undefined) ?? []), artifact]; + for (const token of target(type).requires) requires.add(token); + } + if (requires.size > 0) stack.requires = [...requires]; + return stack; +} + +/** + * The starter's objects plus `waker`, generated the way the command generates + * it: an object it binds is first generated beside the starter's (never the + * starter's own, whose fields a bound view would list) and resolved off the + * project's stack through `stackBindingCandidates`. + */ +async function projectWith(starter: Starter, objects: readonly AnyRec[], waker: string): Promise { + const items: Item[] = objects.map((artifact) => ({ type: 'object', artifact })); + const t = target(waker); + const bindings: ScaffoldBindings = {}; + for (const key of Object.keys(t.binds)) { + if (key !== 'object') throw new Error(`\`os g ${waker}\` binds a '${key}': give it a prerequisite here`); + const object = target('object'); + const mod = await loadSource('object', object.generate(TARGET_STEM, starter.namespace)); + items.push({ type: 'object', artifact: (mod.default ?? mod) as AnyRec }); + const wanted = object.itemName(TARGET_STEM, starter.namespace); + bindings.object = stackBindingCandidates(stackOf(starter.namespace, items)).objects.find((o) => o.name === wanted); + if (!bindings.object) throw new Error(`the object \`os g ${waker}\` binds did not resolve: ${wanted}`); + } + const itemName = t.binds.object === 'name' ? TARGET_STEM : WAKER_ITEM; + const mod = await loadSource(waker, t.generate(itemName, starter.namespace, bindings)); + items.push({ type: waker, artifact: (mod.default ?? mod) as AnyRec }); + return stackOf(starter.namespace, items); +} + +/** + * The `field-no-consumers` paths each command reports for `stack`. Throws when + * the stack does not load or parse: a pipeline that stopped before the rule + * ran would otherwise read as a clean zero. + */ +function fieldNoConsumers(stack: AnyRec): { validate: string[]; lint: string[] } { + defineStack(stack as Parameters[0]); + const normalized = normalizeStackInput(stack) as AnyRec; + const parsed = ObjectStackDefinitionSchema.safeParse(normalized); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('\n')); + } + const run = { normalized, parsed: parsed.data as AnyRec }; + return { + validate: runAuthoringRules('validate', run).filter((f) => f.rule === FIELD_NO_CONSUMERS).map((f) => f.path), + lint: lintConfig(normalized).filter((i) => i.rule === FIELD_NO_CONSUMERS).map((i) => i.path), + }; +} + +/** `obj` with `field`'s `group` removed, everything else as the starter wrote it. */ +function withoutGroup(obj: AnyRec, field: string): AnyRec { + const fields = obj.fields as AnyRec; + const { group: _dropped, ...rest } = fields[field] as AnyRec; + return { ...obj, fields: { ...fields, [field]: rest } }; +} + +// ── The pins ───────────────────────────────────────────────────────────── + +describe('[#21370] the roster is the real one', () => { + it('reads the blank starter and every object-bearing `os init` template', () => { + expect(blankNamespace, 'the blank config declares a namespace').toBeDefined(); + const doors = STARTERS.map((s) => s.door); + expect(doors).toContain('npm create objectstack'); + for (const [key, template] of Object.entries(TEMPLATES)) { + if (Object.keys(template.srcFiles).some((f) => f.endsWith('.object.ts'))) expect(doors).toContain(`os init -t ${key}`); + } + for (const s of STARTERS) expect(s.objects.length, `${s.door} loads its objects`).toBeGreaterThan(0); + }); + + it('carries non-title fields, so the controls below measure something', () => { + // Vacuity guard: with no control the zeros below could not be told from a + // rule that never runs. The two starters the defect was measured on each + // carry at least one. + expect(CONTROLS.some((c) => c.door === 'npm create objectstack')).toBe(true); + expect(CONTROLS.some((c) => c.door === 'os init -t app')).toBe(true); + }); +}); + +describe('[#21370] every starter, then `os g dashboard | view | flow`: zero `field-no-consumers`', () => { + const legs = STARTERS.flatMap((s) => WAKERS.map((waker) => [s.door, waker] as const)); + + it.each(legs)('%s, then `os g %s`: `os validate` and `os lint` report none', async (door, waker) => { + const starter = starterOf(door); + const reported = fieldNoConsumers(await projectWith(starter, starter.objects, waker)); + expect(reported.validate, 'os validate').toEqual([]); + expect(reported.lint, 'os lint').toEqual([]); + }); +}); + +describe('[#21370] CONTROL — a starter field with its `group` removed warns again', () => { + it.each(CONTROLS.map((c) => [c.door, c.object, c.field, c] as const))( + '%s: `%s.%s` without its group is reported by both commands', + async (_door, _object, _field, c) => { + const starter = starterOf(c.door); + const objects = starter.objects.map((obj, i) => (i === c.objectIndex ? withoutGroup(obj, c.field) : obj)); + const at = `objects[${c.objectIndex}].fields.${c.field}`; + const reported = fieldNoConsumers(await projectWith(starter, objects, 'dashboard')); + expect(reported.validate, 'os validate').toEqual([at]); + expect(reported.lint, 'os lint').toEqual([at]); + }, + ); +}); diff --git a/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts b/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts index f6bad90b87a..72ecd4cef2e 100644 --- a/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts +++ b/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts @@ -9,15 +9,29 @@ export const Note = ObjectSchema.create({ icon: 'sticky-note', description: 'A short note — the starter object for a blank environment.', + // Field groups: the sections a note'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 `body`. `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: { title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 200, + group: 'details', }), body: Field.textarea({ label: 'Body', + group: 'details', }), }, From 72bf6e9d14f464166e4b0185e79cfb506e8fa058 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:25:32 +0000 Subject: [PATCH 2/3] test(cli): nightly real-command starter pin, empty the starter ledger, changeset Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/21370-starter-field-groups.md | 12 + .../test/generate-scaffold-gates.e2e.test.ts | 45 ++- .../test/starter-field-consumers.e2e.test.ts | 297 ++++++++++++++++++ 3 files changed, 337 insertions(+), 17 deletions(-) create mode 100644 .changeset/21370-starter-field-groups.md create mode 100644 packages/cli/test/starter-field-consumers.e2e.test.ts 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/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