Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/view-liveness-enrollment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/spec': minor
'@objectstack/cli': minor
---

feat(spec,cli): enroll `view` in the liveness ledger (#2998 Track B)

`view` joins the `GOVERNED` set of the spec property-liveness gate — the
rollout gap that let the objectui#1763/#2545 class of renderer/spec key drift
survive undetected. New `packages/spec/liveness/view.json` classifies all 83
walkable properties (75 ledger entries + framework overlay fields): the `list`
and `form` containers are drilled one level via `children`.

Seeded from the 2026-06 viewschema audit and **re-verified against objectui
HEAD** — four audit-era DEAD findings had since gone live and are classified
from current reads (`form.submitBehavior`, `list.sharing.lockedBy`, list-path
`ViewData` providers, and the post-ADR-0021 `list.chart` dataset shape — the
audit's "chart renderers never migrated" headline is resolved). Final tally:
68 live, 2 experimental (`form.buttons`/`form.defaults`, #2998 Track A
awaiting objectui#2545), 5 dead (`list.responsive`, `list.performance`,
`form.data`, `form.defaultSort`, `form.aria`). All misleading dead props
carry `authorWarn` + `authorHint`.

The CLI's compile-time liveness lint gains `view` coverage
(`TYPE_COLLECTIONS` + view containers labelled by `object`), so authoring a
dead prop — e.g. a spec-valid `chart` list view that renders empty — now warns
at `os build` with a corrective hint.
37 changes: 37 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,41 @@ describe('lintLivenessProperties', () => {
});
expect(findings).toEqual([]);
});

// ── view (#2998 Track B) ──────────────────────────────────────────────────

it('warns on dead list-level responsive config (view ledger, list.responsive)', () => {
const findings = lintLivenessProperties({
views: [{ object: 'task', list: { type: 'grid', responsive: { breakpoint: 'md' } } }],
});
const f = findings.find((x) => x.message.includes('list.responsive'));
expect(f).toBeDefined();
expect(f!.where).toBe("view 'task'"); // container binds via `object`, not `name`
expect(f!.hint.length).toBeGreaterThan(0);
});

it('warns on form.data and form.defaultSort but not on live form config', () => {
const findings = lintLivenessProperties({
views: [{
object: 'task',
form: {
type: 'wizard',
sections: [{ fields: ['title'] }],
data: { provider: 'object', object: 'task' },
defaultSort: [{ field: 'created_at' }],
},
}],
});
const msgs = paths(findings);
expect(msgs.some((m) => m.includes('form.data'))).toBe(true);
expect(msgs.some((m) => m.includes('form.defaultSort'))).toBe(true);
expect(msgs.some((m) => m.includes('form.sections') || m.includes('form.type'))).toBe(false);
});

it('stays silent on a clean grid view', () => {
const findings = lintLivenessProperties({
views: [{ object: 'task', list: { type: 'grid', columns: ['title'] } }],
});
expect(findings).toEqual([]);
});
});
6 changes: 5 additions & 1 deletion packages/cli/src/utils/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
{ type: 'permission', key: 'permissions' },
{ type: 'hook', key: 'hooks' },
{ type: 'page', key: 'pages' },
{ type: 'view', key: 'views' },
];

/**
Expand Down Expand Up @@ -214,7 +215,10 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] {
const warnMap = loadWarnMap(dir, type);
if (warnMap.size === 0) continue;
for (const item of asArray(stack[key])) {
const name = typeof item.name === 'string' ? item.name : `(unnamed ${type})`;
// view containers bind via `object`, not `name`
const name = typeof item.name === 'string' ? item.name
: typeof item.object === 'string' ? item.object
: `(unnamed ${type})`;
checkItem(type, item, `${type} '${name}'`, warnMap, findings);
}
}
Expand Down
7 changes: 4 additions & 3 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill.
| Master-detail controlled-by-parent | ✅ enforced | `object.sharingModel` | `controlled-by-parent.dogfood.test.ts#cbp-controlled-by-parent` |
| Flow nodes | ✅ enforced | `flow.nodes.type` | `flow-node.dogfood.test.ts#flow-node-execution` |
| Analytics dims/measures | ✅ enforced | `dataset.dimensions.dateGranularity` | `analytics-timezone.dogfood.test.ts#analytics-tz-bucketing` |
| Form layout/section/widget | ⛔ pending | — | none yet (form surface not yet governed) |
| Form layout/section/widget | ⛔ pending | — | none yet (`view` is governed as of #2998 Track B, so `view.form.*` can carry the binding — the dogfood proof is the missing half) |

To bind a pending class: add its dogfood proof + `@proof:` tag, set `bound: true` and
its `ledgerBindings` in `proof-registry.mts`, add the `proof` to the ledger entry, and
Expand Down Expand Up @@ -166,7 +166,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type
RecordDetailView had been gating the History tab on it the whole time (#2707).
4. Add the type to `GOVERNED`; confirm the gate is green.

## Current state — 12 governed types
## Current state — 13 governed types

Counts include drilled `children` entries; regenerate with the snippet below rather
than hand-editing (this table drifted badly once — field was listed 34/39 while the
Expand Down Expand Up @@ -201,8 +201,9 @@ EOF
| skill | 10 | – | – | – | fully live |
| dataset | 19 | – | 1 | – | `measures.certified` declared-but-unenforced governance flag |
| page | 16 | – | – | 1 | fully live + one planned |
| view | 68 | 2 | 5 | – | list/form drilled via `children` (#2998 Track B); dead = list.{responsive,performance} + form.{data,defaultSort,aria}, all but aria authorWarn'd; exp = form.{buttons,defaults} awaiting objectui#2545; audit-era DEAD lines superseded by re-verification (submitBehavior, sharing.lockedBy, list ViewData providers, and the ADR-0021 chart shape — all live now); level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only |

The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every
misleading entry carries `authorWarn` so authors hear about it at compile time.
Not yet governed (rollout): view, dashboard, app, report, job, datasource,
Not yet governed (rollout): dashboard, app, report, job, datasource,
translation, email_template, doc, book, validation, seed.
Loading