diff --git a/docs/testing.md b/docs/testing.md index 9a0605e2e1..61f9ce0e59 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -580,6 +580,33 @@ For an enabled-wiring check, first run disabled and normal enabled bounds to com Pass `--boundary-observer `, `--vitest-json `, `--body-unobservable-files `, and `--fully-unobservable-files ` to `scripts/pg-loaded-failure-census.mjs`. Capture the reporter per run with `--reporter=dot --reporter=json --outputFile=`; it provides per-test duration and file identifiers but Vitest 3 provides no hook duration or failure position, so a hook-clock offset is unmeasurable rather than a point estimate. The census tolerates malformed and out-of-order rows, joins ladder/breach/enriched rows by key, and reports joined, `attributed-by-ladder`, `body-unobservable`, `position-unobservable`, and `unjoined` coverage counts plus probe suppression and latency distributions. `afterEach` consumer hooks are position-unobservable because the shared-harness body bracket closes before those hooks run. Failures from fully-unobservable files remain `unjoined` but are counted and listed separately so the known no-harness limitation is never mistaken for a missing observer join. Missing/empty input stays explicitly absent rather than a measured zero. + + +### PostgreSQL campaign cluster-hygiene report + +Before every FN-9152/FN-9153 campaign run, capture the maintenance-cluster rows and retain the three capture files plus the JSON report in the task document. The four counted hygiene classes are `fusion_test_%`, `fusion_schema_template%` per-module templates, `fusion_schema_template__golden` golden templates, and `fusion_pool_%`. The report classifies names itself, so the deliberately conservative SQL patterns cannot silently hide an unexpected matching name. + +A capture is an envelope, not bare `psql -qAt` output: the `# fusion-hygiene-capture v1` banner, `kind`, cluster identity, ISO timestamp, query identity, declared row count, body, and terminal `# end` make `rows: 0` a measured zero. Missing, empty, header-less, truncated, count-mismatched, or wrong-kind captures are `insufficient-data`, never clean evidence. A supplied-but-unmeasured or wrong-kind `--markers`/`--liveness` companion makes the whole report `insufficient-data`; an omitted companion is not evidence, so Path A/C/D clean evidence requires all three captures supplied, measured, and bearing the same cluster identity. A stale marker row is dirty terminal evidence even when all four database counts are zero. With `PG_URL="${FUSION_PG_TEST_URL_BASE:-postgresql://localhost:5432}"`, capture the database and marker rows as follows (write the displayed envelope fields around each query result): + +```bash +capture() { # capture KIND QUERY SQL OUTPUT + kind="$1" query="$2" sql="$3" output="$4" + cluster="$(psql "$PG_URL/postgres" -qAt -c "SELECT current_setting('port') || '|' || version() || '|' || current_database();")" + rows="$(psql "$PG_URL/postgres" -qAt -c "$sql")" + { printf '%s\n' '# fusion-hygiene-capture v1' "# kind: $kind" "# cluster: $cluster" "# captured_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" "# query: $query" "# rows: $(printf '%s\n' "$rows" | sed '/^$/d' | wc -l | tr -d ' ')"; printf '%s\n' "$rows"; printf '%s\n' '# end'; } > "$output" +} +capture databases leftover-v1 "SELECT datname, pg_get_userbyid(datdba), CASE WHEN datistemplate THEN 't' ELSE 'f' END, (SELECT count(*) FROM pg_stat_activity a WHERE a.datname = d.datname) FROM pg_database d WHERE datname LIKE 'fusion_test_%' OR datname LIKE 'fusion_schema_template%' OR datname LIKE 'fusion_pool_%' ORDER BY 1;" databases.capture +capture markers markers-v1 "SELECT name, created_at FROM public._fusion_golden_templates ORDER BY 1;" markers.capture +# For each parsed template owner PID, record exactly one `|alive` or `|dead` body row from `ps -p "$pid" -o pid=` in an analogous `kind: liveness`, `query: liveness-v1` envelope. +node scripts/pg-cluster-hygiene-report.mjs --databases databases.capture --markers markers.capture --liveness liveness.capture --json +``` + + + +The report is advisory: `reclaimable-dead-owner` means only recorded `dead` liveness plus zero sessions; missing liveness, an alive owner, an unparseable name, or sessions retains the row. It never grants approval. Removal requires all of G2–G6: that recorded liveness verdict, immediate zero-session recheck, one literal name-scoped `DROP DATABASE` **without** `FORCE` against a present target, equality-only deletion of that same marker row (never a broad stale-marker sweep), and a human-authored, verbatim, action-explicit, evidence-informed approval recorded after the pre-state evidence in the task's `removal-approval` document. Agents may never self-issue or infer that approval. A refused drop, pre-check abort, partial marker reconciliation, or unmeasurable/dirty post-state is terminal: record the evidence, file or reuse one scoped follow-up, exit honest-blocked, and never retry, force, or claim clean. Record the four counts for every campaign run; an absent report is FN-9152 R3 `leftover-counts-unrecorded` evidence, not a clean arm. + ### PostgreSQL DDL loaded-lane acceptance metric Use `scripts/pg-ddl-lane-metric.mjs` before judging a PostgreSQL DDL structural candidate. Run at least seven **interleaved** control/candidate pairs at `VITEST_MAX_WORKERS=12`; preserve one diagnostics JSONL sink and complete runner log per invocation. The exact lane is: diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/count-mismatch.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/count-mismatch.txt new file mode 100644 index 0000000000..94f30af854 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/count-mismatch.txt @@ -0,0 +1,9 @@ +# fusion-hygiene-capture v1 +# kind: databases +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: leftover-v1 +# rows: 3 +fusion_test_one|eclipxe|f|0 +fusion_pool_one|eclipxe|f|0 +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/empty.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/empty.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-absent.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-absent.txt new file mode 100644 index 0000000000..efebdbd5b3 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-absent.txt @@ -0,0 +1,7 @@ +# fusion-hygiene-capture v1 +# kind: liveness +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: liveness-v1 +# rows: 0 +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-alive.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-alive.txt new file mode 100644 index 0000000000..32fc22a272 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-alive.txt @@ -0,0 +1,8 @@ +# fusion-hygiene-capture v1 +# kind: liveness +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: liveness-v1 +# rows: 1 +39001|alive +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-dead.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-dead.txt new file mode 100644 index 0000000000..739b3d8bc0 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/liveness-dead.txt @@ -0,0 +1,8 @@ +# fusion-hygiene-capture v1 +# kind: liveness +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: liveness-v1 +# rows: 1 +39001|dead +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/malformed.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/malformed.txt new file mode 100644 index 0000000000..e83ad248c9 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/malformed.txt @@ -0,0 +1,11 @@ +# fusion-hygiene-capture v1 +# kind: databases +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: leftover-v1 +# rows: 4 +not-a-database-row +fusion_test_valid|eclipxe|f|0 +fusion_pool_bad|eclipxe|not-bool|zero +fusion_schema_template_99_nonce|eclipxe|false|0 +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/markers-stale.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/markers-stale.txt new file mode 100644 index 0000000000..e9fcfec7a5 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/markers-stale.txt @@ -0,0 +1,8 @@ +# fusion-hygiene-capture v1 +# kind: markers +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: markers-v1 +# rows: 1 +fusion_schema_template_39001_goldenestworkerspnnem8|2026-08-19T10:10:19Z +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/mixed.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/mixed.txt new file mode 100644 index 0000000000..251650ab0a --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/mixed.txt @@ -0,0 +1,17 @@ +# fusion-hygiene-capture v1 +# kind: databases +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: leftover-v1 +# rows: 10 +fusion_schema_template_22_nonce|eclipxe|f|0 +fusion_test_one|eclipxe|f|2 +fusion_pool_one|eclipxe|f|0 +archive_live_fence_1|eclipxe|f|0 +ce_pipeline_pg_1|eclipxe|f|0 +fn-extension-1|eclipxe|f|0 +fusion_pool_one|eclipxe|f|0 +custom_prefix_1|eclipxe|f|0 +autotrade|eclipxe|f|0 +eclipxe|eclipxe|f|0 +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/no-banner.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/no-banner.txt new file mode 100644 index 0000000000..b317826825 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/no-banner.txt @@ -0,0 +1 @@ +fusion_schema_template_39001_goldenestworkerspnnem8|eclipxe|f|0 diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/post-state.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/post-state.txt new file mode 100644 index 0000000000..caa9d05b51 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/post-state.txt @@ -0,0 +1,7 @@ +# fusion-hygiene-capture v1 +# kind: databases +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: leftover-v1 +# rows: 0 +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/pre-state.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/pre-state.txt new file mode 100644 index 0000000000..a08151a897 --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/pre-state.txt @@ -0,0 +1,8 @@ +# fusion-hygiene-capture v1 +# kind: databases +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: leftover-v1 +# rows: 1 +fusion_schema_template_39001_goldenestworkerspnnem8|eclipxe|f|0 +# end diff --git a/scripts/__tests__/fixtures/pg-cluster-hygiene-report/truncated.txt b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/truncated.txt new file mode 100644 index 0000000000..08fceb4ece --- /dev/null +++ b/scripts/__tests__/fixtures/pg-cluster-hygiene-report/truncated.txt @@ -0,0 +1,7 @@ +# fusion-hygiene-capture v1 +# kind: databases +# cluster: 5432|PostgreSQL 16.0|postgres +# captured_at: 2026-08-19T18:18:00Z +# query: leftover-v1 +# rows: 3 +fusion_test_one|eclipxe|f|0 diff --git a/scripts/__tests__/pg-cluster-hygiene-report.test.mjs b/scripts/__tests__/pg-cluster-hygiene-report.test.mjs new file mode 100644 index 0000000000..81b1005425 --- /dev/null +++ b/scripts/__tests__/pg-cluster-hygiene-report.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { URL } from "node:url"; +import test from "node:test"; +import { + buildHygieneReport, + classifyDatabase, + classifyReclaimability, + parseCaptureEnvelope, + parseTemplateOwnerPid, +} from "../pg-cluster-hygiene-report.mjs"; + +const fixture = (name) => readFileSync(new URL(`./fixtures/pg-cluster-hygiene-report/${name}`, import.meta.url), "utf8"); +const captureForKind = (capture, kind) => capture + .replace("# kind: databases", `# kind: ${kind}`) + .replace("# query: leftover-v1", `# query: ${kind === "markers" ? "markers-v1" : "liveness-v1"}`); +const measuredZero = fixture("post-state.txt"); +const zeroMarkers = captureForKind(measuredZero, "markers"); +const zeroLiveness = captureForKind(measuredZero, "liveness"); +const report = (databaseName, options = {}) => buildHygieneReport({ + databaseCapture: fixture(databaseName), + markerCapture: zeroMarkers, + livenessCapture: zeroLiveness, + ...options, +}); + +test("reports the named dead-owner golden template as technically reclaimable, never approved", () => { + const preState = report("pre-state.txt", { livenessCapture: fixture("liveness-dead.txt") }); + assert.equal(preState.status, "measured"); + assert.equal(preState.counts.goldenTemplate, 1); + assert.equal(preState.clean, false); + assert.equal(preState.databases[0].technicalEligibility, "reclaimable-dead-owner"); + assert.equal("approval" in preState, false); + assert.equal("authorization" in preState, false); + assert.equal("safeToDrop" in preState, false); + + assert.equal(report("pre-state.txt", { livenessCapture: fixture("liveness-alive.txt") }).databases[0].technicalEligibility, "retain-live-owner"); + assert.equal(report("pre-state.txt", { livenessCapture: fixture("liveness-absent.txt") }).databases[0].technicalEligibility, "retain-unclassified"); + assert.equal(report("pre-state.txt").databases[0].technicalEligibility, "retain-unclassified"); +}); + +test("recognizes three same-cluster well-formed zeros as measured clean evidence", () => { + const postState = report("post-state.txt"); + assert.equal(postState.status, "measured"); + assert.deepEqual(postState.counts, { test: 0, schemaTemplate: 0, goldenTemplate: 0, pool: 0 }); + assert.equal(postState.clean, true); +}); + +test("rejects every missing or incomplete database capture rather than manufacturing a zero", () => { + const expected = new Map([ + [undefined, "missing"], + [fixture("empty.txt"), "empty"], + [fixture("no-banner.txt"), "no-banner"], + [fixture("truncated.txt"), "truncated"], + [fixture("count-mismatch.txt"), "count-mismatch"], + ]); + for (const [capture, reason] of expected) { + const parsed = parseCaptureEnvelope(capture, { expectedKind: "databases" }); + assert.equal(parsed.status, "insufficient-data"); + assert.equal(parsed.insufficientReason, reason); + const value = buildHygieneReport({ databaseCapture: capture }); + assert.equal(value.status, "insufficient-data"); + assert.equal(value.clean, false); + } +}); + +/* +FNXC:PgClusterHygiene 2026-08-20-00:54: +FN-9154 G1 treats every supplied companion as required evidence. A measured-zero +primary capture must fail closed for malformed or wrong-kind marker and liveness +inputs, rather than treating an unread companion as proof of a clean cluster. +*/ +test("fails closed when any supplied companion envelope is malformed", () => { + const failures = [ + [fixture("empty.txt"), "empty"], + [fixture("no-banner.txt"), "no-banner"], + [fixture("truncated.txt"), "truncated"], + [fixture("count-mismatch.txt"), "count-mismatch"], + [measuredZero, "kind-mismatch"], + ]; + + for (const [capture, reason] of failures) { + for (const [option, kind] of [["markerCapture", "markers"], ["livenessCapture", "liveness"]]) { + const companionCapture = reason === "kind-mismatch" ? capture : captureForKind(capture, kind); + const value = report("post-state.txt", { [option]: companionCapture }); + assert.equal(value.status, "insufficient-data", `${option} ${reason} status`); + assert.equal(value.clean, false, `${option} ${reason} clean`); + assert.equal(value.insufficientReason, reason, `${option} ${reason} propagation`); + } + } + + const badHeader = captureForKind(measuredZero, "markers").replace("# captured_at: 2026-08-19T18:18:00Z", "# captured_at: not-a-date"); + assert.equal(report("post-state.txt", { markerCapture: badHeader }).insufficientReason, "bad-header"); + const wrongQuery = measuredZero.replace("# query: leftover-v1", "# query: unknown-v1"); + assert.equal(parseCaptureEnvelope(wrongQuery, { expectedKind: "databases" }).insufficientReason, "bad-header"); + + const bothBroken = report("post-state.txt", { markerCapture: fixture("empty.txt"), livenessCapture: fixture("truncated.txt") }); + assert.equal(bothBroken.status, "insufficient-data"); + assert.equal(bothBroken.clean, false); + assert.equal(bothBroken.insufficientReason, "empty"); +}); + +/* +FNXC:PgClusterHygiene 2026-08-20-01:41: +FN-9154 G1 permits a clean Path A/C/D verdict only when database, marker, and +liveness captures all measure the same cluster; omission or mixed provenance is +insufficient evidence even when every supplied body is empty. +*/ +test("requires all three measured captures from the same cluster", () => { + for (const captures of [ + { databaseCapture: measuredZero }, + { databaseCapture: measuredZero, markerCapture: zeroMarkers }, + ]) { + const value = buildHygieneReport(captures); + assert.equal(value.status, "insufficient-data"); + assert.equal(value.clean, false); + assert.equal(value.insufficientReason, "missing"); + } + + for (const option of ["markerCapture", "livenessCapture"]) { + const value = report("post-state.txt", { + [option]: (option === "markerCapture" ? zeroMarkers : zeroLiveness) + .replace("# cluster: 5432|PostgreSQL 16.0|postgres", "# cluster: 6543|PostgreSQL 17.0|postgres"), + }); + assert.equal(value.status, "insufficient-data"); + assert.equal(value.clean, false); + assert.equal(value.insufficientReason, "cluster-mismatch"); + } +}); + +/* +FNXC:PgClusterHygiene 2026-08-19-21:49: +FN-9154's campaign hygiene invariant excludes non-campaign databases even when +an over-matching capture query sees them. Keep representative host families in +the fixture so the advisory report cannot propose them for reclamation. +*/ +test("counts distinct campaign names, retains live/in-use rows, and ignores unrelated families", () => { + const value = report("mixed.txt", { livenessCapture: `# fusion-hygiene-capture v1\n# kind: liveness\n# cluster: 5432|PostgreSQL 16.0|postgres\n# captured_at: 2026-08-19T18:18:00Z\n# query: liveness-v1\n# rows: 1\n22|alive\n# end\n` }); + assert.deepEqual(value.counts, { test: 1, schemaTemplate: 1, goldenTemplate: 0, pool: 1 }); + assert.equal(value.databases.find((row) => row.datname === "fusion_test_one").technicalEligibility, "retain-in-use"); + assert.equal(value.databases.find((row) => row.datname === "fusion_schema_template_22_nonce").technicalEligibility, "retain-live-owner"); + for (const name of ["archive_live_fence_1", "ce_pipeline_pg_1", "fn-extension-1", "custom_prefix_1", "autotrade", "eclipxe"]) { + assert.equal(value.databases.find((row) => row.datname === name).classification, "unrelated"); + } + assert.equal(value.databases.filter((row) => row.datname === "fusion_pool_one").length, 1); +}); + +test("retains malformed body evidence while classifying valid rows and stale markers", () => { + const malformed = report("malformed.txt"); + assert.equal(malformed.counts.test, 1); + // `psql -qAt` emits true/false unless the documented query normalizes to t/f. + assert.equal(malformed.malformedLines.length, 3); + const unnormalizedOnly = fixture("post-state.txt") + .replace("# rows: 0", "# rows: 1") + .replace("# end", "fusion_schema_template_99_goldenrun|eclipxe|false|0\n# end"); + const unnormalizedReport = buildHygieneReport({ databaseCapture: unnormalizedOnly, markerCapture: zeroMarkers, livenessCapture: zeroLiveness }); + assert.equal(unnormalizedReport.counts.goldenTemplate, 0); + assert.equal(unnormalizedReport.clean, false); + const markers = report("post-state.txt", { markerCapture: fixture("markers-stale.txt") }); + assert.equal(markers.staleMarkerRows[0].name, "fusion_schema_template_39001_goldenestworkerspnnem8"); + assert.equal(markers.clean, false); +}); + +test("matches the harness template naming grammar", () => { + assert.equal(classifyDatabase("fusion_schema_template_39001_goldenestworkerspnnem8"), "golden-template"); + assert.equal(parseTemplateOwnerPid("fusion_schema_template_39001_goldenestworkerspnnem8"), 39001); + assert.equal(classifyDatabase("fusion_schema_template_22_nonce"), "schema-template"); + assert.equal(parseTemplateOwnerPid("fusion_schema_template_22_nonce"), 22); + assert.equal(parseTemplateOwnerPid("fusion_schema_template_not-a-pid"), null); + assert.equal(classifyReclaimability({ datname: "archive_live_fence_1", conns: 0 }, { liveness: new Map() }), "retain-unclassified"); +}); diff --git a/scripts/pg-cluster-hygiene-report.mjs b/scripts/pg-cluster-hygiene-report.mjs new file mode 100644 index 0000000000..6d062cc065 --- /dev/null +++ b/scripts/pg-cluster-hygiene-report.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +/* +FNXC:PgClusterHygiene 2026-08-19-20:14: +FN-9152 R3 needs machine-recorded PostgreSQL leftover counts: a bare `psql -qAt` +zero-row result is an empty file, so it cannot prove a clean host instead of a +failed capture. G1 therefore requires a complete provenance envelope. A supplied malformed marker +or liveness companion fails the entire report closed: ignoring it could call a cluster +clean without reading its marker table. The parser accepts harness-form `t`/`f`, so +capture queries must normalize PostgreSQL booleans. G2 keeps technical reclaimability +default-closed on recorded liveness, and this advisory parser never authorizes a drop: +G6 approval is human-authored outside this script. It never opens PostgreSQL, runs +tests, or spawns a child process. +*/ + +const BANNER = "# fusion-hygiene-capture v1"; +const KINDS = new Set(["databases", "markers", "liveness"]); +const QUERY_BY_KIND = new Map([ + ["databases", "leftover-v1"], + ["markers", "markers-v1"], + ["liveness", "liveness-v1"], +]); + +/** Parse a self-evidencing capture; absent or incomplete input is never a measured zero. */ +export function parseCaptureEnvelope(text, { expectedKind } = {}) { + if (text == null) return insufficient("missing"); + const source = String(text); + if (source.length === 0) return insufficient("empty"); + const lines = source.split(/\r?\n/); + while (lines.length > 0 && lines.at(-1) === "") lines.pop(); + if (lines[0] !== BANNER) return insufficient("no-banner"); + + const headers = new Map(); + let cursor = 1; + while (cursor < lines.length && /^# [a-z_]+:/.test(lines[cursor])) { + const match = /^# ([a-z_]+):\s*(.*)$/.exec(lines[cursor]); + if (!match || headers.has(match[1])) return insufficient("bad-header"); + headers.set(match[1], match[2]); + cursor += 1; + } + const kind = headers.get("kind"); + const cluster = headers.get("cluster"); + const capturedAt = headers.get("captured_at"); + const query = headers.get("query"); + const rowsValue = headers.get("rows"); + const declaredRowCount = rowsValue != null && /^\d+$/.test(rowsValue) ? Number(rowsValue) : null; + if (!KINDS.has(kind) || !isCluster(cluster) || !isTimestamp(capturedAt) || !query || declaredRowCount == null) return insufficient("bad-header"); + if (expectedKind && kind !== expectedKind) return insufficient("kind-mismatch", { kind, cluster, capturedAt, query, declaredRowCount }); + if (query !== QUERY_BY_KIND.get(kind)) return insufficient("bad-header", { kind, cluster, capturedAt, query, declaredRowCount }); + if (lines.at(-1) !== "# end") return insufficient("truncated", { kind, cluster, capturedAt, query, declaredRowCount }); + const bodyLines = lines.slice(cursor, -1).filter((line) => line !== ""); + if (declaredRowCount !== bodyLines.length) return insufficient("count-mismatch", { kind, cluster, capturedAt, query, declaredRowCount, bodyLines }); + return { status: "measured", kind, cluster, capturedAt, query, declaredRowCount, bodyLines, malformedLines: [], insufficientReason: null }; +} + +function insufficient(insufficientReason, details = {}) { + return { status: "insufficient-data", kind: details.kind ?? null, cluster: details.cluster ?? null, capturedAt: details.capturedAt ?? null, query: details.query ?? null, declaredRowCount: details.declaredRowCount ?? null, bodyLines: details.bodyLines ?? [], malformedLines: [], insufficientReason }; +} + +function isCluster(value) { + return typeof value === "string" && value.split("|").length === 3 && value.split("|").every(Boolean); +} + +function isTimestamp(value) { + return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value)); +} + +export function parseDatabaseRows(bodyLines = []) { + const rows = []; + const malformedLines = []; + for (const line of bodyLines) { + const [datname, owner, datistemplate, conns, ...extra] = String(line).split("|"); + const sessions = /^\d+$/.test(conns ?? "") ? Number(conns) : null; + if (extra.length || !datname || !owner || !["t", "f"].includes(datistemplate) || sessions == null) { + malformedLines.push(line); + } else { + rows.push({ datname, owner, datistemplate: datistemplate === "t", conns: sessions }); + } + } + return { rows, malformedLines }; +} + +export function classifyDatabase(datname) { + if (/^fusion_schema_template_\d+_golden[a-z0-9]*$/.test(datname)) return "golden-template"; + if (datname.startsWith("fusion_schema_template")) return "schema-template"; + if (datname.startsWith("fusion_test_")) return "test"; + if (datname.startsWith("fusion_pool_")) return "pool"; + return "unrelated"; +} + +/** Mirrors the harness's template pid parser, including its lowercase-alnum suffix rule. */ +export function parseTemplateOwnerPid(datname) { + const match = /^fusion_schema_template_(\d+)(?:_[a-z0-9]+)?$/.exec(datname); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) ? pid : null; +} + +export function parseLivenessRows(bodyLines = []) { + const liveness = new Map(); + const malformedLines = []; + for (const line of bodyLines) { + const [pid, verdict, ...extra] = String(line).split("|"); + if (extra.length || !/^\d+$/.test(pid ?? "") || !["alive", "dead"].includes(verdict)) malformedLines.push(line); + else liveness.set(Number(pid), verdict); + } + return { liveness, malformedLines }; +} + +export function classifyReclaimability(row, { liveness } = {}) { + if (row?.conns !== 0) return "retain-in-use"; + const pid = parseTemplateOwnerPid(row?.datname ?? ""); + if (pid == null) return "retain-unclassified"; + const verdict = liveness instanceof Map ? liveness.get(pid) : undefined; + if (verdict === "dead") return "reclaimable-dead-owner"; + if (verdict === "alive") return "retain-live-owner"; + return "retain-unclassified"; +} + +function parseMarkerRows(bodyLines = []) { + const rows = []; + const malformedLines = []; + for (const line of bodyLines) { + const [name, createdAt, ...extra] = String(line).split("|"); + if (extra.length || !name || !createdAt) malformedLines.push(line); + else rows.push({ name, createdAt }); + } + return { rows, malformedLines }; +} + +/* +FNXC:PgClusterHygiene 2026-08-20-01:41: +FN-9154 G1 requires all three complete captures from one cluster before the +advisory report can claim clean. Missing companions, cross-cluster envelopes, +and stale marker rows all fail closed so partial reconciliation cannot become +campaign-admission evidence. +*/ +export function buildHygieneReport({ databaseCapture, markerCapture, livenessCapture } = {}) { + const databases = parseCaptureEnvelope(databaseCapture, { expectedKind: "databases" }); + const markers = parseCaptureEnvelope(markerCapture, { expectedKind: "markers" }); + const livenessCaptureParsed = parseCaptureEnvelope(livenessCapture, { expectedKind: "liveness" }); + const captures = [databases, markers, livenessCaptureParsed]; + const incompleteCapture = captures.find((capture) => capture.status !== "measured"); + if (incompleteCapture) { + return { status: "insufficient-data", counts: emptyCounts(), clean: false, databases: [], staleMarkerRows: [], malformedLines: [], insufficientReason: incompleteCapture.insufficientReason }; + } + if (new Set(captures.map((capture) => capture.cluster)).size !== 1) { + return { status: "insufficient-data", counts: emptyCounts(), clean: false, databases: [], staleMarkerRows: [], malformedLines: [], insufficientReason: "cluster-mismatch" }; + } + const parsedDatabases = parseDatabaseRows(databases.bodyLines); + const parsedLiveness = parseLivenessRows(livenessCaptureParsed.bodyLines); + const uniqueRows = [...new Map(parsedDatabases.rows.map((row) => [row.datname, row])).values()]; + const rows = uniqueRows.map((row) => ({ ...row, classification: classifyDatabase(row.datname), technicalEligibility: classifyReclaimability(row, { liveness: parsedLiveness.liveness }) })); + const counts = emptyCounts(); + for (const row of rows) { + if (row.classification === "test") counts.test += 1; + else if (row.classification === "schema-template") counts.schemaTemplate += 1; + else if (row.classification === "golden-template") counts.goldenTemplate += 1; + else if (row.classification === "pool") counts.pool += 1; + } + const parsedMarkers = parseMarkerRows(markers.bodyLines); + const names = new Set(rows.map((row) => row.datname)); + const staleMarkerRows = parsedMarkers.rows.filter((row) => !names.has(row.name)); + return { + status: "measured", + counts, + // FNXC:PgClusterHygiene 2026-08-20-00:00: A populated row with an unexpected + // shape (notably PostgreSQL's un-normalized true/false boolean) cannot become + // clean evidence merely because it was excluded from the class counters. + clean: Object.values(counts).every((count) => count === 0) + && parsedDatabases.malformedLines.length === 0 + && parsedMarkers.malformedLines.length === 0 + && parsedLiveness.malformedLines.length === 0 + && staleMarkerRows.length === 0, + databases: rows, + staleMarkerRows, + malformedLines: [...parsedDatabases.malformedLines, ...parsedMarkers.malformedLines, ...parsedLiveness.malformedLines], + insufficientReason: null, + }; +} + +function emptyCounts() { + return { test: 0, schemaTemplate: 0, goldenTemplate: 0, pool: 0 }; +} + +function parseArgs(args) { + const result = { databases: undefined, markers: undefined, liveness: undefined, json: false }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--databases") result.databases = args[++index]; + else if (argument === "--markers") result.markers = args[++index]; + else if (argument === "--liveness") result.liveness = args[++index]; + else if (argument === "--json") result.json = true; + else throw new Error(`Unknown argument: ${argument}`); + } + if (!result.databases) throw new Error("Supply --databases "); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const args = parseArgs(process.argv.slice(2)); + const report = buildHygieneReport({ + databaseCapture: readFileSync(args.databases, "utf8"), + markerCapture: args.markers ? readFileSync(args.markers, "utf8") : undefined, + livenessCapture: args.liveness ? readFileSync(args.liveness, "utf8") : undefined, + }); + console.log(JSON.stringify(report, null, 2)); + if (!report.clean) process.exitCode = 1; +}