refactor(stammbaum): single source of truth for findAc3Candidates (#361)

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>
This commit is contained in:
Marcel
2026-05-28 21:15:02 +02:00
parent 2c18cb8b0d
commit 585f28cd23
3 changed files with 66 additions and 83 deletions

View File

@@ -0,0 +1,55 @@
// AC3 = "unseeded loose spouse whose parents are in the graph" (ADR-026).
// Returns the IDs of every captured person matching the predicate. A non-
// empty result is the documented revisit trigger for the dagre deferral
// decision: the canonical-fixture unit suite asserts an empty result against
// the committed JSON, and `frontend/scripts/capture-network-fixture.mjs`
// soft-warns to stderr (does NOT fail capture) so a human-in-the-loop notices
// when the layout branch becomes reachable.
//
// Lives as a plain ESM .mjs module so both the Node-run capture script and
// the TypeScript validator/test suite can import it without a build step —
// single source of truth for the predicate.
/**
* @typedef {{ relationType?: string, personId?: string, relatedPersonId?: string }} Edge
* @typedef {{ id?: string, generation?: number | null }} Node
* @typedef {{ nodes?: Node[], edges?: Edge[] }} NetworkShape
*/
/**
* @param {NetworkShape} network
* @returns {string[]}
*/
export function findAc3Candidates(network) {
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
const edges = Array.isArray(network.edges) ? network.edges : [];
const generationById = new Map();
for (const n of nodes) {
if (n.id) generationById.set(n.id, n.generation);
}
const hasSpouseEdge = new Set();
const seededParentByChild = new Map();
for (const e of edges) {
if (!e.personId || !e.relatedPersonId) continue;
if (e.relationType === 'SPOUSE_OF') {
hasSpouseEdge.add(e.personId);
hasSpouseEdge.add(e.relatedPersonId);
continue;
}
if (e.relationType === 'PARENT_OF') {
const parentGen = generationById.get(e.personId);
if (parentGen != null) seededParentByChild.set(e.relatedPersonId, true);
}
}
const matches = [];
for (const n of nodes) {
if (!n.id) continue;
if (n.generation != null) continue;
if (!seededParentByChild.get(n.id)) continue;
if (!hasSpouseEdge.has(n.id)) continue;
matches.push(n.id);
}
return matches;
}

View File

@@ -17,45 +17,11 @@ type Edge = { relationType?: string; personId?: string; relatedPersonId?: string
type Node = { id?: string; generation?: number | null };
export type NetworkShape = { nodes?: Node[]; edges?: Edge[] };
// AC3 = "unseeded loose spouse whose parents are in the graph" (ADR-026).
// Returns the IDs of every captured person matching the predicate. A non-
// empty result is the documented revisit trigger for the dagre deferral
// decision — the capture script logs a soft-warn (does NOT fail) on every
// recapture so a human-in-the-loop notices when the layout branch becomes
// reachable.
export function findAc3Candidates(network: NetworkShape): string[] {
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
const edges = Array.isArray(network.edges) ? network.edges : [];
const generationById = new Map<string, number | null | undefined>();
for (const n of nodes) {
if (n.id) generationById.set(n.id, n.generation);
}
const hasSpouseEdge = new Set<string>();
const seededParentByChild = new Map<string, boolean>();
for (const e of edges) {
if (!e.personId || !e.relatedPersonId) continue;
if (e.relationType === 'SPOUSE_OF') {
hasSpouseEdge.add(e.personId);
hasSpouseEdge.add(e.relatedPersonId);
continue;
}
if (e.relationType === 'PARENT_OF') {
const parentGen = generationById.get(e.personId);
if (parentGen != null) seededParentByChild.set(e.relatedPersonId, true);
}
}
const matches: string[] = [];
for (const n of nodes) {
if (!n.id) continue;
if (n.generation != null) continue;
if (!seededParentByChild.get(n.id)) continue;
if (!hasSpouseEdge.has(n.id)) continue;
matches.push(n.id);
}
return matches;
}
// The AC3 revisit-trigger predicate lives in a plain .mjs module so the
// Node-run capture script and this TypeScript validator share one
// implementation. Re-exported here so consumers of the fixture-directory
// barrel keep their existing import surface.
export { findAc3Candidates } from './findAc3Candidates.mjs';
export type FixtureStats = {
nodes: number;