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
50 changes: 50 additions & 0 deletions .changeset/18835-list-view-unwalked-field-naming-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/lint": minor
---

fix(lint)!: `list-view-field-unknown` walks the four field-naming keys that had no position row at all

Clause-②: no (narrowing)

**BREAKING** — an accept-set narrowing on the list-view authoring surface. Four declared, authorable field-naming keys had no row in `POSITIONS` in `validate-list-view-field-refs.ts`, so a misspelt field name at any of them cleared the schema door, was walked by nothing, and was dropped by the renderer. From this release each is judged, and two of the four gate `validate` and `build`, so a stack that built yesterday with one of those two misspelt does not build now. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings.

<!-- adr-0087: not-required (no-migration-prescription) nothing is removed and nothing is renamed. Every spelling that parsed before still parses, no stored value has to be rewritten to a different one, and the correct name is whatever the bound object declares -- so there is no mapping for the ledger to carry and `objectstack migrate meta` has nothing to apply. -->

## The keys, and why the tier is not the same for all four

All four are `z.string().optional()` on their config schema, and the schema shape is deliberately not what tiers them — the tier is the consequence, read per key off its own `.describe()` and its renderer (measured in objectui `dda8f3815`).

| key | declared at | tier | what a misspelt name does |
|:--|:--|:--|:--|
| `calendar.allDayField` | `CalendarConfigSchema` | `warning` | `ObjectCalendar` maps each event with `allDay: allDayField ? Boolean(record[allDayField]) : !endDate`, so every row reads `undefined` and no event is banded — and the renderer's own no-end-date inference is switched off by the key's mere presence. Every event still renders, at its start time: one decoration dropped. |
| `gantt.borderColorField` | `GanttConfigSchema` | `warning` | `borderColorRaw = borderColorField ? record[borderColorField] : undefined` leaves `borderColor` undefined for every task. Every bar keeps its fill and renders without its alert outline — `colorField`'s case. |
| `gantt.lockField` | `GanttConfigSchema` | **`error`** | A declared WRITE GUARD that fails OPEN. `locked: lockField ? !!record[lockField] : undefined` reads `undefined` on every row, and the drawer's `recLocked` falls the same way, so every row the author froze becomes draggable, resizable, progress-draggable, link-able, inline-editable and deletable — and the drag persists. |
| `gantt.objectField` | `GanttConfigSchema` | **`error`** | `isSyntheticRow` is `!!objectField && !String(rec[objectField] ?? '').trim()`, so a name no record carries answers TRUE for every row. `onTaskClick` never calls `navigation.handleClick` and `renderRecordOverlay` returns null: no bar in the chart opens a drawer or a detail page. |

The two `error` rows are a consequence the rule's own severity note did not name and now does: a binding whose job is to RESTRICT or to ROUTE, where the miss is read as "no restriction" / "no route" on every row. Nothing is missing from the picture, which is exactly why it gates — it is the shape Prime Directive #10 names, a capability advertised in the metadata and not delivered by the runtime. Both are also worse DECLARED than omitted, because each renderer guards its behaviour on the key's mere presence.

## Measured, one list view carrying every walked position, one mutation at a time

| probe | before | after |
|:--|:--|:--|
| `calendar.allDayField` naming a field that does not exist | silent | `warning` at `views[0].list.calendar.allDayField` |
| `gantt.borderColorField` naming a field that does not exist | silent | `warning` at `views[0].list.gantt.borderColorField` |
| `gantt.lockField` naming a field that does not exist | silent | `error` at `views[0].list.gantt.lockField` |
| `gantt.objectField` naming a field that does not exist | silent | `error` at `views[0].list.gantt.objectField` |
| each of the four naming a REAL field | silent | silent |
| the other 53 walked-position probes | 53 reported, each at its severity | the same 53, each at its same severity |
| the clean fixture carrying all four bound to real fields | 0 findings | 0 findings |

Over this repository's own tree the finding count is **0 before and 0 after**: no example app, fixture or seed authors any of the four keys at all (`git grep` over every tracked file finds the spec declaration, its own schema tests and the generated reference docs, and nothing else), so nothing existing starts reporting.

## What an author does about a report

Nothing is renamed and nothing is removed — every spelling that was valid is still valid, and no stored value has to be rewritten to a different one. What changes is that a name which resolves to no field on the bound object is now reported instead of being dropped in silence.

There is no mapping to apply, and deliberately so: the correct spelling is whatever the bound object declares, which only that object knows. The remedy is always the same — name a field the object actually has, or drop the key — and the finding carries the object's own field list plus a "did you mean" suggestion, so the message itself names the spelling to write.

## Scope — what is deliberately NOT changed

- **No dotted verdict.** The four positions join `POSITIONS` and deliberately not `DOTTED_AXIS`: none of them reaches a query door this rule measured, so a dotted name at one of them is unjudged, exactly as every other renderer binding is. Pinned.
- **No other surface.** `listViews`, `recordTypes` and the other label/field-naming surfaces are untouched; widening to them is a measurement, not a corollary.
- **No new rule id, no message shape change, no severity change for any existing position.** `list-view-field-unknown` gains four more places it can be reported from.
133 changes: 131 additions & 2 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ const OBJECTS = [
{ name: 'payload', type: 'json', label: 'Payload' },
{ name: 'score', type: 'formula', label: 'Score' },
{ name: 'tags', type: 'text', multiple: true, label: 'Tags' },
// [#18835] The targets for the four field-naming keys that had no
// position row, each declared at the type its own `.describe()` names —
// `allDayField` / `lockField` say "boolean field", `objectField` carries
// an object api name and `borderColorField` a colour string.
{ name: 'is_all_day', type: 'boolean', label: 'All day' },
{ name: 'is_locked', type: 'boolean', label: 'Locked' },
{ name: 'alert_color', type: 'text', label: 'Alert colour' },
{ name: 'row_object', type: 'text', label: 'Row object' },
],
},
{
Expand Down Expand Up @@ -81,6 +89,7 @@ const FULL_LIST_VIEW: AnyRec = {
endDateField: 'visible_from',
titleField: 'title',
colorField: 'status',
allDayField: 'is_all_day',
},
gantt: {
startDateField: 'visible_from',
Expand All @@ -98,6 +107,9 @@ const FULL_LIST_VIEW: AnyRec = {
effortField: 'estimate',
tooltipFields: ['status', { field: 'business_unit' }],
quickFilters: [{ field: 'status' }],
borderColorField: 'alert_color',
lockField: 'is_locked',
objectField: 'row_object',
},
timeline: {
startDateField: 'visible_from',
Expand Down Expand Up @@ -235,6 +247,10 @@ describe('#14107 — every other walked position', () => {
[{ calendar: { titleField: BAD } }, 'views[0].list.calendar.titleField', 'warning'],
[{ calendar: { colorField: BAD } }, 'views[0].list.calendar.colorField', 'warning'],
[{ calendar: { startDateField: BAD } }, 'views[0].list.calendar.startDateField', 'error'],
// [#18835] A declared `allDayField` is absolute — it switches the
// renderer's own all-day inference off — so a miss un-bands every event.
// They all still render, at their start time: one decoration.
[{ calendar: { allDayField: BAD } }, 'views[0].list.calendar.allDayField', 'warning'],
[{ gantt: { endDateField: BAD } }, 'views[0].list.gantt.endDateField', 'error'],
[{ gantt: { titleField: BAD } }, 'views[0].list.gantt.titleField', 'error'],
[{ gantt: { progressField: BAD } }, 'views[0].list.gantt.progressField', 'warning'],
Expand All @@ -247,6 +263,13 @@ describe('#14107 — every other walked position', () => {
[{ gantt: { groupByField: BAD } }, 'views[0].list.gantt.groupByField', 'warning'],
[{ gantt: { assigneeField: BAD } }, 'views[0].list.gantt.assigneeField', 'warning'],
[{ gantt: { effortField: BAD } }, 'views[0].list.gantt.effortField', 'warning'],
// [#18835] The three objectui-lifted field bindings, on three different
// levels — see each row's note in the rule. The stroke is a decoration;
// the lock is a write guard that fails open; `objectField` makes every row
// answer the synthetic-row test, so nothing in the chart opens.
[{ gantt: { borderColorField: BAD } }, 'views[0].list.gantt.borderColorField', 'warning'],
[{ gantt: { lockField: BAD } }, 'views[0].list.gantt.lockField', 'error'],
[{ gantt: { objectField: BAD } }, 'views[0].list.gantt.objectField', 'error'],
[{ gantt: { tooltipFields: [BAD] } }, 'views[0].list.gantt.tooltipFields[0]', 'warning'],
[
{ gantt: { tooltipFields: [{ field: BAD }] } },
Expand Down Expand Up @@ -284,8 +307,104 @@ describe('#14107 — every other walked position', () => {

// A floor, so a position quietly dropped from the rule's table cannot pass
// by simply never being asserted.
//
// [#18835] Raised by the four rows this card adds — `calendar.allDayField`
// and the three objectui-lifted `gantt` bindings. The number moves by what
// was added and nothing else; the floor's own semantics are untouched.
it('covers every position the rule walks', () => {
expect(cases.length).toBeGreaterThanOrEqual(47);
expect(cases.length).toBeGreaterThanOrEqual(51);
});
});

/**
* [#18835] The four declared, authorable field-naming keys that had no row in
* `POSITIONS` at all: `calendar.allDayField` and the three objectui-lifted
* `gantt` bindings (`borderColorField`, `lockField`, `objectField`). Each was
* admitted by the schema, walked by nothing, and dropped by the runtime.
*
* All four are `.optional()`, and that is deliberately NOT what tiers them —
* the tier is the consequence, read per key off its own `.describe()` and its
* renderer (objectui `dda8f3815`). Two land in each tier, and the block below
* asserts the tiers where they are felt: `validate` / `build`.
*
* Each key is pinned SEPARATELY, in both directions. One key proven does not
* generalise to the other three: they are four different renderer behaviours
* that happen to share a schema shape.
*/
describe('#18835 — the four keys with no position row', () => {
const KEYS: Array<[string, (bad: string) => AnyRec, string, 'error' | 'warning', string]> = [
[
'calendar.allDayField',
(v) => ({ calendar: { allDayField: v } }),
'views[0].list.calendar.allDayField',
'warning',
'is_all_day',
],
[
'gantt.borderColorField',
(v) => ({ gantt: { borderColorField: v } }),
'views[0].list.gantt.borderColorField',
'warning',
'alert_color',
],
[
'gantt.lockField',
(v) => ({ gantt: { lockField: v } }),
'views[0].list.gantt.lockField',
'error',
'is_locked',
],
[
'gantt.objectField',
(v) => ({ gantt: { objectField: v } }),
'views[0].list.gantt.objectField',
'error',
'row_object',
],
];

for (const [label, patch, path, severity, realField] of KEYS) {
it(`${label} naming a field that does not exist reports at \`${severity}\``, () => {
const findings = validateListViewFieldRefs(stackWith(mutate(patch('zz_no_such_field'))));
expect(idsOf(findings)).toEqual([path]);
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN);
expect(findings[0].severity).toBe(severity);
expect(findings[0].message).toContain('is not a field on object "duly_task"');
expect(findings[0].hint).toContain('Fields on "duly_task"');
});

// The dark half, per key: a real field is silent, so the rows report the
// MISS and not the key's presence.
it(`${label} naming a real field ("${realField}") stays silent`, () => {
expect(validateListViewFieldRefs(stackWith(mutate(patch(realField))))).toEqual([]);
});

const arm = severity === 'error' ? 'gates' : 'advises';
it(`${label} ${arm} \`build\``, () => {
const normalized = stackWith(mutate(patch('zz_no_such_field')));
const { errors, advisories } = splitBySeverity(runAuthoringRules('build', { normalized }));
const gated = errors.some((f) => f.path === path);
const advised = advisories.some((f) => f.path === path);
expect([gated, advised]).toEqual(severity === 'error' ? [true, false] : [false, true]);
});
}

it('the reference-integrity suite carries all four', () => {
// One stack, all four missed at once: the suite reports four findings and
// not one, so no key rides on a neighbour's row.
const stack = stackWith(
mutate({
calendar: { allDayField: 'zz_a' },
gantt: { borderColorField: 'zz_b', lockField: 'zz_c', objectField: 'zz_d' },
}),
);
const mine = validateReferenceIntegrity(stack).filter((f) => f.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(mine.map((f) => f.path).sort()).toEqual([
'views[0].list.calendar.allDayField',
'views[0].list.gantt.borderColorField',
'views[0].list.gantt.lockField',
'views[0].list.gantt.objectField',
]);
});
});

Expand Down Expand Up @@ -535,8 +654,18 @@ describe('#14282 — the measured exclusions: positions read CLIENT-SIDE', () =>
mutate({
rowColor: { field: 'owner.name' },
kanban: { groupByField: 'owner.name' },
calendar: { titleField: 'owner.name' },
calendar: { titleField: 'owner.name', allDayField: 'owner.name' },
gallery: { coverField: 'owner.name' },
// [#18835] The four positions this card adds are in POSITIONS and
// deliberately NOT in DOTTED_AXIS, which is what "a position added to
// the surface map does not silently acquire a dotted verdict nobody
// measured" means in practice. Pinned here so the default is a
// decision rather than an omission.
gantt: {
borderColorField: 'owner.name',
lockField: 'owner.name',
objectField: 'owner.name',
},
tree: { parentField: 'owner.name' },
grouping: { fields: [{ field: 'owner.name' }] },
hiddenFields: ['owner.name'],
Expand Down
52 changes: 52 additions & 0 deletions packages/lint/src/validate-list-view-field-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@
* card into the uncolumned bucket; a `gantt` / `calendar` / `timeline`
* required date field leaves the renderer nothing to place, so the chart is
* blank; a map with no resolvable coordinate field plots no marker.
* [#18835] A third consequence belongs to this tier and the two sentences
* above did not name it: a binding whose job is to RESTRICT or to ROUTE,
* where the miss is read as "no restriction" / "no route" on every row.
* Nothing is missing from the picture — which is exactly why it gates: a
* `gantt.lockField` that resolves to nothing unfreezes every row the author
* declared view-only, and a `gantt.objectField` that resolves to nothing
* makes every row answer the renderer's synthetic-row test, so no bar in
* the chart opens anything. Both are the shape Prime Directive #10 names —
* a capability advertised in the metadata and not delivered by the runtime
* — and both are worse DECLARED than omitted, because each renderer guards
* its behaviour on the key's mere presence.
* - **`warning`** — the renderer drops one decoration and renders the rest:
* an optional colour / title / tooltip / cover binding, a stale
* `hiddenFields` entry that hides nothing, a stale `fieldOrder` entry that
Expand Down Expand Up @@ -345,6 +356,19 @@ const POSITIONS: Record<string, BlockPositions> = {
endDateField: 'warning',
titleField: 'warning',
colorField: 'warning',
// [#18835] `allDayField` names a BOOLEAN field, and a declared one is
// ABSOLUTE — the spec's own `⛔ No default field name` note. Measured in
// objectui `dda8f3815`: `ObjectCalendar` maps every event with
// `allDay: allDayField ? Boolean(record[allDayField]) : !endDate`, so a
// name no record carries reads `undefined` on every row, `Boolean()`
// answers false for all of them, AND the `!endDate` inference that would
// have banded a dateless event is switched off by the key's mere
// presence. Every event still renders and is still placed at its start:
// what is lost is the all-day BAND, one decoration, which is this tier
// and not `startDateField`'s (nothing to place at all). Not the error
// tier's third consequence either — no restriction and no route rides on
// it; a wrongly-banded event is still an event the user can click.
allDayField: 'warning',
},
},
gantt: {
Expand All @@ -362,6 +386,34 @@ const POSITIONS: Record<string, BlockPositions> = {
groupByField: 'warning',
assigneeField: 'warning',
effortField: 'warning',
// ── [#18835] Three of the ten objectui-lifted members (#15469) are
// field bindings, and they share only their `.optional()`. Each is tiered
// by what its own renderer does with a name no record carries, measured
// in objectui `dda8f3815` (`plugin-gantt/src/ObjectGantt.tsx`).
//
// The stroke is opt-in and exceptional, and the renderer says so:
// `borderColorRaw = borderColorField ? record[borderColorField] :
// undefined` leaves `borderColor` undefined for every task, so every bar
// keeps its fill and renders without an outline. `colorField`'s case,
// eight rows up — one decoration dropped, the chart intact.
borderColorField: 'warning',
// ⛔ NOT a decoration: a declared WRITE GUARD that fails OPEN.
// `locked: lockField ? !!record[lockField] : undefined` reads `undefined`
// on every row and the drawer's `recLocked` falls the same way, so every
// row the author froze becomes draggable, resizable, progress-draggable,
// link-able, inline-editable and deletable — and the drag PERSISTS. The
// chart looks perfect and the restriction the author declared is simply
// not in force, which is the error tier's third consequence above.
lockField: 'error',
// The row's OWN object api name, and a miss takes the whole detail
// surface with it: `isSyntheticRow` is `!!objectField &&
// !String(rec[objectField] ?? '').trim()`, so a name no record carries
// answers TRUE for every row. `onTaskClick` then never calls
// `navigation.handleClick` and `renderRecordOverlay` returns null — no
// bar in the chart opens a drawer or a detail page, uniformly and
// silently. Strictly worse than omitting the key, which is what the
// `!!objectField &&` guard exists to make safe.
objectField: 'error',
},
// A quick filter is a FILTER: a stale one filters on a column that does
// not exist and empties the chart.
Expand Down
Loading