linear-grab 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,7 +37,7 @@ const flag = (name, fallback) => {
37
37
  const PORT = Number(flag('--port', '4577'));
38
38
  const DIR = flag('--dir', process.cwd());
39
39
  const CLAUDE_BIN = flag('--claude', 'claude');
40
- const VERSION = '0.26.0';
40
+ const VERSION = '0.27.0';
41
41
 
42
42
  // ---- audit subcommand dispatch ---------------------------------------------
43
43
  // `npx linear-grab-bridge audit` is a headless design gate — it sweeps a
@@ -472,6 +472,39 @@ function sendMessage(task, text) {
472
472
  saveHistory();
473
473
  }
474
474
 
475
+ // ---- shared: rulebook path resolution --------------------------------------
476
+
477
+ /**
478
+ * Resolve the render-rulebook markdown path under DIR, honoring the
479
+ * `.lineargrab.json` key "renderRulebook" (default: React-rerender-primitives.md
480
+ * in the repo root). Guards against absolute paths and repo-escaping traversal.
481
+ * Shared by the /scan/rulebook endpoint AND `audit --renders` so the traversal
482
+ * guard lives in exactly one place.
483
+ *
484
+ * @returns {{ ok: true, path: string } | { ok: false, error: string }}
485
+ */
486
+ function resolveRulebookPath() {
487
+ let relPath = 'React-rerender-primitives.md';
488
+ try {
489
+ const cfg = JSON.parse(readFileSync(join(DIR, '.lineargrab.json'), 'utf8'));
490
+ if (typeof cfg.renderRulebook === 'string' && cfg.renderRulebook.trim()) {
491
+ relPath = cfg.renderRulebook.trim();
492
+ }
493
+ } catch {
494
+ /* missing or invalid config — use the default */
495
+ }
496
+ // Reject absolute paths supplied in the config value.
497
+ if (relPath.startsWith('/') || /^[A-Za-z]:[/\\]/.test(relPath)) {
498
+ return { ok: false, error: 'path outside repo' };
499
+ }
500
+ // Reject resolved paths that escape the repo root (traversal guard).
501
+ const resolved = join(DIR, relPath);
502
+ if (!resolved.startsWith(DIR + sep) && resolved !== DIR) {
503
+ return { ok: false, error: 'path outside repo' };
504
+ }
505
+ return { ok: true, path: resolved };
506
+ }
507
+
475
508
  // ---- HTTP ------------------------------------------------------------------
476
509
 
477
510
  const server = createServer(async (req, res) => {
@@ -790,36 +823,18 @@ const server = createServer(async (req, res) => {
790
823
  const doc = url.searchParams.get('doc') ?? 'render';
791
824
  if (doc !== 'render') return json(res, 200, { ok: false, error: 'unknown doc' });
792
825
 
793
- // Resolve the rulebook path: .lineargrab.json key "renderRulebook" wins,
794
- // else fall back to the canonical filename in the repo root.
795
- let relPath = 'React-rerender-primitives.md';
796
- try {
797
- const cfg = JSON.parse(readFileSync(join(DIR, '.lineargrab.json'), 'utf8'));
798
- if (typeof cfg.renderRulebook === 'string' && cfg.renderRulebook.trim()) {
799
- relPath = cfg.renderRulebook.trim();
800
- }
801
- } catch {
802
- /* missing or invalid config — use the default */
803
- }
804
-
805
- // Reject absolute paths supplied in the config value.
806
- if (relPath.startsWith('/') || /^[A-Za-z]:[/\\]/.test(relPath)) {
807
- return json(res, 400, { ok: false, error: 'path outside repo' });
808
- }
809
-
810
- // Reject resolved paths that escape the repo root (traversal guard).
811
- const resolved = join(DIR, relPath);
812
- if (!resolved.startsWith(DIR + sep) && resolved !== DIR) {
813
- return json(res, 400, { ok: false, error: 'path outside repo' });
814
- }
826
+ // Resolve the rulebook path via the shared helper (traversal guard lives
827
+ // there reused by `audit --renders`).
828
+ const rb = resolveRulebookPath();
829
+ if (!rb.ok) return json(res, 400, rb);
815
830
 
816
831
  let text;
817
832
  try {
818
- text = readFileSync(resolved, 'utf8');
833
+ text = readFileSync(rb.path, 'utf8');
819
834
  } catch {
820
835
  return json(res, 200, { ok: false, error: 'not found' });
821
836
  }
822
- return json(res, 200, { ok: true, text, path: resolved });
837
+ return json(res, 200, { ok: true, text, path: rb.path });
823
838
  }
824
839
  // Reset the staging branch: delete + recreate from the default branch.
825
840
  if (req.method === 'POST' && url.pathname === '/branch/reset') {
@@ -994,6 +1009,10 @@ if (!AUDIT_MODE)
994
1009
  console.log(` ${D}»${R} Check the live render telemetry: read the newest lines of`);
995
1010
  console.log(` .lineargrab/scan.ndjson and summarize the slow components.`);
996
1011
  console.log(` ${D}»${R} curl -s http://127.0.0.1:${PORT}/scan/report ${D}(aggregated view)${R}`);
1012
+ console.log('');
1013
+ console.log(`${B}headless design + render gates${R} ${D}(CI-able, no server needed)${R}`);
1014
+ console.log(` ${D}»${R} npx linear-grab-bridge audit --url http://localhost:3000 ${D}(design slop-scan)${R}`);
1015
+ console.log(` ${D}»${R} npx linear-grab-bridge audit --renders --url http://localhost:3000 ${D}(re-render scan; replays .lineargrab.json scenarios)${R}`);
997
1016
  console.log(` ${D}tip: add a line to your repo's AGENTS.md/CLAUDE.md so agents find it`);
998
1017
  console.log(` on their own: "Live render telemetry: .lineargrab/scan.ndjson`);
999
1018
  console.log(` (react-scan via linear-grab bridge); aggregate: GET /scan/report"${R}`);
@@ -1169,12 +1188,40 @@ async function loadBundleSource() {
1169
1188
  }
1170
1189
  }
1171
1190
 
1172
- /** The audit itself. Returns the process exit code. */
1191
+ /** Read the render-scan bundle once: local file next to the bridge, else CDN.
1192
+ Mirrors loadBundleSource for slop-scan.global.js. */
1193
+ async function readRenderBundle() {
1194
+ const local = new URL('./render-scan.global.js', import.meta.url);
1195
+ try {
1196
+ return readFileSync(local, 'utf8');
1197
+ } catch {
1198
+ /* fall through to CDN */
1199
+ }
1200
+ try {
1201
+ const res = await fetch('https://cdn.jsdelivr.net/npm/linear-grab@latest/dist/render-scan.global.js');
1202
+ if (!res.ok) throw new Error(`CDN ${res.status}`);
1203
+ return await res.text();
1204
+ } catch (e) {
1205
+ die(
1206
+ `could not load the render-scan bundle. Looked for ${local.pathname} and the jsDelivr CDN fallback (${e instanceof Error ? e.message : e}).`,
1207
+ );
1208
+ }
1209
+ }
1210
+
1211
+ /** The audit itself. Returns the process exit code. `audit --renders` forks to
1212
+ the render-telemetry auditor (scripted interactions + React commit grading);
1213
+ everything else runs the design slop-scan sweep below. */
1173
1214
  async function runAudit() {
1174
1215
  if (typeof WebSocket === 'undefined') {
1175
1216
  die(`audit needs Node 22+ (built-in WebSocket); you have ${process.version}`);
1176
1217
  }
1177
1218
 
1219
+ if (argv.includes('--help') || argv.includes('-h')) {
1220
+ printAuditHelp();
1221
+ return 0;
1222
+ }
1223
+ if (argv.includes('--renders')) return runRenderAudit();
1224
+
1178
1225
  const url = flag('--url', 'http://localhost:3000').replace(/\/+$/, '');
1179
1226
  const themeFlag = flag('--theme', 'both');
1180
1227
  const themes = themeFlag === 'both' ? ['light', 'dark'] : [themeFlag];
@@ -1541,6 +1588,12 @@ async function runAudit() {
1541
1588
  );
1542
1589
  } else {
1543
1590
  alog(` ${A.B}new vs baseline${A.R} ${newFindings.length} findings`);
1591
+ if (flakyCount > 0) {
1592
+ alog(
1593
+ ` ${A.Y}${flakyCount} flaky${A.R} ${A.D}new finding(s) did not reproduce in the confirm re-run — not gating; ` +
1594
+ `they union into the baseline on the next --update-baseline${A.R}`,
1595
+ );
1596
+ }
1544
1597
  }
1545
1598
  alog(` report ${A.C}${outPath}${A.R}`);
1546
1599
  if (willFail) {
@@ -1668,6 +1721,786 @@ function writeReportAndNdjson(findings, { url, routes, themes, outPath, ndjsonPa
1668
1721
  appendNdjson(ndjsonPath, findings);
1669
1722
  }
1670
1723
 
1724
+ // ============================================================================
1725
+ // audit --renders — headless render-telemetry gate
1726
+ // ============================================================================
1727
+ //
1728
+ // The re-render sibling of the slop-scan audit above. Instead of grading the
1729
+ // static DOM once per route, it REPLAYS scripted interactions from
1730
+ // .lineargrab.json (renderAudit.scenarios) while the render-scan bundle records
1731
+ // React commits from before-mount, then grades the recording against the
1732
+ // repo's re-render rulebook. Renders are theme-independent, so this runs ONE
1733
+ // theme only (no light/dark sweep — the same commits fire either way).
1734
+
1735
+ /** US keyboard code + windowsVirtualKeyCode for a single-char `key` step. Only
1736
+ the printable set we need for scripted audits; unknown keys fall back to a
1737
+ best-effort char code so a typo degrades instead of throwing. */
1738
+ function keyInfo(key) {
1739
+ const k = String(key);
1740
+ if (/^[a-zA-Z]$/.test(k)) {
1741
+ const upper = k.toUpperCase();
1742
+ return { code: `Key${upper}`, vk: upper.charCodeAt(0), text: k };
1743
+ }
1744
+ if (/^[0-9]$/.test(k)) return { code: `Digit${k}`, vk: k.charCodeAt(0), text: k };
1745
+ const named = {
1746
+ Enter: { code: 'Enter', vk: 13, text: '\r' },
1747
+ Tab: { code: 'Tab', vk: 9 },
1748
+ Escape: { code: 'Escape', vk: 27 },
1749
+ ArrowDown: { code: 'ArrowDown', vk: 40 },
1750
+ ArrowUp: { code: 'ArrowUp', vk: 38 },
1751
+ ArrowLeft: { code: 'ArrowLeft', vk: 37 },
1752
+ ArrowRight: { code: 'ArrowRight', vk: 39 },
1753
+ Backspace: { code: 'Backspace', vk: 8 },
1754
+ ' ': { code: 'Space', vk: 32, text: ' ' },
1755
+ Space: { code: 'Space', vk: 32, text: ' ' },
1756
+ };
1757
+ return named[k] ?? { code: '', vk: k.charCodeAt(0) || 0, text: k.length === 1 ? k : undefined };
1758
+ }
1759
+
1760
+ /** Poll a selector until it exists in the DOM, or reject after ms. Runs the
1761
+ check in-page over CDP (returnByValue boolean). */
1762
+ async function pollSelector(cdp, sessionId, selector, ms) {
1763
+ const deadline = Date.now() + ms;
1764
+ const expr = `!!document.querySelector(${JSON.stringify(selector)})`;
1765
+ for (;;) {
1766
+ const res = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId);
1767
+ if (res.result?.value === true) return true;
1768
+ if (Date.now() >= deadline) return false;
1769
+ await new Promise((r) => setTimeout(r, 100));
1770
+ }
1771
+ }
1772
+
1773
+ /** Poll for a clickable element whose trimmed textContent EQUALS `text` (exact
1774
+ first, then a substring fallback), scroll it into view, and click it. The
1775
+ match + click happen in ONE in-page evaluate so a lazily-mounted target is
1776
+ caught the instant it appears. Returns true on click, false on timeout. */
1777
+ async function pollClickText(cdp, sessionId, text, ms) {
1778
+ const deadline = Date.now() + ms;
1779
+ const expr = `(() => {
1780
+ const want = ${JSON.stringify(String(text))}.trim();
1781
+ const els = Array.from(document.querySelectorAll('button, [role="button"], a'));
1782
+ let hit = els.find((e) => (e.textContent || '').trim() === want);
1783
+ if (!hit) hit = els.find((e) => (e.textContent || '').trim().includes(want));
1784
+ if (!hit) return false;
1785
+ try { hit.scrollIntoView({ block: 'center' }); } catch {}
1786
+ hit.click();
1787
+ return true;
1788
+ })()`;
1789
+ for (;;) {
1790
+ const res = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId);
1791
+ if (res.result?.value === true) return true;
1792
+ if (Date.now() >= deadline) return false;
1793
+ await new Promise((r) => setTimeout(r, 100));
1794
+ }
1795
+ }
1796
+
1797
+ /** Center point of a selector's bounding box (viewport coords), or null. */
1798
+ async function elementCenter(cdp, sessionId, selector) {
1799
+ const expr = `(() => {
1800
+ const el = document.querySelector(${JSON.stringify(selector)});
1801
+ if (!el) return null;
1802
+ const r = el.getBoundingClientRect();
1803
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1804
+ })()`;
1805
+ const res = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId);
1806
+ return res.result?.value ?? null;
1807
+ }
1808
+
1809
+ /** Execute ONE scenario step. Throws only for a waitFor whose selector never
1810
+ appears (the caller ABORTS the scenario then — later steps depend on it);
1811
+ every other failure is surfaced by the caller as a non-fatal warning. */
1812
+ async function runStep(cdp, sessionId, step) {
1813
+ // waitFor: block until the selector mounts. Abort-worthy on timeout.
1814
+ if (typeof step.waitFor === 'string') {
1815
+ const ok = await pollSelector(cdp, sessionId, step.waitFor, Number(step.timeoutMs) || 5000);
1816
+ if (!ok) throw new Error(`waitFor never matched: ${step.waitFor}`);
1817
+ return;
1818
+ }
1819
+
1820
+ // clickText: poll for a clickable whose text matches, then click it.
1821
+ if (typeof step.clickText === 'string') {
1822
+ const ok = await pollClickText(cdp, sessionId, step.clickText, Number(step.timeoutMs) || 5000);
1823
+ if (!ok) throw new Error(`clickText never matched: ${step.clickText}`);
1824
+ return;
1825
+ }
1826
+
1827
+ // click: el.click() on the selector (no polling — pair with a prior waitFor).
1828
+ if (typeof step.click === 'string') {
1829
+ const res = await cdp.send(
1830
+ 'Runtime.evaluate',
1831
+ {
1832
+ expression: `(() => { const el = document.querySelector(${JSON.stringify(step.click)}); if (!el) return false; el.click(); return true; })()`,
1833
+ returnByValue: true,
1834
+ },
1835
+ sessionId,
1836
+ );
1837
+ if (res.result?.value !== true) throw new Error(`click target not found: ${step.click}`);
1838
+ return;
1839
+ }
1840
+
1841
+ // key: dispatch keyDown+keyUp per press via CDP Input, `repeat` times.
1842
+ if (typeof step.key === 'string') {
1843
+ const info = keyInfo(step.key);
1844
+ const repeat = Math.max(1, Number(step.repeat) || 1);
1845
+ const delayMs = Number(step.delayMs) || 0;
1846
+ for (let i = 0; i < repeat; i++) {
1847
+ const base = { key: String(step.key), code: info.code, windowsVirtualKeyCode: info.vk, nativeVirtualKeyCode: info.vk };
1848
+ await cdp.send('Input.dispatchKeyEvent', { type: info.text != null ? 'keyDown' : 'rawKeyDown', ...base, ...(info.text != null ? { text: info.text } : {}) }, sessionId);
1849
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }, sessionId);
1850
+ if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
1851
+ }
1852
+ return;
1853
+ }
1854
+
1855
+ // type: focus the selector (if any), then Input.insertText per char w/ delay.
1856
+ if (typeof step.type === 'string') {
1857
+ if (typeof step.selector === 'string') {
1858
+ const focused = await cdp.send(
1859
+ 'Runtime.evaluate',
1860
+ {
1861
+ expression: `(() => { const el = document.querySelector(${JSON.stringify(step.selector)}); if (!el) return false; el.focus(); return true; })()`,
1862
+ returnByValue: true,
1863
+ },
1864
+ sessionId,
1865
+ );
1866
+ if (focused.result?.value !== true) throw new Error(`type target not found: ${step.selector}`);
1867
+ }
1868
+ const perCharMs = Number(step.perCharMs) || 0;
1869
+ for (const ch of String(step.type)) {
1870
+ await cdp.send('Input.insertText', { text: ch }, sessionId);
1871
+ if (perCharMs) await new Promise((r) => setTimeout(r, perCharMs));
1872
+ }
1873
+ return;
1874
+ }
1875
+
1876
+ // scroll: mouseWheel at the element's center (default deltaY 600).
1877
+ if (typeof step.scroll === 'string') {
1878
+ const center = await elementCenter(cdp, sessionId, step.scroll);
1879
+ if (!center) throw new Error(`scroll target not found: ${step.scroll}`);
1880
+ await cdp.send(
1881
+ 'Input.dispatchMouseEvent',
1882
+ { type: 'mouseWheel', x: center.x, y: center.y, deltaX: Number(step.deltaX) || 0, deltaY: Number(step.deltaY) || 600 },
1883
+ sessionId,
1884
+ );
1885
+ return;
1886
+ }
1887
+
1888
+ // wait: a plain settle pause.
1889
+ if (step.wait != null) {
1890
+ await new Promise((r) => setTimeout(r, Number(step.wait) || 0));
1891
+ return;
1892
+ }
1893
+
1894
+ throw new Error(`unknown step (no known key): ${JSON.stringify(step).slice(0, 80)}`);
1895
+ }
1896
+
1897
+ /** Load + loosely validate renderAudit.scenarios from .lineargrab.json. A
1898
+ malformed scenario is skipped with a printed warning; the tool ships NO
1899
+ built-in app-specific scenarios (it stays generic). */
1900
+ function loadScenarios() {
1901
+ let cfg = null;
1902
+ try {
1903
+ cfg = JSON.parse(readFileSync(join(DIR, '.lineargrab.json'), 'utf8'));
1904
+ } catch {
1905
+ return [];
1906
+ }
1907
+ const raw = cfg?.renderAudit?.scenarios;
1908
+ if (!Array.isArray(raw)) return [];
1909
+ const out = [];
1910
+ for (const s of raw) {
1911
+ if (!s || typeof s !== 'object' || typeof s.name !== 'string' || !s.name.trim()) {
1912
+ alog(` ${A.Y}⚠ skipping malformed scenario (missing name)${A.R}`);
1913
+ continue;
1914
+ }
1915
+ if (s.steps != null && !Array.isArray(s.steps)) {
1916
+ alog(` ${A.Y}⚠ skipping scenario "${s.name}" (steps must be an array)${A.R}`);
1917
+ continue;
1918
+ }
1919
+ out.push({
1920
+ name: String(s.name),
1921
+ route: typeof s.route === 'string' ? s.route : '/',
1922
+ settleMs: Number(s.settleMs) || undefined,
1923
+ steps: Array.isArray(s.steps) ? s.steps : [],
1924
+ });
1925
+ }
1926
+ return out;
1927
+ }
1928
+
1929
+ /** The render audit. Returns the process exit code. */
1930
+ async function runRenderAudit() {
1931
+ const url = flag('--url', 'http://localhost:3000').replace(/\/+$/, '');
1932
+ const [vw, vh] = flag('--viewport', '1440x900')
1933
+ .split('x')
1934
+ .map((n) => Number(n) || 0);
1935
+ const pageTimeout = Number(flag('--timeout', '30000'));
1936
+ const failOn = flag('--fail-on', 'error'); // error | warn | none
1937
+ const updateBaseline = argv.includes('--update-baseline');
1938
+ const onlyScenario = flag('--scenario', null);
1939
+ const auditDir = join(DIR, '.lineargrab');
1940
+ const outPath = flag('--out', join(auditDir, 'render-report.md'));
1941
+ const baselinePath = join(auditDir, 'render-baseline.json');
1942
+ const ndjsonPath = join(auditDir, 'scan.ndjson');
1943
+
1944
+ let scenarios = loadScenarios();
1945
+ if (onlyScenario) scenarios = scenarios.filter((s) => s.name === onlyScenario);
1946
+ if (!scenarios.length) {
1947
+ die(
1948
+ onlyScenario
1949
+ ? `no scenario named "${onlyScenario}" in .lineargrab.json (renderAudit.scenarios)`
1950
+ : 'no render scenarios found. Add renderAudit.scenarios to .lineargrab.json — see `audit --renders` help.',
1951
+ );
1952
+ }
1953
+
1954
+ // Rulebook markdown (config path > default), read via the shared resolver.
1955
+ let rulebookMd = '';
1956
+ const rb = resolveRulebookPath();
1957
+ if (rb.ok) {
1958
+ try {
1959
+ rulebookMd = readFileSync(rb.path, 'utf8');
1960
+ } catch {
1961
+ /* no rulebook file — engine falls back to FALLBACK_RULEBOOK (budgets only) */
1962
+ }
1963
+ }
1964
+
1965
+ // Budgets + extra component-name ignores from config (both overlaid onto
1966
+ // the engine's defaults inside the bundle).
1967
+ let budgets = null;
1968
+ let renderIgnore = null;
1969
+ try {
1970
+ const cfg = JSON.parse(readFileSync(join(DIR, '.lineargrab.json'), 'utf8'));
1971
+ if (cfg.renderBudgets && typeof cfg.renderBudgets === 'object') budgets = cfg.renderBudgets;
1972
+ if (Array.isArray(cfg.renderIgnore)) renderIgnore = cfg.renderIgnore.filter((x) => typeof x === 'string');
1973
+ } catch {
1974
+ /* no config budgets/ignores */
1975
+ }
1976
+
1977
+ const RENDER_BUNDLE = await readRenderBundle();
1978
+ const bin = findBrowser();
1979
+
1980
+ // Auth reuse: same persistent-profile plumbing as the slop audit.
1981
+ const defaultProfile = join(
1982
+ HISTORY_DIR,
1983
+ `audit-profile-${createHash('sha1').update(DIR).digest('hex').slice(0, 8)}`,
1984
+ );
1985
+ const explicitProfile = flag('--profile', null);
1986
+ const loginMode = argv.includes('--login');
1987
+ const fresh = argv.includes('--fresh');
1988
+ const persistentProfile =
1989
+ explicitProfile ?? (!fresh && (loginMode || existsSync(defaultProfile)) ? defaultProfile : null);
1990
+
1991
+ if (loginMode) {
1992
+ const profile = persistentProfile ?? defaultProfile;
1993
+ mkdirSync(profile, { recursive: true });
1994
+ alog('');
1995
+ alog(`${A.B}◆ linear-grab audit --renders --login${A.R}`);
1996
+ alog(` A browser window is opening on ${A.C}${url}${A.R}.`);
1997
+ alog(` Sign in there, then come back and press ${A.B}Enter${A.R} to save the session.`);
1998
+ alog(` ${A.D}profile: ${profile}${A.R}`);
1999
+ const loginChild = spawn(
2000
+ bin,
2001
+ [`--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', url],
2002
+ { stdio: 'ignore', detached: false },
2003
+ );
2004
+ await new Promise((resolve) => process.stdin.once('data', resolve));
2005
+ try {
2006
+ loginChild.kill('SIGTERM');
2007
+ } catch {
2008
+ /* already closed */
2009
+ }
2010
+ alog(`${A.G}✓${A.R} session saved — future ${A.C}audit${A.R} runs use it automatically.`);
2011
+ return 0;
2012
+ }
2013
+
2014
+ alog('');
2015
+ alog(`${A.B}◆ linear-grab audit --renders${A.R} ${A.D}v${VERSION}${A.R}`);
2016
+ alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
2017
+ alog(` url ${A.C}${url}${A.R}`);
2018
+ alog(` scenarios ${A.C}${scenarios.length}${A.R} ${A.D}${scenarios.map((s) => s.name).join(', ')}${A.R}`);
2019
+ alog(` browser ${A.C}${bin}${A.R}`);
2020
+ alog(` ${A.D}renders are theme-independent — one theme only (no light/dark sweep)${A.R}`);
2021
+ if (persistentProfile) alog(` profile ${A.C}${persistentProfile}${A.R} ${A.D}(signed-in session)${A.R}`);
2022
+ alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
2023
+
2024
+ const tmp = persistentProfile ?? mkdtempSync(join(tmpdir(), 'lg-render-'));
2025
+ let child = null;
2026
+ let ws = null;
2027
+ const cleanup = () => {
2028
+ try {
2029
+ if (child && !child.killed) {
2030
+ child.kill('SIGTERM');
2031
+ const c = child;
2032
+ setTimeout(() => {
2033
+ try {
2034
+ c.kill('SIGKILL');
2035
+ } catch {
2036
+ /* gone */
2037
+ }
2038
+ }, 2000).unref?.();
2039
+ }
2040
+ } catch {
2041
+ /* ignore */
2042
+ }
2043
+ try {
2044
+ if (!persistentProfile) rmSync(tmp, { recursive: true, force: true });
2045
+ } catch {
2046
+ /* ignore */
2047
+ }
2048
+ };
2049
+ const onSigint = () => {
2050
+ cleanup();
2051
+ process.exit(130);
2052
+ };
2053
+ process.on('SIGINT', onSigint);
2054
+
2055
+ /** @type {Array<any>} */
2056
+ const allFindings = [];
2057
+ const failedScenarios = [];
2058
+
2059
+ // Baseline key. DELIBERATELY carries NO render count: counts jitter
2060
+ // run-to-run (timing, coalescing), so a count in the key would make every
2061
+ // run "new".
2062
+ const bkey = (f) => `${f.ruleId}|${f.scenario}|${f.component ?? ''}`;
2063
+ // Set when a confirm re-run happened: only new keys that REPRODUCED in the
2064
+ // re-run may fail the build (threshold-straddlers — a component at exactly
2065
+ // 5 renders / 3 commits — flip in and out between runs).
2066
+ let confirmedKeys = null;
2067
+
2068
+ try {
2069
+ try {
2070
+ rmSync(join(tmp, 'DevToolsActivePort'), { force: true });
2071
+ } catch {
2072
+ /* fresh profile */
2073
+ }
2074
+ child = spawn(
2075
+ bin,
2076
+ [
2077
+ '--headless=new',
2078
+ '--remote-debugging-port=0',
2079
+ `--user-data-dir=${tmp}`,
2080
+ '--no-first-run',
2081
+ '--no-default-browser-check',
2082
+ '--disable-extensions',
2083
+ '--hide-scrollbars',
2084
+ 'about:blank',
2085
+ ],
2086
+ { stdio: 'ignore' },
2087
+ );
2088
+ child.on('error', (e) => die(`failed to launch browser: ${e.message}`));
2089
+
2090
+ const endpoint = await waitForEndpoint(tmp);
2091
+ ws = new WebSocket(endpoint);
2092
+ await new Promise((resolve, reject) => {
2093
+ ws.addEventListener('open', resolve, { once: true });
2094
+ ws.addEventListener('error', () => reject(new Error('WebSocket error')), { once: true });
2095
+ });
2096
+ const cdp = makeCdp(ws);
2097
+
2098
+ /** Run ONE scenario on a fresh page target. Returns the tagged findings
2099
+ array, or null when the scenario failed/aborted (already logged). */
2100
+ const execScenario = async (scenario, label = scenario.name) => {
2101
+ const started = Date.now();
2102
+ const route = scenario.route || '/';
2103
+ const target = url + route;
2104
+
2105
+ // A FRESH page target per scenario so recording always begins pre-mount:
2106
+ // addScriptToEvaluateOnNewDocument fires on the next navigation, so the
2107
+ // recorder is armed before React ever commits.
2108
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
2109
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
2110
+ let scenarioFindings = null;
2111
+ try {
2112
+ await cdp.send('Page.enable', {}, sessionId);
2113
+ await cdp.send('Runtime.enable', {}, sessionId);
2114
+ await cdp.send(
2115
+ 'Emulation.setDeviceMetricsOverride',
2116
+ { width: vw, height: vh, deviceScaleFactor: 1, mobile: false },
2117
+ sessionId,
2118
+ );
2119
+ // Inject autostart flag + bundle BEFORE the app's document scripts run.
2120
+ await cdp.send(
2121
+ 'Page.addScriptToEvaluateOnNewDocument',
2122
+ { source: 'window.__RENDER_SCAN_AUTOSTART__=1;\n' + RENDER_BUNDLE },
2123
+ sessionId,
2124
+ );
2125
+
2126
+ const loaded = cdp.once('Page.loadEventFired', sessionId, pageTimeout).then(
2127
+ () => true,
2128
+ () => false,
2129
+ );
2130
+ await cdp.send('Page.navigate', { url: target }, sessionId);
2131
+ if (!(await loaded)) {
2132
+ failedScenarios.push({ name: scenario.name, reason: `load timeout (>${pageTimeout}ms)` });
2133
+ alog(` ${A.Rd}✗${A.R} ${padScenario(label)} ${A.D}load timeout${A.R}`);
2134
+ return null;
2135
+ }
2136
+ // Settle so first-mount commits land before we start interacting.
2137
+ await new Promise((r) => setTimeout(r, 500));
2138
+
2139
+ // Execute steps sequentially. A failed step is a non-fatal warning; a
2140
+ // failed waitFor/clickText ABORTS (later steps depend on the target).
2141
+ let aborted = false;
2142
+ for (const step of scenario.steps) {
2143
+ try {
2144
+ await runStep(cdp, sessionId, step);
2145
+ } catch (err) {
2146
+ const msg = err instanceof Error ? err.message : String(err);
2147
+ const fatal = typeof step.waitFor === 'string' || typeof step.clickText === 'string';
2148
+ alog(` ${fatal ? A.Rd : A.Y}${fatal ? '✗' : '⚠'}${A.R} step ${A.D}${msg.slice(0, 70)}${A.R}`);
2149
+ if (fatal) {
2150
+ failedScenarios.push({ name: scenario.name, reason: msg });
2151
+ aborted = true;
2152
+ break;
2153
+ }
2154
+ }
2155
+ }
2156
+ if (aborted) return null;
2157
+
2158
+ // Settle, then stop + analyze + attribute inside the page.
2159
+ await new Promise((r) => setTimeout(r, scenario.settleMs ?? 1500));
2160
+
2161
+ const finishRes = await cdp.send(
2162
+ 'Runtime.evaluate',
2163
+ {
2164
+ expression: `__RENDER_SCAN__.finish(${JSON.stringify(rulebookMd)}, ${JSON.stringify(budgets)}, ${JSON.stringify(renderIgnore)})`,
2165
+ awaitPromise: true,
2166
+ returnByValue: true,
2167
+ },
2168
+ sessionId,
2169
+ );
2170
+ if (finishRes.exceptionDetails) {
2171
+ const reason = finishRes.exceptionDetails.exception?.description ?? 'finish() threw';
2172
+ failedScenarios.push({ name: scenario.name, reason });
2173
+ alog(` ${A.Rd}✗${A.R} ${padScenario(label)} ${A.D}${reason.slice(0, 60)}${A.R}`);
2174
+ return null;
2175
+ }
2176
+ scenarioFindings = Array.isArray(finishRes.result?.value) ? finishRes.result.value : [];
2177
+
2178
+ // One passive R8 DOM snapshot per scenario, merged in.
2179
+ const snapRes = await cdp.send(
2180
+ 'Runtime.evaluate',
2181
+ { expression: '__RENDER_SCAN__.snapshot()', awaitPromise: true, returnByValue: true },
2182
+ sessionId,
2183
+ );
2184
+ if (!snapRes.exceptionDetails && Array.isArray(snapRes.result?.value)) {
2185
+ scenarioFindings.push(...snapRes.result.value);
2186
+ }
2187
+ } catch (err) {
2188
+ // A flaky target/CDP failure fails THIS scenario, never the whole run.
2189
+ const reason = err instanceof Error ? err.message : String(err);
2190
+ failedScenarios.push({ name: scenario.name, reason });
2191
+ alog(` ${A.Rd}✗${A.R} ${padScenario(label)} ${A.D}${reason.slice(0, 60)}${A.R}`);
2192
+ scenarioFindings = null;
2193
+ } finally {
2194
+ try {
2195
+ await cdp.send('Target.closeTarget', { targetId });
2196
+ } catch {
2197
+ /* target already gone */
2198
+ }
2199
+ }
2200
+
2201
+ if (!scenarioFindings) return null;
2202
+ const tagged = scenarioFindings.map((f) => ({ ...f, scenario: scenario.name, route }));
2203
+ const errs = tagged.filter((f) => f.severity === 'error').length;
2204
+ const warns = tagged.filter((f) => f.severity === 'warn').length;
2205
+ alog(
2206
+ ` ${A.G}✓${A.R} ${padScenario(label)} ` +
2207
+ `${String(errs).padStart(4)} ${A.D}errors${A.R} ${String(warns).padStart(3)} ${A.D}warns${A.R} ` +
2208
+ `${A.D}${Date.now() - started}ms${A.R}`,
2209
+ );
2210
+ return tagged;
2211
+ };
2212
+
2213
+ for (const scenario of scenarios) {
2214
+ const tagged = await execScenario(scenario);
2215
+ if (tagged) allFindings.push(...tagged);
2216
+ }
2217
+
2218
+ // Confirm pass (browser still open): when new gate-severity keys exist vs
2219
+ // the baseline, re-run ONLY the scenarios that produced them and keep the
2220
+ // keys that reproduce. Threshold-straddlers (exactly 5 renders / 3
2221
+ // commits) flip between runs; a key must show up twice to fail the build.
2222
+ if (!updateBaseline && failOn !== 'none') {
2223
+ let baselineKeys = null;
2224
+ try {
2225
+ baselineKeys = new Set(JSON.parse(readFileSync(baselinePath, 'utf8')).keys ?? []);
2226
+ } catch {
2227
+ /* no baseline yet — first run never gates, so nothing to confirm */
2228
+ }
2229
+ if (baselineKeys) {
2230
+ const gateSev = failOn === 'warn' ? ['error', 'warn'] : ['error'];
2231
+ const suspectNames = [
2232
+ ...new Set(
2233
+ allFindings
2234
+ .filter((f) => gateSev.includes(f.severity) && !baselineKeys.has(bkey(f)))
2235
+ .map((f) => f.scenario),
2236
+ ),
2237
+ ];
2238
+ if (suspectNames.length) {
2239
+ alog(` ${A.D}confirming new findings — re-running: ${suspectNames.join(', ')}${A.R}`);
2240
+ confirmedKeys = new Set();
2241
+ for (const name of suspectNames) {
2242
+ const scenario = scenarios.find((sc) => sc.name === name);
2243
+ if (!scenario) continue;
2244
+ const rerun = await execScenario(scenario, `${name} (confirm)`);
2245
+ for (const f of rerun ?? []) confirmedKeys.add(bkey(f));
2246
+ }
2247
+ }
2248
+ }
2249
+ }
2250
+ } finally {
2251
+ try {
2252
+ ws?.close();
2253
+ } catch {
2254
+ /* ignore */
2255
+ }
2256
+ process.off('SIGINT', onSigint);
2257
+ cleanup();
2258
+ }
2259
+
2260
+ // ---- baseline ratchet ----------------------------------------------------
2261
+ const currentKeys = [...new Set(allFindings.map(bkey))];
2262
+
2263
+ bootstrapAuditDir(auditDir);
2264
+
2265
+ if (updateBaseline) {
2266
+ // UNION with the existing baseline: threshold-flaky keys accumulate over
2267
+ // runs and stop flapping, instead of being dropped by an overwrite. Use
2268
+ // --reset-baseline to start over (e.g. after a big perf fix lands).
2269
+ let existing = [];
2270
+ if (!argv.includes('--reset-baseline')) {
2271
+ try {
2272
+ existing = JSON.parse(readFileSync(baselinePath, 'utf8')).keys ?? [];
2273
+ } catch {
2274
+ /* no baseline yet */
2275
+ }
2276
+ }
2277
+ const merged = [...new Set([...existing, ...currentKeys])];
2278
+ writeFileSync(
2279
+ baselinePath,
2280
+ JSON.stringify({ createdAt: new Date().toISOString(), keys: merged }, null, 2),
2281
+ );
2282
+ alog('');
2283
+ alog(
2284
+ `${A.G}✓${A.R} render baseline updated — ${A.B}+${merged.length - existing.length}${A.R} new keys ` +
2285
+ `${A.D}(${merged.length} total${existing.length ? `, was ${existing.length}` : ''})${A.R}`,
2286
+ );
2287
+ alog(` ${A.D}${baselinePath}${A.R}`);
2288
+ for (const f of allFindings) f.isNew = false;
2289
+ writeRenderReportAndNdjson(allFindings, { url, scenarios, outPath, ndjsonPath, newCount: 0, failedScenarios });
2290
+ return 0;
2291
+ }
2292
+
2293
+ let baselineKeys = null;
2294
+ let hasBaseline = false;
2295
+ try {
2296
+ baselineKeys = new Set(JSON.parse(readFileSync(baselinePath, 'utf8')).keys ?? []);
2297
+ hasBaseline = true;
2298
+ } catch {
2299
+ /* no baseline yet — mirror the slop audit: first run passes (exit 0) */
2300
+ }
2301
+
2302
+ for (const f of allFindings) f.isNew = hasBaseline ? !baselineKeys.has(bkey(f)) : true;
2303
+ const newFindings = allFindings.filter((f) => f.isNew);
2304
+
2305
+ const totalErr = allFindings.filter((f) => f.severity === 'error').length;
2306
+ const totalWarn = allFindings.filter((f) => f.severity === 'warn').length;
2307
+ writeRenderReportAndNdjson(allFindings, {
2308
+ url,
2309
+ scenarios,
2310
+ outPath,
2311
+ ndjsonPath,
2312
+ newCount: newFindings.length,
2313
+ failedScenarios,
2314
+ });
2315
+
2316
+ // ---- exit decision -------------------------------------------------------
2317
+ // New error-severity keys vs baseline fail (per --fail-on). Missing baseline
2318
+ // → everything is "new" but we exit 0 with a hint (matches the slop audit's
2319
+ // first-run behavior).
2320
+ const gateSeverity =
2321
+ !hasBaseline || failOn === 'none'
2322
+ ? []
2323
+ : failOn === 'warn'
2324
+ ? newFindings.filter((f) => f.severity === 'error' || f.severity === 'warn')
2325
+ : newFindings.filter((f) => f.severity === 'error');
2326
+ // Only CONFIRMED keys fail (reproduced in the confirm re-run). When no
2327
+ // confirm pass ran (no baseline / fail-on none), gateSeverity is empty
2328
+ // anyway or gates directly.
2329
+ const gate = confirmedKeys ? gateSeverity.filter((f) => confirmedKeys.has(bkey(f))) : gateSeverity;
2330
+ const flakyCount = gateSeverity.length - gate.length;
2331
+ const willFail = gate.length > 0;
2332
+
2333
+ alog('');
2334
+ alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
2335
+ alog(
2336
+ ` ${A.B}totals${A.R} ${totalErr} errors ${totalWarn} warns across ${scenarios.length} scenario(s)`,
2337
+ );
2338
+ if (failedScenarios.length) {
2339
+ alog(` ${A.Y}${failedScenarios.length} scenario(s) failed/aborted${A.R} ${A.D}(see ✗ above)${A.R}`);
2340
+ }
2341
+ if (!hasBaseline) {
2342
+ alog(
2343
+ ` ${A.Y}no baseline yet${A.R} — run with ${A.C}--update-baseline${A.R} to create the ratchet ` +
2344
+ `(all ${newFindings.length} findings count as new until then).`,
2345
+ );
2346
+ } else {
2347
+ alog(` ${A.B}new vs baseline${A.R} ${newFindings.length} findings`);
2348
+ if (flakyCount > 0) {
2349
+ alog(
2350
+ ` ${A.Y}${flakyCount} flaky${A.R} ${A.D}new finding(s) did not reproduce in the confirm re-run — not gating; ` +
2351
+ `they union into the baseline on the next --update-baseline${A.R}`,
2352
+ );
2353
+ }
2354
+ }
2355
+ alog(` report ${A.C}${outPath}${A.R}`);
2356
+ if (willFail) {
2357
+ alog(
2358
+ ` ${A.Rd}${A.B}FAIL${A.R} — ${gate.length} new ${failOn === 'warn' ? 'error/warn' : 'error'}-severity finding(s) (fail-on=${failOn})`,
2359
+ );
2360
+ } else {
2361
+ alog(` ${A.G}${A.B}PASS${A.R} — 0 new findings at/above fail-on=${failOn}`);
2362
+ }
2363
+ alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
2364
+ return willFail ? 1 : 0;
2365
+ }
2366
+
2367
+ const padScenario = (s) => s.slice(0, 30).padEnd(30);
2368
+
2369
+ /** Help for the `audit` subcommand — both the design slop-scan sweep and the
2370
+ render scan, including the full scenario step schema. */
2371
+ function printAuditHelp() {
2372
+ const { B, D, C, G, R } = A;
2373
+ alog('');
2374
+ alog(`${B}linear-grab-bridge audit${R} ${D}— headless, CI-able design + re-render gates (raw CDP, zero deps)${R}`);
2375
+ alog('');
2376
+ alog(`${B}DESIGN slop-scan${R} ${D}(default)${R}`);
2377
+ alog(` ${D}»${R} audit --url http://localhost:3000 ${D}[--routes /,/x] [--theme light|dark|both]${R}`);
2378
+ alog(` Sweeps routes route-by-route and grades the design contract; writes .lineargrab/slop-report.md.`);
2379
+ alog('');
2380
+ alog(`${B}RENDER scan${R} ${D}(--renders)${R}`);
2381
+ alog(` ${D}»${R} audit --renders --url http://localhost:3000 ${D}[--scenario <name>]${R}`);
2382
+ alog(` Replays scripted interactions while recording React commits, then grades the`);
2383
+ alog(` re-render contract. ${D}Renders are theme-independent — ONE theme only (no light/dark sweep).${R}`);
2384
+ alog(` Scenarios come from ${C}.lineargrab.json${R} → ${C}renderAudit.scenarios${R} (array); no built-in scenarios.`);
2385
+ alog('');
2386
+ alog(` ${B}scenario${R} { name, route, settleMs?, steps: [ … ] }`);
2387
+ alog(` ${B}steps${R} ${D}(each step is one key; a failed step warns and continues — except`);
2388
+ alog(` waitFor/clickText, which ABORT the scenario since later steps depend on them):${R}`);
2389
+ alog(` ${G}{ "waitFor": "<selector>", "timeoutMs"?: 5000 }${R} poll until the selector mounts`);
2390
+ alog(` ${G}{ "click": "<selector>" }${R} el.click() the selector`);
2391
+ alog(` ${G}{ "clickText": "<text>", "timeoutMs"?: 5000 }${R} poll for a clickable button/[role=button]/a`);
2392
+ alog(` whose trimmed text EQUALS <text> (else includes), then click`);
2393
+ alog(` ${G}{ "key": "j", "repeat"?: 20, "delayMs"?: 80 }${R} dispatch keyDown+keyUp per press`);
2394
+ alog(` ${G}{ "type": "hello", "selector"?: "…", "perCharMs"?: 40 }${R} focus, then insert text per char`);
2395
+ alog(` ${G}{ "scroll": "<selector>", "deltaY"?: 800 }${R} mouseWheel at the element center`);
2396
+ alog(` ${G}{ "wait": 500 }${R} settle pause (ms)`);
2397
+ alog('');
2398
+ alog(`${B}shared flags${R} ${D}--chrome <path> --login --profile <dir> --fresh --timeout <ms>${R}`);
2399
+ alog(` ${D}--fail-on error|warn|none --update-baseline --out <file>${R}`);
2400
+ alog(` ${B}rulebook${R} .lineargrab.json → renderRulebook (default React-rerender-primitives.md); budgets → renderBudgets`);
2401
+ alog(` ${B}baseline${R} render ratchet at ${C}.lineargrab/render-baseline.json${R} — new error-severity keys vs baseline exit 1`);
2402
+ alog(` ${D}new keys must REPRODUCE in an automatic confirm re-run to fail (threshold-flaky keys don't gate);${R}`);
2403
+ alog(` ${D}--update-baseline UNIONS into the existing baseline; --reset-baseline starts it over${R}`);
2404
+ alog('');
2405
+ }
2406
+
2407
+ /** Append render findings to scan.ndjson with the same rotation as telemetry.
2408
+ kind 'render-scan', mode 'headless', page = '<scenario> <route>'. */
2409
+ function appendRenderNdjson(file, findings) {
2410
+ const lines =
2411
+ findings
2412
+ .slice(0, 5000)
2413
+ .map((f) =>
2414
+ JSON.stringify({
2415
+ kind: 'render-scan',
2416
+ mode: 'headless',
2417
+ page: `${f.scenario} ${f.route}`,
2418
+ at: Date.now(),
2419
+ ruleId: f.ruleId,
2420
+ shape: f.shape,
2421
+ severity: f.severity,
2422
+ suspected: f.suspected,
2423
+ description: f.description,
2424
+ component: f.component ?? null,
2425
+ source: f.source ?? null,
2426
+ renders: f.renders,
2427
+ selfTime: f.selfTime,
2428
+ changes: f.changes,
2429
+ evidence: f.evidence,
2430
+ count: f.count ?? 1,
2431
+ isNew: !!f.isNew,
2432
+ }),
2433
+ )
2434
+ .join('\n') + '\n';
2435
+ try {
2436
+ appendFileSync(file, lines);
2437
+ const size = statSync(file).size;
2438
+ if (size > 2_000_000) {
2439
+ const keep = readFileSync(file, 'utf8');
2440
+ writeFileSync(file, keep.slice(Math.floor(keep.length / 2)).replace(/^[^\n]*\n/, ''));
2441
+ }
2442
+ } catch {
2443
+ /* disk issues — never fail the audit on telemetry */
2444
+ }
2445
+ }
2446
+
2447
+ /** Write the render markdown report + append NDJSON events. Per-scenario
2448
+ section; each finding a `- [severity] suspected R5 — … (Component @ file:line)
2449
+ · evidence` line with ×count and a NEW-vs-baseline marker. */
2450
+ function writeRenderReportAndNdjson(findings, { url, scenarios, outPath, ndjsonPath, newCount, failedScenarios }) {
2451
+ const totalErr = findings.filter((f) => f.severity === 'error').length;
2452
+ const totalWarn = findings.filter((f) => f.severity === 'warn').length;
2453
+
2454
+ const lines = [
2455
+ '# Render scan report',
2456
+ '',
2457
+ `- **url**: ${url}`,
2458
+ `- **scenarios**: ${scenarios.length} (${scenarios.map((s) => s.name).join(', ')})`,
2459
+ `- **totals**: ${totalErr} errors / ${totalWarn} warns`,
2460
+ `- **new vs baseline**: ${newCount}`,
2461
+ `- **generated**: ${new Date().toISOString()}`,
2462
+ '',
2463
+ 'Diagnoses are SUSPECTED (heuristic). The numbers (renders, self ms) are measured.',
2464
+ '',
2465
+ ];
2466
+ if (failedScenarios?.length) {
2467
+ lines.push('> Failed/aborted scenarios: ' + failedScenarios.map((f) => `${f.name} (${f.reason})`).join('; '), '');
2468
+ }
2469
+
2470
+ const byScenario = new Map();
2471
+ for (const f of findings) {
2472
+ if (!byScenario.has(f.scenario)) byScenario.set(f.scenario, []);
2473
+ byScenario.get(f.scenario).push(f);
2474
+ }
2475
+ for (const scenario of scenarios) {
2476
+ const sf = byScenario.get(scenario.name);
2477
+ lines.push(`## ${scenario.name} \`${scenario.route || '/'}\``, '');
2478
+ if (!sf || !sf.length) {
2479
+ lines.push('_No findings._', '');
2480
+ continue;
2481
+ }
2482
+ // Errors first, then warns; within, by count desc.
2483
+ const sorted = [...sf].sort((a, b) => {
2484
+ const sev = (x) => (x.severity === 'error' ? 0 : 1);
2485
+ return sev(a) - sev(b) || (b.count ?? 1) - (a.count ?? 1);
2486
+ });
2487
+ for (const f of sorted) {
2488
+ const where = f.component || f.source ? ` (${[f.component, f.source].filter(Boolean).join(' @ ')})` : '';
2489
+ const times = (f.count ?? 1) > 1 ? ` ×${f.count}` : '';
2490
+ lines.push(`- [${f.severity}] ${f.description}${where}${times} · ${f.evidence}${f.isNew ? ' **NEW**' : ''}`);
2491
+ }
2492
+ lines.push('');
2493
+ }
2494
+
2495
+ try {
2496
+ mkdirSync(join(outPath, '..'), { recursive: true });
2497
+ } catch {
2498
+ /* ignore */
2499
+ }
2500
+ writeFileSync(outPath, lines.join('\n'));
2501
+ appendRenderNdjson(ndjsonPath, findings);
2502
+ }
2503
+
1671
2504
  // Dispatch audit LAST — every const/fn above is now initialized, so no TDZ.
1672
2505
  if (AUDIT_MODE) {
1673
2506
  const code = await runAudit();