From 75e0ef4356a16ea0667a5ce1a0923ab3540a7e5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:13:34 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(lint):=20the=20ADR-0078=20completeness?= =?UTF-8?q?=20gate=20=E2=80=94=20Zod-valid=20but=20runtime-dead=20now=20fa?= =?UTF-8?q?ils=20at=20author=20time=20(#4544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instance can be Zod-valid (gate 1 green), use only live properties (gate 2 green), and have a correctly-authored sibling proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent occupancy-rate formula is stuck at 0 — and the agent reported the work done, because every gate it could see was green. This is worse than the unknown-key hole #4001 just closed. There the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success — false completion without anyone mistyping anything. The review step that catches a human's bare summary (seeing the field render 0) is exactly the step AI authoring removes. One shared predicate, every surface. Instance-completeness checks previously existed ONLY in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` validator and registered as an author-time rule (28 -> 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicates onto the same predicate rather than drifting from it. Every rule cites the runtime line that makes it true, because the completeness audit's scariest candidate (a "sharing rule fails open") collapsed on a three-file read, and #4001's last batches shipped four confidently wrong prescriptions before learning the same thing: field/summary-without-operations engine.ts `if (!d.summaryOperations) continue` error field/formula-without-expression engine.ts plans only fields that HAVE one error field/relationship-without-reference $expand `if (!referenceObject) continue` error field/choice-without-options record-validator.ts: empty list disables the error (select, radio) server-side value check entirely field/choice-without-options same branch, shared with free-form warning (checkboxes) view/layout-without-binding renderer falls back to literal default names warning (kanban, calendar, gantt) The deliberate NON-rules are pinned as hard as the rules. `multiselect` without options is NOT flagged: record-validator.ts says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, making it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are out of v1 for the same reason: config schemas exist, renderer behaviour has not had its verification pass. Verify, then enforce — one shape at a time. It found a real one on its first run against a real app. `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that IS complete, in the object whose entire job is to show what each field type looks like. The canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report`'s `total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets". Verification: 19 predicate tests + 7 walk tests (both spellings of every collection — a gate that walks half the stack is this campaign's recurring "instrument reporting coverage it doesn't have"); CRM and Todo produce zero findings; showcase clean after the fix with its 60 coverage tests passing; full monorepo suite 132/132. Phase 1 of #4544. Phase 2 (the cloud authoring-path config-drop fix) is in the cloud repo; Phase 3 lands the Tier-B shapes one verification pass at a time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --- .changeset/adr-0078-completeness-gate.md | 30 +++ .../src/data/objects/field-zoo.object.ts | 21 +- packages/cli/src/lint/authoring-rules.ts | 19 ++ packages/lint/src/index.ts | 10 + .../validate-functional-completeness.test.ts | 114 +++++++++ .../src/validate-functional-completeness.ts | 121 ++++++++++ .../kernel/functional-completeness.test.ts | 164 +++++++++++++ .../src/kernel/functional-completeness.ts | 224 ++++++++++++++++++ packages/spec/src/kernel/index.ts | 1 + 9 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 .changeset/adr-0078-completeness-gate.md create mode 100644 packages/lint/src/validate-functional-completeness.test.ts create mode 100644 packages/lint/src/validate-functional-completeness.ts create mode 100644 packages/spec/src/kernel/functional-completeness.test.ts create mode 100644 packages/spec/src/kernel/functional-completeness.ts diff --git a/.changeset/adr-0078-completeness-gate.md b/.changeset/adr-0078-completeness-gate.md new file mode 100644 index 0000000000..57eea32c53 --- /dev/null +++ b/.changeset/adr-0078-completeness-gate.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/cli': minor +--- + +The ADR-0078 completeness gate ships: a Zod-valid metadata instance that silently does nothing now fails at author time, on every authoring surface. + +This closes the hole *between* the platform's existing gates. An instance can be Zod-valid (gate 1 green), use only *live* properties (gate 2 green), and a correctly-authored sibling can be proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent "occupancy rate" is stuck at 0 — and the agent reported the work done, because every gate it could see was green. + +**Why this is worse than the unknown-key hole #4001 just closed.** There, the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success. It manufactures false completion without the author mistyping anything — and the review step that catches a human's bare summary (seeing the field render `0`) is exactly the step AI authoring removes. + +**One shared predicate, every surface — the ADR's core decision.** Instance-completeness checks previously existed *only* in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` `validate-functional-completeness` and registered as an author-time rule (28 → 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicate rules onto the same predicate rather than drifting from it. + +**Every rule cites the runtime line that makes it true**, because the completeness audit's scariest candidate — a "sharing rule fails open and shares every record" — collapsed on a three-file read, and #4001's last two batches shipped four confidently wrong prescriptions before learning the same thing: + +| rule | the silent skip | severity | +|---|---|---| +| `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | +| `field/formula-without-expression` | `engine.ts` builds the formula plan only from fields that HAVE one | error | +| `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | +| `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | +| `field/choice-without-options` (`checkboxes`) | same branch, but shared with free-form | warning | +| `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | renderer falls back to literal default field names | warning | + +**The deliberate NON-rules are pinned as hard as the rules.** `multiselect` without options is *not* flagged: `record-validator.ts` says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, which makes it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are likewise out of v1: they have config schemas, but their renderer behaviour has not had its verification pass. + +**It found a real one on its first run against a real app.** `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that *is* complete, in the object whose entire job is to show what each field type looks like. So the canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report.total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets." + +Tracked in #4544. This is Phase 1; Phase 2 (the cloud authoring-path config-drop fix) is in the `cloud` repo, and Phase 3 lands the Tier-B shapes one verification pass at a time. diff --git a/examples/app-showcase/src/data/objects/field-zoo.object.ts b/examples/app-showcase/src/data/objects/field-zoo.object.ts index 409c7fa258..585929e928 100644 --- a/examples/app-showcase/src/data/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/field-zoo.object.ts @@ -131,7 +131,26 @@ export const FieldZoo = ObjectSchema.create({ label: 'Formula (number × percent)', expression: cel`(record.f_number == null ? 0 : record.f_number) * (record.f_percent == null ? 0 : record.f_percent) / 100`, }), - f_summary: Field.summary({ label: 'Roll-up Summary' }), + // NO `summary` field here, deliberately — it is the one type this zoo + // cannot demonstrate. A roll-up aggregates a CHILD object into its parent, + // and the zoo is a leaf: `f_master_detail` below makes it a child of + // `showcase_project`, and nothing is a child of the zoo. A `Field.summary` + // with no `summaryOperations` is not a demo of the type — the engine's + // summary index skips it, so it reads 0 forever. + // + // It sat here as exactly that until the ADR-0078 completeness gate flagged + // it on its first run against a real app (#4544). Worth noting where it + // was: in the object whose whole job is to show what each field type looks + // like, one line below an `f_formula` that IS complete. The canonical + // example of a roll-up in this repo computed nothing — and the rule it + // broke was this file's own: "relationship types point at the other + // showcase objects so they have REAL targets". + // + // `summary` stays covered stack-wide (`collectFieldTypes` walks every + // object): `showcase_invoice.total` is the plain sum, and + // `showcase_expense_report.total_amount` / `approved_amount` show the + // `summaryOperations.filter` variant that rolls ONE child object into two + // different totals. f_autonumber: Field.autonumber({ label: 'Auto Number' }), // ── Embedded structured values (stored as JSON on the row) ─────────── diff --git a/packages/cli/src/lint/authoring-rules.ts b/packages/cli/src/lint/authoring-rules.ts index f9e7c62fea..4d927a3471 100644 --- a/packages/cli/src/lint/authoring-rules.ts +++ b/packages/cli/src/lint/authoring-rules.ts @@ -75,6 +75,7 @@ import { validateStackExpressions, validateListViewMode, + validateFunctionalCompleteness, validateViewContainers, validateWidgetBindings, validateDashboardActionRefs, @@ -229,6 +230,24 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ source: 'packages/lint/src/validate-list-view-mode.ts', run: (stack) => validateListViewMode(stack), }, + // [ADR-0078] A Zod-VALID instance that silently does nothing: a `summary` + // with no `summaryOperations`, a `lookup` with no `reference`, a `select` + // with no `options`. Every key is one we know, so #4001's unknown-key + // rejection cannot see it, and the liveness ledger cannot either (it is + // per-property; the properties ARE live). This is the gate between them. + // + // `gating` because the error-severity shapes are fully inert — the field + // reads 0 forever while authoring reports success, which is the failure the + // ADR was written for (cloud#687). Pre-parse so the findings survive an + // unrelated schema error elsewhere in the stack. + { + name: 'validateFunctionalCompleteness', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-functional-completeness.ts', + run: (stack) => validateFunctionalCompleteness(stack), + }, // A flat list-view object in `views: []` parses to an EMPTY container // (ViewSchema strips unknown keys): the schema step passes, zero views // register, and the Console renders nothing. Pre-parse for the same reason. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d3b666748e..3edb952416 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -28,6 +28,16 @@ export { validateStackExpressions } from './validate-expressions.js'; export type { ExprIssue } from './validate-expressions.js'; export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; + +// [ADR-0078] The functional-completeness gate. All judgement lives in the shared +// predicate in `@objectstack/spec/kernel` (sibling of `isIncoherentAggregate`), +// so cloud graph-lint can re-home its duplicate rules onto the same source and +// the AI-build path cannot drift from the framework. +export { validateFunctionalCompleteness } from './validate-functional-completeness.js'; +export type { + FunctionalCompletenessFinding, + FunctionalCompletenessSeverity, +} from './validate-functional-completeness.js'; export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js'; export { validateFlowTriggerReadiness, diff --git a/packages/lint/src/validate-functional-completeness.test.ts b/packages/lint/src/validate-functional-completeness.test.ts new file mode 100644 index 0000000000..f98a89c63a --- /dev/null +++ b/packages/lint/src/validate-functional-completeness.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for the ADR-0078 completeness validator. + * + * The predicate's own rules are proven in + * `@objectstack/spec`'s `functional-completeness.test.ts`; this file proves the + * WALK — that the rules reach every place a field or list view can be authored, + * in both collection spellings, with a usable location. + * + * That split matters here more than usual. This campaign's recurring finding is + * instruments that report coverage they do not have, and a completeness gate + * that walks half the stack is exactly that: green, and blind to the other half. + */ + +import { describe, expect, it } from 'vitest'; + +import { validateFunctionalCompleteness } from './validate-functional-completeness.js'; + +const bareSummary = { type: 'summary' }; + +describe('validateFunctionalCompleteness — the walk', () => { + it('finds an inert field when objects and fields are ARRAYS', () => { + const findings = validateFunctionalCompleteness({ + objects: [{ name: 'order', fields: [{ name: 'total', ...bareSummary }] }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe('field/summary-without-operations'); + expect(findings[0].where).toBe('object "order" › fields.total'); + expect(findings[0].path).toBe('objects[0].fields[0].summaryOperations'); + }); + + it('finds the same field when objects and fields are name-keyed MAPS', () => { + // Both spellings are authorable, and a walk that handles only one is the + // half-blind instrument this suite exists to prevent. + const findings = validateFunctionalCompleteness({ + objects: { order: { fields: { total: bareSummary } } }, + }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe('object "order" › fields.total'); + expect(findings[0].path).toBe('objects[0].fields.total.summaryOperations'); + }); + + it('carries the fix through as the hint', () => { + const [f] = validateFunctionalCompleteness({ + objects: [{ name: 'o', fields: [{ name: 'rel', type: 'lookup' }] }], + }); + expect(f.hint).toContain('reference'); + expect(f.severity).toBe('error'); + }); + + it('reports every inert field, not just the first', () => { + const findings = validateFunctionalCompleteness({ + objects: [{ + name: 'order', + fields: [ + { name: 'total', type: 'summary' }, + { name: 'rate', type: 'formula' }, + { name: 'acct', type: 'lookup' }, + { name: 'stage', type: 'select' }, + { name: 'ok', type: 'text' }, + ], + }], + }); + expect(findings.map((f) => f.rule).sort()).toEqual([ + 'field/choice-without-options', + 'field/formula-without-expression', + 'field/relationship-without-reference', + 'field/summary-without-operations', + ]); + }); + + it('walks list views in a container — both `list` and named `listViews`', () => { + const findings = validateFunctionalCompleteness({ + views: [{ + object: 'task', + list: { type: 'kanban' }, + listViews: { by_month: { type: 'calendar' } }, + }], + }); + expect(findings.map((f) => f.path).sort()).toEqual([ + 'views[0].list.kanban', + 'views[0].listViews.by_month.calendar', + ]); + expect(findings.every((f) => f.severity === 'warning')).toBe(true); + }); + + it('is silent on a complete stack', () => { + expect(validateFunctionalCompleteness({ + objects: [{ + name: 'order', + fields: [ + { name: 'total', type: 'summary', summaryOperations: { object: 'line', field: 'amt', function: 'sum' } }, + { name: 'acct', type: 'lookup', reference: 'account' }, + { name: 'stage', type: 'select', options: [{ label: 'New', value: 'new' }] }, + { name: 'tags', type: 'multiselect' }, + ], + }], + views: [{ object: 'order', list: { type: 'grid' } }], + })).toEqual([]); + }); + + it('never throws on junk or partial stacks', () => { + for (const junk of [ + undefined, null, 42, 'x', [], {}, + { objects: 'nope' }, { objects: [null, 7] }, + { objects: [{ name: 'o', fields: 'nope' }] }, + { views: [{ list: null }] }, + { views: 'nope' }, + ]) { + expect(() => validateFunctionalCompleteness(junk)).not.toThrow(); + } + }); +}); diff --git a/packages/lint/src/validate-functional-completeness.ts b/packages/lint/src/validate-functional-completeness.ts new file mode 100644 index 0000000000..df73de63c7 --- /dev/null +++ b/packages/lint/src/validate-functional-completeness.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0078 Phase 1] The functional-completeness gate — `validate-functional- +// completeness`, the validator the ADR names and, until now, the one it said +// did not exist. +// +// A pure `(stack) => Finding[]` rule (ADR-0019). All judgement lives in the +// SHARED predicate — `@objectstack/spec/kernel`'s `checkFieldCompleteness` / +// `checkViewCompleteness`, the sibling of `isIncoherentAggregate` that cloud +// graph-lint is meant to re-home onto — so this file is only the walk: where +// fields and list views live in a stack, and how a predicate finding becomes a +// lint finding with a location. If a rule seems wrong, fix the predicate (and +// its runtime citation), never this walk. +// +// Why this closes a real hole: instance-completeness checks existed only in +// cloud's AI-build graph-lint, so a stack authored via `os` + a coding +// assistant, an MCP agent, `os validate` in CI, or a hand author got NONE of +// them (`formula_without_expression` existed nowhere in the framework). One +// predicate, every surface — the ADR's core decision. +// +// Runs on the NORMALIZED (pre-parse) stack like validate-list-view-mode: the +// findings must reach the author even when an unrelated schema error would +// stop the parse, and nothing here depends on parse-time defaults. + +import { + checkFieldCompleteness, + checkViewCompleteness, + type CompletenessFinding, +} from '@objectstack/spec/kernel'; + +export type FunctionalCompletenessSeverity = 'error' | 'warning'; + +export interface FunctionalCompletenessFinding { + severity: FunctionalCompletenessSeverity; + /** Stable rule id from the shared predicate (e.g. `field/summary-without-operations`). */ + rule: string; + /** Human-readable location, e.g. `object "order" › fields.total`. */ + where: string; + /** Config path, e.g. `objects[2].fields.total.summaryOperations`. */ + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** Array-or-name-keyed-map collection → entries with a name and an index label. */ +function entriesOf(v: unknown): Array<{ name: string; def: AnyRec; key: string }> { + if (Array.isArray(v)) { + return v.flatMap((def, i) => + isRec(def) ? [{ name: String(def.name ?? i), def, key: `[${i}]` }] : [], + ); + } + if (isRec(v)) { + return Object.entries(v).flatMap(([name, def]) => + isRec(def) ? [{ name, def: { name, ...def }, key: `.${name}` }] : [], + ); + } + return []; +} + +function push( + out: FunctionalCompletenessFinding[], + found: CompletenessFinding[], + where: string, + basePath: string, +): void { + for (const f of found) { + out.push({ + severity: f.severity, + rule: f.rule, + where, + path: `${basePath}.${f.path}`, + message: f.message, + hint: f.fix, + }); + } +} + +/** + * Walk every field definition and every list-view definition in the stack + * through the shared completeness predicate. + */ +export function validateFunctionalCompleteness(stack: unknown): FunctionalCompletenessFinding[] { + const out: FunctionalCompletenessFinding[] = []; + if (!isRec(stack)) return out; + + // ── Fields: objects[].fields (map or array) ───────────────────────────── + for (const [oi, obj] of entriesOf(stack.objects).entries()) { + for (const field of entriesOf(obj.def.fields)) { + push( + out, + checkFieldCompleteness(field.def), + `object "${obj.name}" › fields.${field.name}`, + `objects[${oi}].fields${field.key}`, + ); + } + } + + // ── List views: views[] containers → list / listViews.* ──────────────── + // (Form views carry no layout-binding contract; field completeness inside + // objects is already covered above.) + for (const [vi, container] of entriesOf(stack.views).entries()) { + const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`; + if (isRec(container.def.list)) { + push(out, checkViewCompleteness(container.def.list), `${where} › list`, `views[${vi}].list`); + } + for (const lv of entriesOf(container.def.listViews)) { + push( + out, + checkViewCompleteness(lv.def), + `${where} › listViews.${lv.name}`, + `views[${vi}].listViews${lv.key}`, + ); + } + } + + return out; +} diff --git a/packages/spec/src/kernel/functional-completeness.test.ts b/packages/spec/src/kernel/functional-completeness.test.ts new file mode 100644 index 0000000000..4d5a640562 --- /dev/null +++ b/packages/spec/src/kernel/functional-completeness.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for the shared functional-completeness predicate (ADR-0078 Phase 1). + * + * Two disciplines from the #4001 campaign carry over: + * + * 1. Every rule is proven to GO RED on the inert shape it exists for — a + * completeness gate that cannot fail on a known-inert instance is the + * hollow-probe defect reproduced in the instrument built against it. + * 2. The deliberate NON-rules are pinned as hard as the rules. `multiselect` + * without options is runtime-blessed free-form (`record-validator.ts:471`, + * verbatim: "free-form (tags without options)") — if someone "completes" + * this module by flagging it, that is a false prescription, and this test + * is where the attempt fails first. + */ + +import { describe, expect, it } from 'vitest'; + +import { + checkFieldCompleteness, + checkViewCompleteness, + FUNCTIONAL_COMPLETENESS_RULES, + FIELD_SUMMARY_WITHOUT_OPERATIONS, + FIELD_FORMULA_WITHOUT_EXPRESSION, + FIELD_RELATIONSHIP_WITHOUT_REFERENCE, + FIELD_CHOICE_WITHOUT_OPTIONS, + VIEW_LAYOUT_WITHOUT_BINDING, +} from './functional-completeness'; + +const only = (findings: ReturnType) => { + expect(findings).toHaveLength(1); + return findings[0]; +}; + +describe('checkFieldCompleteness — the verified inert shapes go red', () => { + it('flags a bare summary as an ERROR (the cloud#687 founding case)', () => { + const f = only(checkFieldCompleteness({ type: 'summary' })); + expect(f.rule).toBe(FIELD_SUMMARY_WITHOUT_OPERATIONS); + expect(f.severity).toBe('error'); + expect(f.fix).toContain('summaryOperations'); + // The message must carry the runtime evidence — a prescription with no + // "why" is the kind this campaign shipped four wrong ones of. + expect(f.message).toContain('engine.ts'); + }); + + it('is silent on a complete summary', () => { + expect(checkFieldCompleteness({ + type: 'summary', + summaryOperations: { object: 'order_line', field: 'amount', function: 'sum' }, + })).toEqual([]); + }); + + it('flags a bare formula as an ERROR', () => { + const f = only(checkFieldCompleteness({ type: 'formula' })); + expect(f.rule).toBe(FIELD_FORMULA_WITHOUT_EXPRESSION); + expect(f.severity).toBe('error'); + }); + + it('is silent on a formula with an expression — either input form', () => { + expect(checkFieldCompleteness({ type: 'formula', expression: 'record.a * record.b' })).toEqual([]); + expect(checkFieldCompleteness({ + type: 'formula', + expression: { dialect: 'cel', source: 'record.a * record.b' }, + })).toEqual([]); + }); + + it.each(['lookup', 'master_detail'])('flags a %s without reference as an ERROR', (type) => { + const f = only(checkFieldCompleteness({ type })); + expect(f.rule).toBe(FIELD_RELATIONSHIP_WITHOUT_REFERENCE); + expect(f.severity).toBe('error'); + expect(checkFieldCompleteness({ type, reference: 'account' })).toEqual([]); + }); + + it('does NOT flag `user` — its target is implicitly sys_user', () => { + expect(checkFieldCompleteness({ type: 'user' })).toEqual([]); + }); + + it.each(['select', 'radio'])('flags a %s without options as an ERROR', (type) => { + const f = only(checkFieldCompleteness({ type })); + expect(f.rule).toBe(FIELD_CHOICE_WITHOUT_OPTIONS); + expect(f.severity).toBe('error'); + expect(checkFieldCompleteness({ type, options: [] })).toHaveLength(1); + expect(checkFieldCompleteness({ + type, + options: [{ label: 'Open', value: 'open' }], + })).toEqual([]); + }); + + it('flags checkboxes without options as a WARNING, not an error', () => { + // Shares the validator's free-form multi branch, so it MAY be deliberate — + // but a zero-box checkbox group almost never is. ADR-0078 §1: degrades → warning. + const f = only(checkFieldCompleteness({ type: 'checkboxes' })); + expect(f.rule).toBe(FIELD_CHOICE_WITHOUT_OPTIONS); + expect(f.severity).toBe('warning'); + }); + + it('does NOT flag multiselect without options — the pinned NON-rule', () => { + // record-validator.ts:471, verbatim: "free-form (tags without options)". + // The runtime blesses this as a mode; flagging it would be a false + // prescription. If product direction ever changes, change the runtime + // first — this pin makes the lint follow the code, never lead it. + expect(checkFieldCompleteness({ type: 'multiselect' })).toEqual([]); + }); + + it('never throws on junk — a lint must not be what crashes a build', () => { + for (const junk of [undefined, null, 42, 'x', [], {}, { type: 7 }, { type: 'nonsense' }]) { + expect(() => checkFieldCompleteness(junk)).not.toThrow(); + expect(checkFieldCompleteness(junk)).toEqual([]); + } + }); +}); + +describe('checkViewCompleteness — layout bindings', () => { + it.each(['kanban', 'calendar', 'gantt'])('flags a %s view missing its block as a WARNING', (type) => { + const f = only(checkViewCompleteness({ type }) as never); + expect(f.rule).toBe(VIEW_LAYOUT_WITHOUT_BINDING); + expect(f.severity).toBe('warning'); + expect(f.path).toBe(type); + }); + + it('is silent when the block is present', () => { + expect(checkViewCompleteness({ + type: 'calendar', + calendar: { startDateField: 'due_at', titleField: 'title' }, + })).toEqual([]); + }); + + it('is silent on grid and on the unverified types (timeline, tree)', () => { + // timeline/tree have config schemas too, but their renderer behaviour has + // not had its verification pass — verify-then-enforce, one shape at a time. + for (const type of ['grid', 'gallery', 'timeline', 'tree', 'map']) { + expect(checkViewCompleteness({ type })).toEqual([]); + } + }); +}); + +describe('registry hygiene', () => { + it('pins the rule-id list — ids are API for suppressions and dashboards', () => { + expect([...FUNCTIONAL_COMPLETENESS_RULES].sort()).toEqual([ + 'field/choice-without-options', + 'field/formula-without-expression', + 'field/relationship-without-reference', + 'field/summary-without-operations', + 'view/layout-without-binding', + ]); + }); + + it('every emitted finding carries a fix — the prescription IS the payload', () => { + const all = [ + ...checkFieldCompleteness({ type: 'summary' }), + ...checkFieldCompleteness({ type: 'formula' }), + ...checkFieldCompleteness({ type: 'lookup' }), + ...checkFieldCompleteness({ type: 'select' }), + ...checkFieldCompleteness({ type: 'checkboxes' }), + ...checkViewCompleteness({ type: 'kanban' }), + ]; + expect(all).toHaveLength(6); + for (const f of all) { + expect(f.fix.length).toBeGreaterThan(8); + expect(f.message.length).toBeGreaterThan(60); + } + }); +}); diff --git a/packages/spec/src/kernel/functional-completeness.ts b/packages/spec/src/kernel/functional-completeness.ts new file mode 100644 index 0000000000..3312f2677f --- /dev/null +++ b/packages/spec/src/kernel/functional-completeness.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Functional completeness — the shared per-type predicate (ADR-0078 Phase 1) + * + * A metadata instance can be Zod-valid, use only *live* properties, and still + * be runtime-DEAD because it omits a sibling config its consumer needs — and + * the consumer silently no-ops instead of erroring. The founding case + * (cloud#687): an AI authored `{ type: 'summary' }` with no + * `summaryOperations`; the engine's index builder skips it, the field reads 0 + * everywhere, the dependent "occupancy rate" formula is forever 0 — and the + * agent reported the work done, because every gate it could see was green. + * + * This module is the single source of truth for "is this instance complete + * enough to run", shared the way `data/aggregation-policy.ts` shares + * `isIncoherentAggregate` (the ADR-0019 pattern): `@objectstack/lint`'s + * `validate-functional-completeness` consumes it for `os build` / `os validate` + * / MCP / hand authoring, and cloud's graph-lint is meant to re-home its + * duplicate rules onto it so the AI-build path cannot drift from the framework + * (ADR-0078 §2 — the path-asymmetry the ADR exists to kill). + * + * ## Discipline: every rule here cites the runtime line that makes it true + * + * The completeness audit's scariest candidate (a "fail-open sharing rule") + * collapsed on a three-file read, and this campaign shipped four confidently + * wrong prescriptions before learning the same lesson — so a rule is added + * here ONLY with the silent-skip site named, and a deliberate NON-rule is + * recorded with the evidence that exempts it. The codebase can be asked; + * these were: + * + * - `summary` w/o `summaryOperations` → `objectql/engine.ts:3001` + * `if (d?.type !== 'summary' || !d.summaryOperations) continue;` + * - `formula` w/o `expression` → `objectql/engine.ts:346` builds the formula + * plan only from fields WITH an expression; a bare formula never computes. + * - `lookup`/`master_detail` w/o `reference` → `objectql/engine.ts:3191` + * `$expand` `if (!referenceObject) continue;` — the relationship silently + * never resolves, and the record picker has no target to search. + * - `select`/`radio` w/o `options` → `record-validator.ts:452` + * `allowed.length > 0 && …`: an empty option list disables server-side + * value validation entirely, while the form control offers nothing to pick. + * - **NON-rule:** `multiselect` w/o `options` — `record-validator.ts:471` + * says, verbatim, `// free-form (tags without options)`. The runtime + * blesses it as a deliberate mode, which makes it ADR-0078 case (3) + * "genuinely optional", not an omission. Flagging it would be this + * campaign's own false-prescription mistake again. + * - `checkboxes` w/o `options` sits between the two: it shares the multi + * branch's free-form validator behaviour, but a checkbox group with zero + * boxes is almost certainly an omission — so it is a WARNING, not an error. + * + * Severity follows ADR-0078 decision 1: `error` when the instance is fully + * inert, `warning` when it degrades to something that partially works. + */ + +/** One completeness violation on one instance. */ +export interface CompletenessFinding { + /** Stable rule id, e.g. `field/summary-without-operations`. */ + rule: string; + /** `error` = fully inert instance; `warning` = degrades (ADR-0078 §1). */ + severity: 'error' | 'warning'; + /** Path of the omitted config relative to the item (e.g. `summaryOperations`). */ + path: string; + /** What is inert, the runtime line that makes it so, and what to add. */ + message: string; + /** One-line prescription, machine-pastable where possible. */ + fix: string; +} + +export const FIELD_SUMMARY_WITHOUT_OPERATIONS = 'field/summary-without-operations'; +export const FIELD_FORMULA_WITHOUT_EXPRESSION = 'field/formula-without-expression'; +export const FIELD_RELATIONSHIP_WITHOUT_REFERENCE = 'field/relationship-without-reference'; +export const FIELD_CHOICE_WITHOUT_OPTIONS = 'field/choice-without-options'; +export const VIEW_LAYOUT_WITHOUT_BINDING = 'view/layout-without-binding'; + +/** Every rule id this module can emit — pinned by tests so ids cannot drift. */ +export const FUNCTIONAL_COMPLETENESS_RULES = [ + FIELD_SUMMARY_WITHOUT_OPERATIONS, + FIELD_FORMULA_WITHOUT_EXPRESSION, + FIELD_RELATIONSHIP_WITHOUT_REFERENCE, + FIELD_CHOICE_WITHOUT_OPTIONS, + VIEW_LAYOUT_WITHOUT_BINDING, +] as const; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +const hasEntries = (v: unknown): boolean => Array.isArray(v) && v.length > 0; + +/** + * Field types whose single-choice control is dead without `options` + * (`record-validator.ts:452` skips validation on an empty list, and the form + * control has nothing to offer). `multiselect` is deliberately absent — see + * the NON-rule note in the module doc. + */ +const DEAD_WITHOUT_OPTIONS_ERROR: ReadonlySet = new Set(['select', 'radio']); +const DEAD_WITHOUT_OPTIONS_WARNING: ReadonlySet = new Set(['checkboxes']); + +/** Relationship types whose `$expand` / picker are inert without `reference`. + * `user` is exempt (its target is implicitly `sys_user`); `tree` is exempt + * pending its own verification pass — only assert what was verified. */ +const RELATIONSHIP_TYPES: ReadonlySet = new Set(['lookup', 'master_detail']); + +/** + * Completeness of a single field definition (an `objects[].fields` entry or a + * standalone `field` metadata item). Pure and total: unknown shapes yield no + * findings, never a throw — a lint must not be the thing that crashes a build. + */ +export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { + if (!isRec(def)) return []; + const type = typeof def.type === 'string' ? def.type : undefined; + if (!type) return []; + const out: CompletenessFinding[] = []; + + if (type === 'summary' && !isRec(def.summaryOperations)) { + out.push({ + rule: FIELD_SUMMARY_WITHOUT_OPERATIONS, + severity: 'error', + path: 'summaryOperations', + message: + 'A `summary` field with no `summaryOperations` computes nothing: the engine\'s ' + + 'summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`), so it ' + + 'reads 0/null everywhere and anything derived from it is stuck at 0 — while every ' + + 'authoring surface reports success. This is the cloud#687 shape ADR-0078 was written for.', + fix: "summaryOperations: { object: '', field: '', function: 'sum' }", + }); + } + + if (type === 'formula' && def.expression === undefined) { + out.push({ + rule: FIELD_FORMULA_WITHOUT_EXPRESSION, + severity: 'error', + path: 'expression', + message: + 'A `formula` field with no `expression` never computes: the engine builds its formula ' + + 'plan only from fields that HAVE one (`engine.ts` — `if (def?.type === \'formula\' && ' + + 'def.expression)`), so this field is permanently empty while parsing and publishing succeed.', + fix: 'expression: F`record. * record.`', + }); + } + + if (RELATIONSHIP_TYPES.has(type) && typeof def.reference !== 'string') { + out.push({ + rule: FIELD_RELATIONSHIP_WITHOUT_REFERENCE, + severity: 'error', + path: 'reference', + message: + `A \`${type}\` field with no \`reference\` is a relationship to nowhere: \`$expand\` ` + + 'silently skips it (`engine.ts` — `if (!referenceObject) continue`) and the record ' + + 'picker has no object to search, so the column stores raw ids that never resolve.', + fix: "reference: ''", + }); + } + + if (DEAD_WITHOUT_OPTIONS_ERROR.has(type) && !hasEntries(def.options)) { + out.push({ + rule: FIELD_CHOICE_WITHOUT_OPTIONS, + severity: 'error', + path: 'options', + message: + `A \`${type}\` field with no \`options\` is a choice with nothing to choose: the form ` + + 'control is empty AND server-side value validation is disabled (`record-validator.ts` ' + + 'skips the check when the allowed list is empty), so any value writes through the API.', + fix: "options: [{ label: '…', value: '…' }]", + }); + } else if (DEAD_WITHOUT_OPTIONS_WARNING.has(type) && !hasEntries(def.options)) { + out.push({ + rule: FIELD_CHOICE_WITHOUT_OPTIONS, + severity: 'warning', + path: 'options', + message: + 'A `checkboxes` field with no `options` renders zero checkboxes. The validator\'s ' + + 'multi-value branch tolerates it as free-form (the `multiselect` tags mode), but a ' + + 'checkbox group is almost never meant to be free-form — declare the boxes, or use ' + + '`multiselect` if free-form tags were the intent.', + fix: "options: [{ label: '…', value: '…' }]", + }); + } + + return out; +} + +/** + * The layout-specific config block each view type is inert without. The + * renderer falls back to LITERAL field names (`start_date`, `status`, …), so a + * view without its block renders — but empty — on any object that does not + * happen to declare those exact fields. Degrades rather than fully dies, and + * the renderer half of the evidence lives in objectui: WARNING, not error + * (ADR-0078 §1), until a verification pass on the renderer promotes it. + * + * `timeline` / `tree` have config schemas too but are NOT flagged yet — same + * verify-then-enforce gate; the audit's Tier-A names only these three. + */ +const VIEW_BINDING_BLOCKS: Readonly> = { + kanban: 'kanban', + calendar: 'calendar', + gantt: 'gantt', +}; + +/** + * Completeness of a single list-view definition (a container's `list` / + * `listViews.*` entry). + */ +export function checkViewCompleteness(view: unknown): CompletenessFinding[] { + if (!isRec(view)) return []; + const type = typeof view.type === 'string' ? view.type : undefined; + if (!type) return []; + const block = VIEW_BINDING_BLOCKS[type]; + if (!block || isRec(view[block])) return []; + return [{ + rule: VIEW_LAYOUT_WITHOUT_BINDING, + severity: 'warning', + path: block, + message: + `A \`${type}\` view with no \`${block}\` block is bound to nothing: the renderer falls ` + + 'back to literal default field names, which works only if the object happens to declare ' + + 'them — on any other object the view renders empty while authoring reports success.', + fix: + type === 'kanban' + ? "kanban: { groupByField: '' }" + : type === 'calendar' + ? "calendar: { startDateField: '', titleField: '' }" + : "gantt: { startDateField: '', endDateField: '', titleField: '' }", + }]; +} diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index 623573001a..c58c77a90d 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -55,3 +55,4 @@ export * from './plugin-registry.zod'; export * from './plugin-security.zod'; export * from './execution-context.zod'; export * from './metadata-create-seeds'; +export * from './functional-completeness'; From c89fa8483937629d6ede3cbac34133e249d3cbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:23:46 +0000 Subject: [PATCH 2/3] chore(spec): regenerate the API surface snapshot for the ADR-0078 kernel exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completeness gate adds nine kernel exports — `checkFieldCompleteness`, `checkViewCompleteness`, `CompletenessFinding`, the five rule-id constants and `FUNCTIONAL_COMPLETENESS_RULES`. `check:api-surface` reported them as `0 breaking (removed/narrowed), 9 added`: purely additive, which is what a new shared predicate should be. Snapshot regenerated so the gate agrees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --- packages/spec/api-surface.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index de37ff8808..242aec3fef 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1488,6 +1488,7 @@ "CompatibilityLevelSchema (const)", "CompatibilityMatrixEntry (type)", "CompatibilityMatrixEntrySchema (const)", + "CompletenessFinding (interface)", "CustomizationOrigin (type)", "CustomizationOriginSchema (const)", "CustomizationPolicy (type)", @@ -1565,6 +1566,11 @@ "ExecutionContextSchema (const)", "ExtensionPoint (type)", "ExtensionPointSchema (const)", + "FIELD_CHOICE_WITHOUT_OPTIONS (const)", + "FIELD_FORMULA_WITHOUT_EXPRESSION (const)", + "FIELD_RELATIONSHIP_WITHOUT_REFERENCE (const)", + "FIELD_SUMMARY_WITHOUT_OPERATIONS (const)", + "FUNCTIONAL_COMPLETENESS_RULES (const)", "FieldChange (type)", "FieldChangeSchema (const)", "GetPackageRequest (type)", @@ -1876,6 +1882,7 @@ "UpgradePlanSchema (const)", "UpgradeSnapshot (type)", "UpgradeSnapshotSchema (const)", + "VIEW_LAYOUT_WITHOUT_BINDING (const)", "ValidationError (type)", "ValidationErrorSchema (const)", "ValidationResult (type)", @@ -1885,6 +1892,8 @@ "VersionConstraint (type)", "VersionConstraintSchema (const)", "VulnerabilitySeverity (type)", + "checkFieldCompleteness (function)", + "checkViewCompleteness (function)", "classifyRequiredCapability (function)", "deriveNamespaceFromPackageId (function)", "evaluateLockForDelete (function)", From 972f560382fd49878d8215f21276fb07cd35aece Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:35:46 +0000 Subject: [PATCH 3/3] chore: lower the showcase i18n ratchet 452 -> 451 after removing the inert roll-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing `showcase_field_zoo.f_summary` took its `label: 'Roll-up Summary'` with it, so app-showcase declares one fewer untranslated string. The ratchet is bidirectional by design — an improvement that leaves the baseline stale would let a later regression back in under it — so the baseline moves down with the count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --- scripts/i18n-coverage-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 6f0cfbaa2c..c4ab262584 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,6 +1,6 @@ { "examples/app-crm/objectstack.config.ts": 89, - "examples/app-showcase/objectstack.config.ts": 452, + "examples/app-showcase/objectstack.config.ts": 451, "examples/app-todo/objectstack.config.ts": 120, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0,