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/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)", 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'; 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,