Skip to content
Merged
67 changes: 67 additions & 0 deletions .changeset/liveness-widget-drill-and-container-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": patch
"@objectstack/lint": patch
---

fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956)

The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live`
verdict plus a `note` asserting that the per-widget props were *"classified in
the DashboardWidgetSchema subtree"*. **No such subtree ever existed.** The gate's
walk drills one level and only through an explicit `children`, and `widgets`
declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema`
were never classified, never counted as unclassified, and every run printed
"all governed-type properties are classified" anyway.

That gap — not evidence — is what carried `widgets[].responsive` through the
#3896 inert-key sweep that removed both its sibling `widgets[].performance` and
its literal namesake `view.responsive`. `view` is drilled through `children`, so
`list.responsive` got asked and went out; `widgets` was never asked. It was
finally retired in #4876 / PR #4995, by hand, four days late.

**What changed for authors**

The `objectstack build` / `objectstack lint` advisory now covers dashboards, so
five widget keys warn at build time (they never did before — `dashboard` was not
in the lint's type collections, because until now its ledger warned on nothing):

| Widget key | Why it warns | What to do instead |
| :--- | :--- | :--- |
| `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance |
| `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` |
| `widgets[].actionType` | pairs with the above | as above |
| `widgets[].actionIcon` | zero readers in either repo | as above |
| `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` |

Advisory only — the build never fails on these. **Nothing is removed and no
runtime behaviour changes**: this records verdicts, it does not act on them.
Enforce-or-remove (ADR-0049) for the five is tracked separately.

Two verdicts worth knowing because they cut the other way: `requiresService` is
**live** — it reads as inert in the renderer repo but the REST layer strips
widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live
on the inline chart path only; on the ADR-0021 dataset path the string arms are
dropped and `{ offset }` fails in the analytics executor.

**What changed for the gate**

`pnpm --filter @objectstack/spec check:liveness` gains a third direction. A
ledger entry sitting on a container property must now declare one of exactly
three dispositions, all of them data: **drilled** (`children`), **deferred** (a
`{ container, to }` row naming the coordinate that does classify the subtree),
or **recorded** (a row in the shrink-only
`scripts/liveness/undrilled-containers.baseline.json`). A container in none of
the three fails, and so does a baseline row whose container has since been
drilled.

A deferral is **resolved, not believed** — the target must exist (a governed
type root, or a drilled `type/prop` coordinate) and classify exactly the
container's child keys; a dangling or drifted target fails. That is the #4956
claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema`
now produces a build failure naming it, where the same words in a `note` were
believed for a release.

Every run reports both populations (today: 58 containers / 292 child keys
classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled`
prints the worklist, and the success line no longer claims a completeness it
does not have.
73 changes: 73 additions & 0 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,4 +400,77 @@ describe('lintLivenessProperties', () => {
});
expect(findings).toEqual([]);
});

// ── #4956: the dashboard widget subtree ────────────────────────────────────
//
// These assertions are what make the drill worth doing on the AUTHOR side.
// Until #4956 the ledger classified `dashboard.widgets` with one blanket
// `live` and claimed in prose that the per-widget keys were classified in a
// "DashboardWidgetSchema subtree" that never existed — so no widget key had a
// verdict, and this lint (which is ledger-driven by design) had nothing to
// say about any of them. Two things had to change together: the ledger gained
// 22 child verdicts, and `dashboard` was registered in TYPE_COLLECTIONS.
// Registering the type is the half that is easy to forget and impossible to
// notice — the ledger would read correct and warn nobody.
describe('dashboard widgets (#4956)', () => {
const dash = (widget: Record<string, unknown>) => ({
dashboards: [{
name: 'sales_overview',
label: 'Sales',
widgets: [{ id: 'total_pipe', type: 'metric', dataset: 'orders', values: ['total'], ...widget }],
}],
});

it('warns on a widget action button that no renderer draws (`actionUrl`)', () => {
const findings = lintLivenessProperties(dash({ actionUrl: '/apps/sales/orders' }));
const hit = findings.find((f) => f.message.includes('widgets.actionUrl'));
expect(hit).toBeDefined();
expect(hit!.where).toContain('sales_overview');
expect(hit!.hint).toMatch(/header\.actions/);
});

it('warns on `colorVariant`, the key this repo\'s own system dashboard authors 7 times', () => {
const findings = lintLivenessProperties(dash({ colorVariant: 'teal' }));
const hit = findings.find((f) => f.message.includes('widgets.colorVariant'));
expect(hit).toBeDefined();
// The hint has to name the surviving home, or the author reads it as
// "widgets cannot be coloured".
expect(hit!.hint).toMatch(/options/);
});

it('warns on a widget `aria` block that never reaches the DOM', () => {
const findings = lintLivenessProperties(dash({ aria: { ariaLabel: 'Total pipeline' } }));
expect(findings.map((f) => f.message).some((m) => m.includes('widgets.aria'))).toBe(true);
});

it('fans out over EVERY widget, not just the first', () => {
const findings = lintLivenessProperties({
dashboards: [{
name: 'ops',
widgets: [
{ id: 'a', type: 'metric', dataset: 'd', values: ['v'] },
{ id: 'b', type: 'metric', dataset: 'd', values: ['v'], actionIcon: 'plus' },
],
}],
});
// The dead key is on the SECOND widget — a walk that only looked at
// `widgets[0]` would be silently half-blind on every real dashboard.
expect(findings.map((f) => f.message).some((m) => m.includes('widgets.actionIcon'))).toBe(true);
});

it('stays silent on a widget built entirely from live keys', () => {
const findings = lintLivenessProperties(dash({
title: 'Total Pipe',
dimensions: ['region'],
filter: { stage: 'closed_won' },
layout: { x: 0, y: 0, w: 3, h: 2 },
options: { limit: 10, sortBy: 'total' },
requiresObject: 'order',
requiresService: 'analytics',
filterBindings: { dateRange: 'closed_at' },
suppressWarnings: ['table-count-only'],
}));
expect(findings).toEqual([]);
});
});
});
9 changes: 9 additions & 0 deletions packages/lint/src/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
{ type: 'email_template', key: 'emailTemplates' },
{ type: 'mapping', key: 'mappings' },
{ type: 'translation', key: 'translations' },
// #4956 — dashboard joins the list the moment its ledger first warns on
// anything, which is exactly the rule the comment above states. Drilling
// `widgets` produced five warned keys (`colorVariant`, `actionUrl`,
// `actionType`, `actionIcon`, `aria`), all under `widgets[]`; `getNested`
// fans a dotted path out over an array level, so `widgets.colorVariant`
// checks every widget on the dashboard. Registering it here is not optional
// bookkeeping: without it the ledger would be newly correct and newly
// silent, which is the shape this lint exists to prevent.
{ type: 'dashboard', key: 'dashboards' },
];

/**
Expand Down
Loading
Loading