@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.
- package/dist/{announce-PGT4SZZB.js → announce-CLQYX5I4.js} +3 -3
- package/dist/{chunk-KQB2BX4V.js → chunk-2ZAZ7ETT.js} +2 -2
- package/dist/{chunk-DMPHP7UP.js → chunk-BI2VI6EM.js} +2 -2
- package/dist/{chunk-OCNSIBMP.js → chunk-C3IQQKXL.js} +26 -1
- package/dist/{chunk-OCNSIBMP.js.map → chunk-C3IQQKXL.js.map} +1 -1
- package/dist/{chunk-4AIYWWKV.js → chunk-KJRVMMM3.js} +2 -2
- package/dist/{chunk-AEUABPAJ.js → chunk-R5IMFAFR.js} +2 -2
- package/dist/{chunk-22GKJO5F.js → chunk-VSGMCCGQ.js} +2 -2
- package/dist/chunk-XPJQXSC4.js +112 -0
- package/dist/chunk-XPJQXSC4.js.map +1 -0
- package/dist/{chunk-6VKYQATC.js → chunk-ZCL7C5LH.js} +1115 -125
- package/dist/chunk-ZCL7C5LH.js.map +1 -0
- package/dist/cli/bin.js +15 -15
- package/dist/cli/commands/audit.js +5 -5
- package/dist/client-G2WGWPCA.js +11 -0
- package/dist/configs/eslint.js +27 -1
- package/dist/configs/eslint.js.map +1 -1
- package/dist/{db-BBEM7CBX.js → db-XKZYDCKM.js} +9 -9
- package/dist/{digest-65BOZ7AT.js → digest-7UBGUBRK.js} +13 -13
- package/dist/{digest-collectors-EHFY2OQC.js → digest-collectors-DJFN5ZDK.js} +7 -7
- package/dist/{ensure-site-WIDO6LGU.js → ensure-site-OAAE5YE2.js} +2 -2
- package/dist/{forms-notify-target-2LWF5C5B.js → forms-notify-target-UC6PF3AE.js} +2 -2
- package/dist/{github-signals-GAHNOAHS.js → github-signals-TX5FPYP6.js} +5 -5
- package/dist/{header-image-YN3AQRY3.js → header-image-PSPT7CUM.js} +2 -2
- package/dist/{health-mirror-EB76EERV.js → health-mirror-XJAKA3EY.js} +3 -3
- package/dist/index.js +8 -8
- package/dist/{launch-4RVWPZGI.js → launch-UD36AHUN.js} +3 -3
- package/dist/{match-harness-2Q6ISEES.js → match-harness-QRG6XZAR.js} +2 -2
- package/dist/migrate-Y7R4CESL.js +7 -0
- package/dist/{orchestrate-MWXENQOS.js → orchestrate-HYVQPRCE.js} +5 -5
- package/dist/{preflight-4VQP7JJL.js → preflight-RYIP54NY.js} +6 -6
- package/dist/{prismic-models-W6CNLTWH.js → prismic-models-P6FWWVRS.js} +2 -2
- package/dist/{prospect-audit-X4Z67LWW.js → prospect-audit-PRHU53HS.js} +3 -3
- package/dist/{prospect-audits-RVYEA4KH.js → prospect-audits-R3LWF6U7.js} +10 -4
- package/dist/{report-4GFD4MW2.js → report-LQS4ECTM.js} +11 -11
- package/dist/{report-mirror-IP64R7B3.js → report-mirror-HDTOJE64.js} +3 -3
- package/dist/{schema-2V7BGYVT.js → schema-A4WCPIF4.js} +1 -1
- package/dist/{schema-2V7BGYVT.js.map → schema-A4WCPIF4.js.map} +1 -1
- package/dist/{selftest-MAB2PBXQ.js → selftest-VXTHRPRN.js} +5 -5
- package/dist/{site-mirror-UKHREAD6.js → site-mirror-WON4FTJ2.js} +3 -3
- package/dist/{submissions-5GKJPCIH.js → submissions-OVZPVBYG.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-6VKYQATC.js.map +0 -1
- package/dist/chunk-7MKT4I5T.js +0 -62
- package/dist/chunk-7MKT4I5T.js.map +0 -1
- package/dist/client-M2V6FHH5.js +0 -11
- package/dist/migrate-K4JETR36.js +0 -7
- /package/dist/{announce-PGT4SZZB.js.map → announce-CLQYX5I4.js.map} +0 -0
- /package/dist/{chunk-KQB2BX4V.js.map → chunk-2ZAZ7ETT.js.map} +0 -0
- /package/dist/{chunk-DMPHP7UP.js.map → chunk-BI2VI6EM.js.map} +0 -0
- /package/dist/{chunk-4AIYWWKV.js.map → chunk-KJRVMMM3.js.map} +0 -0
- /package/dist/{chunk-AEUABPAJ.js.map → chunk-R5IMFAFR.js.map} +0 -0
- /package/dist/{chunk-22GKJO5F.js.map → chunk-VSGMCCGQ.js.map} +0 -0
- /package/dist/{client-M2V6FHH5.js.map → client-G2WGWPCA.js.map} +0 -0
- /package/dist/{db-BBEM7CBX.js.map → db-XKZYDCKM.js.map} +0 -0
- /package/dist/{digest-65BOZ7AT.js.map → digest-7UBGUBRK.js.map} +0 -0
- /package/dist/{digest-collectors-EHFY2OQC.js.map → digest-collectors-DJFN5ZDK.js.map} +0 -0
- /package/dist/{ensure-site-WIDO6LGU.js.map → ensure-site-OAAE5YE2.js.map} +0 -0
- /package/dist/{forms-notify-target-2LWF5C5B.js.map → forms-notify-target-UC6PF3AE.js.map} +0 -0
- /package/dist/{github-signals-GAHNOAHS.js.map → github-signals-TX5FPYP6.js.map} +0 -0
- /package/dist/{header-image-YN3AQRY3.js.map → header-image-PSPT7CUM.js.map} +0 -0
- /package/dist/{health-mirror-EB76EERV.js.map → health-mirror-XJAKA3EY.js.map} +0 -0
- /package/dist/{launch-4RVWPZGI.js.map → launch-UD36AHUN.js.map} +0 -0
- /package/dist/{match-harness-2Q6ISEES.js.map → match-harness-QRG6XZAR.js.map} +0 -0
- /package/dist/{migrate-K4JETR36.js.map → migrate-Y7R4CESL.js.map} +0 -0
- /package/dist/{orchestrate-MWXENQOS.js.map → orchestrate-HYVQPRCE.js.map} +0 -0
- /package/dist/{preflight-4VQP7JJL.js.map → preflight-RYIP54NY.js.map} +0 -0
- /package/dist/{prismic-models-W6CNLTWH.js.map → prismic-models-P6FWWVRS.js.map} +0 -0
- /package/dist/{prospect-audit-X4Z67LWW.js.map → prospect-audit-PRHU53HS.js.map} +0 -0
- /package/dist/{prospect-audits-RVYEA4KH.js.map → prospect-audits-R3LWF6U7.js.map} +0 -0
- /package/dist/{report-4GFD4MW2.js.map → report-LQS4ECTM.js.map} +0 -0
- /package/dist/{report-mirror-IP64R7B3.js.map → report-mirror-HDTOJE64.js.map} +0 -0
- /package/dist/{selftest-MAB2PBXQ.js.map → selftest-VXTHRPRN.js.map} +0 -0
- /package/dist/{site-mirror-UKHREAD6.js.map → site-mirror-WON4FTJ2.js.map} +0 -0
- /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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
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
|
|
1572
|
-
|
|
1573
|
-
${block}
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
|
|
1578
|
-
${
|
|
1579
|
-
|
|
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
|
-
|
|
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
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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-
|
|
2698
|
+
//# sourceMappingURL=chunk-ZCL7C5LH.js.map
|