Build and deploy / build-and-deploy (push) Failing after 5s
Step 5 ships /practice/ and the six practice-area pages (construction,
technology, energy, insurance, shareholder, cross-border) from one route, and
adds the mechanical §4 gate Pouya ruled for.
check:claims — §4 Forbidden becomes a build error
scripts/check-claims.mjs greps dist/**/*.html for 10 patterns, each carrying
the incident that put it there. It strips <style> and non-JSON-LD <script>
first (a bare sweep for "leading" returned 26 hits, 25 of them
var(--leading-body)), self-tests every pattern against fixtures before
sweeping, and refuses a missing, empty or stale dist/. Wired into /build
Phase 5 and both deploy paths.
Q54 — six conduct undertakings publish, and §4 gains a third class
Conduct undertakings sit apart from credentials and offerings: the gate is
that Pouya said it in terms. The strings live in CONDUCT_UNDERTAKINGS so a
softening is one visible diff. (e) and (f) replace the third-person sentences
already on /arbitration/ rather than joining them.
Q49, Q50 recorded as rulings. §7 records the SES us-east-1 stray identity's
deletion. R11 holds typescript at its current major, with the peer-range
reason recorded.
Three facts corrected, two of them already shipped
- The LAT gloss said mediation "before filing and continuing after filing";
the Tribunal names mediation for "Before you apply" only and its second
sentence is about negotiation. An ellipsis in docs/01 had deleted it.
- "Connection allocation" is not an Ontario term.
- "The 2026 privacy statute" does not exist — Bill C-27 died without royal
assent. Struck from docs/03 rather than corrected in place.
ADR Chambers struck from /arbitration/ and from docs/01 item 3 (Pouya,
2026-08-30): the source establishes what the firm publishes, not that an
outside neutral can be appointed under its rules.
claims-auditor gains a second lens — for every quoted source, whether the
sentence beneath stays inside what the quotation establishes. Four shipped
defects had that shape and none of them is greppable.
CLAUDE.md gains a convention: never truncate the output of a check you intend
to believe. `npm run check | tail -3` returns warnings, hints and a blank line
and drops the errors line; it was reported as passing four times while
astro check was exiting 1 with 10 type errors.
Gates, exit status read directly, not through a pipe:
npm run check exit=0
npm run lint exit=0
npm run build exit=0
npm run check:claims exit=0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
539 lines
23 KiB
JavaScript
539 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* `npm run check:claims` — a mechanical gate on the SHIPPED OUTPUT.
|
||
*
|
||
* Pouya's ruling, 2026-08-29, and the reasoning is the point:
|
||
*
|
||
* "Your recurring failure this project is a specific shape: you write a rule
|
||
* into a header comment, then breach it in the file below, in the same
|
||
* change set. Q53's sweep, Q54's gate four times, the D13 'cannot' phrasing
|
||
* fifty lines under your own warning about it. That is not a discipline
|
||
* problem — prose in a comment does not govern the writing that follows it.
|
||
* The pattern that actually worked was mechanical: deleting the `class` prop
|
||
* so passing one is a build error. Nobody has breached that since."
|
||
*
|
||
* So this is not documentation. It is a build error.
|
||
*
|
||
* SCOPE — `dist/**\/*.html` AND NOTHING ELSE. Not `AGENTS.md`, not `docs/`, not
|
||
* `src/`. D19 bounds review to what ships, and §4's Forbidden table says in as
|
||
* many words that it "governs published pages… It is not a word filter over the
|
||
* repository's own documentation." A register that records a forbidden phrase in
|
||
* order to forbid it must not be failed by its own quotation of it.
|
||
*
|
||
* `<style>` BLOCKS AND NON-JSON-LD `<script>` BLOCKS ARE STRIPPED FIRST, and
|
||
* that is load-bearing rather than tidy. Measured against the step-4 build
|
||
* before this file existed: a bare case-insensitive sweep for `leading` returned
|
||
* **26 hits, 25 of them `var(--leading-body)`** inside Astro's inlined critical
|
||
* CSS. A check that cries wolf on every page on its first run is a check
|
||
* somebody deletes in week two. JSON-LD is deliberately NOT stripped — a claim
|
||
* in a `<script type="application/ld+json">` is still a claim, and docs/04 says
|
||
* so: "Marking an unheld credential as held in structured data is a
|
||
* misrepresentation that happens to be machine-readable."
|
||
*
|
||
* EVERY PATTERN CARRIES ITS `incident`, PRINTED ON FAILURE. Pouya's
|
||
* instruction: "so nobody deletes one for being noisy." A pattern whose cost is
|
||
* visible and whose reason is not gets deleted; this prints the reason at the
|
||
* moment the cost is felt.
|
||
*
|
||
* AND IT SELF-TESTS BEFORE IT SWEEPS. See `FIXTURES` below. A regex that has
|
||
* quietly stopped matching passes a clean sweep forever, which is the exact
|
||
* shape of AGENTS.md Q22 — a documented control that no longer existed.
|
||
*/
|
||
|
||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||
import { join, relative, resolve } from 'node:path';
|
||
|
||
const ROOT = resolve(import.meta.dirname, '..');
|
||
const DIST = resolve(ROOT, process.argv[2] ?? 'dist');
|
||
|
||
/* Sources whose mtime, if newer than the newest built page, means this sweep
|
||
would be reading yesterday's output. `docs/` is absent on purpose: it directs
|
||
copy but does not produce it, so a spec edit does not stale the build. */
|
||
const SOURCE_DIRS = ['src', 'public'];
|
||
const SOURCE_FILES = ['astro.config.mjs', 'package.json'];
|
||
|
||
/**
|
||
* The patterns.
|
||
*
|
||
* `re` is matched against the page's text with `<style>` and non-JSON-LD
|
||
* `<script>` removed. All are global and case-insensitive.
|
||
*
|
||
* WORD BOUNDARIES ARE NOT DECORATION ON ANY OF THESE. `\bleading\b` does not
|
||
* match "pleadings" and `\blicensed\b` does not match "licensing" — both are
|
||
* words this site publishes, and both were live false positives when the
|
||
* patterns were first drafted. Loosening a boundary here re-creates the noise
|
||
* this file was built to avoid.
|
||
*/
|
||
const PATTERNS = [
|
||
{
|
||
id: 'licensure-of-pouya',
|
||
rule: 'D13 / §4 Forbidden — the site asserts the JD and nothing further.',
|
||
incident:
|
||
'§4 opened with "Pouya is a licensed legal professional", then "a legal ' +
|
||
'professional", then an assertion that LSO marketing rules apply — three ' +
|
||
'progressively weaker forms of the same unverified claim, each surviving a ' +
|
||
'sweep meant to remove it, in the section written to stop exactly that. ' +
|
||
'§4 does not establish licence status either way.',
|
||
re: /\blawyer\b|\bcalled to the bar\b|\blicensed\b|\bpost-nominal\b/gi,
|
||
note: '"licensing" and "licence" are NOT matched — /practice/technology/ ships "IP and licensing".',
|
||
},
|
||
{
|
||
id: 'acting-for-a-party',
|
||
rule: '§4 Forbidden — implication is the risk, not just the word.',
|
||
incident:
|
||
'§4 bars "acts for clients", "represents parties", "my clients", ' +
|
||
'"my litigation practice". The site sells neutrality; a partisan verb ' +
|
||
'undercuts the central claim before it reaches the licensure question. ' +
|
||
'Settlement counsel was struck from three specs for the same reason (Q42).',
|
||
re: /\bmy litigation practice\b|\bmy law practice\b|\bmy clients?\b|\bacts for\b|\bI act for\b|\brepresents?\b/gi,
|
||
note:
|
||
'"I do not act for a party" is the APPROVED sentence (/mediation/) and does ' +
|
||
'not match. THE SINGULAR WAS ADDED 2026-08-29: the pattern read ' +
|
||
'`my clients` and `/med-arb/` was shipping "The neutral who heard my ' +
|
||
'client in caucus" in its FAQ — the possessive belonged to the counsel ' +
|
||
'voicing the objection, not to Pouya, but the gate cannot tell and the ' +
|
||
'phrase is forbidden in his voice, so the copy changed to "our client" ' +
|
||
'and the pattern widened. Same shape as the capacity-phrasing hole: a ' +
|
||
'pattern narrow enough to miss the real form of the breach.',
|
||
},
|
||
{
|
||
id: 'capacity-phrasing',
|
||
rule: 'docs/03 — when a fact is [unestablished], write around the capacity question.',
|
||
incident:
|
||
'Attempt 1 "I do not give legal advice" was flagged as an ELECTION ' +
|
||
'(entitlement withheld by choice). Attempt 2 "I cannot give legal advice" ' +
|
||
'was flagged as a DENIAL of capacity. Both audits were right: they are ' +
|
||
'opposite answers to a question §4 holds neither side of. The shipped ' +
|
||
'sentence makes no capacity claim at all.',
|
||
re: /\b(cannot|can ?not|can't|do(es)? not|don't|will not|won't|unable to|not (qualified|permitted|able) to)\s+(give|provide|offer)\s+legal advice\b|\bI\s+(?:cannot|can ?not|can't|do not|don't|will not|won't)\b[^.<]{0,25}\badvis(?:e|ing)\b/gi,
|
||
note:
|
||
'THE SECOND ALTERNATIVE WAS ADDED 2026-08-29 BECAUSE THE FIRST MISSED A REAL ' +
|
||
'BREACH. `/practice/cross-cultural/` shipped "What I do not do is advise on the ' +
|
||
'law of another country" — the election form, narrowed by jurisdiction, which ' +
|
||
'makes it worse rather than better: naming only foreign law invites the reader ' +
|
||
'to supply the domestic half. `claims-auditor` caught it and noted, correctly, ' +
|
||
'that this pattern could not, because it required the literal object "legal ' +
|
||
'advice". The verb list is deliberately tight — give / provide / offer / advise ' +
|
||
'/ render — and the window is 25 characters, so the six approved first-person ' +
|
||
'negations this site ships ("I do not act for a party", "I do not run a case", ' +
|
||
'"I will not run a process", "I do not carry a number across the hall", "I will ' +
|
||
'not convert a mediation", "If I cannot decide a remaining issue") all stay ' +
|
||
'silent. Every one of them is in the mustNotMatch fixtures below.',
|
||
},
|
||
{
|
||
id: 'designation-name',
|
||
rule: "ADRIO's own form is Chartered Med-Arbitrator.",
|
||
incident:
|
||
'"Chartered Mediator-Arbitrator" is not the name of anything ADRIC or ' +
|
||
'ADRIO confers. docs/03 §About names the correct form and the source is ' +
|
||
'docs/reference/adrio-designations.md. A designation stated in a form its ' +
|
||
'own institution does not use reads as a designation not held.',
|
||
re: /Chartered Mediator[- ]Arbitrator/gi,
|
||
},
|
||
{
|
||
id: 'rule-set-name',
|
||
rule: 'ADRIC publishes the ADRIC National Mediation Rules.',
|
||
incident:
|
||
'`docs/01` §/mediation/ said "Model Mediation Rules", which is not the ' +
|
||
'name of anything ADRIC publishes — "Model" belongs to the Model Dispute ' +
|
||
'Resolution Clause INSIDE the rules. Caught at build step 4 only because ' +
|
||
'the rules were fetched rather than named from recall (R14).',
|
||
re: /Model Mediation Rules/gi,
|
||
},
|
||
{
|
||
id: 'counts-and-tenure',
|
||
rule: '§4 Forbidden — no count of matters, hours mediated, or years in practice.',
|
||
incident:
|
||
'The site this replaces carried "Since 2009", "sixteen years", London and ' +
|
||
'New York offices and a company number, all artefacts of a purchased ' +
|
||
'template and all false. §4: the practice is new, and small true numbers ' +
|
||
'do not persuade a sophisticated GC — they invite scrutiny.',
|
||
re: /\b\d[\d,]*\s*\+?\s*(matters|mediations|arbitrations|appointments|awards)\b|\b\d[\d,]*\s+years\s+in\b|\bsince\s+(19|20)\d{2}\b|\b(ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty)[\s-]years\b/gi,
|
||
},
|
||
{
|
||
id: 'superlatives',
|
||
rule: '§4 Forbidden — unverifiable as written, and they read as insecure.',
|
||
incident:
|
||
'The reason §4 gives is editorial before it is regulatory: this audience ' +
|
||
'discounts everything after a superlative. AND THE FALSE POSITIVES ARE ' +
|
||
'REAL AND KNOWN — "pleadings" contains "leading" and every inlined ' +
|
||
'stylesheet contains var(--leading-body). Both are handled, by the word ' +
|
||
'boundary and by the <style> strip. Do not loosen the boundary and do not ' +
|
||
'delete this pattern because it once matched a token name.',
|
||
re: /\bleading\b|\bpremier\b|\btop[- ]rated\b|\bbest\b|\bworld[- ]class\b|\bunrivalled\b|\bunparalleled\b/gi,
|
||
},
|
||
|
||
/* --- Beyond Pouya's minimum list, 2026-08-29. Each names a real incident in
|
||
this repository and each has near-zero false-positive surface on this
|
||
site's vocabulary. Strike any of the three and the rest still stand. --- */
|
||
{
|
||
id: 'fabricated-founder',
|
||
rule: '§4 Forbidden — the fictitious founder from the template.',
|
||
incident:
|
||
'The site this replaces carried a founder who does not exist, a ' +
|
||
'testimonial attributed to a person who does not exist, invented matter ' +
|
||
'values and fabricated office locations. This register exists because of ' +
|
||
'that, and this is the one pattern whose match would be unambiguous.',
|
||
re: /\bS\.?\s?M\.?\s+Lawrence\b|\b07452218\b/gi,
|
||
},
|
||
{
|
||
id: 'q-arb-as-held',
|
||
rule: '§4 Forbidden — Q.Arb must never read as held, imminent, or nearly complete.',
|
||
incident:
|
||
'It commenced August 2026. The recurring drift is one word: docs/03 said ' +
|
||
'the Q.Arb "designation commenced in August 2026" — which reads as in ' +
|
||
'force since then, i.e. HELD — in copy an implementer is told to lift ' +
|
||
'verbatim. Found by claims-auditor 2026-08-28. §4 requires "pathway".',
|
||
re: /Q\.?\s?Arb\s+designation\b|\b(holds?|holding|awarded|conferred|earned|achieved)\s+(the\s+|a\s+)?Q\.?\s?Arb\b|Q\.?\s?Arb[^.<]{0,60}\b(nearly|almost) complete\b|Q\.?\s?Arb[^.<]{0,60}\bin progress\b/gi,
|
||
},
|
||
{
|
||
id: 'struck-universal-q39',
|
||
rule: 'Q39 — the class statement about arbitral gating was FALSE and was published once.',
|
||
incident:
|
||
'§4 carried "Anyone may be appointed an arbitrator in Ontario. Nothing in ' +
|
||
'law gates the role behind a designation." Pouya checked the ' +
|
||
'counter-example and confirmed it: "My assertion was FALSE as a ' +
|
||
'universal." Ontario family arbitrators must complete prescribed ' +
|
||
'training. The scoped replacement is COMMERCIAL arbitration and it is ' +
|
||
"Pouya's attributed position, not a verified fact. Swept four times; " +
|
||
'reached a public page once.',
|
||
re: /anyone (may|can) be appointed an arbitrator|nothing in law gates|no (licence|license|designation) is (required|needed) to (be appointed|arbitrate|act as an arbitrator)/gi,
|
||
},
|
||
];
|
||
|
||
/**
|
||
* THE INSTRUMENT CHECK, and it runs before every sweep.
|
||
*
|
||
* `mustMatch` — a pattern that has stopped firing passes a clean sweep forever
|
||
* and looks identical to a clean site. `mustNotMatch` — the known-legitimate
|
||
* strings this site actually publishes; if one of them starts failing, the
|
||
* pattern got looser, not the copy worse.
|
||
*
|
||
* CLAUDE.md, five times over: "a measurement is a claim about your instrument
|
||
* until you check the instrument."
|
||
*/
|
||
const FIXTURES = {
|
||
mustMatch: {
|
||
'licensure-of-pouya': [
|
||
'He is a lawyer.',
|
||
'called to the bar',
|
||
'a licensed neutral',
|
||
],
|
||
'acting-for-a-party': [
|
||
'my litigation practice',
|
||
'acts for clients',
|
||
'I act for the claimant',
|
||
'represents parties',
|
||
'my clients',
|
||
/* The singular, which the pattern missed until 2026-08-29. */
|
||
'the neutral who heard my client in caucus',
|
||
],
|
||
'capacity-phrasing': [
|
||
'I cannot give legal advice',
|
||
'I do not give legal advice',
|
||
"I can't provide legal advice",
|
||
/* The real breach the first form of this pattern could not catch. */
|
||
'What I do not do is advise on the law of another country',
|
||
'I do not advise on foreign law',
|
||
],
|
||
'designation-name': ['Chartered Mediator-Arbitrator'],
|
||
'rule-set-name': ['the Model Mediation Rules'],
|
||
'counts-and-tenure': [
|
||
'40 matters',
|
||
'120 mediations',
|
||
'16 years in ADR',
|
||
'Since 2009',
|
||
'sixteen years',
|
||
],
|
||
superlatives: [
|
||
'a leading neutral',
|
||
'premier',
|
||
'top-rated',
|
||
'the best mediator',
|
||
],
|
||
'fabricated-founder': ['S. M. Lawrence', 'Co. № 07452218'],
|
||
'q-arb-as-held': [
|
||
'the Q.Arb designation',
|
||
'holds Q.Arb',
|
||
'Q.Arb is nearly complete',
|
||
'Q.Arb, in progress',
|
||
],
|
||
'struck-universal-q39': [
|
||
'Anyone may be appointed an arbitrator in Ontario',
|
||
'nothing in law gates the role',
|
||
],
|
||
},
|
||
/* Every one of these is real published or spec-approved copy on this site. */
|
||
mustNotMatch: [
|
||
'I act as a neutral. I do not act for a party in a matter I take, and each party should have their own legal advice.',
|
||
'I run a process, I do not run a case for anybody in it.',
|
||
'I will not run a process whose shape nobody agreed to in advance.',
|
||
'I do not carry a number across the hall that I was not given to carry.',
|
||
'I will not convert a mediation into an arbitration on the day.',
|
||
'Commercial matters. I do not accept family arbitration.',
|
||
/* An OFFERING statement, not a capacity claim. The capacity pattern carried
|
||
`offer` for one pass and fired on this. */
|
||
'I do not offer family arbitration.',
|
||
/* Counsel's voice in /med-arb/'s FAQ, after the possessive was changed. */
|
||
'The neutral who heard our client in caucus then decides the case.',
|
||
/* Undertaking (c) — the nearest miss on the widened capacity pattern. */
|
||
'If I cannot decide a remaining issue without relying on something said to me in confidence, I say so and step out of the arbitral phase rather than decide on it.',
|
||
'Pleadings, disclosure, witnesses, experts, oral argument.',
|
||
/* Wrapped, because the wrapper IS what is being tested. The first draft of
|
||
this fixture was the bare declaration and the self-test failed on it —
|
||
correctly: an unwrapped token name is not something the strip can reach,
|
||
so the fixture was asserting the wrong thing. 25 of the 26 real hits were
|
||
inside <style>. */
|
||
'<style>.hero-lede{line-height:var(--leading-body);color:var(--text-secondary)}</style>',
|
||
'Software contracts, SLA and MSA breakdowns, data residency and processing, AI vendor diligence, IP and licensing.',
|
||
'The Q.Arb pathway commenced in August 2026; C.Med-Arb is the endpoint.',
|
||
'Chartered Med-Arbitrator',
|
||
'The ADRIC National Mediation Rules.',
|
||
'adopted a new edition effective 1 March 2025',
|
||
'Commercial arbitration in Ontario. I do not accept family arbitration.',
|
||
'Published as typical, not as a guarantee.',
|
||
'Liens, delay and change-order claims, scheduling, subcontract and deficiency disputes.',
|
||
],
|
||
};
|
||
|
||
/* --- machinery ----------------------------------------------------------- */
|
||
|
||
/* `relative()` escapes the root with `../../..` when the optional path argument
|
||
points outside the repo (which only a test run does). Show the plain absolute
|
||
path in that case — an unreadable path in a failure report is one more thing
|
||
between a reader and the match. */
|
||
function rel(p) {
|
||
const r = relative(ROOT, p);
|
||
return r && !r.startsWith('..') ? r : p;
|
||
}
|
||
|
||
function walk(dir) {
|
||
const out = [];
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, entry.name);
|
||
if (entry.isDirectory()) out.push(...walk(full));
|
||
else if (entry.name.endsWith('.html')) out.push(full);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function newestMtime(paths) {
|
||
let newest = 0;
|
||
let which = null;
|
||
const visit = (p) => {
|
||
const s = statSync(p);
|
||
if (s.isDirectory()) {
|
||
for (const e of readdirSync(p)) visit(join(p, e));
|
||
} else if (s.mtimeMs > newest) {
|
||
newest = s.mtimeMs;
|
||
which = p;
|
||
}
|
||
};
|
||
for (const p of paths) visit(p);
|
||
return { newest, which };
|
||
}
|
||
|
||
/**
|
||
* Strip what is not published prose or published data.
|
||
*
|
||
* `<style>` goes because Astro inlines critical CSS into every page and the
|
||
* token names collide with the superlative pattern. Non-JSON-LD `<script>` goes
|
||
* because a third-party analytics snippet is somebody else's vocabulary — there
|
||
* is none today (D15's Plausible is one line and not yet added) and there will
|
||
* be. `ld+json` STAYS: it is claim-bearing.
|
||
*
|
||
* Attributes are NOT stripped, deliberately. `<title>`, `<meta name=
|
||
* "description">`, `og:*` and image `alt` text are published copy and are among
|
||
* the places a claim is least likely to be re-read.
|
||
*/
|
||
function publishedText(html) {
|
||
return (
|
||
html
|
||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||
.replace(
|
||
/<script\b(?![^>]*application\/ld\+json)[^>]*>[\s\S]*?<\/script>/gi,
|
||
' ',
|
||
)
|
||
/* Smart punctuation, so `can’t` is matched by a pattern written `can't`.
|
||
Astro emits whatever the source used and the source uses curly quotes. */
|
||
.replace(/[‘’]/g, "'")
|
||
.replace(/[“”]/g, '"')
|
||
.replace(/&#(?:39|x27);/gi, "'")
|
||
.replace(/&/gi, '&')
|
||
.replace(/ | /gi, ' ')
|
||
);
|
||
}
|
||
|
||
function contextAt(text, index, length) {
|
||
const from = Math.max(0, index - 55);
|
||
const to = Math.min(text.length, index + length + 55);
|
||
const snip = text.slice(from, to).replace(/\s+/g, ' ');
|
||
return `${from > 0 ? '…' : ''}${snip}${to < text.length ? '…' : ''}`;
|
||
}
|
||
|
||
function lineOf(text, index) {
|
||
let line = 1;
|
||
for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) line++;
|
||
return line;
|
||
}
|
||
|
||
function scan(text, re) {
|
||
const hits = [];
|
||
re.lastIndex = 0;
|
||
let m;
|
||
while ((m = re.exec(text)) !== null) {
|
||
hits.push({ match: m[0], index: m.index });
|
||
if (m[0].length === 0) re.lastIndex++;
|
||
}
|
||
return hits;
|
||
}
|
||
|
||
function selfTest() {
|
||
const failures = [];
|
||
for (const p of PATTERNS) {
|
||
for (const positive of FIXTURES.mustMatch[p.id] ?? []) {
|
||
if (scan(positive, p.re).length === 0) {
|
||
failures.push(
|
||
`${p.id}: STOPPED MATCHING its own fixture — ${JSON.stringify(positive)}`,
|
||
);
|
||
}
|
||
}
|
||
if (!(p.id in FIXTURES.mustMatch)) {
|
||
failures.push(
|
||
`${p.id}: has no fixture. Every pattern proves it still fires.`,
|
||
);
|
||
}
|
||
for (const negative of FIXTURES.mustNotMatch) {
|
||
const hits = scan(publishedText(negative), p.re);
|
||
if (hits.length > 0) {
|
||
failures.push(
|
||
`${p.id}: matched APPROVED copy — ${JSON.stringify(hits[0].match)} in ${JSON.stringify(negative)}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
/* --- run ----------------------------------------------------------------- */
|
||
|
||
let failed = false;
|
||
|
||
console.log(
|
||
'check:claims — AGENTS.md §4 Forbidden, enforced on shipped output',
|
||
);
|
||
console.log(` target: ${rel(DIST)}/**/*.html\n`);
|
||
|
||
// 1. The instrument, before the measurement.
|
||
const selfTestFailures = selfTest();
|
||
if (selfTestFailures.length > 0) {
|
||
console.error(
|
||
'SELF-TEST FAILED — the patterns cannot be trusted, so nothing was swept.\n',
|
||
);
|
||
for (const f of selfTestFailures) console.error(` ${f}`);
|
||
console.error(
|
||
'\nA pattern that no longer fires passes a clean sweep forever.',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
console.log(
|
||
` self-test: ${PATTERNS.length} patterns fire on their fixtures and stay silent on ${FIXTURES.mustNotMatch.length} approved strings`,
|
||
);
|
||
|
||
// 2. The target has to exist and have content in it. An empty sweep is not a
|
||
// pass — CLAUDE.md: "a command that did not run is not evidence of absence."
|
||
let pages;
|
||
try {
|
||
pages = walk(DIST);
|
||
} catch (err) {
|
||
console.error(`\nFAILED: cannot read ${DIST} — ${err.message}`);
|
||
console.error(
|
||
'Run `npm run build` first. This checks the built output, not the source.',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
if (pages.length === 0) {
|
||
console.error(`\nFAILED: no HTML under ${DIST}. Nothing was checked.`);
|
||
console.error(
|
||
'An empty sweep reads exactly like a clean one. Run `npm run build`.',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
// 3. Staleness. A pass against yesterday's dist is the same false negative in
|
||
// slower motion.
|
||
const newestPage = newestMtime(pages);
|
||
const newestSource = newestMtime([
|
||
...SOURCE_DIRS.map((d) => join(ROOT, d)),
|
||
...SOURCE_FILES.map((f) => join(ROOT, f)),
|
||
]);
|
||
if (newestSource.newest > newestPage.newest) {
|
||
console.error(`\nFAILED: ${rel(DIST)} is older than the source.`);
|
||
console.error(` newest source: ${rel(newestSource.which)}`);
|
||
console.error(` newest page: ${rel(newestPage.which)}`);
|
||
console.error(
|
||
'\nThis would have checked a build that does not include your change.',
|
||
);
|
||
console.error('Run `npm run build`, then this again.');
|
||
process.exit(2);
|
||
}
|
||
|
||
console.log(` pages: ${pages.length}\n`);
|
||
|
||
for (const p of PATTERNS) {
|
||
const hits = [];
|
||
for (const page of pages) {
|
||
const text = publishedText(readFileSync(page, 'utf8'));
|
||
for (const hit of scan(text, p.re)) {
|
||
hits.push({
|
||
page: rel(page),
|
||
line: lineOf(text, hit.index),
|
||
match: hit.match,
|
||
context: contextAt(text, hit.index, hit.match.length),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (hits.length === 0) {
|
||
console.log(` ok ${p.id}`);
|
||
continue;
|
||
}
|
||
|
||
failed = true;
|
||
console.error(
|
||
`\n FAIL ${p.id} — ${hits.length} match${hits.length === 1 ? '' : 'es'}`,
|
||
);
|
||
console.error(` rule: ${p.rule}`);
|
||
console.error(` incident: ${p.incident}`);
|
||
if (p.note) console.error(` note: ${p.note}`);
|
||
console.error('');
|
||
for (const hit of hits) {
|
||
console.error(
|
||
` ${hit.page}:${hit.line} ${JSON.stringify(hit.match)}`,
|
||
);
|
||
console.error(` ${hit.context}`);
|
||
}
|
||
}
|
||
|
||
if (failed) {
|
||
console.error('\n' + '-'.repeat(72));
|
||
console.error(
|
||
'A match is not yet a finding — read the context printed above before acting.',
|
||
);
|
||
console.error(
|
||
'This site has had a superlative sweep hit "pleadings" and a case-insensitive',
|
||
);
|
||
console.error(
|
||
'sweep for LSO hit "I aLSO practise". Read what matched, then either fix the',
|
||
);
|
||
console.error(
|
||
'copy or change this file DELIBERATELY, with an AGENTS.md Change Log entry.',
|
||
);
|
||
console.error('Do not delete a pattern to make a build pass.');
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(
|
||
'\nClean. Every pattern ran and every pattern is still firing on its fixture.',
|
||
);
|