Skip to content

Commit 5d8319f

Browse files
os-litantclaude
andauthored
fix(spec): the rowColor prescription stops handing authors the one spelling the renderer drops (#18849)
Fixes #18791 Clause-②: yes `RowColorConfigSchema.colors` advertised `Map of field value to color (hex/token)`, and the `view/row-color-without-colors` diagnostic checked PRESENCE only. The sole renderer — objectui `plugin-grid`'s `useRowColor` — resolves far less than that. So the chain ran: the gate fires, **the gate's own `fix` string hands the author a hex**, the hex parses, publishes, clears the `!config.colors` guard, turns the gate GREEN, and colours nothing. A control whose own prescription switches it off. ## What the renderer actually does Read at the pinned `.objectui-sha` `53ded82bf7a494f54e344e19099dbf00854b8694`, not at objectui's local HEAD (a different tree this repo does not consume): - `COLOR_TO_CLASS` has **23** entries, every key a bare lower-case word (`red`, `slate`, `grey`, …), each mapping to `bg-NAME-100`. - `colorToClass` returns a `bg-`-prefixed value untouched; otherwise it looks up `color.toLowerCase().trim()` with `hasOwnProperty` and returns `undefined` for everything else. Tailwind v4 has no runtime, so no class can be fabricated from a hex. ## Three landing points 1. **The `describe`** now names the two spellings that reach a class and names a hex only as the thing that does not. 2. **The `fix` string** (highest priority — the only half that ACTIVELY pushed authors into the trap) now prescribes a resolvable colour name. `token` went with the hex: it named nothing an author could look up and stood beside hex as an equal alternative. 3. **A new author-time warning, `view/row-color-unresolvable-value`** — the half presence-only structurally cannot see, because a hex map CLEARS the guard that silences the older rule. The new rule judges the **shape** a value has and deliberately does not transcribe objectui's 23-entry map. Two structural facts carry it, and neither depends on what the map contains: the `bg-` branch tests the raw value, and every key is a bare lower-case word matched after `toLowerCase()` and `trim()`. That makes it **sound** — it never accuses a value the renderer would have resolved, including `'RED'` and `' red '` — and deliberately **incomplete**: an unknown name such as `chartreuse` is shaped like a key and is passed, pinned as a NON-rule. A hand-copy of another repo's vocabulary is a second opinion that drifts silently in both directions. ## Item 3 was gated on blast radius — measured, and it clears The gate: if the rule would refuse anything currently authored, stop and report. - **Nothing is refused at all, and nothing fails on a DEFAULT run.** The finding is `warning`, and `@objectstack/lint`'s `splitBySeverity` sorts everything that is not `error` into advisories, so `os build` / `os validate` / `os lint` still exit 0 on their default paths. The registration-time twin in `@objectstack/objectql` calls `checkFieldCompleteness` and never the view predicate, and warns without ever throwing. ⚠️ **Corrected after the at-tier review (record `5723359135`):** under `os lint --strict` and `os validate --strict` a warning IS a failure — `lint.ts:922` computes `failing = errors.length + (strict ? warnings.length : 0)` and `validate.ts:715` exits 1 on `flags.strict && a non-zero warning count` — so a stack carrying an unresolvable `rowColor.colors` value starts failing those strict runs. That is what the flag is for (its own docblock: so an app can rely on the warning-level rules this registry ships **as its gate**), which is why the seat ruled this additive rather than breaking; the changeset now states the consequence and the fix. ⛔ `os build` is NOT in that pair: `build.ts` is an alias for Compile, which carries `--strict-body` and no `--strict`. - **This repo and the five example apps:** the only shipped `rowColor.colors` map is `examples/app-showcase`'s task grid — `{ low: 'slate', medium: 'blue', high: 'amber', urgent: 'red' }` — four colour names, all resolving. Every other `rowColor` in the tree is `field`-only and belongs to the older rule. Grep controls run both ways: a lit control hitting 11 lines under `examples/`, a fabricated dark control returning exit 1. - **objectui at the pinned sha** does hold three hex `colors` literals, named here so the zero is checkable rather than asserted: all three are objectui's own React test fixtures (`ObjectView.rowColorRelay-7218.test.tsx`, in `app-shell` and `plugin-view`). They assert a relay by `toEqual` and never traverse `checkViewCompleteness`, so this rule does not judge them and does not change their verdict. ## Verification Round resumed after a container restart killed the previous session mid-flight; nothing it implied was taken on trust, and re-measuring found two real gaps, both fixed here. | Run | Verdict | |:---|:---| | `pnpm --filter @objectstack/spec build` | exit 0 — `check-dts-emitted: 34/34` | | `pnpm --filter @objectstack/spec test` (project `local`) | exit 0 — 487 files / 14051 tests | | `pnpm --filter @objectstack/spec test:repo` (project `repo`) | exit 0 — 31 files / 536 tests | | `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm --filter @objectstack/spec check:generated` | exit 0 — all 15 artifacts current | | `check-adr-0087-registration` / `check-changeset-no-major` / `check-empty-changeset` | exit 0 | | `pnpm check:nul-bytes`, `check-spec-docblock-symbol-anchors` | exit 0 | Counts read against `c5e927f9506`. Heavy runs went through `scripts/pm/os-verify-lock.sh`; every exit code was captured after a redirect, never through a pipe. **Gap 1 — the changeset named a symbol that does not exist.** Its "not breaking" paragraph rested on `partitionFindings`; `git grep` found exactly one occurrence in the repository, the changeset's own sentence. The mechanism was real, the name was not — the router is `splitBySeverity` (`packages/lint/src/authoring-rules.ts`). Corrected, because this text ships to consumers as `CHANGELOG.md` and an unresolvable symbol there is a dead end for the reader who greps it — the same defect class as the card itself. **Gap 2 — two generated artifacts were stale.** `VIEW_ROW_COLOR_UNRESOLVABLE_VALUE` is a new public const on `./kernel`, so `check:api-surface` and `check:export-origins` were both red. The tree carried no `.d.ts` at all (a prior `OS_SKIP_DTS=1` build), under which `gen:api-surface` cannot run — so this was rebuilt for real first, then only the two the aggregate proved stale were regenerated. The diff is two added lines and nothing else; `check:api-surface` reads it as `0 breaking (removed/narrowed), 1 added`, which is the accept-set reading the `minor` bump and the clause-② widening declaration already claimed. Test-fixture triage went by the rule's consumer radius, not by the edited package: the `RowColorConfigSchema` fixture that pins "a hex does parse" is deliberately KEPT (the assertion is correct — what was wrong is believing a parse means a colour), while the corpus fixtures that were merely *demonstrating* a hex were respelled, because a fixture is read as an example. ## Acceptance notes Out of scope for this PR, noted rather than fixed: - **objectui's three hex `rowColor.colors` test fixtures** at the pinned sha model the exact trap this card is about, as an example an AI or a human would copy. They are correct *as relay assertions*, so this is a readability trap and not a broken test, and objectui is read-only from here. Reported to the seat with dedupe words rather than filed by me. - `content/docs/references/api/protocol.mdx` and `content/docs/references/data/object.mdx` render `rowColor` as an inline type and so never expand the `colors` describe; only `view.mdx` carries the nested-shape tables that received the new sentence. Generated output, correct as generated — noted, not filed. ⛔ Not addressed here and deliberately untouched: the `view.exportOptions` format enum region of `view.zod.ts`, which on-hold card #8346 declares as its trigger region. This change lives at the `RowColorConfigSchema` describe and does not enter it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e3882c commit 5d8319f

8 files changed

Lines changed: 389 additions & 15 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `rowColor`'s own prescription stops handing authors the one spelling the renderer drops (#18791)
6+
7+
Clause-②: yes
8+
9+
`RowColorConfigSchema.colors` advertised `Map of field value to color (hex/token)`.
10+
The only renderer — objectui `plugin-grid`'s `useRowColor` — hands a `bg-`-prefixed
11+
literal through untouched, otherwise lower-cases and trims the value and resolves it
12+
through its own closed vocabulary of colour NAMES, and returns `undefined` for
13+
everything else. A hex is not a key, and Tailwind v4 has no runtime, so no class can
14+
be fabricated from one.
15+
16+
The `view/row-color-without-colors` diagnostic checks PRESENCE only, so every link in
17+
the chain was shipping code except the author's step: the gate fires, **the gate
18+
itself hands the author a hex**, the hex parses, publishes, turns the gate green, and
19+
colours nothing. A control whose own prescription switches it off. Measured, not
20+
argued: #18787's reverse-verification leg B swapped four colour names for the four
21+
hexes the `priority` field already declares — the app-local resolvability arm went red
22+
naming all four while the presence arm stayed green.
23+
24+
Three things change, none of which moves an accept set:
25+
26+
- **The describe** now names the two spellings that actually reach a class, and names
27+
a hex only as the thing that does not. An author who comes to ask "can I paste the
28+
option colours in?" now finds the answer instead of an invitation.
29+
- **The `fix` string** the presence diagnostic emits prescribes a resolvable colour
30+
name. `token` went with the hex: read as the renderer's colour names it was still
31+
standing beside hex as an equal alternative, and putting a bad option first is as
32+
harmful as offering only the bad option. The string is pinned by feeding the value
33+
it suggests back through `checkViewCompleteness`, so the prescription can only ever
34+
name something the new rule below accepts.
35+
- **A new author-time warning, `view/row-color-unresolvable-value`**, reports values
36+
the resolver drops. This is the half presence-only structurally cannot see: a hex
37+
map CLEARS the `!config.colors` guard, which is exactly what silences the older
38+
rule.
39+
40+
The new rule judges the SHAPE a value has, and deliberately does not transcribe
41+
objectui's 23-entry map. Two structural facts about the resolver are enough and
42+
neither depends on what the map contains: the `bg-` branch tests the raw value, and
43+
every key is a bare lower-case word matched after `toLowerCase()` and `trim()`. So a
44+
value that is neither `bg-`-prefixed nor a bare alphabetic word once normalised cannot
45+
be a key, whatever the map holds. That makes the rule **sound** — it never accuses a
46+
value the renderer would have resolved, including `'RED'` and `' red '` — and
47+
deliberately **incomplete**: an unknown colour name such as `chartreuse` is shaped
48+
like a key and is passed, pinned as a NON-rule. A hand-copy of another repo's
49+
vocabulary is a second opinion that drifts silently in both directions, and where the
50+
vocabulary should be declared so the two sides cannot drift is a cross-repo question
51+
this change deliberately does not answer.
52+
53+
Not breaking, and measured rather than assumed: the finding is `warning` severity,
54+
like its sibling. `@objectstack/lint`'s `splitBySeverity` sorts everything that is not
55+
`error` into advisories, so `os build` / `os validate` / `os lint` still exit 0 on their
56+
DEFAULT paths, and the registration-time twin in `@objectstack/objectql` is field-only —
57+
it calls `checkFieldCompleteness` and never the view predicate — and warns without ever
58+
throwing. Nothing that builds today on a default run starts failing, and nothing authored
59+
today is refused. Under `os lint --strict` / `os validate --strict` a warning IS a
60+
failure — that is what the flag is for — so a stack carrying an unresolvable
61+
`rowColor.colors` value, typically a hex, starts failing those strict runs on upgrade;
62+
the fix is the one the finding prescribes: a resolvable colour name (`red`) or a complete
63+
Tailwind background class (`bg-red-200`).
64+
65+
Blast radius measured over this repo, the five example apps and objectui at the pinned
66+
`.objectui-sha` `53ded82bf7a494f54e344e19099dbf00854b8694`: **zero** authored `colors`
67+
maps reach this rule carrying an unresolvable value — the one shipped map,
68+
`examples/app-showcase`'s task grid, spells all four values as colour names and resolves
69+
clean. The pinned sibling does hold three hex `colors` literals, and they are named here
70+
so the zero is checkable rather than asserted: all three are objectui's OWN React test
71+
fixtures (`ObjectView.rowColorRelay-7218.test.tsx`, in `app-shell` and in `plugin-view`),
72+
they assert a relay by `toEqual`, and they never traverse `checkViewCompleteness` — so
73+
this rule does not judge them and does not change their verdict.

‎content/docs/references/ui/view.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,7 +1051,7 @@ View filter rule
10511051
| Property | Type | Required | Description |
10521052
| :--- | :--- | :--- | :--- |
10531053
| **field** | `string` | ✅ | Field whose value is looked up in the `colors` map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no `colors`, no row is ever coloured, whatever this field holds. Author-time diagnostic `view/row-color-without-colors` reports that combination. |
1054-
| **colors** | `Record<string, string>` | optional | Map of field value to color (hex/token) |
1054+
| **colors** | `Record<string, string>` | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui `plugin-grid`'s `useRowColor` hands a value already written as a complete Tailwind background class (`bg-red-200`) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (`red`, `blue`, `slate`, … each mapping to `bg-NAME-100`), and returns undefined for anything else. A hex, an `rgb()` or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic `view/row-color-unresolvable-value` reports a value that cannot resolve. |
10551055

10561056
### Nested Shape: `ListView.bulkActionDefs[number]`
10571057

@@ -1449,7 +1449,7 @@ View filter rule
14491449
| Property | Type | Required | Description |
14501450
| :--- | :--- | :--- | :--- |
14511451
| **field** | `string` | ✅ | Field whose value is looked up in the `colors` map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no `colors`, no row is ever coloured, whatever this field holds. Author-time diagnostic `view/row-color-without-colors` reports that combination. |
1452-
| **colors** | `Record<string, string>` | optional | Map of field value to color (hex/token) |
1452+
| **colors** | `Record<string, string>` | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui `plugin-grid`'s `useRowColor` hands a value already written as a complete Tailwind background class (`bg-red-200`) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (`red`, `blue`, `slate`, … each mapping to `bg-NAME-100`), and returns undefined for anything else. A hex, an `rgb()` or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic `view/row-color-unresolvable-value` reports a value that cannot resolve. |
14531453

14541454
### Nested Shape: `ObjectListView.bulkActionDefs[number]`
14551455

@@ -1607,7 +1607,7 @@ Row color configuration based on field values
16071607
| Property | Type | Required | Description |
16081608
| :--- | :--- | :--- | :--- |
16091609
| **field** | `string` | ✅ | Field whose value is looked up in the `colors` map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no `colors`, no row is ever coloured, whatever this field holds. Author-time diagnostic `view/row-color-without-colors` reports that combination. |
1610-
| **colors** | `Record<string, string>` | optional | Map of field value to color (hex/token) |
1610+
| **colors** | `Record<string, string>` | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui `plugin-grid`'s `useRowColor` hands a value already written as a complete Tailwind background class (`bg-red-200`) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (`red`, `blue`, `slate`, … each mapping to `bg-NAME-100`), and returns undefined for anything else. A hex, an `rgb()` or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic `view/row-color-unresolvable-value` reports a value that cannot resolve. |
16111611

16121612

16131613
---

‎packages/spec/api-surface/kernel.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@
436436
"UpgradeSnapshotParsed (type)",
437437
"UpgradeSnapshotSchema (const)",
438438
"VIEW_LAYOUT_WITHOUT_BINDING (const)",
439+
"VIEW_ROW_COLOR_UNRESOLVABLE_VALUE (const)",
439440
"VIEW_ROW_COLOR_WITHOUT_COLORS (const)",
440441
"VIEW_TREE_WITHOUT_PARENT_FIELD (const)",
441442
"ValidationError (type)",

‎packages/spec/export-origins/kernel.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@
433433
"UpgradeSnapshotParsed": "src/kernel/package-upgrade.zod.ts#UpgradeSnapshotParsed (type)",
434434
"UpgradeSnapshotSchema": "src/kernel/package-upgrade.zod.ts#UpgradeSnapshotSchema (const)",
435435
"VIEW_LAYOUT_WITHOUT_BINDING": "src/kernel/functional-completeness.ts#VIEW_LAYOUT_WITHOUT_BINDING (const)",
436+
"VIEW_ROW_COLOR_UNRESOLVABLE_VALUE": "src/kernel/functional-completeness.ts#VIEW_ROW_COLOR_UNRESOLVABLE_VALUE (const)",
436437
"VIEW_ROW_COLOR_WITHOUT_COLORS": "src/kernel/functional-completeness.ts#VIEW_ROW_COLOR_WITHOUT_COLORS (const)",
437438
"VIEW_TREE_WITHOUT_PARENT_FIELD": "src/kernel/functional-completeness.ts#VIEW_TREE_WITHOUT_PARENT_FIELD (const)",
438439
"ValidationError": "src/kernel/plugin-validator.zod.ts#ValidationError (type)",

‎packages/spec/src/kernel/functional-completeness.test.ts‎

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
VIEW_LAYOUT_WITHOUT_BINDING,
3030
VIEW_TREE_WITHOUT_PARENT_FIELD,
3131
VIEW_ROW_COLOR_WITHOUT_COLORS,
32+
VIEW_ROW_COLOR_UNRESOLVABLE_VALUE,
3233
WEBHOOK_WITHOUT_TRIGGERS,
3334
} from './functional-completeness';
3435

@@ -331,10 +332,46 @@ describe('checkViewCompleteness — rowColor without a colour map (the parse-cle
331332
expect(f.fix).toContain('colors');
332333
});
333334

335+
// #18791 — the sharpest half of this card. The `fix` string this rule hands
336+
// the author read `colors: { '<field_value>': '<hex_or_token>' }`, and a hex
337+
// is the ONE spelling `colorToClass` cannot resolve. So the chain ran: the
338+
// gate fires, the gate itself hands the author a hex, the hex parses,
339+
// publishes, turns this rule GREEN, and colours nothing. A control whose own
340+
// prescription switches it off.
341+
//
342+
// Pinning the literal string would rot. What is pinned instead is the
343+
// PROPERTY that made it wrong: the value the prescription suggests is fed
344+
// back through this module, and must survive it.
345+
it('hands the author a prescription this module itself accepts (#18791)', () => {
346+
const f = only(checkViewCompleteness({ type: 'grid', rowColor: { field: 'status' } }) as never);
347+
const suggested = /'<field_value>':\s*'([^']+)'/.exec(f.fix)?.[1];
348+
expect(suggested, `no suggested colour value in the prescription: ${f.fix}`).toBeDefined();
349+
expect(
350+
checkViewCompleteness({ type: 'grid', rowColor: { field: 'status', colors: { open: suggested! } } }),
351+
`the prescription suggests \`${suggested}\`, which this module's own resolvability rule rejects — `
352+
+ 'the gate would be handing the author the defect it just reported',
353+
).toEqual([]);
354+
});
355+
356+
it('⛔ names no hex placeholder anywhere in the prescription (#18791)', () => {
357+
// The direct, dumb half of the pin above: whatever the wording becomes, it
358+
// must not put a hex back in front of an author. `token` is refused for the
359+
// same reason — it named nothing an author could look up, and stood beside
360+
// hex as an equal alternative.
361+
const f = only(checkViewCompleteness({ type: 'grid', rowColor: { field: 'status' } }) as never);
362+
expect(f.fix).not.toMatch(/hex|#[0-9a-f]{3}|token/i);
363+
});
364+
334365
it('is silent once a `colors` map is declared — the negative fixture', () => {
366+
// ⚠️ This fixture used to spell the colour `'#0f0'`. That hex is exactly
367+
// the shape the sibling rule below exists to catch, so the negative
368+
// fixture for THIS rule was modelling the trap: it asserted "presence is
369+
// enough" over a map that colours nothing. The value is now a resolvable
370+
// colour name, which is what makes this a clean negative for one rule
371+
// instead of a silent positive for the other.
335372
expect(checkViewCompleteness({
336373
type: 'grid',
337-
rowColor: { field: 'status', colors: { open: '#0f0' } },
374+
rowColor: { field: 'status', colors: { open: 'green' } },
338375
})).toEqual([]);
339376
});
340377

@@ -379,6 +416,109 @@ describe('checkViewCompleteness — rowColor without a colour map (the parse-cle
379416
});
380417
});
381418

419+
/**
420+
* #18791 — the half `view/row-color-without-colors` structurally cannot see.
421+
*
422+
* `RowColorConfigSchema.colors` is `z.record(z.string(), z.string())`, so every
423+
* string parses. `useRowColor.ts`'s `colorToClass` resolves far less: a
424+
* `bg-`-prefixed literal passes through, the lower-cased and trimmed value is
425+
* looked up in a closed vocabulary of colour NAMES, and everything else returns
426+
* `undefined`. A hex map therefore CLEARS the `!config.colors` guard — which is
427+
* to say it turns the presence rule GREEN — and colours nothing, which is why
428+
* presence-only can never be the detector for it.
429+
*
430+
* Measured, not argued: PR #18787's reverse-verification leg B swapped four
431+
* colour names for the four hexes the `priority` field already declares; the
432+
* app-local resolvability arm went red naming all four, and the presence arm
433+
* stayed green.
434+
*/
435+
describe('checkViewCompleteness — rowColor values the renderer resolves to nothing (#18791)', () => {
436+
const grid = (colors: Record<string, unknown>) =>
437+
checkViewCompleteness({ type: 'grid', rowColor: { field: 'priority', colors } });
438+
439+
it('flags a hex map as a WARNING, naming every dead value', () => {
440+
const f = only(grid({ low: '#94A3B8', high: '#EF4444' }) as never);
441+
expect(f.rule).toBe(VIEW_ROW_COLOR_UNRESOLVABLE_VALUE);
442+
expect(f.severity).toBe('warning');
443+
expect(f.path).toBe('rowColor.colors');
444+
// The author has to be able to find them, so each offending entry is named
445+
// with the value it holds — a count alone sends them re-reading the map.
446+
expect(f.message).toContain('`low` = "#94A3B8"');
447+
expect(f.message).toContain('`high` = "#EF4444"');
448+
// …and the runtime symbol that makes it true, per this module's discipline.
449+
expect(f.message).toContain('colorToClass');
450+
expect(f.fix).toContain("field: 'priority'");
451+
});
452+
453+
it('⭐ says out loud that this shape SILENCES the presence rule', () => {
454+
// The whole reason the card is p1: the obvious "fix" for
455+
// `view/row-color-without-colors` is to paste the field's own option
456+
// colours in, which are hexes — strictly worse than the original defect,
457+
// because it removes the one signal that was working. A finding that does
458+
// not say so invites exactly that move again.
459+
const f = only(grid({ low: '#94A3B8' }) as never);
460+
expect(f.message).toContain(VIEW_ROW_COLOR_WITHOUT_COLORS);
461+
expect(grid({ low: '#94A3B8' }).map((x) => x.rule)).not.toContain(VIEW_ROW_COLOR_WITHOUT_COLORS);
462+
});
463+
464+
it('accepts what the renderer accepts — colour names and `bg-` classes', () => {
465+
// The showcase's shipped map, verbatim.
466+
expect(grid({ low: 'slate', medium: 'blue', high: 'amber', urgent: 'red' })).toEqual([]);
467+
// A complete Tailwind class is handed through untouched by `colorToClass`.
468+
expect(grid({ open: 'bg-red-200', shut: 'bg-emerald-50/50' })).toEqual([]);
469+
// The lookup lower-cases and trims, so these resolve too. A rule that
470+
// tested the raw value would report both — a false prescription.
471+
expect(grid({ open: 'RED', shut: ' green ' })).toEqual([]);
472+
});
473+
474+
it('flags the other unresolvable spellings, not just hex', () => {
475+
for (const dead of ['rgb(255,0,0)', 'var(--danger)', '#f00', 'hsl(0 100% 50%)', 'red-500', '']) {
476+
const findings = grid({ open: dead });
477+
expect(findings.map((x) => x.rule), `\`${dead}\` should be reported`)
478+
.toContain(VIEW_ROW_COLOR_UNRESOLVABLE_VALUE);
479+
}
480+
// ⚠️ ` bg-red-100` with a leading space is NOT resolvable: `startsWith`
481+
// tests the RAW value and sees the space, and the lower-cased form is not a
482+
// bare word either. Pinned because it is the one place where "looks like a
483+
// Tailwind class" and "resolves" come apart.
484+
expect(grid({ open: ' bg-red-100' }).map((x) => x.rule)).toContain(VIEW_ROW_COLOR_UNRESOLVABLE_VALUE);
485+
});
486+
487+
it('⛔ PINNED NON-RULE: an unknown colour NAME is passed, deliberately', () => {
488+
// `chartreuse` is shaped like a key and is almost certainly not one, so
489+
// this rule lets it through. That is the price of refusing to transcribe
490+
// another repo's 23-entry map: a copy drifts silently in both directions,
491+
// and a rule that accuses a value the renderer WOULD have resolved is the
492+
// false prescription this module's discipline forbids. Sound, not complete
493+
// — if someone "completes" it by pasting the vocabulary in, this is where
494+
// the trade-off they are reversing is written down.
495+
expect(grid({ open: 'chartreuse' })).toEqual([]);
496+
});
497+
498+
it('⛔ does not double-report the shapes the presence rule owns', () => {
499+
// `{}` is the presence rule's second spelling; it must not also arrive here
500+
// as "zero resolvable values", which would report one defect twice in two
501+
// vocabularies — the thing the sibling block's own tests refuse.
502+
const empty = checkViewCompleteness({ type: 'grid', rowColor: { field: 'priority', colors: {} } });
503+
expect(empty.map((f) => f.rule)).toEqual([VIEW_ROW_COLOR_WITHOUT_COLORS]);
504+
});
505+
506+
it('is silent on the view types whose renderer never reads `rowColor`', () => {
507+
for (const type of ['kanban', 'gallery', 'chart', 'timeline']) {
508+
const findings = checkViewCompleteness({ type, rowColor: { field: 'priority', colors: { a: '#fff' } } });
509+
expect(findings.map((f) => f.rule)).not.toContain(VIEW_ROW_COLOR_UNRESOLVABLE_VALUE);
510+
}
511+
});
512+
513+
it('leaves what the schema refuses to the schema, and never throws', () => {
514+
// Non-string values and non-record maps are parse errors, not completeness
515+
// findings — this module is not a second parser.
516+
expect(grid({ open: 42, shut: null })).toEqual([]);
517+
expect(checkViewCompleteness({ type: 'grid', rowColor: { field: 'priority', colors: 'red' } })).toEqual([]);
518+
expect(() => grid({ open: { nested: true } })).not.toThrow();
519+
});
520+
});
521+
382522
describe('checkWebhookCompleteness — the rule the runtime comment argued against', () => {
383523
it('flags a webhook with no `triggers` as an ERROR', () => {
384524
const f = only(checkWebhookCompleteness({ name: 'notify_slack', url: 'https://x' }) as never);
@@ -431,6 +571,7 @@ describe('registry hygiene', () => {
431571
'field/relationship-without-reference',
432572
'field/summary-without-operations',
433573
'view/layout-without-binding',
574+
'view/row-color-unresolvable-value',
434575
'view/row-color-without-colors',
435576
'view/tree-without-parent-field',
436577
'webhook/without-triggers',
@@ -447,9 +588,10 @@ describe('registry hygiene', () => {
447588
...checkViewCompleteness({ type: 'kanban' }),
448589
...checkViewCompleteness({ type: 'tree', tree: {} }, { name: 'unit', fields: {} }),
449590
...checkViewCompleteness({ type: 'grid', rowColor: { field: 'status' } }),
591+
...checkViewCompleteness({ type: 'grid', rowColor: { field: 'status', colors: { open: '#0f0' } } }),
450592
...checkWebhookCompleteness({ url: 'https://x' }),
451593
];
452-
expect(all).toHaveLength(9);
594+
expect(all).toHaveLength(10);
453595
for (const f of all) {
454596
expect(f.fix.length).toBeGreaterThan(8);
455597
expect(f.message.length).toBeGreaterThan(60);

0 commit comments

Comments
 (0)