mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
247 lines
9.6 KiB
JavaScript
247 lines
9.6 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
// Fail when an active document states a finding count the ledger contradicts
|
|||
|
|
// (the automated half of G-04).
|
|||
|
|
//
|
|||
|
|
// node scripts/check-doc-counts.mjs
|
|||
|
|
// node scripts/check-doc-counts.mjs --selftest
|
|||
|
|
//
|
|||
|
|
// Scope, deliberately small: this counts ledger statuses and compares them to
|
|||
|
|
// the numbers active documents assert. It is not a document-status framework,
|
|||
|
|
// and it does not check that FINDINGS.md is in sync with the ledger — that was
|
|||
|
|
// RL-07, and B1-6 answered it by not tracking FINDINGS.md at all, so there is
|
|||
|
|
// no committed rendering left to drift. `npm run check:docs` runs this script
|
|||
|
|
// and then regenerates the rendering, which is where a generation failure
|
|||
|
|
// surfaces.
|
|||
|
|
//
|
|||
|
|
// It reads findings-ledger.json directly and does NOT import render-ledger.mjs.
|
|||
|
|
// That module has no `import.meta.main` guard, so importing it to reuse
|
|||
|
|
// `validate`/`render` runs `main()` and rewrites FINDINGS.md as a side effect.
|
|||
|
|
//
|
|||
|
|
// ── Why the patterns are narrow ──────────────────────────────────────────────
|
|||
|
|
// "open" is overloaded in this repository. The issue register has 45 open P1
|
|||
|
|
// *rows*; a security scan closed 8 *findings* F1–F8; `G-05 **refuted**` puts a
|
|||
|
|
// digit next to a status word. None of those are ledger counts, and a loose
|
|||
|
|
// pattern flags all of them — a check that cries wolf gets ignored, which is
|
|||
|
|
// the failure mode G-04 already describes.
|
|||
|
|
//
|
|||
|
|
// So a number is only read as a ledger claim in three unambiguous shapes:
|
|||
|
|
//
|
|||
|
|
// 1. An enumeration — two or more "<n> <status>" pairs on one line, e.g.
|
|||
|
|
// "306 fixed / 38 open / 3 declined / 1 duplicate = 348". A lone
|
|||
|
|
// "45 open" is never enough.
|
|||
|
|
// 2. A status table row "| open | **38** |", but only in a table that also
|
|||
|
|
// carries a "| Total | 348 |" row nearby.
|
|||
|
|
// 3. "<n> records" / "<n> findings", but only where the ledger is named
|
|||
|
|
// within the preceding few lines.
|
|||
|
|
//
|
|||
|
|
// Dated docs/audit-*.md are reported, never failed: they are point-in-time
|
|||
|
|
// snapshots that are deliberately not maintained, and editing them is out of
|
|||
|
|
// scope for the repository-layout work. audit-2026-08-19.md does claim zero
|
|||
|
|
// open findings — true when written, false now, and left alone on purpose.
|
|||
|
|
|
|||
|
|
import { readFileSync, existsSync } from "node:fs";
|
|||
|
|
import { dirname, join, resolve } from "node:path";
|
|||
|
|
import { fileURLToPath } from "node:url";
|
|||
|
|
|
|||
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|||
|
|
|
|||
|
|
// Active documents that assert a count. Adding a count to a document means
|
|||
|
|
// adding it here — an unlisted document is not checked.
|
|||
|
|
const WATCHED = [
|
|||
|
|
"docs/README.md",
|
|||
|
|
"docs/plans/README.md",
|
|||
|
|
"docs/plans/hp-0-scorecard-2026-08-25.md",
|
|||
|
|
"docs/plans/hp-1-scorecard-2026-08-27.md",
|
|||
|
|
"docs/plans/repo-health-issue-register-2026-08-23.md",
|
|||
|
|
"docs/plans/b0-baseline-2026-08-25.md",
|
|||
|
|
"docs/plans/b1-repository-foundation-2026-08-25.md",
|
|||
|
|
"CLAUDE.md",
|
|||
|
|
"README.md",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Reported but never failed — dated snapshots, see the header.
|
|||
|
|
const REPORT_ONLY = ["docs/audit-"];
|
|||
|
|
|
|||
|
|
const STATUSES = ["open", "fixed", "declined", "duplicate", "refuted", "blocked"];
|
|||
|
|
const S = STATUSES.join("|");
|
|||
|
|
|
|||
|
|
// Never let a digit that belongs to an identifier or comparison start a claim:
|
|||
|
|
// G-05, >=20, CGO_ENABLED=0, version=1.2.0-alpha.3.
|
|||
|
|
const LEAD = "(?<![\\w.\\-=<>/])";
|
|||
|
|
const PAIR = new RegExp(`${LEAD}(\\d+)\\*{0,2}\\s+\\*{0,2}(${S})\\b`, "gi");
|
|||
|
|
const LEDGER_CONTEXT = /ledger|OC-\d|findings-ledger|FINDINGS\.md/i;
|
|||
|
|
|
|||
|
|
export function tally(ledger) {
|
|||
|
|
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
|||
|
|
for (const f of ledger.findings) if (f.status in counts) counts[f.status]++;
|
|||
|
|
counts.total = ledger.findings.length;
|
|||
|
|
return counts;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function claimsIn(text) {
|
|||
|
|
const out = [];
|
|||
|
|
const lines = text.split("\n");
|
|||
|
|
|
|||
|
|
// Which lines sit in a status table that has a Total row within 10 lines?
|
|||
|
|
const totalRowAt = new Set();
|
|||
|
|
lines.forEach((l, i) => {
|
|||
|
|
if (/^\|\s*\*{0,2}total\*{0,2}\s*\|\s*\*{0,2}\d+\*{0,2}\s*\|/i.test(l)) totalRowAt.add(i);
|
|||
|
|
});
|
|||
|
|
const nearTotalRow = (i) => [...totalRowAt].some((t) => Math.abs(t - i) <= 10);
|
|||
|
|
|
|||
|
|
lines.forEach((line, i) => {
|
|||
|
|
const at = i + 1;
|
|||
|
|
|
|||
|
|
// 1. Enumeration: two or more "<n> <status>" pairs on one line.
|
|||
|
|
const pairs = [...line.matchAll(PAIR)];
|
|||
|
|
if (pairs.length >= 2) {
|
|||
|
|
for (const m of pairs) {
|
|||
|
|
out.push({ line: at, kind: m[2].toLowerCase(), value: Number(m[1]), text: m[0].trim() });
|
|||
|
|
}
|
|||
|
|
// "... = 348" closing an enumeration is the total.
|
|||
|
|
const eq = line.match(/=\s*\*{0,2}(\d+)\*{0,2}/);
|
|||
|
|
if (eq) out.push({ line: at, kind: "total", value: Number(eq[1]), text: eq[0].trim() });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Status table row, only inside a table that totals itself.
|
|||
|
|
const row = line.match(
|
|||
|
|
new RegExp(`^\\|\\s*\\*{0,2}(${S})\\*{0,2}\\s*\\|\\s*\\*{0,2}(\\d+)\\*{0,2}\\s*\\|`, "i"),
|
|||
|
|
);
|
|||
|
|
if (row && nearTotalRow(i)) {
|
|||
|
|
out.push({
|
|||
|
|
line: at,
|
|||
|
|
kind: row[1].toLowerCase(),
|
|||
|
|
value: Number(row[2]),
|
|||
|
|
text: row[0].trim(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const totalRow = line.match(/^\|\s*\*{0,2}total\*{0,2}\s*\|\s*\*{0,2}(\d+)\*{0,2}\s*\|/i);
|
|||
|
|
if (totalRow)
|
|||
|
|
out.push({ line: at, kind: "total", value: Number(totalRow[1]), text: totalRow[0].trim() });
|
|||
|
|
|
|||
|
|
// 3. "<n> records"/"<n> findings", only near an explicit mention of the ledger.
|
|||
|
|
const ctx = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
|
|||
|
|
if (LEDGER_CONTEXT.test(ctx)) {
|
|||
|
|
for (const m of line.matchAll(
|
|||
|
|
new RegExp(`${LEAD}(\\d+)\\*{0,2}\\s+(?:records?|findings?)\\b`, "gi"),
|
|||
|
|
)) {
|
|||
|
|
out.push({ line: at, kind: "total", value: Number(m[1]), text: m[0].trim() });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
const ledgerPath = join(ROOT, ".superpowers/findings-ledger.json");
|
|||
|
|
if (!existsSync(ledgerPath)) {
|
|||
|
|
console.error(`missing ${ledgerPath}`);
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
const counts = tally(JSON.parse(readFileSync(ledgerPath, "utf8")));
|
|||
|
|
console.log(`ledger: ${STATUSES.map((s) => `${counts[s]} ${s}`).join(" / ")} = ${counts.total}`);
|
|||
|
|
|
|||
|
|
const failures = [];
|
|||
|
|
const notes = [];
|
|||
|
|
let claimCount = 0;
|
|||
|
|
|
|||
|
|
for (const rel of WATCHED) {
|
|||
|
|
const p = join(ROOT, rel);
|
|||
|
|
if (!existsSync(p)) {
|
|||
|
|
failures.push(
|
|||
|
|
`${rel}: watched file does not exist — fix the list in scripts/check-doc-counts.mjs`,
|
|||
|
|
);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
for (const c of claimsIn(readFileSync(p, "utf8"))) {
|
|||
|
|
const actual = counts[c.kind];
|
|||
|
|
if (actual === undefined) continue;
|
|||
|
|
claimCount++;
|
|||
|
|
if (c.value === actual) continue;
|
|||
|
|
const entry = `${rel}:${c.line} claims "${c.text}" — ledger says ${c.kind} = ${actual}`;
|
|||
|
|
if (REPORT_ONLY.some((prefix) => rel.startsWith(prefix))) notes.push(entry);
|
|||
|
|
else failures.push(entry);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const n of notes) console.log(`NOTE ${n}`);
|
|||
|
|
|
|||
|
|
if (failures.length) {
|
|||
|
|
console.error(`\n${failures.length} document claim(s) contradict the ledger:\n`);
|
|||
|
|
for (const f of failures) console.error(` ${f}`);
|
|||
|
|
console.error(
|
|||
|
|
"\nThe ledger is the source of truth. Update the document, or if the ledger is\n" +
|
|||
|
|
"wrong, fix .superpowers/findings-ledger.json and re-render FINDINGS.md.",
|
|||
|
|
);
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
console.log(
|
|||
|
|
`\n${claimCount} claim(s) across ${WATCHED.length} watched document(s) agree with the ledger.`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selftest() {
|
|||
|
|
let failed = 0;
|
|||
|
|
const assert = (cond, msg) => {
|
|||
|
|
console.log(`${cond ? "PASS" : "FAIL"} ${msg}`);
|
|||
|
|
if (!cond) failed++;
|
|||
|
|
};
|
|||
|
|
const t = tally({ findings: [{ status: "open" }, { status: "open" }, { status: "fixed" }] });
|
|||
|
|
assert(t.open === 2 && t.fixed === 1 && t.total === 3, "tally counts by status and total");
|
|||
|
|
assert(t.refuted === 0, "a declared-but-unused status counts 0, not undefined");
|
|||
|
|
|
|||
|
|
const c = claimsIn;
|
|||
|
|
const has = (s, kind, value) => c(s).some((x) => x.kind === kind && x.value === value);
|
|||
|
|
|
|||
|
|
assert(
|
|||
|
|
has("Ledger: **306 fixed / 38 open / 3 declined / 1 duplicate = 348**.", "open", 38),
|
|||
|
|
"enumeration: reads each pair",
|
|||
|
|
);
|
|||
|
|
assert(
|
|||
|
|
has("Ledger: **306 fixed / 38 open / 3 declined / 1 duplicate = 348**.", "total", 348),
|
|||
|
|
"enumeration: reads the = total",
|
|||
|
|
);
|
|||
|
|
assert(
|
|||
|
|
has("**38 open** · 0 blocked · 306 fixed · 3 declined", "fixed", 306),
|
|||
|
|
"enumeration: FINDINGS.md header shape",
|
|||
|
|
);
|
|||
|
|
assert(
|
|||
|
|
has("| open | **38** |\n| **Total** | **348** |", "open", 38),
|
|||
|
|
"status table with a Total row",
|
|||
|
|
);
|
|||
|
|
assert(has("the ledger holds\n348 records", "total", 348), '"N records" near a ledger mention');
|
|||
|
|
|
|||
|
|
// The false positives that made a looser version unusable.
|
|||
|
|
assert(
|
|||
|
|
c("The 45 open P1 rows are tracked in the register.").length === 0,
|
|||
|
|
'a lone "45 open" is not a ledger claim',
|
|||
|
|
);
|
|||
|
|
assert(
|
|||
|
|
c("| All 8 findings F1-F8 closed |").length === 0,
|
|||
|
|
"a different register is not a ledger claim",
|
|||
|
|
);
|
|||
|
|
assert(
|
|||
|
|
c("| `golangci-lint` | claimed broken (G-05) | G-05 **refuted** |").length === 0,
|
|||
|
|
'"G-05 refuted" is an id, not a count',
|
|||
|
|
);
|
|||
|
|
assert(c('`tools/mcp-introspect/package.json` (`">=20"`)').length === 0, '">=20" is not a count');
|
|||
|
|
assert(
|
|||
|
|
c('go build -ldflags "-X main.version=1.2.0-alpha.3"').length === 0,
|
|||
|
|
"a version string is not a count",
|
|||
|
|
);
|
|||
|
|
assert(c("11 medium, 27 low").length === 0, "severities are not statuses");
|
|||
|
|
assert(c("22 sit under Client/").length === 0, "a bare number is not a claim");
|
|||
|
|
assert(
|
|||
|
|
c("348 records in some unrelated table").length === 0,
|
|||
|
|
'"N records" without ledger context is ignored',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
console.log(
|
|||
|
|
failed ? `\nselftest: ${failed} assertion(s) failed` : "\nselftest: all assertions pass",
|
|||
|
|
);
|
|||
|
|
process.exit(failed ? 1 : 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.argv.includes("--selftest")) selftest();
|
|||
|
|
else main();
|