Repository navigation
feat: read design tells in any language, and keep the verdicts honest - #235
Merged
Merged
Conversation
The README said 42 commands and the CLI answers 45. The three missing entries were gate, scrub-lint and scrub-scaffold — and gate is the composed judge every other linter reports through, so the most central command in the kernel was the one the table forgot. Kernel tests read 3,430; the suite now runs 3,699 green across 210 files. The 2026-08-16 changelog row keeps its 42: that release did carry 42, and a changelog that edits itself to match today is not a changelog.
…written once Rules bound to CSS syntax can never leave the web. Bound to design facts — a resolved color, a font family, a radius, a border on one side, a nesting depth — the same rule judges an .html, a .tsx, a .swift and a .dart. The contract that matters is NOT-EVALUATED. An extractor declares, per fact kind, the strongest confidence it can reach; a rule declares the kinds it needs. Against a given extractor a rule is RUNNABLE or NOT-EVALUATED with the missing kinds named — partition() throws if a rule lands in neither, so a lost rule can never read as one fewer finding. This is what stops a polyglot linter from turning "I cannot see it" into "it is clean". The collector refuses facts that contradict the declaration: an undeclared kind, a foreign extractor id, or a confidence stronger than declared. Provenance is required by the type, so a fact nobody can point at cannot be constructed. CHECK_CATALOG.requires widens from "none" | "tokens" to also accept a fact-kind set. All 89 existing rows keep the legacy string form and evaluate identically. audit-detect.ts gains a drift guard rather than a promise: the contract test builds DesignFacts from a fixture AuditNode covering every fact-bearing field, so adding one there goes red until the IR can express it. Verified: 21 new tests; 8 red probes (rule-always-runnable, atLeast-always-true, both collector guards, the deterministic sort, undeclared kind, missing provenance at compile time, catalog rows unchanged) each confirmed red when broken; full suite 3739 passed / 215 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signs are not defects. A cream-palette surface is worth printing and never worth
failing a build over, so FloorSeverity gains a third tier: detected, listed,
excluded from the failure count. The exit code keeps its exact current meaning —
it keys off errors alone.
The tier's danger is arithmetic, not typing. Thirteen call sites derived
warningCount as `findings.length - errorCount`, which buckets advisory into
warnings with no type error to catch it. countBySeverity() in finding-schema is
now the ONE counter; a fourth tier becomes a one-place change instead of a
fourteen-place hunt.
Scope is deliberately narrow: only families on FloorSeverity adopt it. flow-lint,
ds-soul, ds-usage-lint, validate-prompt-plan and specimen-check declare their own
two-value Severity and cannot emit advisory, so forcing the shared helper there
would only have churned a public envelope — diagram-real-flow-proof caught
exactly that and the fix was to shrink the change, not to edit the test.
Inline waivers travel with the source in whatever comment syntax it uses:
<!-- design-os-disable side-tab -- exported brand doc -->
.brand { font-family: Inter } /* design-os-disable-line overused-font -- why */
// design-os-disable-next-line pulsing-dot -- genuinely live data
A reason is mandatory (ui gate --skip already requires one) and a directive
without one is reported malformed rather than honoured. Every waived finding
leaves a record carrying its reason, so the total stays visible.
The comment tail is stripped before matching: without it the `--` of an HTML
terminator parses as the reason separator and a waiver with NO reason gets
honoured with the reason '>'.
Verified: 11 new tests; 6 red probes (advisory folded into warnings, warnings
made to fail, reason-less waiver honoured, -next-line off by one, silent waive,
comment tail unstripped) each confirmed red when broken; full suite 3750 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The raw-string linters could never know which rule wins for which element. LAYOUT_PROP_RE scoped inside @Keyframes let `transition: width` through across 22 files in four products for exactly that reason. Selectors now match the real DOM, ordered by specificity then document position, so the value a rule reads is the value that will render. This is the reversal of the zero-runtime-dependency property, ratified by the owner on 2026-08-26: htmlparser2, css-tree, css-select, domutils. The guard is narrowed rather than deleted — tests/approved-runtime-deps.ts names the four, and a fifth still turns the suite red. Real data moved this twice, as it always does: - The repo's own site/index.html keeps its CSS in three linked files and one <style>. An inline-only engine reported a page with no colours at all. Linked LOCAL stylesheets are now read from the same filesystem the linter already reads — not a network fetch, determinism intact; http(s) and data: stay declared-unresolved. - Two shipped examples are Tailwind-CDN pages: zero stylesheets, the whole design in class attributes. The engine genuinely cannot see them, and silence there is 'I could not read this' mistaken for 'this is clean'. It now counts and names the gap. Shorthands are expanded for padding/margin/border-width/gap. `padding: 16px` is how stylesheets are actually written, and without expansion cramped-padding and monotonous-spacing report a clean page for most real projects. @media rules are collected under their condition and kept OUT of the unconditional cascade, so a mobile-only rule is never reported as always-on. Verified: 33 new tests across fixture and real-data suites; 8 red probes, two of which came back GREEN on the first attempt — the cycle guard was passing only because MAX_DEPTH also terminates, and the source-offset guard passed because 'line > 0' is true even when every line collapses to 1. Both assertions were strengthened until the break went red. Full suite 3784 passed / 218 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now every linter took exactly one .html file, so a React app could not be linted and a SwiftUI app was invisible — the open design-os-code-surface-gap. The resolver lands at the shared layer rather than in whichever linter the gap surfaced in; the repo already paid for the other choice when a redirect-stub rule patched into a11y-lint reappeared in validate-layout. SKIP_DIRS, MAX_DEPTH and MAX_ENTRIES are now exported from project-scan and shared rather than forked. Those are corpus-counted decisions — ten plausible additions measured 0 hits across nine projects while .venv alone was 8,187 files — and a second copy would drift from the measured one. Nothing leaves the resolver unaccounted for: an unclaimed extension is SKIPPED with a reason, a missing path says so, and a walk that hits its budget sets `truncated` with a note naming what was not examined. A silent cap reads as 'covered everything' when it did not. Adds a css-only extractor for bare stylesheets. A .css file states values but not which elements receive them, so it declares no structure and no text, and every rule needing those is NOT-EVALUATED rather than guessed from selector names. A testability seam was needed to keep an assertion honest: APFS returns these filenames already sorted, so deleting the walk's own .sort() could not go red on this machine. The guard is real on filesystems that return arbitrary order, and untested is the same as unguarded — so resolveTargets takes an optional readDir and the truncation test hands it a deliberately shuffled reader. Verified: 12 new tests; 8 red probes. Two came back green first: dropping the walk sort (fixed by the seam above) and the budget cap, which had no test at all until one was written. Full suite 3796 passed / 219 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
taste asks whether a rubric law was broken. tell asks whether a habit was revealed: an involuntary, machine-detectable sign that a surface was made without design judgment. Different question, so a different family, and mostly advisory — a tell prints, it does not fail a build. Rules read DesignFacts and nothing else. A rule that reached for an HTML string would make the family web-only again, so a test strips comments from every rule module and asserts no extractor import survives. knowledge/design-tells.md ships in the same commit: a standard without an emitter and a linter drifts, and this repo has already watched its own compiler violate a convention its knowledge taught. On the probe file the xia analysis measured, ui tell-lint now reports 10 findings — the same count impeccable's detector reported, reached through a resolved cascade rather than regex. Three things the first real run exposed: - One CSS rule matching N elements produced N identical findings: side-tab twice and cramped-padding eight times for a single stylesheet line. Provenance gained a nodeRef, findings dedupe on it, and padding collapsed to one finding per element naming its sides. Accurate output nobody will read is not output. - tsup externalises dependencies by default, so the build output had silently stopped being relocatable — copied anywhere without its packages beside it, it died before any command ran. The first check missed it because grepping the entry file showed nothing; the externals had moved into the shared chunk. Now bundled, and the guard is behavioural: run the copy in isolation. - css-tree's root entry require()s ../data/patch.json at runtime, which no bundler can follow. The engine imports css-tree/parser, /walker and /generator instead, with local type declarations, and a test parses CSS from the isolated binary to prove it. Verified: 66 new tests, one per rule proving it fires plus a floor of false-positive cases; 8 red probes, of which FIVE came back green first — the NOT-EVALUATED bypass, side-tab's one-side condition, nested-cards' parent check, the purple threshold and the deduplicator were all "obviously" covered by a nearby assertion that tested something else. Fixing the purple probe also fixed the rule: the old threshold required r-g > 30, which excluded indigo (#6366f1, r-g = -3), a colour squarely in the family. Full suite 3865 passed / 220 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dited The architecture's claim, now paid: a ~150-line extractor per language is all that stands between the rule set and a new platform. SwiftUI and Flutter each report six tells on their slop fixture and zero on their clean one, through rules written once against DesignFacts. Platform defaults live in the extractor that knows the platform. Flutter's default IS Roboto, so a TextStyle with no fontFamily emits Roboto once per file and overused-font fires — correctly. SwiftUI's SF is a system face, not a badly made choice, so nothing is emitted and the rule stays silent. A rule that had to know which platform it was judging would not be language-independent any more. Neither declares `structure`: view and widget nesting is real in the source but not readable from a line scan, so every nesting and rhythm rule reports NOT-EVALUATED rather than guessing. Under-declaring is honest; over-declaring is a silent false green. Two bugs the first real run exposed, both of the same shape — a rule that looks implemented and can never fire: - Rules paired facts by LINE EQUALITY, which is a cascade assumption. SwiftUI and Flutter spell one view as a chain of modifiers on consecutive lines, so side-tab and pulsing-dot were dead on every native file. sameOwner() now matches on nodeRef when both facts name one and a tight line window otherwise. - Opening that window then mispaired headings: heading-rhythm read the h2's size for the h1 and reported a scale nobody wrote. sameOwner answers "could these belong together"; nearestOwner answers "THE partner", same line first. Both directions are now pinned by tests. The Flutter border regex used `[^)]`, which closes on the nested Color(...) call and hides the width after it — implemented, never firing. Bounded `[\s\S]` instead, with a test carrying the exact nested shape. docs/extractor-authoring.md states the contract so the next platform is a file plus two fixtures: register a profile, emit into a collector that refuses undeclared facts, never guess, and declare only what you can see. Verified: 23 new tests; 6 red probes all confirmed red, including a return to line equality (w 8000 hich kills the native rules) and dropping Flutter's Roboto default. Full suite 3888 passed / 221 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eets
React carries its design in three places and only one is CSS. Utilities are the
dominant one and they are pure data — `rounded-2xl` means 16px with no
evaluation — so the resolver reads them exactly, keeping the SIDE of
`border-l-4`, which is what makes side-tab reachable from a className.
An SFC is two documents in one file, and splitting them lets each half be read
at the fidelity it deserves: the <style> block through the real cascade at
resolved confidence, the template through the scanner at literal. Reading the
whole file with one tool would drop the better half for no reason.
A bare stylesheet states values but not which elements receive them, so
css-only supplies no structure and no text, and every nesting rule is
NOT-EVALUATED rather than guessed from selector names.
What the extractors REFUSE is the load-bearing part:
- `className={cn(base, isActive && "border-l-4")}` cannot be evaluated, so it
emits nothing and is counted. A guess is a finding about a component that
never renders that way.
- A utility-shaped token the resolver does not carry (`bg-teal-750`) is counted
by name — "unmapped utility" is actionable, a silent drop is not.
- A styled-components `${}` interpolation is flagged; the declarations around it
still land.
JSX structure is supplied at HEURISTIC confidence only. Nesting is real in the
source but a scanner reads it by brace counting, which is wrong the moment a
component moves to another file — so rules needing exact nesting get a heuristic
answer and the coverage matrix says so.
tests/fixtures is excluded from typecheck. tell-react-slop.tsx deliberately
references an undefined `isActive` because that IS the unresolvable case under
test; making the fixture answer to the compiler would defeat its purpose. eslint
already ignored the path for the same reason.
Verified: 21 new tests; 8 red probes. Two came back green first — both landed on
the css-only branch rather than the SFC branch they were aimed at, and neither
tier's confidence or line mapping had an assertion until one was written.
Full suite 3909 passed / 222 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tells
ui a11y-lint printed "0 static findings … rendered criteria need a browser" and
left it there. With a resolved cascade the excuse is gone: the colour a text node
is painted on is computable without one. On the probe fixture this reaches
2.24:1 for #9ca3af on #7c3aed — the same number impeccable measured, through the
cascade instead of regex.
No extraction was needed. contrastRatio and relativeLuminance have lived in
color-scale.ts since the DS token work; ds-a11y checked declared TOKEN pairs
with them, and this checks the painted result. Reuse before extraction, before
writing.
Three refusals are the point:
- `resolved` confidence is required. A ratio built from literals no cascade
resolved is a number that means nothing, so on SwiftUI and Flutter this is
NOT-EVALUATED rather than wrong.
- Text over a gradient is NOT COMPUTABLE — a ratio against a gradient is not one
number. The run is reported partial, and the count prints.
- The walk passes THROUGH a translucent paint instead of blending it. A 10% veil
does not establish the background, and inventing the blend would be a finding
about a page nobody wrote.
The four voice tells read only the `text` fact, so they judge Swift and Dart as
readily as HTML. em-dash-overuse is a RATE with a 60-word floor, not a count: a
raw count flags a long well-written page and misses a short saturated one.
Two guards did not guard, both because the test could not discriminate:
- The translucent-veil case used #767676, which passes on black AND on white, so
deleting the alpha check changed nothing. #7a7a7a passes on black (4.89) and
fails on white (4.29) — only that colour separates the two paths.
- The ancestor walk was rescued by the document-background fallback until the
fixture gained a second subtree with its own background.
The voice ids are paired against RUNTIME EMISSION, not against a grep of their
own source: the literal extractor cannot see a positional `finding("id", …)`,
and widening the grep would have left it verifying itself.
Verified: 17 new tests; 9 red probes all confirmed red. Full suite 3927 passed /
223 files. Catalog now 130 rows: layout 20, a11y 14, taste 34, tell 36,
content 16, autofix 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…encies Hostless CI needs ui gate to evaluate the seven rules that require a real render, which was the single named condition for reopening this. The answer is not puppeteer. The rule this repo runs on is not "never use a browser" — it is never take a browser DEPENDENCY. ui vr already states the split: the binary never renders, it compares screenshots the host produced, with a hand-written PNG codec so no npm image library is needed. Node ships a global WebSocket and fetch, CDP is JSON-RPC over one socket, and the es:lazy ladder puts stdlib before installed dep. So the client is ~180 lines and imports nothing, and it drives a browser that is ALREADY on the machine — no download, no install, no version pinned to ours. Nondeterminism is quarantined in the capture step. The seven rules are pure functions of a versioned RuntimeCapture and are tested on fixtures with no browser in the room, which is what lets a hostless run verify them. Every capture carries its engine, so a finding is never "the page is broken" but "broken under Chrome 151 at 1280x800" — skew between runner images becomes a stated condition rather than a flake. content-hidden-at-rest is the rule that justifies the tier: copy still at opacity 0 after the page settles ships a blank page, static analysis provably cannot see it, and generators produce it routinely. Three real bugs the probes found: - The capture collected console errors into an array NOTHING ever wrote to, so script-error could never fire on a live page. It surfaced only because deleting the domain enables left the suite green — that emptiness is what a green means there. The client now dispatches CDP events and subscribes BEFORE navigating, since an exception thrown during load is the one worth catching. - A fixed debug port made the session compete with itself: Chrome's renderer and GPU children hold it briefly after the parent exits, so a second capture hung for the full 20s launch timeout. Port 0 plus DevToolsActivePort removes the conflict class instead of timing around it. - Teardown returned before the child had exited. It now sends SIGTERM, escalates to SIGKILL after 2s, and WAITS — stop cleanly first, hard only if ignored. DOM and CSS are deliberately not enabled. The feasibility probe hit their trap (CSS.getComputedStyleForNode returns undefined, with no error, unless both are on) but this reads through one injected expression rather than four calls per node, so the area is avoided entirely. Verified: 25 new tests, including three LIVE smokes against real Chrome — a resolved var(--brand), a deliberate throw reaching script-error, and two back-to-back captures proving no orphan. The suite says out loud when no browser is present rather than skipping silently. 11 red probes, one of which came back green and exposed the empty-consoleErrors bug. Full suite 3952 passed / 224 files. Catalog now 137 rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ow reaches ui gate composes `tell`, and gate coverage reports 137 checks across six families. The rendered rows are reported INACTIVE without a capture: marking them active would be precisely the silent pass NOT-EVALUATED exists to prevent. ui tell-lint --coverage lists all 43, not only the 36 fact rules. A rule nobody can see in the matrix is a rule nobody notices has stopped running. knowledge/design-tells.md gains its Rendered section and its routing front-matter; the index is emitted rather than hand-edited, which ui knowledge check enforces. docs/tell-lint.md is the command reference, including a table of everything a run reports it could NOT do — the undercount tier, the unresolvable read, the NOT-EVALUATED rule, the uncomputable contrast pair, the truncated walk. The README marketing body changes because the story did: the floor is no longer only for the HTML the model just wrote. Point it at a directory and it reads the app you already have, in six languages, because the rules are written against design facts rather than CSS syntax. Every number in the README was MEASURED, not remembered, and three were already wrong: taste-lint was quoted at 14 checks against a catalog of 34, validate-layout at 12 against 20, content-lint at 10 against 16. A test now derives each count from CHECK_CATALOG and fails when the README disagrees — verified by reverting one number and watching it go red. The hero's "zero-dependency kernel" line is gone, since that is no longer true. Full suite 3953 passed / 224 files. 46 commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… asked for
--render was documented in the help and did nothing: runRenderedPass existed and
was never called. A flag that reads as implemented and silently does nothing is
the same failure the NOT-EVALUATED contract exists to prevent, one level up.
Wiring it needed an async command, and the CLI's run() is synchronous with 78
test files reading its number. Widening the return type to a union broke all of
them for one command's sake, so run() stays synchronous and runAsync() serves the
entrypoint. Calling an async command through run() is a clear error rather than
an unawaited promise reported as exit 0.
The other bug the first live run exposed: `--browser /nope` silently fell through
to Chrome. Every rendered finding is stated under its engine, so quietly
rendering with a DIFFERENT engine than the operator named invalidates the whole
report while looking like it worked. An explicit path that does not exist is now
a hard stop; the environment and platform fallbacks still apply when nothing was
named.
$ ui tell-lint page.html --render
rendered tier: Chrome/151.0.7922.174
✗ [content-hidden-at-rest] html > body > h1.hero: "Never revealed" is still
at opacity 0 after 700ms — it is in the DOM and not on the screen
$ ui tell-lint page.html --render --browser /nope
rendered tier NOT-EVALUATED — --browser /nope does not exist
Full suite 3955 passed / 224 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installed from the tarball and pointed at five real repos — 376 to 2,464 source files each. Every one of these was invisible to the suite: **A component file is not a page.** flat-type-hierarchy fired 27 times on dana-ui, and all 27 were 12/14/16px in component files — text-xs/text-sm/ text-base, which is interface text, not a broken heading scale. A fragment has no display type to be flat, so the rule now requires a size that reads as display before judging. **rounded-full IS the chip case.** cramped-padding exempted only SMALL radii, so it flagged most pills in the app — 4px on a 999px-radius surface is a pill doing what pills do. 46 findings became 19, and the 19 are genuine 4-6px padding on 12-16px surfaces. **One decision reported many times.** A single CSS declaration paints every element its selector matches, so one authored mistake arrived as N findings differing only by locator: 54 identical cramped-padding lines in one file, 210 identical contrast lines in another. Collapsed by (checkId, message) with the count and an example node. Fixed once in lintTell for all three families rather than in the one that surfaced it — the same blind spot had two symptoms. **239 lines of skip noise.** The skip list printed one line per file and buried eight findings under "no extractor claims .ts". Summarised by reason; the counts stay, so nothing is hidden. **lineOfOffset rescanned from byte zero on every call.** O(offset) per lookup, thousands of lookups, on a 1.1MB scraped page: 18 seconds for one file. A prebuilt index makes it 2.4s — 7.6x — and the output is byte-identical, which is the invariant that mattered, not the number. Caching selector matches per rule rather than per declaration was tried first and won only 13%; the measurement said where the time actually went. Net on real repos: design-starter-lab 58s → 3.1s, jang-personal-site 58s → 10.4s, the rest sub-second. Advisory noise roughly halved with no genuine finding lost — the two low-contrast errors on dana-ui survive, and they are real: its splash screen paints #A3A3A3 on #FFFFFF at 2.52:1 before Tailwind loads. Full suite 3962 passed / 224 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pointed at jang-personal-site — 221 files — and one of nine errors read "text #ffffff on #fdfdfd is 1.02:1". White on near-white is invisible, so either the site was broken or the check was. It was the check. #FDFDFD came from `<div style="background-color:#FDFDFD">` holding a video poster. The fallback took "the first opaque background encountered", so unrelated text anywhere on the page was judged against a video placeholder — a ratio for a surface nobody ever sees. The page background now comes from <html> or <body> and from nowhere else; with no root background the pair is NOT COMPUTABLE, which is the honest answer. The first fix for this was worse than the bug. Refusing whenever a media element sat anywhere in the ancestor chain silenced 271 pairs on that site, because an `<svg>` icon makes `body` media-covered and with it every text node on the page — including three real syntax-highlighting failures (#3178c6 on #101010 at 4.2:1). Narrowed to the media's DIRECT parent: the container that literally wraps a video is the one whose background the video hides. 271 not-computable became 96, all in 10 of 221 files whose <body> paints through Tailwind classes the cascade cannot read. Net: 9 errors → 6, and every one of the 6 is real — two gradient-clipped text rules and four marginal AA failures on code blocks and buttons. A probe caught the last gap: deleting root detection left the suite green, because every fixture had a complete parentRef chain and never needed the fallback. The fallback only matters for a floating node, which an extractor with heuristic structure can produce. Both directions now have a test. Full suite 3971 passed / 224 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran the reference implementation and this one over the same real pages — files neither tool was written against — and the counts disagreed. impeccable found 7 nested cards on one page where this found 0. The cause was a rule that was too strict, not an extractor that could not see. The extractor found all 10 cards; every one sat inside a layout wrapper — `div.flex > div.flex > div.preview-card` inside another preview-card — and the rule demanded a card be the DIRECT parent. A wrapper between two cards does not stop them reading as cards inside cards; visually the nesting is exactly what the reader sees. Now proved through the ref path: a card whose ref extends another card's ref is nested, at any depth. The chain is still required. The prototype's mistake was calling any two radii nesting; the first implementation's mistake was demanding direct parenthood; containment proved through refs is the middle that is true. Sibling cards are still not nested, and a card inside a non-card is still not nested — both pinned. Verified: on the page that exposed this, 0 findings became 2 real ones, reported as one finding with a count. Full suite 3973 passed / 224 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Counsel from the strongest model ran both this tool and the reference on a page
neither had been pointed at, and found 16 findings I had shipped two commits
earlier: `a.card > div > div.card-title` reported as a card nested in a card,
because `/(^|-)card($|-)/i` matches `card-title`. My own ancestry fix turned that
regex from harmless into 16 false positives.
Name matching was failing in BOTH directions and I had only measured one:
- 16 phantoms on classic CSS, where a card's own title carries the word;
- 0 cards on a Tailwind page carrying 9 rounded containers, where nothing is
named at all, so four rules were silently inert on every utility-first codebase.
Roles are now a derived pass over the IR, shared by every language, reading the
facts the extractors already emit. This is the repo's own architecture applied to
roles: rules were written once over the IR, roles were not, and lived as a regex
inside one extractor.
The predicate is deliberately GENEROUS, and the measurements say why. Three
stricter variants, three real pages:
variant checkout tailwind card-title
(shadow|border)&(radius|bg) 558 7 29
shadow|(border&bg) 38 0 29
(shadow|border)&radius&bg 16 0 29
Every variant that tamed the busy page also took the Tailwind page to zero,
because a Tailwind card and an ordinary bordered div carry identical facts. They
are not separable at this fidelity and pretending otherwise just moves the error.
So the discrimination moved into the RULE. nested-cards now requires both
surfaces to be visually DISTINCT — its own background or a shadow. Two
transparent bordered divs nested inside each other read as layout, not as cards
inside cards. That took the busy page from 520 findings to 5, cost the Tailwind
case nothing, and cost the card-title case nothing.
A container clause ("a card holds something") was tried and dropped: 558 → 537
while taking Tailwind to 0. Measured, then abandoned — the note stays in the
source so the next person does not re-derive it.
Net on the two pages that exposed the two failures: 16 phantoms → 0, and 0 cards
→ 7. Across five real projects the advisory totals barely moved, which is the
point: the change traded noise for reach rather than adding either.
Verified: 19 new tests pinning both directions against the real files that
produced them; 5 red probes all confirmed red. Full suite 3991 passed / 225 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fixture states the rule author's model of the world. It catches drift away from that model and, structurally, never an error inside it — which is why 8 of 8 substantive defects on this branch came from real data and 0 from the suite. The field corpus pins real pages with a recorded verdict and a reason per finding. A fix that silences an adjudicated true positive now turns the suite red, naming the finding and quoting the reason someone wrote when they judged it real — the guard the two over-widened fixes needed. A finding nobody has judged fails as unadjudicated, printing a paste-ready row. Verdicts are tp, fp, and fp-open. The third is a known, reasoned, unfixed false positive: the runner asserts it still fires and counts it as FP debt, so the live FP rate is printed rather than left to drift. The first page adjudicated found a defect 4,038 tests missed. tight-leading read `(t.sizePx ?? 16) <= 20`, substituting 16 for an unresolvable clamp() size and then reporting a 94px headline as "line-height 0.93 on 16px body copy" — three false positives from one silent substitution. The sibling rules already avoided this by choosing defaults that cannot fire (`sizePx ?? 99`, `durationMs ?? 9999`), so the fix restores the house idiom: require the size, and stop claiming one the rule does not have. Both directions red-probed. Breaking cream-palette fails naming it and its reason; reintroducing the tight-leading bug fails as three unadjudicated rows. In-repo pages are referenced by path and pinned by content hash rather than copied — a copy diverges silently, a hash mismatch asks for re-adjudication.
Widens the corpus to every language with real source available, and makes the absent ones visible instead of merely missing. Coverage is registry-driven: the test reads EXTRACTOR_PROFILES and fails BY NAME for a profile with neither a page nor a waiver. A hand-kept list goes stale the moment a ninth extractor lands, and the blind spot arrives silently. Covered: html-cascade, css-only, jsx-tailwind, swiftui. Waived with evidence: sfc and flutter, because no real Vue, Svelte or Dart source exists on this machine — the only candidates were fixtures this repo wrote, and adjudicating our own fixture would make the corpus circular. Both extractors are proven working anyway (6 findings each on their slop fixtures). figma-nodes is not file-based; rendered-cdp is engine-relative and would pin a browser, not a rule. The corpus now also asserts both severity tiers are exercised. One made only of advisory findings cannot guard the tier that actually fails the gate. The three dispatch branches in the command move to core/lint-file-by-extractor. The corpus needed the same dispatch, and a second copy would have been worse than duplication: the corpus would judge a pipeline that had drifted from the one users run, and certify the wrong thing. Corpus data is excluded from tsc and eslint, extending the convention already written for tests/fixtures — a React file from another repo answers to that repo's tsconfig, not ours. Standing measurement, printed every run: 4 pages, 28 adjudicated findings, 25 true positive, 3 open false positive — live FP rate 10.7%. Both probes red then restored: an injected ninth profile is named as uncovered; removing the only error-severity page fails the tier assertion.
…ge clean A page the reader was blind to and a page with nothing wrong printed the same zero. Two defects on this branch lived in exactly that gap: the engine saw nothing and said nothing. The envelope now carries a per-kind fact census, and human output prints it for any file with no findings — which is precisely when a reader needs to know which kind of zero this is. Advisory: it reports, it never changes the exit code. Picking a failure threshold before measuring what a normal census looks like would be the same guess this instrument exists to replace. It found a third defect on its first real run. A Vercel export reported 0 findings with undercount:false — a fully confident clean read — while the census showed 982 elements yielding 4 colour facts and zero spacing, typography, border or radius. Its four stylesheets are linked by absolute server paths that do not exist on disk. extractHtml had always returned unresolvedSheets; no caller had ever read it, so a page whose every stylesheet failed to load was indistinguishable from a clean one. An unresolved sheet now marks the run UNDERCOUNT and the stylesheets are named, not merely counted. Corpus rows pin a census FLOOR, never an equality. A page can keep producing the same findings while the reader quietly goes blind on a whole fact kind — the rules that need it stop running and NOT-EVALUATED absorbs it. An equality would redden on unrelated edits and the row would be deleted within a week. Also pins jang-personal-site/reference-html, the page carrying this branch's most important history: 19 findings before role synthesis, 16 of them phantom nested-cards, 3 after. That regression can no longer return unnoticed. Its first finding also exercises the third rung of the verdict key, which carries neither line nor nodeRef. Corpus now: 5 pages, 31 adjudicated findings across 4 extractor profiles.
…ecutable pair Of 63+ deliberate red probes fired while building this family, 13 came back green. Every one was a number nothing asserted: the guard existed, moving it broke nothing, and the suite went on certifying a rule that no longer worked. Hand-probing is what failed, so this replaces it with a structural check. 51 tuning knobs move to TELL_THRESHOLDS, each with an owner and its provenance. The scars are recorded next to the values they explain — the purple window that excluded indigo #6366f1, the cream floor that excluded #FAF7F0 itself, the display floor that came from 27 of 27 hits being UI chrome. Classified OUT deliberately: presence checks (px > 0), geometric definitions (sides.length >= 4 means "all four sides"), and array guards that only say there is not enough here to compare. Turning those does not retune a rule, it breaks one, and a table that absorbs every integer teaches nothing. A number is not pinned by being written down. BOUNDARY_PAIRS supplies an executable pair per constant — a value AT the threshold that must NOT trip and one PAST it that MUST — as either a call to the real colour predicate or two tiny documents through the real extractor and linter. Never a re-implementation of the comparison, which would be green by construction and blind to the one thing worth catching: a constant nothing consumes. 21 are pinned that way; the other 30 are listed in UNPINNED with a reason each, and the count prints on every run so the gap gets driven down rather than tolerated. A new constant with neither a pair nor a reason fails the meta-test. The meta-test earned itself immediately: the first undersized-ui-text pair used a <p>, stayed green in BOTH directions, and pinned nothing — the rule judges label text. That is precisely the pair shape the 13 green probes were made of. Corpus delta across the whole migration: zero. Both probes red then restored. An unpaired constant is named. Restoring the historical cream floor of 12 reddens with the SCAR message saying why 12 was wrong — a regression that shipped once and now cannot.
Two structural changes that close classes rather than cases. The fixture contract gains a third class: the NEAR-MISS, the tempting adjacent thing that must stay silent. A rule that only ever fires has not been shown to discriminate — but neither has one that only stays quiet on obviously-fine input. The hard cases look exactly like the tell and are not it, and the two-fixture contract structurally cannot express one. The six worked examples are not invented; each is a defect that shipped: a pill for cramped-padding, the system font stack for overused-font, card-title for nested-cards, a video poster for documentBg, a clamp() headline for tight-leading, a nav CTA for hero-eyebrow-chip. The backlog is stated rather than implied — 43 rules, 6 near-misses, so ~37 rules have none. Three laws state what no fixture can: that the answer does not depend on things it must not depend on. L1, an unrelated fact changes nothing. L2, fact order changes nothing. L3, duplicating a fact never doubles a finding — the law that makes the 54-findings-from-210-duplicate-facts defect impossible by construction, asserted at the sink so it covers every rule including unwritten ones. Probing them found two things worth recording, both now in the docblock. L3 is guarded TWICE: disabling the dedup alone leaves it green, disabling collapseRepeated alone leaves it green, only both together turn it red. And L2 does not rest on the engine's final sort at all — deleting that sort leaves the law green, and under a change that genuinely varies with input order it fails only 2 of its 5 cases. A single-seed L2 would have missed the break, which is why there are four seeds and a reversal. The first draft of L2 was itself tautological: the helper sorted the output before comparing, so order dependence could not be observed through it. That is the same failure shape as the 13 green probes, found this time in the probe's own scaffolding rather than in the code under test.
Automates the red-probe discipline that failed 13 times out of 63 when done by hand. Stryker mutates a rule predicate and reports whether any test noticed; the survived-mutant list is the machine-generated version of "these guards do not guard", which until now was compiled from memory. Nightly and on demand, NEVER on a pull request. As a gate it is slow, noisy on cosmetic code, and gets argued with until someone disables it — at which point the signal is gone entirely. As an audit it produces a number that trends and a list somebody reads on purpose. continue-on-error is the point, not an oversight: a falling mutation score is information, not a broken build. Scoped to the six rule modules, with string, regex and object-literal mutants excluded — mutating a fixHint produces noise with no signal. It runs against a vitest projection of the tests that judge those rules, because re-running 4,147 tests per mutant would produce a job nobody waits for and therefore nobody reads. The sandbox is an allow-list rather than a deny-list. Stryker copies the repo per run and this root holds things that cannot be copied — a symlinked `taste` socket, Swift .build sockets under plans/ — and chasing each one is a losing game. Baseline for trending: 62.91% over 1,162 mutants, 731 killed, 418 survived, 2m25s. Weakest tell-rules-labels.ts at 46.71%, strongest tell-rules-type.ts at 71.43%. The first survivors are already useful: emptying ICON_TAGS survives, so nothing asserts tag-based icon detection. devDependencies only. The runtime dependency list stays at four and its guard is untouched and passing. docs/tell-lint.md gains the fact census and a section on the three instruments that keep the rules honest — the corpus, the threshold table, and this audit.
A field corpus with adjudicated verdicts, a printed live false-positive rate, a fact census so a zero can be read, thresholds pinned by executable boundary pairs, three metamorphic laws, and a nightly mutation audit. Also records the two defects the corpus found on its first pages: tight-leading substituting 16 for an unresolvable clamp() size, and unresolvedSheets that no caller had ever read.
…by name on Node 20 Two failures that were green on a macOS/Node 22 laptop and red on CI. Both are the same shape: an assumption about the environment that had never been tested anywhere else. Two tests read probe fixtures out of `plans/`, which is gitignored — so the files existed on one machine and on no CI runner. Moved into `tests/fixtures/` where test inputs belong. This is the repo's own scar about a test asserting its own filesystem path, arriving in a new costume. `WebSocket` only became a Node global in 22, and this package supports `>=20`, so the rendered tier cannot open a debugger socket on the Node 20 job — which has Chrome installed, so the live tests did not skip and instead threw a bare ReferenceError from inside a rule run. `cdpUnavailableReason()` now names the requirement, and the tier reports it NOT-EVALUATED rather than crashing: its seven rules do not get to count as clean because the runtime could not run them. `runRenderedPass` also gains a try/catch, because a capture failure mid-run used to take the whole command down — it now reports the tier NOT-EVALUATED and names the cause, which is what the comment above it already claimed it did. The live rendered tests skip on either reason, loudly, keeping the existing contract that a skipped smoke must never read as a pass. engines stays >=20. Dropping a published Node version to make a test pass would be a user-facing breaking change in service of an opt-in tier.
…thing Found by adversarial review, and it is the exact failure this instrument exists to prevent — a false green inside the thing built to catch false greens. DARK_SURFACE_MAX_CHANNEL's pair hardcoded `r < 60 && g < 60` inline. It never read TELL_THRESHOLDS and never ran the dark-glow rule, so moving the constant could not redden it, while it was still counted in the pinned tally. The tally was therefore a false claim. It now runs two documents through the real linter, like every other css pair. Two more pairs had slack a drifting constant could hide inside. LINE_LENGTH_MAX_CHARS used 300 and 440, leaving [301,439] free to move silently; REPEATED_TEXT_MIN_CHARS used 13 and 56. Both are now exactly at the threshold and exactly one step past it. A pair with slack pins a neighbourhood, not a value. All three re-probed by moving their constant: each now goes red. The old dark-surface pair stayed green under the same treatment, which is how the review found it.
This was referenced Aug 28, 2026
CI caught this one too. The restoration check asserted `typeof globalThis.WebSocket === "function"`, which is only true on Node 22 — so on the Node 20 job, where there is no WebSocket to restore, the one test describing cross-version behaviour failed on the very version it exists to describe. It now asserts restoration to whatever the runtime had to begin with, captured before anything touches it. Verified in the failing condition rather than by reasoning about it: run with the global deleted, the file gives 27 passed and 3 live-capture tests skipped, which is the Node 20 shape. Green on a Node 22 laptop was what produced the bug.
jangtrinh
added a commit
that referenced
this pull request
Oct 29, 2026
…#235) * docs: sync the command count and test total with the binary The README said 42 commands and the CLI answers 45. The three missing entries were gate, scrub-lint and scrub-scaffold — and gate is the composed judge every other linter reports through, so the most central command in the kernel was the one the table forgot. Kernel tests read 3,430; the suite now runs 3,699 green across 210 files. The 2026-08-16 changelog row keeps its 42: that release did carry 42, and a changelog that edits itself to match today is not a changelog. * feat(facts): add the DesignFacts IR — one model, N extractors, rules written once Rules bound to CSS syntax can never leave the web. Bound to design facts — a resolved color, a font family, a radius, a border on one side, a nesting depth — the same rule judges an .html, a .tsx, a .swift and a .dart. The contract that matters is NOT-EVALUATED. An extractor declares, per fact kind, the strongest confidence it can reach; a rule declares the kinds it needs. Against a given extractor a rule is RUNNABLE or NOT-EVALUATED with the missing kinds named — partition() throws if a rule lands in neither, so a lost rule can never read as one fewer finding. This is what stops a polyglot linter from turning "I cannot see it" into "it is clean". The collector refuses facts that contradict the declaration: an undeclared kind, a foreign extractor id, or a confidence stronger than declared. Provenance is required by the type, so a fact nobody can point at cannot be constructed. CHECK_CATALOG.requires widens from "none" | "tokens" to also accept a fact-kind set. All 89 existing rows keep the legacy string form and evaluate identically. audit-detect.ts gains a drift guard rather than a promise: the contract test builds DesignFacts from a fixture AuditNode covering every fact-bearing field, so adding one there goes red until the IR can express it. Verified: 21 new tests; 8 red probes (rule-always-runnable, atLeast-always-true, both collector guards, the deterministic sort, undeclared kind, missing provenance at compile time, catalog rows unchanged) each confirmed red when broken; full suite 3739 passed / 215 files. * feat(findings): add the advisory severity tier and in-file waivers Signs are not defects. A cream-palette surface is worth printing and never worth failing a build over, so FloorSeverity gains a third tier: detected, listed, excluded from the failure count. The exit code keeps its exact current meaning — it keys off errors alone. The tier's danger is arithmetic, not typing. Thirteen call sites derived warningCount as `findings.length - errorCount`, which buckets advisory into warnings with no type error to catch it. countBySeverity() in finding-schema is now the ONE counter; a fourth tier becomes a one-place change instead of a fourteen-place hunt. Scope is deliberately narrow: only families on FloorSeverity adopt it. flow-lint, ds-soul, ds-usage-lint, validate-prompt-plan and specimen-check declare their own two-value Severity and cannot emit advisory, so forcing the shared helper there would only have churned a public envelope — diagram-real-flow-proof caught exactly that and the fix was to shrink the change, not to edit the test. Inline waivers travel with the source in whatever comment syntax it uses: <!-- design-os-disable side-tab -- exported brand doc --> .brand { font-family: Inter } /* design-os-disable-line overused-font -- why */ // design-os-disable-next-line pulsing-dot -- genuinely live data A reason is mandatory (ui gate --skip already requires one) and a directive without one is reported malformed rather than honoured. Every waived finding leaves a record carrying its reason, so the total stays visible. The comment tail is stripped before matching: without it the `--` of an HTML terminator parses as the reason separator and a waiver with NO reason gets honoured with the reason '>'. Verified: 11 new tests; 6 red probes (advisory folded into warnings, warnings made to fail, reason-less waiver honoured, -next-line off by one, silent waive, comment tail unstripped) each confirmed red when broken; full suite 3750 passed. * feat(extractors): resolve the CSS cascade into DesignFacts The raw-string linters could never know which rule wins for which element. LAYOUT_PROP_RE scoped inside @Keyframes let `transition: width` through across 22 files in four products for exactly that reason. Selectors now match the real DOM, ordered by specificity then document position, so the value a rule reads is the value that will render. This is the reversal of the zero-runtime-dependency property, ratified by the owner on 2026-08-26: htmlparser2, css-tree, css-select, domutils. The guard is narrowed rather than deleted — tests/approved-runtime-deps.ts names the four, and a fifth still turns the suite red. Real data moved this twice, as it always does: - The repo's own site/index.html keeps its CSS in three linked files and one <style>. An inline-only engine reported a page with no colours at all. Linked LOCAL stylesheets are now read from the same filesystem the linter already reads — not a network fetch, determinism intact; http(s) and data: stay declared-unresolved. - Two shipped examples are Tailwind-CDN pages: zero stylesheets, the whole design in class attributes. The engine genuinely cannot see them, and silence there is 'I could not read this' mistaken for 'this is clean'. It now counts and names the gap. Shorthands are expanded for padding/margin/border-width/gap. `padding: 16px` is how stylesheets are actually written, and without expansion cramped-padding and monotonous-spacing report a clean page for most real projects. @media rules are collected under their condition and kept OUT of the unconditional cascade, so a mobile-only rule is never reported as always-on. Verified: 33 new tests across fixture and real-data suites; 8 red probes, two of which came back GREEN on the first attempt — the cycle guard was passing only because MAX_DEPTH also terminates, and the source-offset guard passed because 'line > 0' is true even when every line collapses to 1. Both assertions were strengthened until the break went red. Full suite 3784 passed / 218 files. * feat(lint): resolve file, directory and glob targets for every linter Until now every linter took exactly one .html file, so a React app could not be linted and a SwiftUI app was invisible — the open design-os-code-surface-gap. The resolver lands at the shared layer rather than in whichever linter the gap surfaced in; the repo already paid for the other choice when a redirect-stub rule patched into a11y-lint reappeared in validate-layout. SKIP_DIRS, MAX_DEPTH and MAX_ENTRIES are now exported from project-scan and shared rather than forked. Those are corpus-counted decisions — ten plausible additions measured 0 hits across nine projects while .venv alone was 8,187 files — and a second copy would drift from the measured one. Nothing leaves the resolver unaccounted for: an unclaimed extension is SKIPPED with a reason, a missing path says so, and a walk that hits its budget sets `truncated` with a note naming what was not examined. A silent cap reads as 'covered everything' when it did not. Adds a css-only extractor for bare stylesheets. A .css file states values but not which elements receive them, so it declares no structure and no text, and every rule needing those is NOT-EVALUATED rather than guessed from selector names. A testability seam was needed to keep an assertion honest: APFS returns these filenames already sorted, so deleting the walk's own .sort() could not go red on this machine. The guard is real on filesystems that return arbitrary order, and untested is the same as unguarded — so resolveTargets takes an optional readDir and the truncation test hands it a deliberately shuffled reader. Verified: 12 new tests; 8 red probes. Two came back green first: dropping the walk sort (fixed by the seam above) and the budget cap, which had no test at all until one was written. Full suite 3796 passed / 219 files. * feat(tell): add the tell family — 36 rules over facts, in any language taste asks whether a rubric law was broken. tell asks whether a habit was revealed: an involuntary, machine-detectable sign that a surface was made without design judgment. Different question, so a different family, and mostly advisory — a tell prints, it does not fail a build. Rules read DesignFacts and nothing else. A rule that reached for an HTML string would make the family web-only again, so a test strips comments from every rule module and asserts no extractor import survives. knowledge/design-tells.md ships in the same commit: a standard without an emitter and a linter drifts, and this repo has already watched its own compiler violate a convention its knowledge taught. On the probe file the xia analysis measured, ui tell-lint now reports 10 findings — the same count impeccable's detector reported, reached through a resolved cascade rather than regex. Three things the first real run exposed: - One CSS rule matching N elements produced N identical findings: side-tab twice and cramped-padding eight times for a single stylesheet line. Provenance gained a nodeRef, findings dedupe on it, and padding collapsed to one finding per element naming its sides. Accurate output nobody will read is not output. - tsup externalises dependencies by default, so the build output had silently stopped being relocatable — copied anywhere without its packages beside it, it died before any command ran. The first check missed it because grepping the entry file showed nothing; the externals had moved into the shared chunk. Now bundled, and the guard is behavioural: run the copy in isolation. - css-tree's root entry require()s ../data/patch.json at runtime, which no bundler can follow. The engine imports css-tree/parser, /walker and /generator instead, with local type declarations, and a test parses CSS from the isolated binary to prove it. Verified: 66 new tests, one per rule proving it fires plus a floor of false-positive cases; 8 red probes, of which FIVE came back green first — the NOT-EVALUATED bypass, side-tab's one-side condition, nested-cards' parent check, the purple threshold and the deduplicator were all "obviously" covered by a nearby assertion that tested something else. Fixing the purple probe also fixed the rule: the old threshold required r-g > 30, which excluded indigo (#6366f1, r-g = -3), a colour squarely in the family. Full suite 3865 passed / 220 files. * feat(extractors): read SwiftUI and Flutter — same 36 rules, no rule edited The architecture's claim, now paid: a ~150-line extractor per language is all that stands between the rule set and a new platform. SwiftUI and Flutter each report six tells on their slop fixture and zero on their clean one, through rules written once against DesignFacts. Platform defaults live in the extractor that knows the platform. Flutter's default IS Roboto, so a TextStyle with no fontFamily emits Roboto once per file and overused-font fires — correctly. SwiftUI's SF is a system face, not a badly made choice, so nothing is emitted and the rule stays silent. A rule that had to know which platform it was judging would not be language-independent any more. Neither declares `structure`: view and widget nesting is real in the source but not readable from a line scan, so every nesting and rhythm rule reports NOT-EVALUATED rather than guessing. Under-declaring is honest; over-declaring is a silent false green. Two bugs the first real run exposed, both of the same shape — a rule that looks implemented and can never fire: - Rules paired facts by LINE EQUALITY, which is a cascade assumption. SwiftUI and Flutter spell one view as a chain of modifiers on consecutive lines, so side-tab and pulsing-dot were dead on every native file. sameOwner() now matches on nodeRef when both facts name one and a tight line window otherwise. - Opening that window then mispaired headings: heading-rhythm read the h2's size for the h1 and reported a scale nobody wrote. sameOwner answers "could these belong together"; nearestOwner answers "THE partner", same line first. Both directions are now pinned by tests. The Flutter border regex used `[^)]`, which closes on the nested Color(...) call and hides the width after it — implemented, never firing. Bounded `[\s\S]` instead, with a test carrying the exact nested shape. docs/extractor-authoring.md states the contract so the ne F6C0 xt platform is a file plus two fixtures: register a profile, emit into a collector that refuses undeclared facts, never guess, and declare only what you can see. Verified: 23 new tests; 6 red probes all confirmed red, including a return to line equality (which kills the native rules) and dropping Flutter's Roboto default. Full suite 3888 passed / 221 files. * feat(extractors): read JSX/Tailwind, Vue/Svelte SFCs and bare stylesheets React carries its design in three places and only one is CSS. Utilities are the dominant one and they are pure data — `rounded-2xl` means 16px with no evaluation — so the resolver reads them exactly, keeping the SIDE of `border-l-4`, which is what makes side-tab reachable from a className. An SFC is two documents in one file, and splitting them lets each half be read at the fidelity it deserves: the <style> block through the real cascade at resolved confidence, the template through the scanner at literal. Reading the whole file with one tool would drop the better half for no reason. A bare stylesheet states values but not which elements receive them, so css-only supplies no structure and no text, and every nesting rule is NOT-EVALUATED rather than guessed from selector names. What the extractors REFUSE is the load-bearing part: - `className={cn(base, isActive && "border-l-4")}` cannot be evaluated, so it emits nothing and is counted. A guess is a finding about a component that never renders that way. - A utility-shaped token the resolver does not carry (`bg-teal-750`) is counted by name — "unmapped utility" is actionable, a silent drop is not. - A styled-components `${}` interpolation is flagged; the declarations around it still land. JSX structure is supplied at HEURISTIC confidence only. Nesting is real in the source but a scanner reads it by brace counting, which is wrong the moment a component moves to another file — so rules needing exact nesting get a heuristic answer and the coverage matrix says so. tests/fixtures is excluded from typecheck. tell-react-slop.tsx deliberately references an undefined `isActive` because that IS the unresolvable case under test; making the fixture answer to the compiler would defeat its purpose. eslint already ignored the path for the same reason. Verified: 21 new tests; 8 red probes. Two came back green first — both landed on the css-only branch rather than the SFC branch they were aimed at, and neither tier's confidence or line mapping had an assertion until one was written. Full suite 3909 passed / 222 files. * feat(a11y): compute WCAG contrast from facts, and add the four voice tells ui a11y-lint printed "0 static findings … rendered criteria need a browser" and left it there. With a resolved cascade the excuse is gone: the colour a text node is painted on is computable without one. On the probe fixture this reaches 2.24:1 for #9ca3af on #7c3aed — the same number impeccable measured, through the cascade instead of regex. No extraction was needed. contrastRatio and relativeLuminance have lived in color-scale.ts since the DS token work; ds-a11y checked declared TOKEN pairs with them, and this checks the painted result. Reuse before extraction, before writing. Three refusals are the point: - `resolved` confidence is required. A ratio built from literals no cascade resolved is a number that means nothing, so on SwiftUI and Flutter this is NOT-EVALUATED rather than wrong. - Text over a gradient is NOT COMPUTABLE — a ratio against a gradient is not one number. The run is reported partial, and the count prints. - The walk passes THROUGH a translucent paint instead of blending it. A 10% veil does not establish the background, and inventing the blend would be a finding about a page nobody wrote. The four voice tells read only the `text` fact, so they judge Swift and Dart as readily as HTML. em-dash-overuse is a RATE with a 60-word floor, not a count: a raw count flags a long well-written page and misses a short saturated one. Two guards did not guard, both because the test could not discriminate: - The translucent-veil case used #767676, which passes on black AND on white, so deleting the alpha check changed nothing. #7a7a7a passes on black (4.89) and fails on white (4.29) — only that colour separates the two paths. - The ancestor walk was rescued by the document-background fallback until the fixture gained a second subtree with its own background. The voice ids are paired against RUNTIME EMISSION, not against a grep of their own source: the literal extractor cannot see a positional `finding("id", …)`, and widening the grep would have left it verifying itself. Verified: 17 new tests; 9 red probes all confirmed red. Full suite 3927 passed / 223 files. Catalog now 130 rows: layout 20, a11y 14, taste 34, tell 36, content 16, autofix 10. * feat(rendered): drive an installed browser over CDP, with zero dependencies Hostless CI needs ui gate to evaluate the seven rules that require a real render, which was the single named condition for reopening this. The answer is not puppeteer. The rule this repo runs on is not "never use a browser" — it is never take a browser DEPENDENCY. ui vr already states the split: the binary never renders, it compares screenshots the host produced, with a hand-written PNG codec so no npm image library is needed. Node ships a global WebSocket and fetch, CDP is JSON-RPC over one socket, and the es:lazy ladder puts stdlib before installed dep. So the client is ~180 lines and imports nothing, and it drives a browser that is ALREADY on the machine — no download, no install, no version pinned to ours. Nondeterminism is quarantined in the capture step. The seven rules are pure functions of a versioned RuntimeCapture and are tested on fixtures with no browser in the room, which is what lets a hostless run verify them. Every capture carries its engine, so a finding is never "the page is broken" but "broken under Chrome 151 at 1280x800" — skew between runner images becomes a stated condition rather than a flake. content-hidden-at-rest is the rule that justifies the tier: copy still at opacity 0 after the page settles ships a blank page, static analysis provably cannot see it, and generators produce it routinely. Three real bugs the probes found: - The capture collected console errors into an array NOTHING ever wrote to, so script-error could never fire on a live page. It surfaced only because deleting the domain enables left the suite green — that emptiness is what a green means there. The client now dispatches CDP events and subscribes BEFORE navigating, since an exception thrown during load is the one worth catching. - A fixed debug port made the session compete with itself: Chrome's renderer and GPU children hold it briefly after the parent exits, so a second capture hung for the full 20s launch timeout. Port 0 plus DevToolsActivePort removes the conflict class instead of timing around it. - Teardown returned before the child had exited. It now sends SIGTERM, escalates to SIGKILL after 2s, and WAITS — stop cleanly first, hard only if ignored. DOM and CSS are deliberately not enabled. The feasibility probe hit their trap (CSS.getComputedStyleForNode returns undefined, with no error, unless both are on) but this reads through one injected expression rather than four calls per node, so the area is avoided entirely. Verified: 25 new tests, including three LIVE smokes against real Chrome — a resolved var(--brand), a deliberate throw reaching script-error, and two back-to-back captures proving no orphan. The suite says out loud when no browser is present rather than skipping silently. 11 red probes, one of which came back green and exposed the empty-consoleErrors bug. Full suite 3952 passed / 224 files. Catalog now 137 rows. * docs: compose the tell family into the gate, and say what the floor now reaches ui gate composes `tell`, and gate coverage reports 137 checks across six families. The rendered rows are reported INACTIVE without a capture: marking them active would be precisely the silent pass NOT-EVALUATED exists to prevent. ui tell-lint --coverage lists all 43, not only the 36 fact rules. A rule nobody can see in the matrix is a rule nobody notices has stopped running. knowledge/design-tells.md gains its Rendered section and its routing front-matter; the index is emitted rather than hand-edited, which ui knowledge check enforces. docs/tell-lint.md is the command reference, including a table of everything a run reports it could NOT do — the undercount tier, the unresolvable read, the NOT-EVALUATED rule, the uncomputable contrast pair, the truncated walk. The README marketing body changes because the story did: the floor is no longer only for the HTML the model just wrote. Point it at a directory and it reads the app you already have, in six languages, because the rules are written against design facts rather than CSS syntax. Every number in the README was MEASURED, not remembered, and three were already wrong: taste-lint was quoted at 14 checks against a catalog of 34, validate-layout at 12 against 20, content-lint at 10 against 16. A test now derives each count from CHECK_CATALOG and fails when the README disagrees — verified by reverting one number and watching it go red. The hero's "zero-dependency kernel" line is gone, since that is no longer true. Full suite 3953 passed / 224 files. 46 commands. * fix(tell-lint): wire --render, and stop substituting an engine nobody asked for --render was documented in the help and did nothing: runRenderedPass existed and was never called. A flag that reads as implemented and silently does nothing is the same failure the NOT-EVALUATED contract exists to prevent, one level up. Wiring it needed an async command, and the CLI's run() is synchronous with 78 test files reading its number. Widening the return type to a union broke all of them for one command's sake, so run() stays synchronous and runAsync() serves the entrypoint. Calling an async command through run() is a clear error rather than an unawaited promise reported as exit 0. The other bug the first live run exposed: `--browser /nope` silently fell through to Chrome. Every rendered finding is stated under its engine, so quietly rendering with a DIFFERENT engine than the operator named invalidates the whole report while looking like it worked. An explicit path that does not exist is now a hard stop; the environment and platform fallbacks still apply when nothing was named. $ ui tell-lint page.html --render rendered tier: Chrome/151.0.7922.174 ✗ [content-hidden-at-rest] html > body > h1.hero: "Never revealed" is still at opacity 0 after 700ms — it is in the DOM and not on the screen $ ui tell-lint page.html --render --browser /nope rendered tier NOT-EVALUATED — --browser /nope does not exist Full suite 3955 passed / 224 files. * fix(tell): five defects real projects found that fixtures could not Installed from the tarball and pointed at five real repos — 376 to 2,464 source files each. Every one of these was invisible to the suite: **A component file is not a page.** flat-type-hierarchy fired 27 times on dana-ui, and all 27 were 12/14/16px in component files — text-xs/text-sm/ text-base, which is interface text, not a broken heading scale. A fragment has no display type to be flat, so the rule now requires a size that reads as display before judging. **rounded-full IS the chip case.** cramped-padding exempted only SMALL radii, so it flagged most pills in the app — 4px on a 999px-radius surface is a pill doing what pills do. 46 findings became 19, and the 19 are genuine 4-6px padding on 12-16px surfaces. **One decision reported many times.** A single CSS declaration paints every element its selector matches, so one authored mistake arrived as N findings differing only by locator: 54 identical cramped-padding lines in one file, 210 identical contrast lines in another. Collapsed by (checkId, message) with the count and an example node. Fixed once in lintTell for all three families rather than in the one that surfaced it — the same blind spot had two symptoms. **239 lines of skip noise.** The skip list printed one line per file and buried eight findings under "no extractor claims .ts". Summarised by reason; the counts stay, so nothing is hidden. **lineOfOffset rescanned from byte zero on every call.** O(offset) per lookup, thousands of lookups, on a 1.1MB scraped page: 18 seconds for one file. A prebuilt index makes it 2.4s — 7.6x — and the output is byte-identical, which is the invariant that mattered, not the number. Caching selector matches per rule rather than per declaration was tried first and won only 13%; the measurement said where the time actually went. Net on real repos: design-starter-lab 58s → 3.1s, jang-personal-site 58s → 10.4s, the rest sub-second. Advisory noise roughly halved with no genuine finding lost — the two low-contrast errors on dana-ui survive, and they are real: its splash screen paints #A3A3A3 on #FFFFFF at 2.52:1 before Tailwind loads. Full suite 3962 passed / 224 files. * fix(a11y): the page background comes from the root, never from a guess Pointed at jang-personal-site — 221 files — and one of nine errors read "text #ffffff on #fdfdfd is 1.02:1". White on near-white is invisible, so either the site was broken or the check was. It was the check. #FDFDFD came from `<div style="background-color:#FDFDFD">` holding a video poster. The fallback took "the first opaque background encountered", so unrelated text anywhere on the page was judged against a video placeholder — a ratio for a surface nobody ever sees. The page background now comes from <html> or <body> and from nowhere else; with no root background the pair is NOT COMPUTABLE, which is the honest answer. The first fix for this was worse than the bug. Refusing whenever a media element sat anywhere in the ancestor chain silenced 271 pairs on that site, because an `<svg>` icon makes `body` media-covered and with it every text node on the page — including three real syntax-highlighting failures (#3178c6 on #101010 at 4.2:1). Narrowed to the media's DIRECT parent: the container that literally wraps a video is the one whose background the video hides. 271 not-computable became 96, all in 10 of 221 files whose <body> paints through Tailwind classes the cascade cannot read. Net: 9 errors → 6, and every one of the 6 is real — two gradient-clipped text rules and four marginal AA failures on code blocks and buttons. A probe caught the last gap: deleting root detection left the suite green, because every fixture had a complete parentRef chain and never needed the fallback. The fallback only matters for a floating node, which an extractor with heuristic structure can produce. Both directions now have a test. Full suite 3971 passed / 224 files. * fix(tell): nested-cards proves containment, not direct parenthood Ran the reference implementation and this one over the same real pages — files neither tool was written against — and the counts disagreed. impeccable found 7 nested cards on one page where this found 0. The cause was a rule that was too strict, not an extractor that could not see. The extractor found all 10 cards; every one sat inside a layout wrapper — `div.flex > div.flex > div.preview-card` inside another preview-card — and the rule demanded a card be the DIRECT parent. A wrapper between two cards does not stop them reading as cards inside cards; visually the nesting is exactly what the reader sees. Now proved through the ref path: a card whose ref extends another card's ref is nested, at any depth. The chain is still required. The prototype's mistake was calling any two radii nesting; the first implementation's mistake was demanding direct parenthood; containment proved through refs is the middle that is true. Sibling cards are still not nested, and a card inside a non-card is still not nested — both pinned. Verified: on the page that exposed this, 0 findings became 2 real ones, reported as one finding with a count. Full suite 3973 passed / 224 files. * feat(facts): derive roles from facts, not from class names Counsel from the strongest model ran both this tool and the reference on a page neither had been pointed at, and found 16 findings I had shipped two commits earlier: `a.card > div > div.card-title` reported as a card nested in a card, because `/(^|-)card($|-)/i` matches `card-title`. My own ancestry fix turned that regex from harmless into 16 false positives. Name matching was failing in BOTH directions and I had only measured one: - 16 phantoms on classic CSS, where a card's own title carries the word; - 0 cards on a Tailwind page carrying 9 rounded containers, where nothing is named at all, so four rules were silently inert on every utility-first codebase. Roles are now a derived pass over the IR, shared by every language, reading the facts the extractors already emit. This is the repo's own architecture applied to roles: rules were written once over the IR, roles were not, and lived as a regex inside one extractor. The predicate is deliberately GENEROUS, and the measurements say why. Three stricter variants, three real pages: variant checkout tailwind card-title (shadow|border)&(radius|bg) 558 7 29 shadow|(border&bg) 38 0 29 (shadow|border)&radius&bg 16 0 29 Every variant that tamed the busy page also took the Tailwind page to zero, because a Tailwind card and an ordinary bordered div carry identical facts. They are not separable at this fidelity and pretending otherwise just moves the error. So the discrimination moved into the RULE. nested-cards now requires both surfaces to be visually DISTINCT — its own background or a shadow. Two transparent bordered divs nested inside each other read as layout, not as cards inside cards. That took the busy page from 520 findings to 5, cost the Tailwind case nothing, and cost the card-title case nothing. A container clause ("a card holds something") was tried and dropped: 558 → 537 while taking Tailwind to 0. Measured, then abandoned — the note stays in the source so the next person does not re-derive it. Net on the two pages that exposed the two failures: 16 phantoms → 0, and 0 cards → 7. Across five real projects the advisory totals barely moved, which is the point: the change traded noise for reach rather than adding either. Verified: 19 new tests pinning both directions against the real files that produced them; 5 red probes all confirmed red. Full suite 3991 passed / 225 files. * feat(corpus): adjudicate real pages, and fix the bug the first one found A fixture states the rule author's model of the world. It catches drift away from that model and, structurally, never an error inside it — which is why 8 of 8 substantive defects on this branch came from real data and 0 from the suite. The field corpus pins real pages with a recorded verdict and a reason per finding. A fix that silences an adjudicated true positive now turns the suite red, naming the finding and quoting the reason someone wrote when they judged it real — the guard the two over-widened fixes needed. A finding nobody has judged fails as unadjudicated, printing a paste-ready row. Verdicts are tp, fp, and fp-open. The third is a known, reasoned, unfixed false positive: the runner asserts it still fires and counts it as FP debt, so the live FP rate is printed rather than left to drift. The first page adjudicated found a defect 4,038 tests missed. tight-leading read `(t.sizePx ?? 16) <= 20`, substituting 16 for an unresolvable clamp() size and then reporting a 94px headline as "line-height 0.93 on 16px body copy" — three false positives from one silent substitution. The sibling rules already avoided this by choosing defaults that cannot fire (`sizePx ?? 99`, `durationMs ?? 9999`), so the fix restores the house idiom: require the size, and stop claiming one the rule does not have. Both directions red-probed. Breaking cream-palette fails naming it and its reason; reintroducing the tight-leading bug fails as three unadjudicated rows. In-repo pages are referenced by path and pinned by content hash rather than copied — a copy diverges silently, a hash mismatch asks for re-adjudication. * feat(corpus): cover four extractor profiles, and name the gaps out loud Widens the corpus to every language with real source available, and makes the absent ones visible instead of merely missing. Coverage is registry-driven: the test reads EXTRACTOR_PROFILES and fails BY NAME for a profile with neither a page nor a waiver. A hand-kept list goes stale the moment a ninth extractor lands, and the blind spot arrives silently. Covered: html-cascade, css-only, jsx-tailwind, swiftui. Waived with evidence: sfc and flutter, because no real Vue, Svelte or Dart source exists on this machine — the only candidates were fixtures this repo wrote, and adjudicating our own fixture would make the corpus circular. Both extractors are proven working anyway (6 findings each on their slop fixtures). figma-nodes is not file-based; rendered-cdp is engine-relative and would pin a browser, not a rule. The corpus now also asserts both severity tiers are exercised. One made only of advisory findings cannot guard the tier that actually fails the gate. The three dispatch branches in the command move to core/lint-file-by-extractor. The corpus needed the same dispatch, and a second copy would have been worse than duplication: the corpus would judge a pipeline that had drifted from the one users run, and certify the wrong thing. Corpus data is excluded from tsc and eslint, extending the convention already written for tests/fixtures — a React file from another repo answers to that repo's tsconfig, not ours. Standing measurement, printed every run: 4 pages, 28 adjudicated findings, 25 true positive, 3 open false positive — live FP rate 10.7%. Both probes red then restored: an injected ninth profile is named as uncovered; removing the only error-severity page fails the tier assertion. * feat(tell): report what the engine saw, and stop calling an unread page clean A page the reader was blind to and a page with nothing wrong printed the same zero. Two defects on this branch lived in exactly that gap: the engine saw nothing and said nothing. The envelope now carries a per-kind fact census, and human output prints it for any file with no findings — which is precisely when a reader needs to know which kind of zero this is. Advisory: it reports, it never changes the exit code. Picking a failure threshold before measuring what a normal census looks like would be the same guess this instrument exists to replace. It found a third defect on its first real run. A Vercel export reported 0 findings with undercount:false — a fully confident clean read — while the census showed 982 elements yielding 4 colour facts and zero spacing, typography, border or radius. Its four stylesheets are linked by absolute server paths that do not exist on disk. extractHtml had always returned unresolvedSheets; no caller had ever read it, so a page whose every stylesheet failed to load was indistinguishable from a clean one. An unresolved sheet now marks the run UNDERCOUNT and the stylesheets are named, not merely counted. Corpus rows pin a census FLOOR, never an equality. A page can keep producing the same findings while the reader quietly goes blind on a whole fact kind — the rules that need it stop running and NOT-EVALUATED absorbs it. An equality would redden on unrelated edits and the row would be deleted within a week. Also pins jang-personal-site/reference-html, the page carrying this branch's most important history: 19 findings before role synthesis, 16 of them phantom nested-cards, 3 after. That regression can no longer return unnoticed. Its first finding also exercises the third rung of the verdict key, which carries neither line nor nodeRef. Corpus now: 5 pages, 31 adjudicated findings across 4 extractor profiles. * feat(tell): put every tuning knob in one table, and pin it with an executable pair Of 63+ deliberate red probes fired while building this family, 13 came back green. Every one was a number nothing asserted: the guard existed, moving it broke nothing, and the suite went on certifying a rule that no longer worked. Hand-probing is what failed, so this replaces it with a structural check. 51 tuning knobs move to TELL_THRESHOLDS, each with an owner and its provenance. The scars are recorded next to the values they explain — the purple window that excluded indigo #6366f1, the cream floor that excluded #FAF7F0 itself, the display floor that came from 27 of 27 hits being UI chrome. Classified OUT deliberately: presence checks (px > 0), geometric definitions (sides.length >= 4 means "all four sides"), and array guards that only say there is not enough here to compare. Turning those does not retune a rule, it breaks one, and a table that absorbs every integer teaches nothing. A number is not pinned by being written down. BOUNDARY_PAIRS supplies an executable pair per constant — a value AT the threshold that must NOT trip and one PAST it that MUST — as either a call to the real colour predicate or two tiny documents through the real extractor and linter. Never a re-implementation of the comparison, which would be green by construction and blind to the one thing worth catching: a constant nothing consumes. 21 are pinned that way; the other 30 are listed in UNPINNED with a reason each, and the count prints on every run so the gap gets driven down rather than tolerated. A new constant with neither a pair nor a reason fails the meta-test. The meta-test earned itself immediately: the first undersized-ui-text pair used a <p>, stayed green in BOTH directions, and pinned nothing — the rule judges label text. That is precisely the pair shape the 13 green probes were made of. Corpus delta across the whole migration: zero. Both probes red then restored. An unpaired constant is named. Restoring the historical cream floor of 12 reddens with the SCAR message saying why 12 was wrong — a regression that shipped once and now cannot. * feat(tell): add the near-miss fixture class and three metamorphic laws Two structural changes that close classes rather than cases. The fixture contract gains a third class: the NEAR-MISS, the tempting adjacent thing that must stay silent. A rule that only ever fires has not been shown to discriminate — but neither has one that only stays quiet on obviously-fine input. The hard cases look exactly like the tell and are not it, and the two-fixture contract structurally cannot express one. The six worked examples are not invented; each is a defect that shipped: a pill for cramped-padding, the system font stack for overused-font, card-title for nested-cards, a video poster for documentBg, a clamp() headline for tight-leading, a nav CTA for hero-eyebrow-chip. The backlog is stated rather than implied — 43 rules, 6 near-misses, so ~37 rules have none. Three laws state what no fixture can: that the answer does not depend on things it must not depend on. L1, an unrelated fact changes nothing. L2, fact order changes nothing. L3, duplicating a fact never doubles a finding — the law that makes the 54-findings-from-210-duplicate-facts defect impossible by construction, asserted at the sink so it covers every rule including unwritten ones. Probing them found two things worth recording, both now in the docblock. L3 is guarded TWICE: disabling the dedup alone leaves it green, disabling collapseRepeated alone leaves it green, only both together turn it red. And L2 does not rest on the engine's final sort at all — deleting that sort leaves the law green, and under a change that genuinely varies with input order it fails only 2 of its 5 cases. A single-seed L2 would have missed the break, which is why there are four seeds and a reversal. The first draft of L2 was itself tautological: the helper sorted the output before comparing, so order dependence could not be observed through it. That is the same failure shape as the 13 green probes, found this time in the probe's own scaffolding rather than in the code under test. * feat(tell): add the nightly mutation audit, scoped to rule predicates Automates the red-probe discipline that failed 13 times out of 63 when done by hand. Stryker mutates a rule predicate and reports whether any test noticed; the survived-mutant list is the machine-generated version of "these guards do not guard", which until now was compiled from memory. Nightly and on demand, NEVER on a pull request. As a gate it is slow, noisy on cosmetic code, and gets argued with until someone disables it — at which point the signal is gone entirely. As an audit it produces a number that trends and a list somebody reads on purpose. continue-on-error is the point, not an oversight: a falling mutation score is information, not a broken build. Scoped to the six rule modules, with string, regex and object-literal mutants excluded — mutating a fixHint produces noise with no signal. It runs against a vitest projection of the tests that judge those rules, because re-running 4,147 tests per mutant would produce a job nobody waits for and therefore nobody reads. The sandbox is an allow-list rather than a deny-list. Stryker copies the repo per run and this root holds things that cannot be copied — a symlinked `taste` socket, Swift .build sockets under plans/ — and chasing each one is a losing game. Baseline for trending: 62.91% over 1,162 mutants, 731 killed, 418 survived, 2m25s. Weakest tell-rules-labels.ts at 46.71%, strongest tell-rules-type.ts at 71.43%. The first survivors are already useful: emptying ICON_TAGS survives, so nothing asserts tag-based icon detection. devDependencies only. The runtime dependency list stays at four and its guard is untouched and passing. docs/tell-lint.md gains the fact census and a section on the three instruments that keep the rules honest — the corpus, the threshold table, and this audit. * docs: record the instruments that keep the tell rules honest A field corpus with adjudicated verdicts, a printed live false-positive rate, a fact census so a zero can be read, thresholds pinned by executable boundary pairs, three metamorphic laws, and a nightly mutation audit. Also records the two defects the corpus found on its first pages: tight-leading substituting 16 for an unresolvable clamp() size, and unresolvedSheets that no caller had ever read. * fix(ci): stop reading fixtures from a gitignored dir, and refuse CDP by name on Node 20 Two failures that were green on a macOS/Node 22 laptop and red on CI. Both are the same shape: an assumption about the environment that had never been tested anywhere else. Two tests read probe fixtures out of `plans/`, which is gitignored — so the files existed on one machine and on no CI runner. Moved into `tests/fixtures/` where test inputs belong. This is the repo's own scar about a test asserting its own filesystem path, arriving in a new costume. `WebSocket` only became a Node global in 22, and this package supports `>=20`, so the rendered tier cannot open a debugger socket on the Node 20 job — which has Chrome installed, so the live tests did not skip and instead threw a bare ReferenceError from inside a rule run. `cdpUnavailableReason()` now names the requirement, and the tier reports it NOT-EVALUATED rather than crashing: its seven rules do not get to count as clean because the runtime could not run them. `runRenderedPass` also gains a try/catch, because a capture failure mid-run used to take the whole command down — it now reports the tier NOT-EVALUATED and names the cause, which is what the comment above it already claimed it did. The live rendered tests skip on either reason, loudly, keeping the existing contract that a skipped smoke must never read as a pass. engines stays >=20. Dropping a published Node version to make a test pass would be a user-facing breaking change in service of an opt-in tier. * fix(tell): a boundary pair that recomputes its own comparison pins nothing Found by adversarial review, and it is the exact failure this instrument exists to prevent — a false green inside the thing built to catch false greens. DARK_SURFACE_MAX_CHANNEL's pair hardcoded `r < 60 && g < 60` inline. It never read TELL_THRESHOLDS and never ran the dark-glow rule, so moving the constant could not redden it, while it was still counted in the pinned tally. The tally was therefore a false claim. It now runs two documents through the real linter, like every other css pair. Two more pairs had slack a drifting constant could hide inside. LINE_LENGTH_MAX_CHARS used 300 and 440, leaving [301,439] free to move silently; REPEATED_TEXT_MIN_CHARS used 13 and 56. Both are now exactly at the threshold and exactly one step past it. A pair with slack pins a neighbourhood, not a value. All three re-probed by moving their constant: each now goes red. The old dark-surface pair stayed green under the same treatment, which is how the review found it. * fix(test): the version-gate test assumed the version it was gating on CI caught this one too. The restoration check asserted `typeof globalThis.WebSocket === "function"`, which is only true on Node 22 — so on the Node 20 job, where there is no WebSocket to restore, the one test describing cross-version behaviour failed on the very version it exists to describe. It now asserts restoration to whatever the runtime had to begin with, captured before anything touches it. Verified in the failing condition rather than by reasoning about it: run with the global deleted, the file gives 27 passed and 3 live-capture tests skipped, which is the Node 20 shape. Green on a Node 22 laptop was what produced the bug.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
ui tell-lint— design:os reads your app whatever it is written in — plus theinstruments that keep its verdicts honest.
One DesignFacts IR, N language extractors, 43
tellrules written once. Rules bound toCSS syntax can never leave the web; bound to design facts, the same rules judge HTML,
JSX/TSX, Vue, Svelte, bare CSS, SwiftUI and Flutter. Adding a platform is one ~150-line
extractor and two fixtures, and no rule is edited.
Why the second half exists
The gate this repo sells is one "the model cannot talk past". That holds only while its
verdicts are right — a gate with known false positives is not a gate, it is a suggestion,
because the first human override teaches everyone that findings are negotiable.
Measured on this branch while building it:
A fixture states the rule author's model of the world. It catches drift away from that
model and, structurally, never an error inside it. So the second half of this PR builds
the instruments that can.
The instruments
tp/fpverdict and awritten reason. Silencing an adjudicated true positive turns the suite red, naming the
finding and quoting the reason. An unjudged finding fails as
unadjudicatedwith apaste-ready row.
fp-open— a known, reasoned, unfixed false positive. Counted, so the live FP rateprints on every run instead of drifting.
clean page and a page the reader was blind to are otherwise identical.
TELL_THRESHOLDS— every number that decides a verdict, with provenance, pinned by anexecutable boundary pair (silent at the threshold, firing past it).
change a verdict. The last makes the duplicate-findings defect impossible by construction.
Three defects this found, all in code the suite had passed
tight-leadingfired on display headlines.(t.sizePx ?? 16) <= 20substituted 16for an unresolvable
clamp()size, then reported a 94px headline as "16px body copy".extractHtmlhadalways returned
unresolvedSheets; no caller read it. The census exposed it — 982elements, 4 colour facts, 0 spacing.
<p>where the rule judgeslabel text, so it pinned nothing.
Standing numbers
Honest gaps
sfcandflutterare waived, not covered. No real Vue, Svelte or Dart source existsto adjudicate; the only candidates were fixtures this repo wrote, and adjudicating our own
fixture makes the corpus circular. Both extractors are proven working (6 findings each on
their slop fixtures).
the census ranges were only just measured, and picking a threshold before measuring is the
guess this work exists to replace.
Verification
npm run typecheck,npm run lint,npm run build,npm test— all green after the lastedit. 231 files / 4,147 tests. Every guard in this PR was red-probed and restored.