import { AXIS_DOCUMENTS, UPSTREAM_ROOTS } from "../core/constants/path.constants.ts"; import type { Finding, Remediation } from "../core/types/segment.types.ts"; import { type Reference, referencesIn } from "../core/matchers/reference.matcher.ts"; import type { RuleContext, RuleDeclaration, RuleResult } from "../core/types/rule.types.ts"; import { basename, dirname, resolve } from "node:path"; import { contentIsImmutable, surfacePrefix, surfaceRoot } from "../../config/surface.config.ts"; import { existsSync, readdirSync } from "node:fs"; import { BOARD_PATH } from "../core/constants/board.constants.ts"; import type { TaxonomyData } from "../core/types/taxonomy.types.ts"; import { cyclesIn } from "../core/analyzers/graph.analyzer.ts"; import { readDocument } from "../core/readers/document.reader.ts"; const directoriesIn = function directoriesIn(root: string): string[] { return readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); }; const rootsOf = function rootsOf(repoRoot: string, taxonomy: TaxonomyData): ReadonlySet { return new Set([ ...directoriesIn(repoRoot), ...directoriesIn(surfaceRoot()), ...taxonomy.concernFolders, ...Object.values(taxonomy.containers).flat(), ...Object.values(taxonomy.specialContainers).flat(), ]); }; const namesInTree = function namesInTree(target: string, roots: ReadonlySet, candidates: number): boolean { if (candidates > 0) { return true; } const slash = target.indexOf("/"); return slash !== -1 && roots.has(target.slice(0, slash)); }; const resolvesAgainstAncestor = function resolvesAgainstAncestor( path: string, target: string, existing: ReadonlySet, ): boolean { let directory = dirname(path); while (directory.length > 0 && directory !== ".") { if (existing.has(`${directory}/${target}`)) { return true; } const cut = directory.lastIndexOf("/"); if (cut === -1) { break; } directory = directory.slice(0, cut); } return existing.has(`${directory}/${target}`); }; const settledTarget = function settledTarget( context: RuleContext, target: string, existing: ReadonlySet, ): string | null { if (existing.has(target)) { return target; } if (existsSync(resolve(context.repoRoot, target))) { return target; } const prefix = surfacePrefix(); const prefixed = prefix.length === 0 ? target : `${prefix}/${target}`; return existsSync(resolve(context.repoRoot, prefixed)) ? prefixed : null; }; const recordEdge = function recordEdge(edges: Map>, from: string, to: string): void { if (from === to) { return; } if (!to.endsWith(".md")) { return; } const cited = edges.get(from) ?? new Set(); cited.add(to); edges.set(from, cited); }; const remediate = function remediate(reference: Reference, path: string, candidate: string | null): Remediation { if (candidate !== null) { return { action: "rename", decide: null, deterministic: true, from: reference.target, target: path, to: candidate, }; } return { action: "rename", decide: "the referenced file does not exist and no single file in the tree carries its basename — " + "locate the intended target, or drop the reference if what it pointed at is gone", deterministic: false, from: reference.target, target: path, to: null, }; }; interface ResolveScope { readonly context: RuleContext; readonly existing: ReadonlySet; readonly roots: ReadonlySet; readonly byBasename: ReadonlyMap; } interface ReferenceOutcome { readonly edge: string | null; readonly finding: Finding | null; } const NO_OUTCOME: ReferenceOutcome = { edge: null, finding: null }; const unresolvedFinding = function unresolvedFinding( path: string, reference: Reference, candidates: readonly string[], ): Finding { const candidate = candidates.length === 1 ? (candidates[0] ?? null) : null; return { actual: reference.target, expected: candidate, healed: false, line: reference.line, locus: reference.locus, path, remediation: remediate(reference, path, candidate), rule: "reference/unresolved", stack: [ { check: "scheme", resolved: "in-tree" }, { check: "rootRelative", resolved: "absent" }, { check: "documentRelative", resolved: "absent" }, { check: "basenameCandidates", resolved: String(candidates.length) }, ], }; }; const referenceOutcome = function referenceOutcome(path: string, reference: Reference, scope: ResolveScope): ReferenceOutcome { const settled = settledTarget(scope.context, reference.target, scope.existing); if (settled !== null) { return { edge: settled, finding: null }; } const candidates = scope.byBasename.get(basename(reference.target)) ?? []; const ignored = resolvesAgainstAncestor(path, reference.target, scope.existing) || !namesInTree(reference.target, scope.roots, candidates.length); return ignored ? NO_OUTCOME : { edge: null, finding: unresolvedFinding(path, reference, candidates) }; }; const groupByBasename = function groupByBasename(paths: readonly string[]): Map { const byBasename = new Map(); for (const path of paths) { byBasename.set(basename(path), [...(byBasename.get(basename(path)) ?? []), path]); } return byBasename; }; const reachableOf = function reachableOf(context: RuleContext): string[] { const axes = AXIS_DOCUMENTS.filter( (axis) => context.paths.includes(axis) || existsSync(resolve(context.repoRoot, axis)), ); const board = existsSync(resolve(context.repoRoot, BOARD_PATH)) ? [BOARD_PATH] : []; return [...new Set([...context.paths, ...axes, ...board])]; }; const cycleFinding = function cycleFinding(cycle: readonly string[], graphSize: number): Finding { const entry = cycle[0] ?? ""; return { actual: `${cycle.join(" > ")} > ${entry}`, expected: null, healed: false, line: 1, locus: cycle.join(" > "), path: entry, remediation: { action: "none", decide: "a closed citation loop has no entry point, so a reader following the graph to understand the subject is returned to where they started and no document in the loop is the one that states the thing. Break it by deciding which document OWNS the subject and making the others cite it one-way — never by deleting a citation to satisfy the count, because the loop is a statement that ownership was never settled", deterministic: false, from: entry, target: entry, to: null, }, rule: "reference/citationCycle", stack: [ { check: "graph", resolved: String(graphSize) }, { check: "cycleLength", resolved: String(cycle.length) }, ], }; }; const byName = function byName(left: string, right: string): number { return left.localeCompare(right, "en"); }; export const rule: RuleDeclaration = { check(context: RuleContext): RuleResult { const scope: ResolveScope = { byBasename: groupByBasename(context.paths), context, existing: new Set(context.paths), roots: rootsOf(context.repoRoot, context.taxonomy), }; const reachable = reachableOf(context); const owned = reachable.filter((path) => !UPSTREAM_ROOTS.some((root) => path.startsWith(root))); const skippedAsImmutableContent = owned.filter((path) => contentIsImmutable(path)); const walked = owned.filter((path) => !contentIsImmutable(path)); const reached = reachable.filter((path) => !skippedAsImmutableContent.includes(path)); const outcomes = walked.flatMap((path) => readDocument(path, context.read(path)) .segments.flatMap((segment) => referencesIn(segment)) .map((reference) => ({ path, ...referenceOutcome(path, reference, scope) })), ); const edges = new Map>(); for (const { edge, path } of outcomes) { if (edge !== null) { recordEdge(edges, path, edge); } } const cycles = cyclesIn(edges); const findings = [ ...outcomes.flatMap((outcome) => (outcome.finding === null ? [] : [outcome.finding])), ...cycles.map((cycle) => cycleFinding(cycle, edges.size)), ]; return { derivations: { citing: [...edges.keys()].toSorted(byName), cycles: cycles.map((cycle) => cycle.join(" > ")), reached: reached.toSorted(byName), skippedAsImmutableContent: skippedAsImmutableContent.toSorted(byName), }, findings, healed: [], }; }, extensions: [".md"], heals: false, invariant: "every in-tree reference in a governed document and on the coordination board resolves to a file that exists, and no set of documents cites itself in a closed loop", jurisdiction: "taxonomy", kinds: ["unresolved", "citationCycle"], reads: [...AXIS_DOCUMENTS, BOARD_PATH], readsTree: "a reference resolves against the whole tree rather than against the readable path set — it may " + "point at a binary, a generated artifact or a directory none of which the run hands to a rule — so " + "this rule reaches past the context by construction", stage: "content", };