@reddoorla/maintenance 0.95.0 → 0.95.1

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.
Files changed (75) hide show
  1. package/dist/{announce-PGT4SZZB.js → announce-CLQYX5I4.js} +3 -3
  2. package/dist/{chunk-KQB2BX4V.js → chunk-2ZAZ7ETT.js} +2 -2
  3. package/dist/{chunk-DMPHP7UP.js → chunk-BI2VI6EM.js} +2 -2
  4. package/dist/{chunk-OCNSIBMP.js → chunk-C3IQQKXL.js} +26 -1
  5. package/dist/{chunk-OCNSIBMP.js.map → chunk-C3IQQKXL.js.map} +1 -1
  6. package/dist/{chunk-4AIYWWKV.js → chunk-KJRVMMM3.js} +2 -2
  7. package/dist/{chunk-AEUABPAJ.js → chunk-R5IMFAFR.js} +2 -2
  8. package/dist/{chunk-22GKJO5F.js → chunk-VSGMCCGQ.js} +2 -2
  9. package/dist/chunk-XPJQXSC4.js +112 -0
  10. package/dist/chunk-XPJQXSC4.js.map +1 -0
  11. package/dist/{chunk-6VKYQATC.js → chunk-ZCL7C5LH.js} +1115 -125
  12. package/dist/chunk-ZCL7C5LH.js.map +1 -0
  13. package/dist/cli/bin.js +15 -15
  14. package/dist/cli/commands/audit.js +5 -5
  15. package/dist/client-G2WGWPCA.js +11 -0
  16. package/dist/configs/eslint.js +27 -1
  17. package/dist/configs/eslint.js.map +1 -1
  18. package/dist/{db-BBEM7CBX.js → db-XKZYDCKM.js} +9 -9
  19. package/dist/{digest-65BOZ7AT.js → digest-7UBGUBRK.js} +13 -13
  20. package/dist/{digest-collectors-EHFY2OQC.js → digest-collectors-DJFN5ZDK.js} +7 -7
  21. package/dist/{ensure-site-WIDO6LGU.js → ensure-site-OAAE5YE2.js} +2 -2
  22. package/dist/{forms-notify-target-2LWF5C5B.js → forms-notify-target-UC6PF3AE.js} +2 -2
  23. package/dist/{github-signals-GAHNOAHS.js → github-signals-TX5FPYP6.js} +5 -5
  24. package/dist/{header-image-YN3AQRY3.js → header-image-PSPT7CUM.js} +2 -2
  25. package/dist/{health-mirror-EB76EERV.js → health-mirror-XJAKA3EY.js} +3 -3
  26. package/dist/index.js +8 -8
  27. package/dist/{launch-4RVWPZGI.js → launch-UD36AHUN.js} +3 -3
  28. package/dist/{match-harness-2Q6ISEES.js → match-harness-QRG6XZAR.js} +2 -2
  29. package/dist/migrate-Y7R4CESL.js +7 -0
  30. package/dist/{orchestrate-MWXENQOS.js → orchestrate-HYVQPRCE.js} +5 -5
  31. package/dist/{preflight-4VQP7JJL.js → preflight-RYIP54NY.js} +6 -6
  32. package/dist/{prismic-models-W6CNLTWH.js → prismic-models-P6FWWVRS.js} +2 -2
  33. package/dist/{prospect-audit-X4Z67LWW.js → prospect-audit-PRHU53HS.js} +3 -3
  34. package/dist/{prospect-audits-RVYEA4KH.js → prospect-audits-R3LWF6U7.js} +10 -4
  35. package/dist/{report-4GFD4MW2.js → report-LQS4ECTM.js} +11 -11
  36. package/dist/{report-mirror-IP64R7B3.js → report-mirror-HDTOJE64.js} +3 -3
  37. package/dist/{schema-2V7BGYVT.js → schema-A4WCPIF4.js} +1 -1
  38. package/dist/{schema-2V7BGYVT.js.map → schema-A4WCPIF4.js.map} +1 -1
  39. package/dist/{selftest-MAB2PBXQ.js → selftest-VXTHRPRN.js} +5 -5
  40. package/dist/{site-mirror-UKHREAD6.js → site-mirror-WON4FTJ2.js} +3 -3
  41. package/dist/{submissions-5GKJPCIH.js → submissions-OVZPVBYG.js} +2 -2
  42. package/package.json +1 -1
  43. package/dist/chunk-6VKYQATC.js.map +0 -1
  44. package/dist/chunk-7MKT4I5T.js +0 -62
  45. package/dist/chunk-7MKT4I5T.js.map +0 -1
  46. package/dist/client-M2V6FHH5.js +0 -11
  47. package/dist/migrate-K4JETR36.js +0 -7
  48. /package/dist/{announce-PGT4SZZB.js.map → announce-CLQYX5I4.js.map} +0 -0
  49. /package/dist/{chunk-KQB2BX4V.js.map → chunk-2ZAZ7ETT.js.map} +0 -0
  50. /package/dist/{chunk-DMPHP7UP.js.map → chunk-BI2VI6EM.js.map} +0 -0
  51. /package/dist/{chunk-4AIYWWKV.js.map → chunk-KJRVMMM3.js.map} +0 -0
  52. /package/dist/{chunk-AEUABPAJ.js.map → chunk-R5IMFAFR.js.map} +0 -0
  53. /package/dist/{chunk-22GKJO5F.js.map → chunk-VSGMCCGQ.js.map} +0 -0
  54. /package/dist/{client-M2V6FHH5.js.map → client-G2WGWPCA.js.map} +0 -0
  55. /package/dist/{db-BBEM7CBX.js.map → db-XKZYDCKM.js.map} +0 -0
  56. /package/dist/{digest-65BOZ7AT.js.map → digest-7UBGUBRK.js.map} +0 -0
  57. /package/dist/{digest-collectors-EHFY2OQC.js.map → digest-collectors-DJFN5ZDK.js.map} +0 -0
  58. /package/dist/{ensure-site-WIDO6LGU.js.map → ensure-site-OAAE5YE2.js.map} +0 -0
  59. /package/dist/{forms-notify-target-2LWF5C5B.js.map → forms-notify-target-UC6PF3AE.js.map} +0 -0
  60. /package/dist/{github-signals-GAHNOAHS.js.map → github-signals-TX5FPYP6.js.map} +0 -0
  61. /package/dist/{header-image-YN3AQRY3.js.map → header-image-PSPT7CUM.js.map} +0 -0
  62. /package/dist/{health-mirror-EB76EERV.js.map → health-mirror-XJAKA3EY.js.map} +0 -0
  63. /package/dist/{launch-4RVWPZGI.js.map → launch-UD36AHUN.js.map} +0 -0
  64. /package/dist/{match-harness-2Q6ISEES.js.map → match-harness-QRG6XZAR.js.map} +0 -0
  65. /package/dist/{migrate-K4JETR36.js.map → migrate-Y7R4CESL.js.map} +0 -0
  66. /package/dist/{orchestrate-MWXENQOS.js.map → orchestrate-HYVQPRCE.js.map} +0 -0
  67. /package/dist/{preflight-4VQP7JJL.js.map → preflight-RYIP54NY.js.map} +0 -0
  68. /package/dist/{prismic-models-W6CNLTWH.js.map → prismic-models-P6FWWVRS.js.map} +0 -0
  69. /package/dist/{prospect-audit-X4Z67LWW.js.map → prospect-audit-PRHU53HS.js.map} +0 -0
  70. /package/dist/{prospect-audits-RVYEA4KH.js.map → prospect-audits-R3LWF6U7.js.map} +0 -0
  71. /package/dist/{report-4GFD4MW2.js.map → report-LQS4ECTM.js.map} +0 -0
  72. /package/dist/{report-mirror-IP64R7B3.js.map → report-mirror-HDTOJE64.js.map} +0 -0
  73. /package/dist/{selftest-MAB2PBXQ.js.map → selftest-VXTHRPRN.js.map} +0 -0
  74. /package/dist/{site-mirror-UKHREAD6.js.map → site-mirror-WON4FTJ2.js.map} +0 -0
  75. /package/dist/{submissions-5GKJPCIH.js.map → submissions-OVZPVBYG.js.map} +0 -0
@@ -52,6 +52,9 @@ var HARNESS_MJS_TEMPLATE = `// The single source for everything the matching gat
52
52
  // node matching/harness.mjs --env shell-safe KEY='value' lines
53
53
  // node matching/harness.mjs --table key<TAB>ref<TAB>cand<TAB>anchors
54
54
  // node matching/harness.mjs --check-ref the D11 preflight; exit 2 on failure
55
+ // node matching/harness.mjs --check-run <page> <out-dir> <startedAt-iso>
56
+ // did THIS run leave a countable report?
57
+ // exit 2 when it did not
55
58
  import { readFileSync, realpathSync } from "node:fs";
56
59
  import { homedir } from "node:os";
57
60
  import { join } from "node:path";
@@ -80,12 +83,64 @@ export const SELF_HOSTS = CFG.selfHosts ?? [];
80
83
  export const PAGES = Object.entries(CFG.pages).map(([key, p]) => ({ key, ...p }));
81
84
  export const byKey = Object.fromEntries(PAGES.map((p) => [p.key, p]));
82
85
 
86
+ /** Can this page's region count be PREDICTED at all? It needs anchors \u2014 see
87
+ * TOTALS below \u2014 AND a matrix to measure them at. Exported beside TOTALS
88
+ * rather than left for each consumer to re-derive, because "remember to ask
89
+ * first" is exactly what failed: checkRun remembered, next.mjs did not, for
90
+ * two releases.
91
+ *
92
+ * \`MATRIX.length > 0\` is not defensive padding. TOTALS is
93
+ * \`(anchors + 1) * MATRIX.length\`, so an ANCHORED page with \`matrix: []\`
94
+ * yields 0 \u2014 truthy-adjacent, arithmetically fatal. Measured on a page with 3
95
+ * anchors, an empty matrix and 8 passing regions: without this clause the
96
+ * scorer printed \`SCORE 8/0 regions passing\` and \`Backlog is empty \u2014 Phases 5
97
+ * and 6 are what is left\`, exit 0. The absurd fraction would be questioned;
98
+ * the sentence would not. \`pass/0\` is also Infinity, so such a page sorts
99
+ * BEST and can never be named \`worst\` \u2014 the same ranking bug this change set
100
+ * removed for unanchored pages, one input along. */
101
+ export const scorable = (key) =>
102
+ (byKey[key]?.anchors?.length ?? 0) > 0 && MATRIX.length > 0;
103
+
104
+ /** WHY a page is not scorable, in the words of the thing that is actually
105
+ * missing. A refusal that states a cause it did not check is the shape
106
+ * CLAUDE.md names: a field must never be named after something it cannot
107
+ * observe. "no anchors" printed for a page carrying three of them sends the
108
+ * operator to edit the one part of harness.json that was already right. */
109
+ export const unscorableWhy = (key) =>
110
+ (byKey[key]?.anchors?.length ?? 0) === 0
111
+ ? "no anchors"
112
+ : MATRIX.length === 0
113
+ ? "matrix is empty"
114
+ : null;
115
+
83
116
  // DERIVED, never hand-typed: page-diff cuts one region before the first anchor
84
117
  // ("top") plus one per anchor, at every viewport. The old hand-written map went
85
118
  // stale the moment an anchor list changed, and a wrong denominator makes the
86
119
  // score a lie in the flattering direction.
120
+ //
121
+ // THAT IDENTITY HOLDS ONLY WITH ANCHORS. \`splitRegions\` (page-diff.mjs:103-110)
122
+ // only cuts by anchor when there are anchors to cut by; with none it falls back
123
+ // to the page's own <section> boxes, and with none of those to an even four-row
124
+ // grid (lib/regions.mjs:27-35, gridRows = 4, labels \`grid-<r>-<c>\`). So the
125
+ // count is DATA-DEPENDENT, the two pages need not even agree, and no formula
126
+ // over anchors can predict it.
127
+ //
128
+ // \`checkRun\` below already reaches this conclusion \u2014 its \`if (secs.length)\`
129
+ // guard declines to assert a region count without anchors, and says why. That
130
+ // fix was applied to the VALIDATOR and never carried to the DENOMINATOR, so
131
+ // next.mjs went on dividing a real pass count by an imaginary total. Measured
132
+ // 2026-09-09 on a seed harness (anchors: [], matrix of 3): page-diff produced
133
+ // 12 grid regions, all passing, and next.mjs printed \`SCORE 12/3 regions
134
+ // passing\` followed by "Backlog is empty \u2014 Phases 5 and 6 are what is left",
135
+ // exit 0. On a matrix of 4 the same seed prints \`SCORE 16/4\`. The absurd
136
+ // fraction would have been questioned; the sentence would not.
137
+ //
138
+ // So an unpredictable page gets NO NUMBER \u2014 \`null\`, not a plausible-looking
139
+ // integer. That is a SIGNAL, not a barrier: \`a + null\` is \`a\`, so a consumer
140
+ // that sums TOTALS without asking \`scorable()\` still gets a too-small
141
+ // denominator. The barrier is \`scorable()\` plus next.mjs's exit-2 refusal.
87
142
  export const TOTALS = Object.fromEntries(
88
- PAGES.map((p) => [p.key, (p.anchors.length + 1) * MATRIX.length]),
143
+ PAGES.map((p) => [p.key, scorable(p.key) ? (p.anchors.length + 1) * MATRIX.length : null]),
89
144
  );
90
145
 
91
146
  /** The SPEC.md heading predicate, shared by gate.sh's preflight and
@@ -152,6 +207,160 @@ export async function checkRef() {
152
207
  return { ok: true, why: \`\${REF}/ \u2192 200, no redirect, refMark present, candMark absent\` };
153
208
  }
154
209
 
210
+ /**
211
+ * Would next.mjs COUNT a run with this meta? Returns the reason it would not,
212
+ * as a string, or null when it would.
213
+ *
214
+ * A reason string and not a boolean, because the two callers must tell the
215
+ * cases apart: next.mjs treats "schema" as a page BLANKED (it has its own
216
+ * message and its own exit) and merely skips the rest of the diagnostics,
217
+ * while gate.sh prints whatever this says.
218
+ *
219
+ * It lives HERE rather than inside next.mjs because gate.sh now asks the same
220
+ * question, and a question asked twice drifts: a gate that greens a run
221
+ * next.mjs then drops is the same false green one step along. census.sh's
222
+ * GUARD 2c (census.sh:153-168) records exactly that drift between
223
+ * style-census's printer and census-count.mjs's parser \u2014 a COMPLETE census
224
+ * reported as 0 mismatches because the two had versioned apart.
225
+ */
226
+ export function uncountable(m) {
227
+ // Missing schemaVersion means "written before the field existed" = 0. It is
228
+ // not an error on its own; it is only fatal when it would blank a page,
229
+ // which is next.mjs's call to make, not this predicate's.
230
+ if ((m.schemaVersion ?? 0) !== REPORT_SCHEMA) return "schema";
231
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
232
+ // page. An --mask-photos probe of yfv made \`top\` @834 read 43.9% while the
233
+ // real gate had it passing at 1.3%.
234
+ if ((m.mask?.length ?? 0) > 0) return \`mask=[\${m.mask.join(", ")}]\`;
235
+ if (m.neutralizeMedia) return "neutralize-media";
236
+ if (m.maskPhotos) return "mask-photos";
237
+ if (m.truncated) return "truncated";
238
+ if (m.threshold !== THRESHOLD) return \`threshold \${m.threshold} != \${THRESHOLD}\`;
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Did THIS run of page-diff leave a report the scorer will actually count?
244
+ *
245
+ * gate.sh cannot use page-diff's exit status for this. page-diff exits 1 for a
246
+ * region that legitimately FAILED (page-diff.mjs:227) and node exits 1 for the
247
+ * bare \`throw e\` one line below it, so the status cannot tell a finding from a
248
+ * crash-before-looking \u2014 census.sh:17-32 records the same shape for
249
+ * style-census. Measured on 29 Navy with the reference alive and no dev
250
+ * server: every page-diff died in \`page.goto\`, the gate printed \`home exit=1\`
251
+ * and \`ALL DONE\`, exited 0, and wrote no report at all.
252
+ *
253
+ * So the evidence is the artefact only a completed run leaves: the report
254
+ * next.mjs will read, fresh, over the matrix and anchors the table declares.
255
+ * Cheapest and most specific arm first.
256
+ */
257
+ export function checkRun(page, dir, startedAt) {
258
+ const path = join(dir, "report.json");
259
+ let report;
260
+ try {
261
+ report = JSON.parse(readFileSync(path, "utf8"));
262
+ } catch (e) {
263
+ if (e.code === "ENOENT")
264
+ return { ok: false, why: \`\${path}: no report.json \u2014 the run wrote nothing\` };
265
+ return { ok: false, why: \`\${path}: \${e.message}\` };
266
+ }
267
+ const meta = report.meta ?? {};
268
+
269
+ const uncount = uncountable(meta);
270
+ if (uncount)
271
+ return { ok: false, why: \`\${path}: next.mjs would not count this run (\${uncount})\` };
272
+
273
+ // FRESHNESS. lib/report.mjs:45 is \`mkdirSync(outDir, {recursive:true})\` and
274
+ // nothing ever clears the directory, so a crashed re-run under a tag used
275
+ // before leaves the PREVIOUS round's report exactly where it was \u2014 measured
276
+ // 2026-09-09, sha unchanged across the crash. Requiring report.json without
277
+ // this arm reproduces the green one step along. meta.generatedAt is built
278
+ // after \`finally { await browser.close() }\` (page-diff.mjs:163-176), so it
279
+ // is an artefact of the run that wrote it and not of the file's mtime.
280
+ const since = Date.parse(startedAt);
281
+ // NOT skipped when startedAt is unusable: a fail-open default is the exact
282
+ // shape this guard exists to stop.
283
+ if (!Number.isFinite(since))
284
+ return {
285
+ ok: false,
286
+ why: \`startedAt \${JSON.stringify(startedAt)} is not a timestamp \u2014 cannot tell this run's report from a previous round's\`,
287
+ };
288
+ const at = Date.parse(meta.generatedAt ?? "");
289
+ if (!Number.isFinite(at))
290
+ return {
291
+ ok: false,
292
+ why: \`\${path}: no usable meta.generatedAt \u2014 cannot tell this run's report from a previous round's\`,
293
+ };
294
+ if (at < since)
295
+ return {
296
+ ok: false,
297
+ why: \`\${path}: STALE \u2014 written \${meta.generatedAt}, this run started \${startedAt}. page-diff never cleared the directory.\`,
298
+ };
299
+
300
+ // COVERAGE \u2014 what the run was ASKED for, against the table.
301
+ const vws = meta.viewports ?? [];
302
+ if (vws.join(",") !== MATRIX.join(","))
303
+ return {
304
+ ok: false,
305
+ why: \`\${path}: ran viewports [\${vws.join(",")}], harness.json matrix is [\${MATRIX.join(",")}]\`,
306
+ };
307
+ const secs = meta.sections ?? [];
308
+ const want = byKey[page]?.anchors ?? [];
309
+ if (secs.join("\\0") !== want.join("\\0"))
310
+ return {
311
+ ok: false,
312
+ why: \`\${path}: ran sections [\${secs.join(" | ")}], harness.json anchors are [\${want.join(" | ")}]\`,
313
+ };
314
+
315
+ // ...and what it actually PRODUCED. Every viewport the run says it covered
316
+ // has to appear in the regions. Deliberately compared against the run's own
317
+ // meta.viewports and not against MATRIX: the arm above owns "the run used the
318
+ // wrong matrix", and folding the two together would make either one
319
+ // unfalsifiable on its own.
320
+ if (!Array.isArray(report.regions) || report.regions.length === 0)
321
+ return { ok: false, why: \`\${path}: no regions \u2014 nothing was compared\` };
322
+ const seen = new Set(report.regions.map((r) => r.viewport));
323
+ const missing = vws.filter((v) => !seen.has(v));
324
+ if (missing.length)
325
+ return {
326
+ ok: false,
327
+ why: \`\${path}: no region at viewport(s) [\${missing.join(",")}] \u2014 the run covered [\${[...seen].join(",")}]\`,
328
+ };
329
+
330
+ // With anchors the region count is an exact identity: page-diff cuts one
331
+ // region before the first anchor plus one per anchor, at every viewport
332
+ // (regionsFromAnchors, page-diff.mjs:105-109). Measured over the 298 clean
333
+ // gate-shaped runs in the corpus this harness was cut from, all of them
334
+ // anchored: regions.length === (sections + 1) * viewports holds 298/298,
335
+ // while regions.length === TOTALS[page] holds only 260/298 \u2014 the 38 are
336
+ // legitimately narrower HAND rounds. So the identity is checked against the
337
+ // run's OWN meta and the matrix/anchors are checked against the table above.
338
+ //
339
+ // WITHOUT anchors there is no such identity, and asserting one is a FALSE
340
+ // REFUSAL of the shape every new site starts in. page-diff falls back to each
341
+ // page's own <section> boxes and, with none, an even four-row grid
342
+ // (splitRegions, page-diff.mjs:103-110), so the count is data-dependent and
343
+ // the two pages need not even agree. Measured 2026-09-09 against the real
344
+ // page-diff on a seed harness (anchors: [], matrix of 4): 16 regions labelled
345
+ // grid-0-0 \u2026 grid-3-0, not the 4 this identity predicted. The 298/298 above
346
+ // was measured over anchored runs only and never covered this case.
347
+ if (secs.length) {
348
+ const expected = (secs.length + 1) * vws.length;
349
+ if (report.regions.length !== expected)
350
+ return {
351
+ ok: false,
352
+ why: \`\${path}: \${report.regions.length} region(s), expected \${expected} = (\${secs.length} anchors + 1) x \${vws.length} viewport(s)\`,
353
+ };
354
+ }
355
+
356
+ // A green that STATES what it is made of, so a green over nothing reads
357
+ // differently from a green over the matrix (census.sh:219's habit).
358
+ return {
359
+ ok: true,
360
+ why: \`\${report.regions.length} region(s) over \${vws.length} viewport(s), written \${meta.generatedAt}\`,
361
+ };
362
+ }
363
+
155
364
  // CLI. Both sides go through realpathSync. \`import.meta.url\` is ALREADY the
156
365
  // resolved real path (node resolves symlinks unless --preserve-symlinks) while
157
366
  // process.argv[1] is the path as typed, so a plain pathToFileURL compare goes
@@ -193,8 +402,19 @@ if (isMain()) {
193
402
  const r = await checkRef();
194
403
  console.log(\`\${r.ok ? "REF OK" : "REF REFUSED"} \u2014 \${r.why}\`);
195
404
  process.exit(r.ok ? 0 : 2);
405
+ } else if (mode === "--check-run") {
406
+ // startedAt is REQUIRED, never defaulted: an optional one is a fail-open
407
+ // door in the one guard that decides whether a page was measured at all.
408
+ const [page, dir, startedAt] = process.argv.slice(3);
409
+ if (!page || !dir || !startedAt) {
410
+ console.error("usage: harness.mjs --check-run <page> <out-dir> <startedAt-iso>");
411
+ process.exit(2);
412
+ }
413
+ const r = checkRun(page, dir, startedAt);
414
+ console.log(\`\${r.ok ? "RUN OK" : "NO RUN"} \u2014 \${r.why}\`);
415
+ process.exit(r.ok ? 0 : 2);
196
416
  } else {
197
- console.error("usage: harness.mjs --env | --table | --check-ref");
417
+ console.error("usage: harness.mjs --env | --table | --check-ref | --check-run");
198
418
  process.exit(2);
199
419
  }
200
420
  }
@@ -263,6 +483,17 @@ esac
263
483
  shift || true
264
484
  WANT=("$@")
265
485
 
486
+ # What this round actually measured. These are read by the terminal block far
487
+ # below, and the page loop is \`done < <(...)\` \u2014 process substitution, NOT a
488
+ # pipe \u2014 precisely so they survive it (gate.sh's own note at the loop, and
489
+ # census.sh:193-198, record the same trap). Converting that loop to a pipe
490
+ # would leave every counter here reading 0, which is the green this file now
491
+ # exists to stop.
492
+ MEASURED=0
493
+ ATTEMPTED=0
494
+ UNMEASURED=""
495
+ SEEN=""
496
+
266
497
  # Fail closed on the reference before spending a single run. A 200 is NOT
267
498
  # evidence: a production host that has cut over to OUR build answers 200, and
268
499
  # every region then scores near zero against itself. --check-ref requires an
@@ -293,6 +524,9 @@ has_spec() { # page
293
524
 
294
525
  run() { # tag refpath candpath sections
295
526
  local page="$1" refpath="$2" candpath="$3" sections="$4"
527
+ # BEFORE the WANT filter, so the known-page list stays complete even when
528
+ # WANT matches nothing and there is a typo to name. Mirrors census.sh:107.
529
+ SEEN="$SEEN $page"
296
530
  if [ \${#WANT[@]} -gt 0 ]; then
297
531
  local hit=0
298
532
  for w in "\${WANT[@]}"; do [ "$w" = "$page" ] && hit=1; done
@@ -315,11 +549,35 @@ run() { # tag refpath candpath sections
315
549
  fi
316
550
  fi
317
551
  echo "########## $page ##########"
552
+ # Same clock source and same format as page-diff's own meta.generatedAt, so
553
+ # the freshness comparison below is between two ISO strings from one clock.
554
+ local started
555
+ started="$(node -e 'process.stdout.write(new Date().toISOString())')"
318
556
  node "$PD" --ref "$REF$refpath" --cand "$CAND$candpath" \\
319
557
  --viewports "$MATRIX" --threshold "$THRESHOLD" \\
320
558
  --sections "$sections" --out "matching/out-$TAG-$page" \\
321
559
  > "matching/out-$TAG-$page.log" 2>&1
322
- echo "$page exit=$?"
560
+ # IMMEDIATELY after the invocation. Any command in between \u2014 the echo
561
+ # included \u2014 destroys $?.
562
+ local status=$?
563
+ echo "$page exit=$status"
564
+ ATTEMPTED=$((ATTEMPTED + 1))
565
+ # $status may DENY a green; it may never GRANT one, and here it does neither.
566
+ # page-diff exits 1 for a region that legitimately FAILED (page-diff.mjs:227)
567
+ # and node exits 1 for the bare \`throw e\` below it, so the status cannot tell
568
+ # a finding from a crash before it looked \u2014 and a matching round's steady
569
+ # state IS a failing gate, so denying on it would break every normal round.
570
+ # Measured on 29 Navy with the reference alive and no dev server: \`home
571
+ # exit=1\`, \`ALL DONE\`, exit 0, and no matching/out-*-home/ written at all.
572
+ # What only a completed run leaves is the report next.mjs will count.
573
+ # census.sh:17-32 is the same argument for style-census.
574
+ local evidence
575
+ if evidence="$(node "$(dirname "$0")/harness.mjs" --check-run "$page" "matching/out-$TAG-$page" "$started" 2>&1)"; then
576
+ MEASURED=$((MEASURED + 1))
577
+ else
578
+ UNMEASURED="$UNMEASURED $page"
579
+ echo " NOT MEASURED: $evidence"
580
+ fi
323
581
  }
324
582
 
325
583
  # The page table is matching/harness.json. Process substitution, NOT a pipe:
@@ -329,14 +587,48 @@ while IFS=$'\\t' read -r key refpath candpath anchors; do
329
587
  run "$key" "$refpath" "$candpath" "$anchors"
330
588
  done < <(node "$(dirname "$0")/harness.mjs" --table)
331
589
 
590
+ # BOTH kinds of incompleteness are reported in one run: a page refused before
591
+ # it ran, and a page that ran and left nothing countable. Exiting on the first
592
+ # would hide the second from an operator who then fixes only what was printed.
593
+ INCOMPLETE=0
594
+ if [ -n "$UNMEASURED" ]; then
595
+ echo
596
+ echo "GATE INCOMPLETE ($TAG) \u2014 $((ATTEMPTED - MEASURED)) of $ATTEMPTED page(s) produced no"
597
+ echo "countable report:$UNMEASURED"
598
+ echo "Those pages have NOT been measured. next.mjs drops an unreported page from"
599
+ echo "BOTH sides of the score, so the pages that did report would read as the"
600
+ echo "whole site. Full runs: matching/out-$TAG-<page>.log"
601
+ INCOMPLETE=1
602
+ fi
332
603
  if [ "\${FAILED_PREFLIGHT:-0}" = "1" ]; then
333
604
  echo
334
605
  echo "GATE INCOMPLETE ($TAG) \u2014 one or more pages were refused for a missing"
335
606
  echo "SPEC.md section. Those pages have NOT been measured; do not report a"
336
607
  echo "score for them."
608
+ INCOMPLETE=1
609
+ fi
610
+ if [ "$INCOMPLETE" = "1" ]; then
337
611
  exit 2
338
612
  fi
339
- echo "ALL DONE ($TAG)"
613
+ # A green over ZERO pages is still a green. Measured: \`gate.sh nosuch
614
+ # nosuchpage\` printed ALL DONE and exited 0 having run nothing and printed no
615
+ # page header at all. census.sh:199-209 refuses exactly this shape, and
616
+ # strikes.mjs:147-156 was written because a typo silently greened rule 3 on six
617
+ # of nine pages.
618
+ if [ "$ATTEMPTED" -eq 0 ]; then
619
+ if [ -n "$SEEN" ]; then
620
+ echo "gate.sh: \\"\${WANT[*]:-}\\" matches no page \u2014 refusing to report ALL DONE." >&2
621
+ echo " known pages:$SEEN" >&2
622
+ else
623
+ echo "gate.sh: the page table is empty \u2014 refusing to report ALL DONE." >&2
624
+ echo " node matching/harness.mjs --table printed no rows; check" >&2
625
+ echo " \\"pages\\" in matching/harness.json." >&2
626
+ fi
627
+ exit 2
628
+ fi
629
+ # A green that STATES what it is made of (census.sh:219), so a green over
630
+ # nothing cannot read like a green over the table.
631
+ echo "ALL DONE ($TAG) \u2014 $MEASURED of $ATTEMPTED page(s) measured, each one counted."
340
632
  `;
341
633
  var CENSUS_SH_RELATIVE = "matching/census.sh";
342
634
  var CENSUS_SH_TEMPLATE = `#!/usr/bin/env bash
@@ -602,7 +894,16 @@ if (existsSync(PAUSE)) {
602
894
  }
603
895
 
604
896
  import { FLOORS, ACCEPTED } from "./floors.mjs";
605
- import { TOTALS, THRESHOLD, MAX_HEIGHT_DELTA, REPORT_SCHEMA } from "./harness.mjs";
897
+ import {
898
+ TOTALS,
899
+ THRESHOLD,
900
+ MAX_HEIGHT_DELTA,
901
+ REPORT_SCHEMA,
902
+ uncountable,
903
+ scorable,
904
+ unscorableWhy,
905
+ byKey,
906
+ } from "./harness.mjs";
606
907
 
607
908
  // Reports written by a different page-diff, by page key. Kept rather than
608
909
  // dropped: silently ignoring them is how a page vanishes from the score.
@@ -611,7 +912,12 @@ const schemaMismatch = new Set();
611
912
  const latest = new Map();
612
913
  for (const d of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
613
914
  const m = /^out-[^-]+-(.+)$/.exec(d);
614
- if (!m || !TOTALS[m[1]]) continue;
915
+ // Membership in the page table is asked of the TABLE. \`!TOTALS[m[1]]\` was a
916
+ // NUMBER standing in for a fact: it is null for an unanchored page and 0 for
917
+ // an empty matrix, and either dropped the page out of \`latest\` while
918
+ // \`Object.keys(TOTALS)\` still listed it \u2014 so a gate run that had just
919
+ // SUCCEEDED came back as "no countable gate run".
920
+ if (!m || !byKey[m[1]]) continue;
615
921
  let report, mtime;
616
922
  try {
617
923
  const p = join(DIR, d, "report.json");
@@ -623,22 +929,19 @@ for (const d of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
623
929
  // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
624
930
  // page. Picking one up as "latest" silently reports scores nobody can ship \u2014
625
931
  // it happened immediately: an --mask-photos probe of yfv made \`top\` @834 read
626
- // 43.9% here while the real gate had it passing at 1.3%.
627
- const meta = report.meta ?? {};
628
- // Missing schemaVersion means "written before the field existed" = 0. It is
629
- // not an error on its own; it is only fatal when it would blank a page.
630
- if ((meta.schemaVersion ?? 0) !== REPORT_SCHEMA) {
932
+ // 43.9% here while the real gate had it passing at 1.3%. Missing
933
+ // schemaVersion means "written before the field existed" = 0; it is not an
934
+ // error on its own, only when it would blank a page.
935
+ //
936
+ // The predicate itself lives in harness.mjs now, because gate.sh asks the
937
+ // same question per run and two copies of one question drift \u2014 a gate that
938
+ // greens a run this file then drops is the same false green one step along.
939
+ const why = uncountable(report.meta ?? {});
940
+ if (why === "schema") {
631
941
  schemaMismatch.add(m[1]);
632
942
  continue;
633
943
  }
634
- if (
635
- (meta.mask?.length ?? 0) > 0 ||
636
- meta.neutralizeMedia ||
637
- meta.maskPhotos ||
638
- meta.truncated
639
- )
640
- continue;
641
- if (meta.threshold !== THRESHOLD) continue;
944
+ if (why) continue;
642
945
  const prev = latest.get(m[1]);
643
946
  if (!prev || mtime > prev.mtime) latest.set(m[1], { dir: d, mtime, report });
644
947
  }
@@ -689,7 +992,22 @@ for (const [page, { dir, report }] of latest) {
689
992
  }
690
993
  }
691
994
 
995
+ // Pages that REPORTED but cannot be scored: no anchors, so their regions are
996
+ // whatever page-diff's fallback cut and there is no model for one to be right
997
+ // against. Kept separate from \`scored\` and never ranked with it.
998
+ const unscorable = [...latest.entries()]
999
+ .filter(([p]) => !scorable(p))
1000
+ .map(([p, v]) => ({ p, regions: v.report.regions.length }))
1001
+ .sort((a, b) => a.p.localeCompare(b.p));
1002
+
1003
+ // \`.filter(scorable)\` before \`.map\`, because the sort below divides by \`total\`
1004
+ // and an unanchored page's ratio is NOT BOUNDED BY 1. A passing one scored
1005
+ // 16/4 = 4.0 and sorted LAST, i.e. best, so \`worst\` could never name the one
1006
+ // page whose Phase 1 was not done; a failing one scored 0/4 = 0.0, sorted
1007
+ // FIRST, and put \`grid-0-0\`, \`grid-1-0\` \u2026 on the agenda \u2014 an instruction to fix
1008
+ // geometry against regions page-diff invented.
692
1009
  const scored = [...latest.entries()]
1010
+ .filter(([p]) => scorable(p))
693
1011
  .map(([p, v]) => ({
694
1012
  p,
695
1013
  pass: v.report.regions.filter((r) => r.pass).length,
@@ -697,13 +1015,50 @@ const scored = [...latest.entries()]
697
1015
  }))
698
1016
  .sort((a, b) => a.pass / a.total - b.pass / b.total);
699
1017
 
1018
+ // The denominator is the DECLARED site, not the pages that happened to report.
1019
+ // Summed over \`scored\` it shrank to match the numerator: 8 of 9 pages reporting
1020
+ // read SCORE 160/160 while the ninth, whose page-diff had crashed, was in
1021
+ // neither the numerator nor the denominator nor the list below. harness.mjs:61-67
1022
+ // already gives the reason \u2014 "a wrong denominator makes the score a lie in the
1023
+ // flattering direction" \u2014 and that fix was applied per REGION (\`total:
1024
+ // TOTALS[p]\`) and never per PAGE.
1025
+ const unmeasured = Object.keys(TOTALS)
1026
+ .filter((p) => !latest.has(p))
1027
+ .sort();
700
1028
  const sum = scored.reduce((a, s) => a + s.pass, 0);
701
- const max = scored.reduce((a, s) => a + s.total, 0);
702
- console.log(\`SCORE \${sum}/\${max} regions passing\\n\`);
1029
+ // Summed EXPLICITLY over the scorable pages rather than over TOTALS' values:
1030
+ // \`a + null\` is silently \`a\`, and a denominator that is right only because of a
1031
+ // coercion is the same lie one refactor away.
1032
+ const pagesAll = Object.keys(TOTALS).length;
1033
+ const pagesScorable = Object.keys(TOTALS).filter((p) => scorable(p)).length;
1034
+ const max = Object.keys(TOTALS).reduce((a, p) => a + (scorable(p) ? TOTALS[p] : 0), 0);
1035
+
1036
+ // A score is printed only if SOMETHING can carry one. \`SCORE 0/0\` is a third
1037
+ // lie and the one that reads best of all, so it is never printed: with nothing
1038
+ // scorable the line says so in words and gives no fraction to quote.
703
1039
  console.log(
704
- scored
705
- .map((s) => \` \${s.p.padEnd(9)} \${String(s.pass).padStart(2)}/\${s.total}\`)
706
- .join("\\n"),
1040
+ (pagesScorable === 0
1041
+ ? \`NO SCORE \u2014 0 of \${pagesAll} page(s) can carry one.\`
1042
+ : \`SCORE \${sum}/\${max} regions passing over \${pagesScorable} of \${pagesAll} page(s)\` +
1043
+ (unmeasured.length ? \` \u2014 \${unmeasured.length} page(s) NOT MEASURED\` : "")) + "\\n",
1044
+ );
1045
+ console.log(
1046
+ [
1047
+ ...scored.map((s) => \` \${s.p.padEnd(9)} \${String(s.pass).padStart(2)}/\${s.total}\`),
1048
+ // The run's OWN region count, as EVIDENCE FOR THE REFUSAL \u2014 it proves a run
1049
+ // happened and explains why it cannot be scored, so a refusal is not
1050
+ // mistaken for a crash. The pass FRACTION is deliberately withheld: it is
1051
+ // the number with no referent, and the number that gets quoted.
1052
+ ...unscorable.map(
1053
+ (u) =>
1054
+ \` \${u.p.padEnd(9)} \${String(u.regions).padStart(2)} region(s) NOT SCORABLE \u2014 \${unscorableWhy(u.p)}\`,
1055
+ ),
1056
+ // \`?/N\`, never \`0/N\`: an unmeasured page is not a page that scored zero,
1057
+ // and printing zero would be a different lie. \`?/?\` when the page is ALSO
1058
+ // unanchored \u2014 \`?/null\` would name the denominator "null", which is worse
1059
+ // than the number it replaced.
1060
+ ...unmeasured.map((p) => \` \${p.padEnd(9)} ?/\${TOTALS[p] ?? "?"} NOT MEASURED\`),
1061
+ ].join("\\n"),
707
1062
  );
708
1063
 
709
1064
  if (accepted.length) {
@@ -712,6 +1067,47 @@ if (accepted.length) {
712
1067
  console.log(\` \${a.page} @\${a.vw} "\${a.label}" \u2014 \${a.why.slice(0, 96)}\u2026\`);
713
1068
  }
714
1069
 
1070
+ // BEFORE the \`!rows.length\` branch, and deliberately so: an unmeasured page
1071
+ // contributes no failing region, so that branch would print "Backlog is empty"
1072
+ // and exit 0 over a page nobody had looked at. Exit 2 matches the two guards
1073
+ // above (\`blanked\`, \`latest.size === 0\`) \u2014 neither "clean" nor "work remains"
1074
+ // but "this cannot be scored", the one answer rule 5's while-it-exits-1 loop
1075
+ // cannot swallow. The score print stays above it so the partial state is still
1076
+ // visible.
1077
+ // A page with no anchors has not finished Phase 1, so a score has no referent
1078
+ // and this refuses to invent one \u2014 the same answer \`refMark: ""\` gets from
1079
+ // checkRef and an absent \`## <page>\` SPEC section gets from gate.sh. All three
1080
+ // are seed sentinels, and this was the one that failed OPEN. Repo CLAUDE.md
1081
+ // rule 1's corollary is the whole argument: a field that can only observe
1082
+ // configuration must never be named after the thing it cannot observe, and
1083
+ // "12 of 12 grid rows passed" observes a screenshot cut into quarters, not a
1084
+ // design anyone specced.
1085
+ //
1086
+ // PRINTED here, EXITED below: \`unmeasured\` and \`unscorable\` are different pages
1087
+ // with different remedies, and exiting inside the first block would hide the
1088
+ // second from an operator who then fixes only what was printed.
1089
+ if (unscorable.length) {
1090
+ console.error(
1091
+ \`\\nnext: \${unscorable.length} page(s) have no anchors, so their regions are page-diff's own\\n\` +
1092
+ \` fallback cut and cannot be scored \u2014 \` +
1093
+ unscorable.map((u) => \`\${u.p} (\${u.regions} region(s))\`).join(", ") +
1094
+ \`.\\n Set "anchors" for them in matching/harness.json to section texts that exist on\\n\` +
1095
+ \` BOTH the reference and the candidate, then re-run: bash matching/gate.sh <tag> \` +
1096
+ unscorable.map((u) => u.p).join(" "),
1097
+ );
1098
+ }
1099
+
1100
+ if (unmeasured.length) {
1101
+ console.error(
1102
+ \`\\nnext: \${unmeasured.length} page(s) have no countable gate run \u2014 \${unmeasured.join(", ")}.\\n\` +
1103
+ \` Re-run: bash matching/gate.sh <tag> \${unmeasured.join(" ")}\`,
1104
+ );
1105
+ process.exit(2);
1106
+ }
1107
+
1108
+ // See above: deliberately a second statement, not an \`else\`.
1109
+ if (unscorable.length) process.exit(2);
1110
+
715
1111
  if (!rows.length) {
716
1112
  console.log(
717
1113
  \`\\nNo open geometry failures. \${floorTotal} declared floor(s) remain.\`,
@@ -723,6 +1119,16 @@ if (!rows.length) {
723
1119
  }
724
1120
 
725
1121
  // Worst page first, then worst region inside it: fix where the model is most wrong.
1122
+ //
1123
+ // INVARIANT, documented rather than guarded because no test could redden a
1124
+ // guard here: \`scored[0]\` is safe because \`scored\` is empty only when every
1125
+ // page in \`latest\` is unanchored \u2014 and that means \`unscorable.length > 0\`, so
1126
+ // the exit above already fired. (\`latest\` being empty is caught further up.)
1127
+ // \`rows\` likewise still contains unanchored pages' failing regions; they are
1128
+ // never printed because that same exit fires first. Both facts depend on the
1129
+ // exit staying ABOVE this line \u2014 a second filter here would be a second place
1130
+ // to keep in sync, which is how checkRun and next.mjs drifted apart to begin
1131
+ // with. If that exit ever moves, this becomes a TypeError.
726
1132
  const worst = scored[0].p;
727
1133
  rows.sort(
728
1134
  (a, b) =>
@@ -1416,95 +1822,608 @@ var MATCH_HARNESS_FILES = [
1416
1822
  { rel: SITE_PAGES_JS_RELATIVE, template: SITE_PAGES_JS_TEMPLATE, owner: "site" },
1417
1823
  { rel: SITE_PAGES_TEST_RELATIVE, template: SITE_PAGES_TEST_TEMPLATE, owner: "recipe" }
1418
1824
  ];
1419
- var MATCH_HARNESS_PREVIOUS = {};
1420
- var GITIGNORE_MARKER = "# reddoor-maint match-harness: scripts + records tracked, workspace ignored";
1421
- var GITIGNORE_BLOCK = `scratch-diff*/
1422
- matching/*
1423
- !matching/*.sh
1424
- !matching/*.mjs
1425
- !matching/*.md
1426
- # The pause switch. Extensionless on purpose (it is a sentinel, not a doc), so
1427
- # the whitelist above misses it \u2014 and an ignored switch is not a switch: it
1428
- # would work on one machine and be absent from every fresh clone, CI run and
1429
- # agent that most needs to see it.
1430
- !matching/PAUSED
1431
- !matching/spec-sections/
1432
- !matching/states/
1433
- matching/states/*
1434
- !matching/states/*.mjs
1435
- # derived artefacts, even where the extensions above would catch them
1436
- matching/*.log
1437
- matching/*.json
1438
- # ...except the page table itself, which is the harness's configuration and the
1439
- # one thing a fresh clone cannot reconstruct.
1440
- !matching/harness.json
1441
- matching/*.png
1442
- `;
1443
- var PRETTIERIGNORE_MARKER = "# reddoor-maint match-harness";
1444
- var PRETTIERIGNORE_BLOCK = `# Matching harness \u2014 every file the \`match-harness\` recipe OWNS. It installs
1445
- # them byte-for-byte and byte-compares them on the next install, so a site whose
1446
- # prettier config differs must not reformat them: a reformatted file is
1447
- # indistinguishable from a hand edit, and the recipe refuses to overwrite a hand
1448
- # edit. Measured on a \`useTabs\`/\`singleQuote\` site, all three src/ entries
1449
- # below are rewritten by \`prettier --write .\`, and the next upgrade is refused
1450
- # for all three.
1451
- #
1452
- # The bracketed route segment MUST stay backslash-escaped. This file is
1453
- # gitignore glob syntax, in which \`[uid]\` is a CHARACTER CLASS matching one of
1454
- # \`u\`, \`i\`, \`d\` \u2014 an unescaped entry silently ignores nothing at all.
1455
- #
1456
- # \`matching/*.mjs\` also catches two SITE-owned records (floors.mjs,
1457
- # census-deviations.mjs). Accepted, not overlooked: eslint lints .mjs, so those
1458
- # two keep a style check either way, and naming seven scripts instead of one
1459
- # glob goes stale on the next script the harness gains.
1460
- #
1461
- # harness.json is here because it is a TABLE, not prose: it carries a
1462
- # hand-chosen row layout that prettier reflows.
1463
- #
1464
- # Everything else under matching/ (SPEC.md, LEDGER.md, spec-sections/,
1465
- # states/*.mjs) stays inside \`prettier --check .\`. That is deliberately narrow:
1466
- # eslint does not lint Markdown, so for the records prettier is the ONLY style
1467
- # check there is, and a blanket \`matching/\` entry would leave them with none.
1468
- matching/*.mjs
1469
- matching/*.sh
1470
- matching/harness.json
1471
- src/routes/dev/match/\\[uid\\]/+page.server.ts
1472
- src/routes/dev/match/\\[uid\\]/+page.svelte
1473
- src/lib/site-pages.test.ts
1474
- `;
1475
- var CLAUDE_MD_MARKER = "## Matching rules (installed by reddoor-maint match-harness)";
1476
- var CLAUDE_MD_BLOCK = `
1477
- The \`matching-a-page\` skill governs a live-reference rebuild. These five rules
1478
- exist because the skill alone did not hold on the project this harness came
1479
- from \u2014 each one is a drift that actually happened, with the mechanical check
1480
- that now catches it. \`matching/harness.json\` is this site's configuration;
1481
- \`matching/LEDGER.md\` is the dated record of every deviation, floor and mask.
1482
-
1483
- ### 1. Source prescribes, rects only verify
1825
+ var HARNESS_MJS_PREV_0_95_0 = `// The single source for everything the matching gates need to know about this
1826
+ // site. DATA lives in matching/harness.json (site-edited); this file is the
1827
+ // READ LAYER \u2014 edit harness.json, not this. (It is installed and upgraded by
1828
+ // the \`reddoor-maint match-harness\` recipe, which owns these bytes: a hand
1829
+ // edit here is flagged on the next run and never silently overwritten.)
1830
+ //
1831
+ // It exists because the page table, the two hosts, the matrix, the threshold
1832
+ // and the skill path were hand-copied all over matching/. Re-measured
1833
+ // 2026-09-09 AFTER the six-probe conversion, over the 216 tracked scripts under
1834
+ // matching/ (214 top-level \u2014 212 .mjs + 2 .sh \u2014 plus 2 in states/):
1835
+ //
1836
+ // \u2022 a hand-typed copy of the page table (three or more gate keys sitting next
1837
+ // to their route): 8 files. Exactly ONE of them, probe-chrome-count.mjs,
1838
+ // still carries all nine rows; probe-anchors.mjs carries five; the other
1839
+ // six are three-row detail triples (team/svc/qa).
1840
+ // \u2022 the skill path (~/.claude/skills/matching-a-page): 193 copies
1841
+ // \u2022 the viewport matrix (1440/834/390): 51 copies
1842
+ // \u2022 REF pointed at a host listed in selfHosts \u2014 i.e. comparing the candidate
1843
+ // with itself: 12 scripts, one of which (probe-chrome-count.mjs) is also
1844
+ // the last nine-row table carrier
1845
+ //
1846
+ // The first bullet read "the nine-row page table: 5 copies \u2014 gate.sh,
1847
+ // probe-anchor-parity.mjs, sweep-all10.sh, sweep-all16.sh, sweep-final.sh" when
1848
+ // it was written here at 922dde3. It was wrong within the hour and wrong on two
1849
+ // counts: 4e2cd7b took the table out of gate.sh, and the list never named
1850
+ // states/index.mjs or probe-chrome-count.mjs, which were both carrying nine-row
1851
+ // copies at the time. A census is a claim about code; it has to be measured
1852
+ // against the tree, not recalled.
1853
+ //
1854
+ // node matching/harness.mjs --env shell-safe KEY='value' lines
1855
+ // node matching/harness.mjs --table key<TAB>ref<TAB>cand<TAB>anchors
1856
+ // node matching/harness.mjs --check-ref the D11 preflight; exit 2 on failure
1857
+ import { readFileSync, realpathSync } from "node:fs";
1858
+ import { homedir } from "node:os";
1859
+ import { join } from "node:path";
1860
+ import { fileURLToPath, pathToFileURL } from "node:url";
1484
1861
 
1485
- Every geometry fix must cite the rule it came from: a line in the captured
1486
- reference stylesheet under \`matching/spec/\`, or the reference's HTML. "The probe
1487
- says the gap is 40px" is not a source. If you cannot name the line you are
1488
- guessing \u2014 go read the stylesheet first.
1862
+ // fileURLToPath, not URL#pathname: pathname is percent-encoded, so a checkout
1863
+ // under a directory with a space in it would resolve to a path that does not
1864
+ // exist.
1865
+ const DIR = fileURLToPath(new URL(".", import.meta.url));
1866
+ const CFG = JSON.parse(readFileSync(join(DIR, "harness.json"), "utf8"));
1489
1867
 
1490
- **Check:** the commit body must name the file:line for each fix.
1491
- **Operator's challenge:** _"which line of the reference stylesheet says that?"_
1868
+ // Env overrides exist for one-off probes only. They are NOT how a site is
1869
+ // configured \u2014 harness.json is, so that what a gate ran against is committed.
1870
+ export const REF = process.env.MATCH_REF ?? CFG.ref;
1871
+ export const CAND = process.env.MATCH_CAND ?? process.env.CAND_BASE ?? CFG.cand;
1872
+ export const MATRIX = CFG.matrix;
1873
+ export const THRESHOLD = CFG.threshold;
1874
+ export const MAX_HEIGHT_DELTA = CFG.maxHeightDelta;
1875
+ export const REF_MARK = CFG.refMark;
1876
+ export const CAND_MARK = CFG.candMark;
1877
+ export const SELF_HOSTS = CFG.selfHosts ?? [];
1492
1878
 
1493
- ### 2. Phase 1 before Phase 4
1879
+ /** One record per gated page, in harness.json order. \`key\` is the gate key
1880
+ * (out-<TAG>-<key>, the SPEC heading, spec-sections/<key>.md); \`uid\` is the
1881
+ * Prismic uid or null where the page has no /dev/match twin. */
1882
+ export const PAGES = Object.entries(CFG.pages).map(([key, p]) => ({ key, ...p }));
1883
+ export const byKey = Object.fromEntries(PAGES.map((p) => [p.key, p]));
1494
1884
 
1495
- A page gets its section census and per-section spec in \`matching/SPEC.md\` BEFORE
1496
- its geometry is touched. No SPEC section, no geometry round. The census is the
1497
- coverage denominator; skipping it is how a reference's root-font ladder and its
1498
- per-component height ladders get discovered reactively, after the region has
1499
- already failed several rounds.
1885
+ // DERIVED, never hand-typed: page-diff cuts one region before the first anchor
1886
+ // ("top") plus one per anchor, at every viewport. The old hand-written map went
1887
+ // stale the moment an anchor list changed, and a wrong denominator makes the
1888
+ // score a lie in the flattering direction.
1889
+ export const TOTALS = Object.fromEntries(
1890
+ PAGES.map((p) => [p.key, (p.anchors.length + 1) * MATRIX.length]),
1891
+ );
1500
1892
 
1501
- **Check:** \`matching/gate.sh\` refuses to run a page with no \`SPEC.md\` section.
1502
- **Operator's challenge:** _"show me the SPEC section for that region."_
1893
+ /** The SPEC.md heading predicate, shared by gate.sh's preflight and
1894
+ * build-spec.mjs so a section can never build fine and then refuse at the
1895
+ * gate. Matches the key followed by any non-key character (\`## team\` matches,
1896
+ * \`## teamfoo\` does not, \`## our-team\` cannot match \`team\`). */
1897
+ export const specHeadingRe = (key) => new RegExp(\`^##+ +\${key}([^A-Za-z0-9_-]|$)\`, "m");
1503
1898
 
1504
- ### 3. Three strikes, then stop
1899
+ export const SKILL_DIR =
1900
+ process.env.MATCHING_SKILL_DIR ?? join(homedir(), ".claude/skills/matching-a-page");
1901
+ export const PD = join(SKILL_DIR, "page-diff.mjs");
1902
+ export const SC = join(SKILL_DIR, "style-census.mjs");
1903
+ export const PLAYWRIGHT = pathToFileURL(join(SKILL_DIR, "node_modules/playwright/index.mjs")).href;
1505
1904
 
1506
- A failing region that has not improved across 3+ gate runs does not get a fourth
1507
- attempt. Present the attempts. Never widen the threshold, add a mask, or
1905
+ /** The report format this site's scripts can read, so gate.sh can compare it
1906
+ * with \`page-diff --version\` before spending a run and next.mjs can refuse
1907
+ * rather than quietly drop a page whose newest report came from another
1908
+ * schema. Both do that now \u2014 gate.sh preflights \`page-diff --version\` against
1909
+ * this value before spending a run, and next.mjs counts a foreign-schema
1910
+ * report as MISSING rather than skipping it. (This said "neither does that
1911
+ * yet" \u2014 true at 922dde3 where it was written, false from 4e2cd7b, which
1912
+ * gave gate.sh the preflight and did not come back here.) Checked 2026-09-09
1913
+ * against the installed skill: \`page-diff --version\` \u2192 \`page-diff 0.1.0
1914
+ * report-schema 1\`. */
1915
+ export const REPORT_SCHEMA = 1;
1916
+
1917
+ /**
1918
+ * Fail-closed reference preflight. A 200 is NOT evidence: a host that has been
1919
+ * repointed at our own build answers 200, and so does a staging host serving a
1920
+ * 404 page. Both have happened on a real site \u2014 see the dated measurement in
1921
+ * LEDGER.md. A pass here requires an artefact only the reference produces.
1922
+ */
1923
+ export async function checkRef() {
1924
+ if (!REF_MARK) {
1925
+ return {
1926
+ ok: false,
1927
+ why: "harness.json refMark is empty \u2014 set it to a string only the reference serves (a Webflow site id, a build hash). A 200 is not evidence.",
1928
+ };
1929
+ }
1930
+ const host = new URL(REF).host;
1931
+ if (SELF_HOSTS.includes(host)) return { ok: false, why: \`REF host \${host} is in selfHosts\` };
1932
+ if (host === new URL(CAND).host) return { ok: false, why: \`REF host \${host} equals CAND's host\` };
1933
+ let res;
1934
+ try {
1935
+ res = await fetch(\`\${REF}/\`, { redirect: "manual" });
1936
+ } catch (e) {
1937
+ return { ok: false, why: \`GET \${REF}/ failed: \${e.message}\` };
1938
+ }
1939
+ if (res.status !== 200)
1940
+ return { ok: false, why: \`GET \${REF}/ \u2192 HTTP \${res.status}, expected 200\` };
1941
+ const loc = res.headers.get("location");
1942
+ if (loc) return { ok: false, why: \`GET \${REF}/ \u2192 \${res.status} redirect to \${loc}\` };
1943
+ const body = await res.text();
1944
+ if (!body.includes(REF_MARK))
1945
+ return {
1946
+ ok: false,
1947
+ why: \`\${REF}/ served 200 but WITHOUT refMark \${JSON.stringify(REF_MARK)} \u2014 that is not the reference\`,
1948
+ };
1949
+ if (CAND_MARK && body.includes(CAND_MARK))
1950
+ return {
1951
+ ok: false,
1952
+ why: \`\${REF}/ contains candMark \${JSON.stringify(CAND_MARK)} \u2014 REF is serving OUR build\`,
1953
+ };
1954
+ return { ok: true, why: \`\${REF}/ \u2192 200, no redirect, refMark present, candMark absent\` };
1955
+ }
1956
+
1957
+ // CLI. Both sides go through realpathSync. \`import.meta.url\` is ALREADY the
1958
+ // resolved real path (node resolves symlinks unless --preserve-symlinks) while
1959
+ // process.argv[1] is the path as typed, so a plain pathToFileURL compare goes
1960
+ // false the moment any component of the invoked path is a symlink \u2014 and then
1961
+ // the CLI prints nothing and exits 0, which every caller reads as success.
1962
+ // page-diff.mjs:184-189 records exactly that defect and the same fix: "The
1963
+ // pathToFileURL compare that replaced the old template string is still false
1964
+ // whenever ANY component of the invoked path is a symlink \u2014 which is how this
1965
+ // skill is installed now (~/.claude/skills/matching-a-page -> the claude-skills
1966
+ // checkout). isMain() resolves the real path on both sides."
1967
+ const isMain = () => {
1968
+ if (!process.argv[1]) return false;
1969
+ try {
1970
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
1971
+ } catch {
1972
+ return false;
1973
+ }
1974
+ };
1975
+
1976
+ if (isMain()) {
1977
+ const mode = process.argv[2];
1978
+ const q = (v) => \`'\${String(v).replace(/'/g, \`'\\\\''\`)}'\`;
1979
+ if (mode === "--env") {
1980
+ const pairs = [
1981
+ ["REF", REF],
1982
+ ["CAND", CAND],
1983
+ ["MATRIX", MATRIX.join(",")],
1984
+ ["VIEWPORTS_SP", MATRIX.join(" ")],
1985
+ ["THRESHOLD", THRESHOLD],
1986
+ ["MAX_HEIGHT_DELTA", MAX_HEIGHT_DELTA],
1987
+ ["PD", PD],
1988
+ ["SC", SC],
1989
+ ["REPORT_SCHEMA", REPORT_SCHEMA],
1990
+ ];
1991
+ for (const [k, v] of pairs) console.log(\`\${k}=\${q(v)}\`);
1992
+ } else if (mode === "--table") {
1993
+ for (const p of PAGES) console.log([p.key, p.ref, p.cand, p.anchors.join(",")].join("\\t"));
1994
+ } else if (mode === "--check-ref") {
1995
+ const r = await checkRef();
1996
+ console.log(\`\${r.ok ? "REF OK" : "REF REFUSED"} \u2014 \${r.why}\`);
1997
+ process.exit(r.ok ? 0 : 2);
1998
+ } else {
1999
+ console.error("usage: harness.mjs --env | --table | --check-ref");
2000
+ process.exit(2);
2001
+ }
2002
+ }
2003
+ `;
2004
+ var GATE_SH_PREV_0_95_0 = `#!/usr/bin/env bash
2005
+ # The matching gate. This file is generic \u2014 everything specific to a site lives
2006
+ # in matching/harness.json (the data) and matching/LEDGER.md (the why). It is
2007
+ # installed and upgraded by the \`reddoor-maint match-harness\` recipe, which
2008
+ # owns these bytes: a hand edit here is flagged on the next run, never
2009
+ # silently overwritten.
2010
+ #
2011
+ # bash matching/gate.sh <round-tag> [page ...]
2012
+ #
2013
+ # Runs page-diff for every page in the table (or just the named ones) at the
2014
+ # full breakpoint matrix and writes matching/out-<round-tag>-<page>/.
2015
+ #
2016
+ # The matrix, the anchor lists, the reference and the candidate are DATA. Why a
2017
+ # site chose them \u2014 which live breakpoint band hid what, why an anchor is a
2018
+ # heading and not a button label \u2014 belongs in matching/LEDGER.md, which is dated
2019
+ # and append-only, because JSON holds no comments.
2020
+ #
2021
+ # NO MASKS and the threshold from harness.json everywhere: the numbers stay
2022
+ # honest and a known floor stays visible as its own region. A media-neutralised
2023
+ # secondary read is \`node "$PD" ... --neutralize-media\`; next.mjs ignores such
2024
+ # runs on purpose (next.mjs:57-64).
2025
+ #
2026
+ # PREFLIGHT (see the matching rules in CLAUDE.md): a page with no section in
2027
+ # matching/SPEC.md has not had Phase 1 done, and its geometry must not be
2028
+ # touched. Skipping the spec is how a reference's root-font ladder and its
2029
+ # per-component height ladders get found reactively, after the region has
2030
+ # already failed several rounds. This refuses the run instead of trusting anyone
2031
+ # to remember.
2032
+ set -u
2033
+ # Everything configurable lives in matching/harness.json; harness.mjs is the one
2034
+ # reader. --env emits shell-safe assignments (REF, CAND, MATRIX, VIEWPORTS_SP,
2035
+ # THRESHOLD, MAX_HEIGHT_DELTA, PD, SC, REPORT_SCHEMA).
2036
+ eval "$(node "$(dirname "$0")/harness.mjs" --env)"
2037
+
2038
+ # The skill must be able to write reports this site's scripts can read. Cheap,
2039
+ # local, and it fails before any browser starts.
2040
+ PD_SCHEMA="$(node "$PD" --version 2>/dev/null | awk '{print $4}')"
2041
+ if [ "$PD_SCHEMA" != "$REPORT_SCHEMA" ]; then
2042
+ echo "gate.sh: page-diff writes report schema '\${PD_SCHEMA:-none}', this harness reads $REPORT_SCHEMA." >&2
2043
+ echo " Update matching/harness.mjs REPORT_SCHEMA or the matching-a-page skill." >&2
2044
+ exit 2
2045
+ fi
2046
+ SPEC="$(dirname "$0")/SPEC.md"
2047
+ TAG="\${1:?usage: gate.sh <round-tag> [page ...]}"
2048
+ # The tag must not contain a hyphen. Output dirs are "out-<TAG>-<page>", and
2049
+ # next.mjs recovers the page with /^out-[^-]+-(.+)$/ \u2014 it splits on the FIRST
2050
+ # hyphen, so a tag like "r-forms-2026-08-07" yields the page key
2051
+ # "forms-2026-08-07-yfv" and that run is silently never counted. It cannot split
2052
+ # on the last hyphen instead, because page keys have hyphens of their own
2053
+ # ("our-team", "ask-the-doctor"). Failing here is the cheap end of that: a
2054
+ # mis-tagged round otherwise LOOKS green because next.mjs keeps reading an older
2055
+ # report for the page you just changed. (Cost this once, 2026-08-07.)
2056
+ case "$TAG" in
2057
+ *-*)
2058
+ echo "gate.sh: round tag must not contain a hyphen (got '$TAG')." >&2
2059
+ echo " out-<TAG>-<page> is parsed on the first hyphen, so a" >&2
2060
+ echo " hyphenated tag hides the run from next.mjs. Try '\${TAG//-/}'." >&2
2061
+ exit 2
2062
+ ;;
2063
+ esac
2064
+ shift || true
2065
+ WANT=("$@")
2066
+
2067
+ # Fail closed on the reference before spending a single run. A 200 is NOT
2068
+ # evidence: a production host that has cut over to OUR build answers 200, and
2069
+ # every region then scores near zero against itself. --check-ref requires an
2070
+ # artefact only the reference serves (harness.json refMark) and refuses a
2071
+ # redirect, a host listed in selfHosts, and a body carrying candMark. Measured
2072
+ # on the site this harness was cut from, 2026-09-09 AFTER the consolidation:
2073
+ # 12 of its 214 top-level matching scripts still assign REF a host listed in
2074
+ # selfHosts \u2014 i.e. compare the candidate with itself. It was 33 of 229 before.
2075
+ # (This comment read "33 of its 230" while sitting in the post-consolidation
2076
+ # tree, contradicting harness.mjs's 12 three files away.)
2077
+ if ! node "$(dirname "$0")/harness.mjs" --check-ref; then
2078
+ echo "gate.sh: refusing to gate against an unverified reference." >&2
2079
+ exit 2
2080
+ fi
2081
+
2082
+ # Set SPEC_OPTIONAL=1 only for a read-only baseline sweep of pages you are not
2083
+ # about to edit. It is recorded in the round tag so the exemption is visible.
2084
+ SPEC_OPTIONAL="\${SPEC_OPTIONAL:-0}"
2085
+
2086
+ has_spec() { # page
2087
+ # NB: the obvious \`( |$|\\b)\` guard is rejected by ugrep as an empty
2088
+ # subexpression, and a preflight that errors out fails CLOSED \u2014 it refused all
2089
+ # 9 pages while SPEC.md was complete. Match the page key followed by any
2090
+ # non-key character (so \`## team\` matches but \`## teamfoo\` does not; \`##
2091
+ # our-team\` cannot match \`team\` because the key must follow the spaces).
2092
+ [ -f "$SPEC" ] && grep -qE "^##+ +$1([^A-Za-z0-9_-]|$)" "$SPEC"
2093
+ }
2094
+
2095
+ run() { # tag refpath candpath sections
2096
+ local page="$1" refpath="$2" candpath="$3" sections="$4"
2097
+ if [ \${#WANT[@]} -gt 0 ]; then
2098
+ local hit=0
2099
+ for w in "\${WANT[@]}"; do [ "$w" = "$page" ] && hit=1; done
2100
+ [ $hit -eq 1 ] || return 0
2101
+ fi
2102
+ if ! has_spec "$page"; then
2103
+ if [ "$SPEC_OPTIONAL" = "1" ]; then
2104
+ echo "########## $page ##########"
2105
+ echo "WARNING: no '## $page' section in matching/SPEC.md \u2014 Phase 1 not done."
2106
+ echo " Running anyway because SPEC_OPTIONAL=1 (baseline read only)."
2107
+ echo " Do NOT apply geometry fixes off this run."
2108
+ else
2109
+ echo "########## $page ##########"
2110
+ echo "REFUSED: no '## $page' section in matching/SPEC.md."
2111
+ echo " Phase 1 (section census + per-section spec, read from"
2112
+ echo " matching/spec/) comes before geometry."
2113
+ echo " See CLAUDE.md rule 2. SPEC_OPTIONAL=1 for a baseline read."
2114
+ FAILED_PREFLIGHT=1
2115
+ return 0
2116
+ fi
2117
+ fi
2118
+ echo "########## $page ##########"
2119
+ node "$PD" --ref "$REF$refpath" --cand "$CAND$candpath" \\
2120
+ --viewports "$MATRIX" --threshold "$THRESHOLD" \\
2121
+ --sections "$sections" --out "matching/out-$TAG-$page" \\
2122
+ > "matching/out-$TAG-$page.log" 2>&1
2123
+ echo "$page exit=$?"
2124
+ }
2125
+
2126
+ # The page table is matching/harness.json. Process substitution, NOT a pipe:
2127
+ # a pipe would run this loop in a subshell and FAILED_PREFLIGHT would not
2128
+ # survive to the check below, so a refused page would exit 0.
2129
+ while IFS=$'\\t' read -r key refpath candpath anchors; do
2130
+ run "$key" "$refpath" "$candpath" "$anchors"
2131
+ done < <(node "$(dirname "$0")/harness.mjs" --table)
2132
+
2133
+ if [ "\${FAILED_PREFLIGHT:-0}" = "1" ]; then
2134
+ echo
2135
+ echo "GATE INCOMPLETE ($TAG) \u2014 one or more pages were refused for a missing"
2136
+ echo "SPEC.md section. Those pages have NOT been measured; do not report a"
2137
+ echo "score for them."
2138
+ exit 2
2139
+ fi
2140
+ echo "ALL DONE ($TAG)"
2141
+ `;
2142
+ var NEXT_MJS_PREV_0_95_0 = `// What is still broken, ranked. Exits 1 while work remains.
2143
+ //
2144
+ // node matching/next.mjs
2145
+ //
2146
+ // Round protocol step 0 (repo CLAUDE.md rule 5). A commit is a CHECKPOINT, not
2147
+ // a stopping point: after committing, run this. If it exits 1 there is a named
2148
+ // next action and the round continues without handing control back.
2149
+ //
2150
+ // Reads the most recent gate log per page rather than the whole corpus, so it
2151
+ // reflects HEAD rather than history (that is strikes.mjs's job).
2152
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2153
+ import { join } from "node:path";
2154
+
2155
+ const DIR = new URL(".", import.meta.url).pathname;
2156
+
2157
+ // PAUSE SWITCH. While matching/PAUSED exists this hands out no agenda and
2158
+ // exits 0. It is deliberately the FIRST thing that runs: no report is read, no
2159
+ // score is printed, nothing tempting is put on screen to argue with.
2160
+ //
2161
+ // Why an exit code and not a note somewhere: rule 5 is a LOOP \u2014 "after
2162
+ // committing, run next.mjs; while it exits 1 there is a named next action and
2163
+ // the round continues". A pause written as prose loses to that loop, because
2164
+ // the loop is mechanical and the prose is not. Exiting 0 satisfies rule 5
2165
+ // truthfully rather than suspending it: there is no next action.
2166
+ const PAUSE = join(DIR, "PAUSED");
2167
+ if (existsSync(PAUSE)) {
2168
+ console.log("MATCHING PAUSED \u2014 no agenda, and none is to be inferred.\\n");
2169
+ console.log(readFileSync(PAUSE, "utf8").trimEnd());
2170
+ process.exit(0);
2171
+ }
2172
+
2173
+ import { FLOORS, ACCEPTED } from "./floors.mjs";
2174
+ import { TOTALS, THRESHOLD, MAX_HEIGHT_DELTA, REPORT_SCHEMA } from "./harness.mjs";
2175
+
2176
+ // Reports written by a different page-diff, by page key. Kept rather than
2177
+ // dropped: silently ignoring them is how a page vanishes from the score.
2178
+ const schemaMismatch = new Set();
2179
+
2180
+ const latest = new Map();
2181
+ for (const d of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
2182
+ const m = /^out-[^-]+-(.+)$/.exec(d);
2183
+ if (!m || !TOTALS[m[1]]) continue;
2184
+ let report, mtime;
2185
+ try {
2186
+ const p = join(DIR, d, "report.json");
2187
+ report = JSON.parse(readFileSync(p, "utf8"));
2188
+ mtime = statSync(p).mtimeMs;
2189
+ } catch {
2190
+ continue;
2191
+ }
2192
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
2193
+ // page. Picking one up as "latest" silently reports scores nobody can ship \u2014
2194
+ // it happened immediately: an --mask-photos probe of yfv made \`top\` @834 read
2195
+ // 43.9% here while the real gate had it passing at 1.3%.
2196
+ const meta = report.meta ?? {};
2197
+ // Missing schemaVersion means "written before the field existed" = 0. It is
2198
+ // not an error on its own; it is only fatal when it would blank a page.
2199
+ if ((meta.schemaVersion ?? 0) !== REPORT_SCHEMA) {
2200
+ schemaMismatch.add(m[1]);
2201
+ continue;
2202
+ }
2203
+ if (
2204
+ (meta.mask?.length ?? 0) > 0 ||
2205
+ meta.neutralizeMedia ||
2206
+ meta.maskPhotos ||
2207
+ meta.truncated
2208
+ )
2209
+ continue;
2210
+ if (meta.threshold !== THRESHOLD) continue;
2211
+ const prev = latest.get(m[1]);
2212
+ if (!prev || mtime > prev.mtime) latest.set(m[1], { dir: d, mtime, report });
2213
+ }
2214
+
2215
+ const blanked = [...schemaMismatch].filter((p) => !latest.has(p));
2216
+ if (blanked.length) {
2217
+ console.error(
2218
+ \`next: \${blanked.length} page(s) have no run at report schema \${REPORT_SCHEMA} \u2014 \` +
2219
+ \`their newest reports came from a different page-diff (\${blanked.sort().join(", ")}).\\n\` +
2220
+ \` Re-run: bash matching/gate.sh <tag> \${blanked.sort().join(" ")}\`,
2221
+ );
2222
+ process.exit(2);
2223
+ }
2224
+ if (latest.size === 0) {
2225
+ console.error(
2226
+ "next: no parseable gate run under matching/ \u2014 refusing to report a score.\\n" +
2227
+ " Run bash matching/gate.sh <tag> first.",
2228
+ );
2229
+ process.exit(2);
2230
+ }
2231
+
2232
+ const rows = [];
2233
+ const accepted = [];
2234
+ let openTotal = 0;
2235
+ let floorTotal = 0;
2236
+ for (const [page, { dir, report }] of latest) {
2237
+ const fails = report.regions.filter((r) => !r.pass);
2238
+ for (const f of fails) {
2239
+ const floor = FLOORS.find((fl) => fl.match(f, page));
2240
+ if (floor) {
2241
+ floorTotal++;
2242
+ continue;
2243
+ }
2244
+ const ack = ACCEPTED.find((a) => a.match(f, page));
2245
+ if (ack) {
2246
+ accepted.push({ page, vw: f.viewport, label: f.label, why: ack.why });
2247
+ continue;
2248
+ }
2249
+ openTotal++;
2250
+ rows.push({
2251
+ page,
2252
+ vw: f.viewport,
2253
+ label: f.label,
2254
+ mm: f.mismatchFraction,
2255
+ dh: f.heightDeltaFraction ?? 0,
2256
+ dir,
2257
+ });
2258
+ }
2259
+ }
2260
+
2261
+ const scored = [...latest.entries()]
2262
+ .map(([p, v]) => ({
2263
+ p,
2264
+ pass: v.report.regions.filter((r) => r.pass).length,
2265
+ total: TOTALS[p],
2266
+ }))
2267
+ .sort((a, b) => a.pass / a.total - b.pass / b.total);
2268
+
2269
+ const sum = scored.reduce((a, s) => a + s.pass, 0);
2270
+ const max = scored.reduce((a, s) => a + s.total, 0);
2271
+ console.log(\`SCORE \${sum}/\${max} regions passing\\n\`);
2272
+ console.log(
2273
+ scored
2274
+ .map((s) => \` \${s.p.padEnd(9)} \${String(s.pass).padStart(2)}/\${s.total}\`)
2275
+ .join("\\n"),
2276
+ );
2277
+
2278
+ if (accepted.length) {
2279
+ console.log(\`\\nOperator-ACCEPTED failures (left failing on purpose):\`);
2280
+ for (const a of accepted)
2281
+ console.log(\` \${a.page} @\${a.vw} "\${a.label}" \u2014 \${a.why.slice(0, 96)}\u2026\`);
2282
+ }
2283
+
2284
+ if (!rows.length) {
2285
+ console.log(
2286
+ \`\\nNo open geometry failures. \${floorTotal} declared floor(s) remain.\`,
2287
+ );
2288
+ console.log(
2289
+ "Backlog is empty \u2014 Phases 5 (states) and 6 (adversarial review) are what is left.",
2290
+ );
2291
+ process.exit(0);
2292
+ }
2293
+
2294
+ // Worst page first, then worst region inside it: fix where the model is most wrong.
2295
+ const worst = scored[0].p;
2296
+ rows.sort(
2297
+ (a, b) =>
2298
+ (a.page === worst ? -1 : 0) - (b.page === worst ? -1 : 0) || b.mm - a.mm,
2299
+ );
2300
+
2301
+ console.log(
2302
+ \`\\n\${openTotal} open failure(s) + \${floorTotal} declared floor(s).\`,
2303
+ );
2304
+ console.log(\`\\nNEXT: \${worst} \u2014 worst page. Its open regions:\\n\`);
2305
+ for (const r of rows.filter((r) => r.page === worst)) {
2306
+ const why = [];
2307
+ if (r.mm > THRESHOLD) why.push(\`pixels \${(r.mm * 100).toFixed(1)}%\`);
2308
+ if (Math.abs(r.dh) > MAX_HEIGHT_DELTA)
2309
+ why.push(\`height \${(r.dh * 100).toFixed(1)}%\`);
2310
+ console.log(
2311
+ \` @\${String(r.vw).padEnd(5)} \${r.label.slice(0, 44).padEnd(45)} \${why.join(" + ")}\`,
2312
+ );
2313
+ }
2314
+ console.log(\`\\nBefore treating any of these as geometry:\`);
2315
+ console.log(
2316
+ \` node matching/probe-anchor-parity.mjs \${worst} # is the gate cutting comparably?\`,
2317
+ );
2318
+ console.log(
2319
+ \` node matching/strikes.mjs \${worst} # has it stalled? then change the MODEL\`,
2320
+ );
2321
+ console.log(
2322
+ \`\\nRound continues. Do not hand back control with work outstanding.\`,
2323
+ );
2324
+ process.exit(1);
2325
+ `;
2326
+ var MATCH_HARNESS_PREVIOUS = {
2327
+ "matching/harness.mjs": [HARNESS_MJS_PREV_0_95_0],
2328
+ "matching/gate.sh": [GATE_SH_PREV_0_95_0],
2329
+ "matching/next.mjs": [NEXT_MJS_PREV_0_95_0]
2330
+ };
2331
+ var MATCH_HARNESS_COUPLED = [
2332
+ "matching/harness.mjs",
2333
+ "matching/gate.sh",
2334
+ "matching/census.sh",
2335
+ "matching/next.mjs",
2336
+ "matching/strikes.mjs",
2337
+ "matching/build-spec.mjs"
2338
+ ];
2339
+ var GITIGNORE_MARKER = "# reddoor-maint match-harness: scripts + records tracked, workspace ignored";
2340
+ var GITIGNORE_BLOCK = `scratch-diff*/
2341
+ matching/*
2342
+ !matching/*.sh
2343
+ !matching/*.mjs
2344
+ !matching/*.md
2345
+ # The pause switch. Extensionless on purpose (it is a sentinel, not a doc), so
2346
+ # the whitelist above misses it \u2014 and an ignored switch is not a switch: it
2347
+ # would work on one machine and be absent from every fresh clone, CI run and
2348
+ # agent that most needs to see it.
2349
+ !matching/PAUSED
2350
+ !matching/spec-sections/
2351
+ !matching/states/
2352
+ matching/states/*
2353
+ !matching/states/*.mjs
2354
+ # derived artefacts, even where the extensions above would catch them
2355
+ matching/*.log
2356
+ matching/*.json
2357
+ # ...except the page table itself, which is the harness's configuration and the
2358
+ # one thing a fresh clone cannot reconstruct.
2359
+ !matching/harness.json
2360
+ matching/*.png
2361
+ `;
2362
+ var PRETTIERIGNORE_MARKER = "# reddoor-maint match-harness";
2363
+ var PRETTIERIGNORE_BLOCK = `# Matching harness \u2014 every file the \`match-harness\` recipe OWNS. It installs
2364
+ # them byte-for-byte and byte-compares them on the next install, so a site whose
2365
+ # prettier config differs must not reformat them: a reformatted file is
2366
+ # indistinguishable from a hand edit, and the recipe refuses to overwrite a hand
2367
+ # edit. Measured on a \`useTabs\`/\`singleQuote\` site, all three src/ entries
2368
+ # below are rewritten by \`prettier --write .\`, and the next upgrade is refused
2369
+ # for all three.
2370
+ #
2371
+ # The bracketed route segment MUST stay backslash-escaped. This file is
2372
+ # gitignore glob syntax, in which \`[uid]\` is a CHARACTER CLASS matching one of
2373
+ # \`u\`, \`i\`, \`d\` \u2014 an unescaped entry silently ignores nothing at all.
2374
+ #
2375
+ # \`matching/*.mjs\` also catches two SITE-owned records (floors.mjs,
2376
+ # census-deviations.mjs). Accepted, not overlooked: eslint lints .mjs, so those
2377
+ # two keep a style check either way, and naming seven scripts instead of one
2378
+ # glob goes stale on the next script the harness gains.
2379
+ #
2380
+ # harness.json is here because it is a TABLE, not prose: it carries a
2381
+ # hand-chosen row layout that prettier reflows.
2382
+ #
2383
+ # Everything else under matching/ (SPEC.md, LEDGER.md, spec-sections/,
2384
+ # states/*.mjs) stays inside \`prettier --check .\`. That is deliberately narrow:
2385
+ # eslint does not lint Markdown, so for the records prettier is the ONLY style
2386
+ # check there is, and a blanket \`matching/\` entry would leave them with none.
2387
+ matching/*.mjs
2388
+ matching/*.sh
2389
+ matching/harness.json
2390
+ src/routes/dev/match/\\[uid\\]/+page.server.ts
2391
+ src/routes/dev/match/\\[uid\\]/+page.svelte
2392
+ src/lib/site-pages.test.ts
2393
+ `;
2394
+ var CLAUDE_MD_MARKER = "## Matching rules (installed by reddoor-maint match-harness)";
2395
+ var CLAUDE_MD_BLOCK = `
2396
+ The \`matching-a-page\` skill governs a live-reference rebuild. These five rules
2397
+ exist because the skill alone did not hold on the project this harness came
2398
+ from \u2014 each one is a drift that actually happened, with the mechanical check
2399
+ that now catches it. \`matching/harness.json\` is this site's configuration;
2400
+ \`matching/LEDGER.md\` is the dated record of every deviation, floor and mask.
2401
+
2402
+ ### 1. Source prescribes, rects only verify
2403
+
2404
+ Every geometry fix must cite the rule it came from: a line in the captured
2405
+ reference stylesheet under \`matching/spec/\`, or the reference's HTML. "The probe
2406
+ says the gap is 40px" is not a source. If you cannot name the line you are
2407
+ guessing \u2014 go read the stylesheet first.
2408
+
2409
+ **Check:** the commit body must name the file:line for each fix.
2410
+ **Operator's challenge:** _"which line of the reference stylesheet says that?"_
2411
+
2412
+ ### 2. Phase 1 before Phase 4
2413
+
2414
+ A page gets its section census and per-section spec in \`matching/SPEC.md\` BEFORE
2415
+ its geometry is touched. No SPEC section, no geometry round. The census is the
2416
+ coverage denominator; skipping it is how a reference's root-font ladder and its
2417
+ per-component height ladders get discovered reactively, after the region has
2418
+ already failed several rounds.
2419
+
2420
+ **Check:** \`matching/gate.sh\` refuses to run a page with no \`SPEC.md\` section.
2421
+ **Operator's challenge:** _"show me the SPEC section for that region."_
2422
+
2423
+ ### 3. Three strikes, then stop
2424
+
2425
+ A failing region that has not improved across 3+ gate runs does not get a fourth
2426
+ attempt. Present the attempts. Never widen the threshold, add a mask, or
1508
2427
  reclassify it as a floor to make it go away.
1509
2428
 
1510
2429
  **Check:** \`node matching/strikes.mjs <page>\` \u2014 exits 1 while any region is
@@ -1556,6 +2475,9 @@ operator's call.
1556
2475
  6. \`pnpm verify\`, then commit and push.
1557
2476
  `;
1558
2477
 
2478
+ // src/recipes/match-harness/previous.ts
2479
+ var MATCH_HARNESS_BLOCK_PREVIOUS = {};
2480
+
1559
2481
  // src/recipes/match-harness/index.ts
1560
2482
  var PRETTIER_TIMEOUT_MS = 6e4;
1561
2483
  async function readIfExists(path) {
@@ -1568,15 +2490,40 @@ async function readIfExists(path) {
1568
2490
  function normalize(s) {
1569
2491
  return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
1570
2492
  }
1571
- function mergeBlock(existing, marker, block) {
1572
- if (existing === null) return `${marker}
1573
- ${block}`;
1574
- if (existing.includes(marker)) return null;
1575
- const base = existing.endsWith("\n") ? existing : `${existing}
2493
+ function planBlockWrite(existing, marker, endMarker, block, previous) {
2494
+ const region = `${marker}
2495
+ ${block}
2496
+ ${endMarker}`;
2497
+ if (existing === null) return { action: "write", content: `${region}
2498
+ ` };
2499
+ const start = existing.indexOf(marker);
2500
+ if (start === -1) {
2501
+ const base = existing.endsWith("\n") ? existing : `${existing}
1576
2502
  `;
1577
- return `${base}
1578
- ${marker}
1579
- ${block}`;
2503
+ return { action: "write", content: `${base}
2504
+ ${region}
2505
+ ` };
2506
+ }
2507
+ const head = existing.slice(0, start);
2508
+ const bodyStart = start + marker.length + 1;
2509
+ const endAt = existing.indexOf(endMarker, bodyStart);
2510
+ if (endAt !== -1) {
2511
+ const body = existing.slice(bodyStart, endAt);
2512
+ if (normalize(body) === normalize(block)) return { action: "skip" };
2513
+ if (previous.some((p) => normalize(body) === normalize(p)))
2514
+ return {
2515
+ action: "replace",
2516
+ content: head + region + existing.slice(endAt + endMarker.length)
2517
+ };
2518
+ return { action: "flag" };
2519
+ }
2520
+ for (const candidate of [block, ...previous]) {
2521
+ if (!existing.startsWith(candidate, bodyStart)) continue;
2522
+ const content = `${head}${region}
2523
+ ${existing.slice(bodyStart + candidate.length)}`;
2524
+ return candidate === block ? { action: "terminate", content } : { action: "replace", content };
2525
+ }
2526
+ return { action: "flag" };
1580
2527
  }
1581
2528
  function planFileWrite(existing, template, owner, previous) {
1582
2529
  if (existing === null) return "write";
@@ -1585,10 +2532,13 @@ function planFileWrite(existing, template, owner, previous) {
1585
2532
  if (previous.some((p) => normalize(existing) === normalize(p))) return "replace";
1586
2533
  return "flag";
1587
2534
  }
2535
+ var GITIGNORE_END_MARKER = "# end reddoor-maint match-harness";
2536
+ var PRETTIERIGNORE_END_MARKER = "# end reddoor-maint match-harness";
2537
+ var CLAUDE_MD_END_MARKER = "<!-- end reddoor-maint match-harness -->";
1588
2538
  var APPENDED_BLOCKS = [
1589
- [".gitignore", GITIGNORE_MARKER, GITIGNORE_BLOCK],
1590
- [".prettierignore", PRETTIERIGNORE_MARKER, PRETTIERIGNORE_BLOCK],
1591
- ["CLAUDE.md", CLAUDE_MD_MARKER, CLAUDE_MD_BLOCK]
2539
+ [".gitignore", GITIGNORE_MARKER, GITIGNORE_END_MARKER, GITIGNORE_BLOCK],
2540
+ [".prettierignore", PRETTIERIGNORE_MARKER, PRETTIERIGNORE_END_MARKER, PRETTIERIGNORE_BLOCK],
2541
+ ["CLAUDE.md", CLAUDE_MD_MARKER, CLAUDE_MD_END_MARKER, CLAUDE_MD_BLOCK]
1592
2542
  ];
1593
2543
  var MATCH_HARNESS_INSTALLED_PATHS = [
1594
2544
  ...MATCH_HARNESS_FILES.map((f) => f.rel),
@@ -1619,6 +2569,8 @@ async function matchHarness(site, opts, deps = { spawn: defaultSpawn }) {
1619
2569
  const written = [];
1620
2570
  const before = /* @__PURE__ */ new Map();
1621
2571
  const previousAll = deps.previous ?? MATCH_HARNESS_PREVIOUS;
2572
+ const blockPreviousAll = deps.blockPrevious ?? MATCH_HARNESS_BLOCK_PREVIOUS;
2573
+ const plans = [];
1622
2574
  for (const f of MATCH_HARNESS_FILES) {
1623
2575
  const target = join(cwd, f.rel);
1624
2576
  let template = f.template;
@@ -1630,29 +2582,67 @@ async function matchHarness(site, opts, deps = { spawn: defaultSpawn }) {
1630
2582
  template = JSON.stringify(seed, null, 2) + "\n";
1631
2583
  }
1632
2584
  const existing = await readIfExists(target);
1633
- const action = planFileWrite(existing, template, f.owner, previousAll[f.rel] ?? []);
2585
+ plans.push({
2586
+ f,
2587
+ template,
2588
+ existing,
2589
+ action: planFileWrite(existing, template, f.owner, previousAll[f.rel] ?? [])
2590
+ });
2591
+ }
2592
+ const coupled = new Set(MATCH_HARNESS_COUPLED);
2593
+ const blockedBy = plans.filter((p) => coupled.has(p.f.rel) && p.action === "flag").map((p) => p.f.rel);
2594
+ const demoted = plans.filter(
2595
+ (p) => coupled.has(p.f.rel) && p.action === "replace" && !blockedBy.includes(p.f.rel)
2596
+ );
2597
+ if (blockedBy.length > 0 && demoted.length > 0) {
2598
+ for (const p of demoted) p.action = "flag";
2599
+ notes.push(
2600
+ `${blockedBy.join(", ")} differs from the shipped template, so the whole coupled set was left alone: ${MATCH_HARNESS_COUPLED.join(", ")} upgrade together. matching/gate.sh calls \`harness.mjs --check-run\` and matching/next.mjs imports from harness.mjs, so upgrading one without the others leaves a harness that cannot run.`
2601
+ );
2602
+ }
2603
+ const demotedRels = new Set(demoted.map((p) => p.f.rel));
2604
+ for (const { f, template, existing, action } of plans) {
1634
2605
  if (action === "flag") {
2606
+ if (demotedRels.has(f.rel)) continue;
1635
2607
  notes.push(
1636
2608
  `${f.rel} differs from the shipped template and was left alone (hand-edited?)`
1637
2609
  );
1638
2610
  continue;
1639
2611
  }
1640
2612
  if (action === "skip") continue;
2613
+ const target = join(cwd, f.rel);
1641
2614
  await mkdir(dirname(target), { recursive: true });
1642
2615
  before.set(f.rel, existing);
1643
2616
  await writeFile(target, template, "utf-8");
1644
2617
  written.push(f.rel);
1645
2618
  if (action === "replace") notes.push(`${f.rel} upgraded from a previous version`);
1646
2619
  }
1647
- for (const [rel, marker, block] of APPENDED_BLOCKS) {
2620
+ for (const [rel, marker, endMarker, block] of APPENDED_BLOCKS) {
1648
2621
  const path = join(cwd, rel);
1649
2622
  const existing = await readIfExists(path);
1650
- const merged = mergeBlock(existing, marker, block);
1651
- if (merged !== null) {
1652
- before.set(rel, existing);
1653
- await writeFile(path, merged, "utf-8");
1654
- written.push(rel);
2623
+ const plan = planBlockWrite(
2624
+ existing,
2625
+ marker,
2626
+ endMarker,
2627
+ block,
2628
+ blockPreviousAll[rel] ?? []
2629
+ );
2630
+ if (plan.action === "skip") continue;
2631
+ if (plan.action === "flag") {
2632
+ notes.push(
2633
+ `${rel} carries a match-harness block that differs from every block this recipe has shipped, and was left alone (hand-edited?)`
2634
+ );
2635
+ continue;
1655
2636
  }
2637
+ before.set(rel, existing);
2638
+ await writeFile(path, plan.content, "utf-8");
2639
+ written.push(rel);
2640
+ if (plan.action === "replace")
2641
+ notes.push(`${rel}'s match-harness block upgraded from a previous version`);
2642
+ if (plan.action === "terminate")
2643
+ notes.push(
2644
+ `${rel}'s match-harness block region was terminated so a future version can update it`
2645
+ );
1656
2646
  }
1657
2647
  const siteOwned = new Set(
1658
2648
  MATCH_HARNESS_FILES.filter((f) => f.owner === "site").map((f) => f.rel)
@@ -1705,4 +2695,4 @@ async function matchHarness(site, opts, deps = { spawn: defaultSpawn }) {
1705
2695
  export {
1706
2696
  matchHarness
1707
2697
  };
1708
- //# sourceMappingURL=chunk-6VKYQATC.js.map
2698
+ //# sourceMappingURL=chunk-ZCL7C5LH.js.map