8000 feat: read design tells in any language, and keep the verdicts honest by jangtrinh · Pull Request #235 · jangtrinh/design-os · GitHub
[go: up one dir, main page]

Skip to content

feat: read design tells in any language, and keep the verdicts honest - #235

Merged
jangtrinh merged 26 commits into
mainfrom
feat/design-facts-ir
Aug 28, 2026
Merged

jangtrinh merged 26 commits into
mainfrom
feat/design-facts-ir

Conversation

@jangtrinh
Copy link
Copy Markdown
Owner

What this is

ui tell-lint — design:os reads your app whatever it is written in — plus the
instruments that keep its verdicts honest.

One DesignFacts IR, N language extractors, 43 tell rules written once. Rules bound to
CSS 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:

Substantive defects found by real data 8
Substantive defects found by the 4,000-test fixture suite 0
Deliberate red probes fired 63+
Probes that came back green (a guard that did not guard) 13
Times a precision fix silenced real findings 2

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

  • Field corpus — real pages pinned, every finding carrying a tp/fp verdict and a
    written reason. Silencing an adjudicated true positive turns the suite red, naming the
    finding and quoting the reason. An unjudged finding fails as unadjudicated with a
    paste-ready row.
  • fp-open — a known, reasoned, unfixed false positive. Counted, so the live FP rate
    prints on every run
    instead of drifting.
  • Fact census — per-kind fact counts, printed whenever a file yields no findings. A
    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 an
    executable boundary pair (silent at the threshold, firing past it).
  • Three metamorphic laws — unrelated facts, fact order, and duplicate facts cannot
    change a verdict. The last makes the duplicate-findings defect impossible by construction.
  • Nightly mutation audit — scoped to rule predicates, never on a PR. Baseline 62.91%.

Three defects this found, all in code the suite had passed

  1. tight-leading fired on display headlines. (t.sizePx ?? 16) <= 20 substituted 16
    for an unresolvable clamp() size, then reported a 94px headline as "16px body copy".
  2. A page with four dead stylesheets reported a confident clean read. extractHtml had
    always returned unresolvedSheets; no caller read it. The census exposed it — 982
    elements, 4 colour facts, 0 spacing.
  3. A boundary pair green in both directions — it used a <p> where the rule judges
    label text, so it pinned nothing.

Standing numbers

Field corpus 5 pages · 31 adjudicated findings · live FP rate 10.7%
Coverage 4 extractor profiles covered, 4 waived with evidence
Thresholds 51 tabled · 21 pinned by pairs · 30 listed unpinned with reasons
Mutation audit 62.91% — 731 killed / 418 survived
Near-miss fixtures 6 of 43 rules — ~37 still have none, stated not implied

Honest gaps

  • sfc and flutter are waived, not covered. No real Vue, Svelte or Dart source exists
    to 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).
  • 30 of 51 thresholds are still guarded only by rule fixtures.
  • Whether a degenerate fact census should become a non-zero exit is deliberately unanswered —
    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 last
edit. 231 files / 4,147 tests. Every guard in this PR was red-probed and restored.

jangtrinh and others added 25 commits August 28, 2026 21:50
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.
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
jangtrinh merged commit f0d481c into main Aug 28, 2026
4 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0