Skip to content

feat(lint): reject an unpaired getBadgeColorClasses call - #5292

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5191-badge-pairing-lint-rule
Aug 19, 2026
Merged

feat(lint): reject an unpaired getBadgeColorClasses call#5292
os-support-ai merged 1 commit into
mainfrom
claude/issue-5191-badge-pairing-lint-rule

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5191

Scoped to the lint-rule option only, per the triage comment on the issue. The consolidated-export option is an explicit non-goal and is not touched here: packages/fields' published surface is unchanged, and ObjectGrid.tsx is not re-entered.

The class this closes

@object-ui/fields answers "what colour is this badge?" in two incompatible currencies:

  • getBadgeColorClasses(color, value) returns a class string — complete-looking, and structurally unable to carry a runtime colour, so an author-declared hex is quantized onto one of nine palette families.
  • getBadgeHexAppearance(color) returns { className, style } or undefined — the correct answer, but only if the caller consults it first and carries the style through. That className reads CSS custom properties that only the style half supplies.

A class-only call compiles, renders, and looks right for family-name declarations (the common case), so it fails only for authors who declared a hex — and it fails by rendering a plausible neighbouring colour rather than by breaking. #5141 fixed that in the cell renderer; #5183 fixed four more sites. Both rounds were per-site, because nothing rejected the class-only call at write time.

What was verified before writing

Re-derived on origin/main @ 12841b6 (still the tip when this branch was cut, matching the triage comment):

  • Both exports live: packages/fields/src/index.tsx:1409getBadgeHexAppearance, :1440getBadgeColorClasses — located by reading, not by the cited line numbers.
  • All four cross-package call sites pair them: plugin-grid/src/ObjectGrid.tsx:3174/3178 (compact card) and :3394/3397 (group header), plugin-kanban/src/ObjectKanban.tsx:413/416 and :471/474. No live unpaired site exists today, so this rule is preventive.
  • plugin-calendar/src/CalendarView.tsx:34 is a comment mention only — confirmed: it is the sole occurrence of the symbol anywhere in that package, there is no import and no call.
  • No eslint rule for this already existed (grep -rni badge eslint-rules/ returns nothing; counter-probed with context.report, which hits all six existing rule files).

The rule

object-ui/no-unpaired-badge-color-classes — a getBadgeColorClasses call must have a getBadgeHexAppearance consult lexically visible from it: the same function scope, or one enclosing it (module scope included).

Scope-level rather than syntax-level, which decides three cases the rule's test pins:

  • the if/else spelling pairs exactly as well as the ternary — the rule reads scopes, not conditional shapes, so it cannot be defeated by rewriting the branch;
  • hoisting a loop-invariant consult above a .map() and calling the class helper inside the callback stays clean, because an enclosing scope is what "visible" means (a rule that flagged that correct code is a rule someone would delete);
  • a consult in a sibling function does not pair. Otherwise one correct site would license every unpaired site added beside it — and the files that already pair, ObjectGrid.tsx and ObjectKanban.tsx, are exactly where a fifth badge surface gets written.

Import aliases are followed on both halves, and the namespace form (fields.getBadgeColorClasses(...)) is matched on the member name. Known limit, documented in the rule header: passing either helper around as a bare function reference is not tracked — no call site in this repo does that, and a reference-flow pass would trade a large false-positive surface for a case nobody writes.

Severity: error, and why

error, matching every other object-ui/* ratchet in eslint.config.js. A warn would be decoration here: .github/workflows/lint.yml deliberately sets no --max-warnings (its own header says so, and the repo carries ten thousand warnings), so a warning cannot fail CI. The lint workflow gates pull requests and every one of the 46 workspace packages runs eslint . (scripts/check-lint-coverage.mjs: 46/46), so an error here does turn a build red.

The rule lints clean today with no allowlist, so landing it at error costs nothing.

Both halves of the real eslint run

Not just the unit test — an actual eslint invocation, at commit 7d1256b32.

Half 1 — it fires. A deliberately-unpaired fixture (temporary, never committed) at packages/plugin-grid/src/tmp-fixture-5191.tsx:

/home/user/objectui-issue-5191/packages/plugin-grid/src/tmp-fixture-5191.tsx
7:22 error `getBadgeColorClasses` alone quantizes an author-declared hex onto one of the nine
palette families — the defect fixed per-site in objectui#5141 and objectui#5183. Consult
`getBadgeHexAppearance(color)` in this scope (or an enclosing one) first, use its `className` AND
carry its `style` through to the element, and fall back to `getBadgeColorClasses(color, value)`
only when it returns `undefined` object-ui/no-unpaired-badge-color-classes
✖ 1 problem (1 error, 0 warnings)
exit=1

Rewriting that same fixture into the paired form makes it silent: exit=0, no findings.

Half 2 — it is quiet on today's correct code, which matters as much: a rule that flags correct code gets deleted.

pnpm exec eslint . → files linted 3357 | errors 0 | no-unpaired-badge-color-classes findings 0

Repo-wide, at 7d1256b32. All four live paired call sites are inside that set.

Reverse verification

Predicted before running: with the fixture present and the config block removed, the fixture stops being flagged — a straight red-to-green flip on the fixture, exit 0, not a count shift elsewhere, since nothing else in the repo emits this diagnostic and no other gate reads its output. No rebuild concern applies: eslint.config.js imports ./eslint-rules/index.js as source, and eslint-rules/ has no build output, so there is no artifact between the edit and the thing under test.

legstatepredictedobserved
0rule wired (committed)1 error, exit 11 error, exit 1
Aconfig block removed0 findings, exit 00 findings, exit 0
Brestored via git checkout1 error, exit 11 error, exit 1

Leg B restored the file byte-identically to the commit (git diff --stat HEAD -- eslint.config.js empty). The fixture was deleted afterwards; the tree is clean.

Why packages/fields is out of scope

The config block ignores packages/fields/**, because that package owns both helpers: it defines them, its own badge renderer pairs them anyway (index.tsx:1559/1560), and src/__tests__/badge-hex-fidelity-5141.test.tsx deliberately exercises each half in isolation. That carve-out is load-bearing and was measured, not assumed — forcing the rule on for that file with --rule reports its 7 deliberate single-helper calls.

Everywhere else, including tests, is in scope: outside fields, a call to this helper is a badge surface.

Verification

At commit 7d1256b32, clean tree:

  • pnpm exec vitest run eslint-rules/no-unpaired-badge-color-classes.test.js — 18 passed (10 valid shapes, 8 invalid). The valid list is transcribed from the live call sites; the invalid list is the historical defect shape plus the ways a fifth site could look paired without being paired.
  • pnpm exec vitest run eslint-rules/ scripts/ — 62 files, 1394 tests passed. Includes the lint-config meta-tests (lint-workflow, turbo-lint-inputs, turbo-task-guard-coverage).
  • pnpm exec eslint . — 3357 files, 0 errors.
  • node scripts/check-control-bytes.mjs — OK, 4684 tracked text files.
  • node scripts/check-lint-coverage.mjs — 46/46 packages linted, 0 with outstanding errors.
  • node scripts/check-changeset-presence.mjs — "No source of a released package changed in this range, so no changeset is owed." CI-time only, matching the triage note; checked with the script rather than assumed.

File surface

eslint-rules/no-unpaired-badge-color-classes.js, its test, eslint-rules/index.js, eslint.config.js. Nothing else — no packages/fields export change, no ObjectGrid.tsx, no governed surfaces.

Generated by Claude Code


Generated by Claude Code

`getBadgeColorClasses(color, value)` returns a class string and therefore
cannot carry an author-declared hex — it quantizes the declared colour onto
one of nine palette families. The correct answer is
`getBadgeHexAppearance(color)`, whose `className` reads CSS custom properties
that only its `style` half supplies. A class-only call compiles, renders and
looks right for family-name declarations, so it fails only for authors who
declared a hex, and it fails by rendering a plausible neighbouring colour
rather than by breaking. #5141 fixed that in the cell renderer and #5183 fixed
four more sites; both rounds were per-site, because nothing rejected the
class-only call at write time.
Adds `object-ui/no-unpaired-badge-color-classes`: a `getBadgeColorClasses`
call must have a `getBadgeHexAppearance` consult lexically visible from it —
the same function scope or an enclosing one. Scope-level rather than
syntax-level, so the `if/else` spelling pairs as well as the ternary and a
hoisted loop-invariant consult above a `.map()` stays clean; a consult in a
sibling function does NOT pair, so one correct site cannot license every
unpaired site added beside it.
Severity `error`: `.github/workflows/lint.yml` deliberately sets no
`--max-warnings`, so a `warn` here could not fail CI. Every live call site
already pairs the two helpers (plugin-grid ObjectGrid group header + compact
card, plugin-kanban card badges), so the rule lints clean today with no
allowlist — measured repo-wide, 3357 files, 0 findings.
`packages/fields` is out of scope: it owns both helpers and unit-tests each
half in isolation (forcing the rule on there reports its 7 deliberate
single-helper calls).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fields] getBadgeColorClasses stays reachable as a class-only export, so a fifth badge call site can silently reintroduce the hex/family split

2 participants

@os-support-ai@claude