Extracts the AC3 revisit-trigger predicate into a plain .mjs module both the Node-run capture script and the TypeScript validator import directly. Removes the line-for-line duplicate (and its "keep both in sync" comment) that Felix + Markus flagged in cycle-3 review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
215 lines
7.1 KiB
JavaScript
215 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Local-only. Never invoked from CI. Re-run intentionally; commit the
|
|
// resulting JSON in one atomic commit.
|
|
//
|
|
// Captures the current /api/network response into the canonical fixture used
|
|
// by buildLayout.test.ts. Asserts a minimum shape so a silently-empty backend
|
|
// can't write a vacuous fixture.
|
|
|
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
import { findAc3Candidates } from '../src/lib/person/genealogy/__fixtures__/findAc3Candidates.mjs';
|
|
|
|
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
|
const EMAIL = process.env.CAPTURE_EMAIL;
|
|
const PASSWORD = process.env.CAPTURE_PASSWORD;
|
|
|
|
// Preflight guards: this script writes the canonical Stammbaum fixture from
|
|
// a *running* backend with admin-shaped credentials. Two slips would be
|
|
// silent disasters — running with default creds against staging/prod, or
|
|
// running with a typo'd BACKEND_URL that happens to resolve. Refuse both
|
|
// before sending a single byte.
|
|
preflight();
|
|
|
|
function preflight() {
|
|
const failures = [];
|
|
if (!EMAIL) {
|
|
failures.push('CAPTURE_EMAIL must be set explicitly (no default).');
|
|
}
|
|
if (!PASSWORD) {
|
|
failures.push('CAPTURE_PASSWORD must be set explicitly (no default).');
|
|
}
|
|
if (!isLocalhost(BACKEND_URL)) {
|
|
failures.push(
|
|
`BACKEND_URL must point at localhost / 127.0.0.1 (got: ${BACKEND_URL}). ` +
|
|
'This script is local-only.'
|
|
);
|
|
}
|
|
if (failures.length > 0) {
|
|
console.error('Preflight failed:');
|
|
for (const f of failures) console.error(` - ${f}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
function isLocalhost(url) {
|
|
try {
|
|
const host = new URL(url).hostname;
|
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`;
|
|
|
|
// Sanity floors — calibrated against the canonical dataset (May 2026: 62
|
|
// nodes, 5 generations, 28 SPOUSE_OF edges, 1 multi-spouse person). The point
|
|
// is catching a silently empty backend or a structurally-regressed snapshot,
|
|
// not strict size validation; raise these only if the canonical graph grows
|
|
// substantially. The matching gates in
|
|
// src/lib/person/genealogy/__fixtures__/validateFixture.ts cover unit-test
|
|
// coverage; the floors are intentionally duplicated as numeric constants
|
|
// (the AC3 revisit predicate, by contrast, now lives in one shared module).
|
|
const MIN_NODES = 50;
|
|
const MIN_GENERATIONS = 5;
|
|
const MIN_SPOUSE_OF_EDGES = 1;
|
|
const MIN_MULTI_SPOUSE_PERSONS = 1;
|
|
|
|
function parseSetCookies(headers) {
|
|
const out = new Map();
|
|
const raw = headers.getSetCookie?.() ?? [];
|
|
for (const line of raw) {
|
|
const [pair] = line.split(';');
|
|
const eq = pair.indexOf('=');
|
|
if (eq < 0) continue;
|
|
out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serialiseCookies(jar) {
|
|
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
}
|
|
|
|
async function login(jar) {
|
|
const xsrf = randomUUID();
|
|
jar.set('XSRF-TOKEN', xsrf);
|
|
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-XSRF-TOKEN': xsrf,
|
|
Cookie: serialiseCookies(jar)
|
|
},
|
|
body: JSON.stringify({ email: EMAIL, password: PASSWORD })
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Login failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
for (const [k, v] of parseSetCookies(res.headers)) jar.set(k, v);
|
|
}
|
|
|
|
async function fetchNetwork(jar) {
|
|
const res = await fetch(`${BACKEND_URL}/api/network`, {
|
|
headers: { Cookie: serialiseCookies(jar) }
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`GET /api/network failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function validate(network) {
|
|
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
|
|
const edges = Array.isArray(network.edges) ? network.edges : [];
|
|
const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF');
|
|
const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null));
|
|
const multiSpousePersons = countMultiSpousePersons(spouseEdges);
|
|
|
|
const failures = [];
|
|
if (nodes.length < MIN_NODES) {
|
|
failures.push(`expected >= ${MIN_NODES} nodes, got ${nodes.length}`);
|
|
}
|
|
if (generations.size < MIN_GENERATIONS) {
|
|
failures.push(`expected >= ${MIN_GENERATIONS} distinct generations, got ${generations.size}`);
|
|
}
|
|
if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) {
|
|
failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`);
|
|
}
|
|
if (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) {
|
|
failures.push(
|
|
`expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` +
|
|
`got ${multiSpousePersons}`
|
|
);
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`);
|
|
}
|
|
|
|
return {
|
|
nodes: nodes.length,
|
|
edges: edges.length,
|
|
spouseEdges: spouseEdges.length,
|
|
generations: [...generations].sort((a, b) => a - b),
|
|
multiSpousePersons
|
|
};
|
|
}
|
|
|
|
function countMultiSpousePersons(spouseEdges) {
|
|
const partners = new Map();
|
|
for (const e of spouseEdges) {
|
|
if (!e.personId || !e.relatedPersonId) continue;
|
|
mapAddToSet(partners, e.personId, e.relatedPersonId);
|
|
mapAddToSet(partners, e.relatedPersonId, e.personId);
|
|
}
|
|
let count = 0;
|
|
for (const set of partners.values()) {
|
|
if (set.size >= 2) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function mapAddToSet(map, key, value) {
|
|
const s = map.get(key);
|
|
if (s) s.add(value);
|
|
else map.set(key, new Set([value]));
|
|
}
|
|
|
|
function writeFixture(network) {
|
|
mkdirSync(dirname(FIXTURE_PATH), { recursive: true });
|
|
writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8');
|
|
}
|
|
|
|
async function main() {
|
|
const jar = new Map();
|
|
console.error(`Capturing /api/network from ${BACKEND_URL} as ${EMAIL} ...`);
|
|
await login(jar);
|
|
const network = await fetchNetwork(jar);
|
|
const stats = validate(network);
|
|
writeFixture(network);
|
|
console.error(
|
|
`Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n multi-spouse persons: ${stats.multiSpousePersons}\n generations: G${stats.generations.join(', G')}`
|
|
);
|
|
warnIfAc3Reachable(network);
|
|
}
|
|
|
|
function warnIfAc3Reachable(network) {
|
|
const candidates = findAc3Candidates(network);
|
|
if (candidates.length === 0) return;
|
|
// Soft, non-blocking. The ADR-026 deferral decision says: revisit the
|
|
// dagre adoption choice as soon as the canonical fixture starts to
|
|
// contain a parented unseeded spouse. This warning is that signal —
|
|
// fail-open so a recapture still writes the fixture (the human needs
|
|
// to see the data to decide), but loud enough that nobody can miss it.
|
|
console.error('');
|
|
console.error(
|
|
`⚠ AC3 revisit trigger reached (ADR-026): ${candidates.length} unseeded ` +
|
|
'person(s) with a seeded parent AND a SPOUSE_OF edge in the captured graph.'
|
|
);
|
|
console.error(` Candidates: ${candidates.join(', ')}`);
|
|
console.error(
|
|
' Action: re-evaluate the dagre adoption deferral. See ADR-026 §Notes ' +
|
|
'for the assessor + cadence.'
|
|
);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err.message);
|
|
process.exit(1);
|
|
});
|