Skip to content

fix(boringstack): read eslint from structured JSON, not scraped stylish text#121

Merged
agjs merged 5 commits into
mainfrom
fix/structured-lint-json
Jul 17, 2026
Merged

fix(boringstack): read eslint from structured JSON, not scraped stylish text#121
agjs merged 5 commits into
mainfrom
fix/structured-lint-json

Conversation

@agjs

@agjs agjs commented Jul 17, 2026

Copy link
Copy Markdown
Owner

What & why

The BoringStack gate composes bun test + tsc + eslint output and the harness scrapes it into failure signatures (failure:<file>:<line>:<rule>:<message>) for the differential gate. eslint's human "stylish" format is ambiguous — it truncates multi-line rule messages and interleaves with other tool output — so the model repeatedly saw unactionable half-messages (e.g. …detected in module: with no categories, no fix) and sprayed near-green on live builds.

This makes each app also emit its own eslint as --format json (wrapped in ::tsforge-eslint-json <app>:: markers, non-aborting — check still owns pass/fail), and the parser reads exact {file,line,column,ruleId,message} from that structured data instead of guessing from text.

How it stays correct (the load-bearing part)

Both the JSON aid and check's own stylish eslint appear in the output, so the parser must reconcile them without either double-counting or losing an error. The design that survived review:

  • Per-error dedup, not per-app suppression. A stylish row is dropped only when the JSON already reported the same error, keyed on file:line:column:rule. A stylish-only error the JSON never emitted (different line, column, or rule) is always kept. Column is in the dedup key so two reports of one rule on the same line at different columns stay distinct.
  • Truncation-safe by stripping, not a flag. Only a closed JSON block is stripped from the scanned text (its signatures already extracted); an unterminated/truncated block is left in place and parsed line-by-line, so it can never swallow the diagnostics that follow. The block regex is tempered against a second opening marker, so an unterminated block can't pair with a later block's end marker and strip everything between them.
  • No false coverage. An empty [], a wrong-shaped array ([1,2,3]), or a malformed message entry contributes no dedup keys, so it suppresses nothing — a broken/absent JSON block always falls back to stylish.

Tests

New cases cover: JSON → exact signatures with stylish deduped; a stylish-only error at another line/column kept; unterminated block doesn't swallow later tsc errors; unterminated-then-closed blocks don't cross-strip; malformed/empty/wrong-shaped blocks preserve stylish; error TS… inside a JSON message not mis-parsed; whitespace-normalized signatures stable across JSON/stylish. Gate tests tightened so a dropped UI check/validate now fails. Full bun run validate green (7/7).

Known limitation (pre-existing, out of scope)

structuredFailure does not carry column, so two errors with identical file/line/rule/message at different columns collapse to one signature — this has always been true of the stylish path and is unchanged here. Fixing it means putting column in the signature format itself, which ripples into the differential baseline, signatureToError, and memory; tracked as a follow-up rather than bolted onto this parsing change.

Review

Passed the local harness-review panel (4 reviewers) across 4 hardening rounds — the panel caught a real critical (global vs per-app coverage) and several edge cases that drove the design to per-error dedup + strip-based truncation safety.

agjs added 5 commits July 17, 2026 09:06
…ish text

Root cause of a whole class of near-green stalls: the gate scraped eslint's "stylish"
(human) output, which is ambiguous — multi-line messages truncated to their first
line (the model never saw the fix), and interleaved output mis-attributed errors to
the wrong file. #120 patched the heuristic; the reviewer panel proved (5 rounds) that
stylish text can't be parsed reliably — it has no delimiters.

Fix at the source: the gate now emits each app's OWN eslint as `--format json` (via
`bun run --silent lint -- --format json`, reusing the app's exact config/globs — no
coupling), wrapped in `::tsforge-eslint-json::` markers. extract-failures parses that
into exact `failure:<file>:<line>:<rule>:<message>` signatures — unambiguous file,
line, ruleId, and FULL message. When a JSON block is present the line loop ignores the
stylish eslint rows (JSON is the single source of truth, no double-count); when it's
ABSENT or malformed it falls back to scraping stylish, so a lint error is never
silently lost (no false green). tsc / bun `(fail)` / knip / lint-meta are unchanged.

This raises feedback fidelity for EVERY eslint rule at once — the model always gets
the complete error + fix, not a mutilated first line.

Tests: JSON → exact signatures (incl. the full multi-line module-boundaries message);
stylish rows for the same run aren't double-counted; a `error TS…` string inside a
JSON message isn't mis-read as tsc; tsc/`(fail)` still parse alongside JSON; malformed
JSON falls back to stylish. Gate tests assert the JSON pass is emitted (fast + full).
Full `bun run validate` green.
… lost lint)

Panel caught a critical: the "eslint came from JSON" decision was GLOBAL, but the gate
emits one block per app (api, ui). If the API block parsed (even `[]`) while the UI
block was empty/malformed, the UI's stylish rows were suppressed and its real lint
errors were silently lost — the exact near-green regression this change exists to fix.
Also, any JSON array (`[1,2,3]`) counted as coverage.

Fix: coverage is PER-APP. Each block is tagged `::tsforge-eslint-json <app>::`; a
block counts only if it parses into a genuine eslint-result array (every element has
`filePath` + `messages`; empty `[]` = a valid green app). `extractFailures` tracks the
set of covered apps and ignores stylish ERROR rows only for an app in that set — an
uncovered app (missing/malformed/wrong-shaped block) still falls back to scraping
stylish, so no lint error is ever lost. Skip narrowed to error rows (warnings were
never captured by the stylish path anyway).

Tests: API `[]` + malformed UI block + UI stylish error → the UI error is still
captured; a wrong-shaped `[1,2,3]` array does not count as coverage (stylish scraped).
Plus the prior JSON tests, now app-tagged. Full `bun run validate` green.

Notes (accepted): the JSON pass reruns eslint but with `--cache`, so `check`'s lint is
a warm cache hit; `2>/dev/null` keeps the JSON clean — a real lint crash still surfaces
via `check`'s own lint run.
…table signatures

Second panel pass on the per-app eslint-JSON change surfaced four more real ways a
lint error could be silently lost. All fixed:

1. Coverage now means "the JSON actually YIELDED ≥1 error", not merely "the block
   parsed". An empty `[]`, an all-green app, a 0-file run, or a shape-valid payload
   whose message entries are malformed (`[{filePath,messages:[{}]}]`) yield zero
   errors → the app is NOT covered → its stylish rows are still scraped. Safe both
   ways: a truly green app has no stylish error rows to lose, and a broken JSON pass
   falls back to `check`'s own stylish output. Resolves the `[]`-vacuous and
   incomplete-payload findings at once.

2. Truncation safety: an unterminated JSON block (missing end marker) now ends at the
   next `::tsforge-app` stage marker instead of leaving `inEslintJson` true across the
   app boundary and swallowing every later line (tsc errors, bun fails, stylish rows).

3. Stable signatures: a multi-line JSON message is whitespace-collapsed so it keys to
   the SAME signature as the stylish path — else a block that intermittently fails to
   parse would make a pre-existing failure look novel in the differential.

4. `isEslintResultArray` is now a type guard, dropping the dead `!Array.isArray` re-check.

Tests: malformed-message block → not covered (stylish captured); unterminated block →
next app's tsc error still captured; JSON multi-line message signature has no %0A and
matches the space-joined form. Full `bun run validate` green (7/7).
… per-app suppression)

Third panel pass drove the design to its correct shape. The prior per-app "coverage"
model could, in edge cases, suppress a real lint error. Replaced it with per-error dedup:

- Dedup key is per (file,line,rule), not per app. A stylish row is dropped ONLY when the
  JSON already reported the SAME error at that exact location; a stylish-only error the
  JSON never emitted (a JSON subset) is always kept. This removes the whole class of
  "JSON parsed → suppress all stylish for the app" findings.
- Truncation safety via STRIPPING closed blocks rather than a sticky skip flag. Only a
  CLOSED `::tsforge-eslint-json …::…::-end::` block is removed from the scanned text
  (its signatures already extracted). An UNTERMINATED block (killed/truncated capture) is
  left in place and parsed line-by-line — its partial JSON line isn't an error row, so it
  is skipped harmlessly and later tsc/stylish/knip diagnostics are never swallowed.
- Deleted the `inEslintJson` + `eslintJsonApps` state fields and the sticky
  consumeEslintJsonBlock() entirely — the design is now stateless w.r.t. JSON blocks.

Tests: JSON error at a.ts:6 + stylish-only error at a.ts:9 → BOTH captured (the subset
case); unterminated block → next app's tsc error still captured; malformed/empty/wrong-
shaped blocks contribute no dedup keys so stylish is preserved; gate tests tightened so a
dropped UI check/validate now fails. Full `bun run validate` green (7/7).
…s-block strip)

Fourth panel pass, two more real edges closed:

- Dedup key now includes COLUMN (file:line:column:rule), so two reports of the same rule
  on the same line at different columns stay distinct — a column-less key could drop a
  stylish error at a different column that the JSON never reported. Column lives only in
  the internal dedup key, not the signature, so no signature format changes. parsedDiagnostic
  now returns { signature, dedupKey }; the loop dedups on dedupKey.
- The block regex is TEMPERED against a second opening marker, so an unterminated block
  can no longer pair with a LATER closed block's end marker and strip the tsc/stylish/knip
  output between them. One shared source (ESLINT_JSON_BLOCK_SOURCE) for both the strip and
  the parse; a fresh RegExp per use avoids g-flag lastIndex carryover.

Tests: unterminated-then-closed block keeps the intervening tsc error AND the later UI JSON
error; a stylish error at a different column on the same line/rule is not dropped. Full
`bun run validate` green (7/7).
@agjs
agjs merged commit 264b1ec into main Jul 17, 2026
8 checks passed
@agjs
agjs deleted the fix/structured-lint-json branch July 17, 2026 08:22
agjs added a commit that referenced this pull request Jul 17, 2026
…ung the gate

PR #121 added a tempered-quantifier regex to locate closed eslint-JSON blocks:
`::…json \S+::((?:(?!::…json \S+::)[\s\S])*?)::…json-end::`. On tiny test fixtures it
was instant, but on REAL gate output (measured 1.24MB: full `bun test` + eslint
--format json for ~160 files × 2 apps) the negative-lookahead-per-char non-greedy body
backtracks O(n²)+ — extractFailures ran >240s without finishing, silently hanging the
whole gate (no subprocess, pure CPU: the exact "running gate · turn N…" freeze observed
on a live build).

Fix: locate closed blocks by a LINEAR merge scan (findClosedEslintJsonBlocks) — collect
all opening markers (one regex pass) and all end markers (allIndicesOf, one pass), then
pair them with a single forward-only pointer so each opening claims the first end after
it and before the next opening. No per-opening rescan-to-EOF (which would itself be
O(n²)), no catastrophic regex. Unterminated blocks are skipped (same semantics). Strip
parsed blocks by range (stripRanges) instead of a second catastrophic .replace.

Result: the 1.24MB output parses in ~4ms. All #121 structured-JSON behavior is unchanged
(per-error dedup, column keys, whitespace-normalized signatures, truncation safety).
Tests: a ~1MB realistic output AND a pathological ~1MB of openings-with-no-ends both
parse in <2s (the latter is the per-opening-indexOf O(n²) trap the merge scan defeats).
agjs added a commit that referenced this pull request Jul 17, 2026
…ung the gate

PR #121 added a tempered-quantifier regex to locate closed eslint-JSON blocks:
`::…json \S+::((?:(?!::…json \S+::)[\s\S])*?)::…json-end::`. On tiny test fixtures it
was instant, but on REAL gate output (measured 1.24MB: full `bun test` + eslint
--format json for ~160 files × 2 apps) the negative-lookahead-per-char non-greedy body
backtracks O(n²)+ — extractFailures ran >240s without finishing, silently hanging the
whole gate (no subprocess, pure CPU: the exact "running gate · turn N…" freeze observed
on a live build).

Fix: locate closed blocks by a LINEAR merge scan (findClosedEslintJsonBlocks) — collect
all opening markers (one regex pass) and all end markers (allIndicesOf, one pass), then
pair them with a single forward-only pointer so each opening claims the first end after
it and before the next opening. No per-opening rescan-to-EOF (which would itself be
O(n²)), no catastrophic regex. Unterminated blocks are skipped (same semantics). Strip
parsed blocks by range (stripRanges) instead of a second catastrophic .replace.

Result: the 1.24MB output parses in ~4ms. All #121 structured-JSON behavior is unchanged
(per-error dedup, column keys, whitespace-normalized signatures, truncation safety).
Tests: a ~1MB realistic output AND a pathological ~1MB of openings-with-no-ends both
parse in <2s (the latter is the per-opening-indexOf O(n²) trap the merge scan defeats).
agjs added a commit that referenced this pull request Jul 17, 2026
…ung the gate (#122)

PR #121 added a tempered-quantifier regex to locate closed eslint-JSON blocks:
`::…json \S+::((?:(?!::…json \S+::)[\s\S])*?)::…json-end::`. On tiny test fixtures it
was instant, but on REAL gate output (measured 1.24MB: full `bun test` + eslint
--format json for ~160 files × 2 apps) the negative-lookahead-per-char non-greedy body
backtracks O(n²)+ — extractFailures ran >240s without finishing, silently hanging the
whole gate (no subprocess, pure CPU: the exact "running gate · turn N…" freeze observed
on a live build).

Fix: locate closed blocks by a LINEAR merge scan (findClosedEslintJsonBlocks) — collect
all opening markers (one regex pass) and all end markers (allIndicesOf, one pass), then
pair them with a single forward-only pointer so each opening claims the first end after
it and before the next opening. No per-opening rescan-to-EOF (which would itself be
O(n²)), no catastrophic regex. Unterminated blocks are skipped (same semantics). Strip
parsed blocks by range (stripRanges) instead of a second catastrophic .replace.

Result: the 1.24MB output parses in ~4ms. All #121 structured-JSON behavior is unchanged
(per-error dedup, column keys, whitespace-normalized signatures, truncation safety).
Tests: a ~1MB realistic output AND a pathological ~1MB of openings-with-no-ends both
parse in <2s (the latter is the per-opening-indexOf O(n²) trap the merge scan defeats).
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