Skip to content

Commit 939f3ea

Browse files
os-billclaude
andauthored
fix(lint): walk kanban.titleField at calendar's level — the one item-titled face with no position row (#18833)
Fixes #18565 `POSITIONS` in `packages/lint/src/validate-list-view-field-refs.ts` declares, per view face, which field-reference keys are walked and at what level. `kanban` was the only item-titled face with no `titleField` row. Since #16894 the key is authorable on `KanbanConfigSchema`, so a misspelt field name cleared the schema door, was walked by nothing, and left the board titled from the ADR-0079 display-name chain instead of the field the author named — a wrong value on a board that renders correctly, reported by nothing, while the byte-identical typo one block away on `calendar` or `timeline` was reported. Clause-②: no Adding a position row to an internal table. No published schema shape moves, nothing previously accepted is refused, and the authorable accept set stays `packages/spec`'s — untouched here. ## Where the symbols actually are Anchored on symbols, not on the card's line numbers, and then compared with them. Read on this branch's base `a7bafc29af`: | symbol | the card's table | read on `a7bafc29af` | |:--|:--|:--| | `const POSITIONS` | — | `:310` | | `kanban:` block | `:322` | `:322` — `scalars: { groupByField: 'error', summarizeField: 'warning' }`, no `titleField` | | `calendar.titleField` | `:332` | `:332` | | `gantt.titleField` | `:340` | `:340` | | `timeline.titleField` | `:359` | `:359` | | `gallery` / `map` `titleField` | one row, `:366` | two rows: gallery `:366`, map `:375` | The card's numbers hold; only its last row merges two faces that sit nine lines apart. ## Question 1 — which level, and why it is not name similarity Every item-titled face's `titleField` row, side by side with the schema that declares the key: | face | `POSITIONS` row (`a7bafc29af`) | level | declaration in `packages/spec/src/ui/view.zod.ts` | the key is | |:--|:--|:--|:--|:--| | **kanban** | **absent** (block at `:322`) | **none** | `:1467` `z.string().optional()` | optional | | calendar | `:332` | `warning` | `:1523` `z.string().optional()` | optional | | gallery | `:366` | `warning` | `:1142` `z.string().optional()` | optional | | map | `:375` | `warning` | `:1761` `z.string().optional()` | optional | | timeline | `:359` | `error` | `:1156` `z.string()` | **required** | | gantt | `:340` | `error` | `:1606` `z.string()` | **required** | The siblings do not in fact disagree per face. The split is exactly the schema's own requiredness: the two faces that spell `titleField` required take `error`, the three that spell it optional take `warning`. That is the rule's own two-tier line, applied — `error` is "the miss changes what data the view returns, or collapses the layout it configures", `warning` is "the renderer drops one decoration and renders the rest: an optional colour / title / tooltip / cover binding". kanban's shape is measured isomorphic to the **calendar** row, on three counts: 1. **The schema.** `KanbanConfigSchema.titleField` is `z.string().optional()` (`:1467`), the same as calendar's (`:1523`), not the bare `z.string()` of timeline (`:1156`) and gantt (`:1606`). 2. **The declaration's own words.** #16894's docblock on that key says it is "OPTIONAL, deliberately — the shape `CalendarConfigSchema` already writes down for this exact key", and names `TimelineConfigSchema` and `GanttConfigSchema` as "the two siblings this declaration does NOT copy". 3. **The renderer, measured rather than assumed.** In objectui at `dda8f3815`, `packages/plugin-kanban/src/ObjectKanban.tsx` resolves the card title as: `resolveKanbanTitleField(schema)` returns the written name, the card reads `rec[titleField]`, gets nothing for a name no record carries, and falls through to `getRecordDisplayName` — "objectDef.titleFormat, objectDef.displayNameField, type-aware field derivation, `Record #id` floor" (its own comment, the ADR-0079 chain). The drawer heading takes the same resolver. Every card still renders; one decoration is wrong. So: `titleField: 'warning'` on `kanban`. Compare `kanban.groupByField`, which is `error` on the other branch of that same line — a bad group-by collapses every card into one uncolumned lane, which is layout, not decoration. ## Question 2 — does the new row red existing metadata? No. BEFORE reading taken. Two instruments, each with its own non-zero control. **(a) The rule, run over the repo's own example apps, before and after.** Stacks loaded from `objectstack.config.ts` (`app-crm`, `app-todo`, `app-multi-package`) and from the objects/views barrels (`app-showcase`): | corpus | kanban blocks | kanban blocks carrying `titleField` | findings BEFORE | findings AFTER | |:--|:--|:--|:--|:--| | `app-crm` | 2 | 0 | 0 | 0 | | `app-todo` | 0 | 0 | 0 | 0 | | `app-multi-package` | 0 | 0 | 0 | 0 | | `app-showcase` | 3 | 0 | 0 | 0 | Control that the walker really reaches these stacks: pointing an existing `calendar.titleField` at a bogus name reports `warning list-view-field-unknown` at `views[0].listViews.calendar.calendar.titleField` (crm) and `views[5].list.calendar.titleField` (showcase) — both BEFORE and AFTER. **(b) A repo-wide census of authored `kanban` blocks**, brace-matched rather than line-matched, over every `ts/tsx/js/mjs/cjs/json/mdx/md/yaml/yml` file outside `node_modules`, `dist`, `.git`, `.turbo`, `.cache`: 82 `kanban` blocks, 26 of which mention `titleField` — and all 26 are generated JSON Schema under `packages/spec/json-schema/**`, i.e. the schema DECLARATION of the key from #16894, not authored view metadata. Non-zero controls from the same scanner on the same run: calendar 50, gantt 47, timeline 43, gallery 36, map 35 blocks carrying the key, first hits in real example views. So the row starts walking a key that no authored view in this repository writes today: nothing existing turns red, and no view file needed editing (none was edited). ## LIT — the behaviour flip One list view carrying every walked position, one mutation at a time, rule source imported directly (no `dist` in the path, so no stale build can fake either reading): | probe | BEFORE | AFTER | |:--|:--|:--| | `kanban.titleField: 'ttle'` (no such field) | **silent, 0 findings** | **`warning` `list-view-field-unknown` at `views[0].list.kanban.titleField`** — "titleField "ttle" is not a field on object "duly_task". Did you mean "title"? …" | The same flip on real corpus metadata: injecting `titleField: 'zz_no_such_field'` into the boards of `app-crm` and `app-showcase` reports 0 findings BEFORE and 1 `warning` AFTER, at `views[1].listViews.pipeline.kanban.titleField` and `views[4].listViews.by_status.kanban.titleField`. ## DARK — nothing else moved - A kanban view whose `titleField` names a **real** field: 0 findings BEFORE, 0 findings AFTER. - The full clean surface (every walked position bound to a real field, `kanban.titleField` included): 0 findings BEFORE and AFTER. - All 52 positions probed one at a time: **BEFORE 51 reported / 1 silent** (that one silent position being `kanban.titleField`), **AFTER 52 reported / 0 silent**. Every one of the other 51 keeps its identical severity — the before/after diff of the probe output is three hunks, all three naming `kanban.titleField` and nothing else. The 51 non-zero readings are this probe's own control: the single zero was a true zero. ## Changeset — decided by measuring published bytes `@objectstack/lint`'s `files[]` is `["dist", "README.md", "CHANGELOG.md"]`, so the question is whether this source file reaches `dist`. Measured, not inferred: `summarizeField` — a row of this very table — occurs in `dist/index.js`, `dist/index.cjs` and `dist/runtime.js`; positive control `list-view-field-unknown` occurs twice in each; negative control `duly_task` (a symbol that exists only in the test file) occurs zero times in each. After the change, `dist/index.js:8605` reads `scalars: { groupByField: "error", summarizeField: "warning", titleField: "warning" }`. The change ships, so it carries a changeset (`minor` — a rule reports where it was silent, no id and no severity moves elsewhere). ## Verification Everything below at `02c18bfcc2`, which is this PR's tip. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-list-view-field-refs.test.ts` — 113 passed. - `pnpm --filter @objectstack/lint test` — 104 files, 3885 tests passed. - `pnpm --filter @objectstack/lint typecheck` — `tsc --noEmit` plus `check:test-typecheck`, exit 0. - `pnpm --filter '@objectstack/lint...' build` — the package and its upstream closure, exit 0. - `pnpm lint` (`eslint . --no-inline-config`, the whole repo) — exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 58 families for this diff; all 58 were run and reconciled with `--ran`: **55 exit 0, 3 NOT MEASURED, 0 red**. The three are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`, each exiting 3 with `PREREQUISITE NOT MET` because they read a built `dist` for the whole workspace, which this worktree does not have (only lint's closure was built). CI builds that closure before those steps. ## Acceptance notes - **The test file was declared read-only by the dispatching claim, and this PR edits it anyway — in its own commit, `02c18bfcc2`, so it can be dropped alone.** The claim's open surface is the rule file plus derivatives; the executor's own binding rules require the change to carry test coverage, and the suite's own docblock calls its severity table "the readable half of the rule's own POSITIONS table". Measured before touching it: across all 28 open PRs, zero hold either `validate-list-view-field-refs.ts` or its test (live control: `scripts/check-cross-package-test-inputs.mjs` reads `[18817]`), so nothing collides. The rule commit stands alone without it and the suite is green either way. The seat decides. - The floor in that test (`expect(cases.length).toBeGreaterThanOrEqual(47)`) is a floor: it catches a case DROPPED from the test table, and it does not catch a position ADDED to the rule without a row here — which is what the docblock above it claims it does. Noted here and handed back, not folded in. - Four more field-naming keys are declared on view-face configs and walked by no `POSITIONS` row — measured silent with live controls, same class as this card, handed back rather than fixed here: `calendar.allDayField`, `gantt.borderColorField`, `gantt.lockField`, `gantt.objectField`. - The card's "more durable half" — whether `POSITIONS` should be reconciled against the spec member lists by a gate rather than by hand — is untouched here, as the card asks. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42f8df1 commit 939f3ea

3 files changed

Lines changed: 44 additions & 3 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint): `list-view-field-unknown` walks `kanban.titleField` — the one item-titled face the position table never listed (#18565)
6+
7+
Clause-②: no
8+
9+
`POSITIONS` in `validate-list-view-field-refs.ts` declares, per view face, which field-reference keys are walked and at what level, and `kanban` was the only item-titled face with no `titleField` row. From #16894 the key is authorable on `KanbanConfigSchema`, so from that release a misspelt field name cleared the schema door, was walked by nothing, and the board fell back to the ADR-0079 record display name — a title the author did not ask for, on a board that renders correctly, with no gate reporting the miss. The byte-identical typo one block away on `calendar` or `timeline` was reported.
10+
11+
Measured on this branch, one list view carrying every walked position, one mutation at a time:
12+
13+
| probe | before | after |
14+
|:--|:--|:--|
15+
| `kanban.titleField` naming a field that does not exist | silent | `warning` `list-view-field-unknown` at `views[0].list.kanban.titleField` |
16+
| `kanban.titleField` naming a real field | silent | silent |
17+
| the other 51 walked positions | 51 reported, 1 silent (this one) | the same 51, each at its same severity |
18+
19+
Over the repo's own example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`) the findings count is **0 before and 0 after**: five kanban blocks are authored there and none carries `titleField`, so nothing existing starts reporting. Injecting `titleField: 'zz_no_such_field'` into those same boards flips 0 → 1 warning in `app-crm` and `app-showcase`.
20+
21+
**`warning`, the level `calendar` takes — not the level of the two siblings that spell the key required.** `KanbanConfigSchema` declares `titleField` OPTIONAL (#16894 copied `CalendarConfigSchema` for this exact key and names `TimelineConfigSchema` / `GanttConfigSchema`, the two that spell it required, as the siblings it deliberately does not copy), and the board resolves an unresolvable name through the ADR-0079 display-name chain: measured in objectui `dda8f3815`, `resolveKanbanTitleField` returns the written name, the card reads `rec[titleField]`, finds nothing and falls to `getRecordDisplayName`. Every card still renders — the warning tier's own case in this rule's module note ("the renderer drops one decoration and renders the rest: an optional colour / title / tooltip / cover binding"), where `kanban.groupByField` is the error tier's, collapsing every card into one uncolumned lane.
22+
23+
No rule id, no severity and no message shape changes for any other position; `list-view-field-unknown` gains one more place it can be reported from.

‎packages/lint/src/validate-list-view-field-refs.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const FULL_LIST_VIEW: AnyRec = {
7575
tabs: [{ name: 'open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }],
7676
},
7777
tabs: [{ name: 'mine', filter: [{ field: 'business_unit', operator: 'equals', value: 'x' }] }],
78-
kanban: { groupByField: 'status', summarizeField: 'estimate', columns: ['title'] },
78+
kanban: { groupByField: 'status', summarizeField: 'estimate', columns: ['title'], titleField: 'title' },
7979
calendar: {
8080
startDateField: 'due_at',
8181
endDateField: 'visible_from',
@@ -227,6 +227,10 @@ describe('#14107 — every other walked position', () => {
227227
],
228228
[{ kanban: { summarizeField: BAD } }, 'views[0].list.kanban.summarizeField', 'warning'],
229229
[{ kanban: { columns: [BAD] } }, 'views[0].list.kanban.columns[0]', 'warning'],
230+
// [#18565] Calendar's level, not the required siblings' — see the row's
231+
// own note in the rule. The board renders every card; only the title is
232+
// not the one the author named.
233+
[{ kanban: { titleField: BAD } }, 'views[0].list.kanban.titleField', 'warning'],
230234
[{ calendar: { endDateField: BAD } }, 'views[0].list.calendar.endDateField', 'warning'],
231235
[{ calendar: { titleField: BAD } }, 'views[0].list.calendar.titleField', 'warning'],
232236
[{ calendar: { colorField: BAD } }, 'views[0].list.calendar.colorField', 'warning'],
@@ -281,7 +285,7 @@ describe('#14107 — every other walked position', () => {
281285
// A floor, so a position quietly dropped from the rule's table cannot pass
282286
// by simply never being asserted.
283287
it('covers every position the rule walks', () => {
284-
expect(cases.length).toBeGreaterThanOrEqual(46);
288+
expect(cases.length).toBeGreaterThanOrEqual(47);
285289
});
286290
});
287291

‎packages/lint/src/validate-list-view-field-refs.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,21 @@ const POSITIONS: Record<string, BlockPositions> = {
322322
kanban: {
323323
// `columns` here are the fields shown ON a card, not the board's columns:
324324
// a stale entry leaves one blank line on the card and the board renders.
325-
scalars: { groupByField: 'error', summarizeField: 'warning' },
325+
//
326+
// [#18565] `titleField` takes CALENDAR's level, not the level of the two
327+
// siblings that spell the key required. `KanbanConfigSchema` declares it
328+
// OPTIONAL (#16894, which copied `CalendarConfigSchema` for this exact key
329+
// and names `TimelineConfigSchema` / `GanttConfigSchema` — the two whose
330+
// rows below read `error` — as the siblings it deliberately does NOT
331+
// copy), and the board resolves a name no record carries through the
332+
// ADR-0079 display-name chain: measured in objectui `dda8f3815`,
333+
// `resolveKanbanTitleField` returns the written name, the card reads
334+
// `rec[titleField]`, finds nothing and falls to `getRecordDisplayName`.
335+
// Every card still renders, titled from a value the author did not ask
336+
// for — the warning tier's own case (one decoration dropped, the rest
337+
// rendered), where `groupByField` above is the error tier's (every card
338+
// collapses into one uncolumned lane).
339+
scalars: { groupByField: 'error', summarizeField: 'warning', titleField: 'warning' },
326340
lists: { columns: 'warning' },
327341
},
328342
calendar: {

0 commit comments

Comments
 (0)