import { BINDING_PATH, SLOT_CONSUMERS } from "../core/constants/binding.constants.ts"; import { type Resolution, enumeratesVocabulary, slotStates, slotsIn } from "../core/analyzers/binding.analyzer.ts"; import type { RuleContext, RuleDeclaration, RuleResult } from "../core/types/rule.types.ts"; import { renderBinding, writeBinding } from "../core/generators/binding.generator.ts"; import type { Finding } from "../core/types/segment.types.ts"; import { sameDocument } from "../core/normalizers/document.normalizer.ts"; import { underRoots } from "../core/filters/scope.filter.ts"; const finding = function finding(path: string, line: number, slot: string): Finding { return { actual: `${slot} is consumed but the adapter binding does not resolve it`, expected: `${slot} declared in ${BINDING_PATH}, or resolved ABSENT`, healed: false, line, locus: slot, path, remediation: { action: "declare", decide: `an abstract slot resolves against the adapter binding or resolves ABSENT, and an ABSENT slot means the branch using it does not run — which is declared rather than faked. Two artifacts can carry the repair and only one of them is this finding's subject: either ${path} stops naming ${slot}, or ${BINDING_PATH} binds it to its project value or declares it ABSENT. Deciding which is the judgement, so neither is offered as the target`, deterministic: false, from: slot, target: path, to: BINDING_PATH, }, rule: "binding/unresolvedSlot", stack: [ { check: "slot", resolved: slot }, { check: "binding", resolved: BINDING_PATH }, { check: "resolution", resolved: "absent" }, ], }; }; const misSectioned = function misSectioned(path: string, line: number, slot: string, actual: string): Finding { return { actual: `${slot} names a section that does not declare it, and ${actual} does`, expected: actual, healed: false, line, locus: slot, path, remediation: { action: "rename", decide: "", deterministic: true, from: slot, target: path, to: actual }, rule: "binding/slotInWrongSection", stack: [ { check: "slot", resolved: slot }, { check: "declared", resolved: actual }, ], }; }; const unhonoured = function unhonoured(path: string, line: number, slot: string, state: Resolution): Finding { return { actual: `${slot} is consumed as though it resolved, and the adapter resolves it ${state}`, expected: `the consuming line names ${state}, so the branch reading ${slot} does not run`, healed: false, line, locus: slot, path, remediation: { action: "declare", decide: `a slot has three resolution states and only one of them lets its branch run. The adapter is checked for whether a slot is DECLARED; nothing checks whether the CONSUMER honors what it declares, so a spec reading a non-resolving slot as though it resolved passes every existing check — which makes a gate satisfiable only by fabricating the evidence it demands. Two repairs are available and choosing between them is the judgement: ${path} states the ${state} resolution at the consuming line and does not run that branch, or it stops naming ${slot} at all. ABSENT means this deployment has no analogue and the branch is skipped; DEFERRED means the branch is BLOCKED rather than skipped, and collapsing the second into the first answers a different question in the right shape`, deterministic: false, from: slot, target: path, to: state, }, rule: "binding/stateNotHonoured", stack: [ { check: "slot", resolved: slot }, { check: "binding", resolved: BINDING_PATH }, { check: "resolution", resolved: state }, ], }; }; const drifted = function drifted(path: string): Finding { return { actual: "the adapter binding on disk differs from the configuration it is rendered from", expected: "the rendered binding", healed: true, line: 0, locus: "adapter binding", path, remediation: { action: "delete", decide: "", deterministic: true, from: path, target: path, to: null }, rule: "binding/bindingDrift", stack: [ { check: "source", resolved: "the surface configuration" }, { check: "rendered", resolved: "differs from the document on disk" }, ], }; }; interface SlotKey { readonly namespace: string; readonly key: string; } const slotKey = function slotKey(name: string): SlotKey | null { const body = name.slice(1, -1); const dot = body.indexOf("."); return dot <= 0 ? null : { key: body.slice(dot + 1), namespace: body.slice(0, dot) }; }; interface Declared { readonly names: ReadonlySet; readonly namespaces: ReadonlySet; readonly byKey: ReadonlyMap; readonly states: ReadonlyMap; } const declaredIn = function declaredIn(rendered: string): Declared { const names = slotsIn(rendered).map((slot) => slot.name); const keyed = names.flatMap((name) => { const parsed = slotKey(name); return parsed === null ? [] : [{ name, ...parsed }]; }); return { byKey: new Map(keyed.map((entry) => [entry.key, entry.name])), names: new Set(names), namespaces: new Set(keyed.map((entry) => entry.namespace)), states: slotStates(rendered), }; }; const blocksOf = function blocksOf(lines: readonly string[]): string[] { const blocks: string[] = []; let held: string[] = []; for (const line of [...lines, ""]) { if (line.trim().length > 0) { held.push(line); } else { blocks.push(held.join("\n")); held = []; } } return blocks; }; const honoredIn = function honoredIn(lines: readonly string[], states: ReadonlyMap): Set { return new Set( blocksOf(lines).flatMap((text) => slotsIn(text) .filter((use) => { const state = states.get(use.name); return state !== undefined && text.includes(state); }) .map((use) => use.name), ), ); }; type Slot = ReturnType[number]; const firstOf = function firstOf(slots: readonly Slot[]): Set { return new Set(slots.filter((slot, index) => slots.findIndex((other) => other.name === slot.name) === index)); }; const undeclaredFinding = function undeclaredFinding(path: string, slot: Slot, declared: Declared): Finding[] { const parsed = slotKey(slot.name); if (declared.names.has(slot.name) || parsed === null || !declared.namespaces.has(parsed.namespace)) { return []; } const elsewhere = declared.byKey.get(parsed.key); return [ elsewhere === undefined ? finding(path, slot.line, slot.name) : misSectioned(path, slot.line, slot.name, elsewhere), ]; }; const consumerFindings = function consumerFindings(path: string, source: string, declared: Declared): Finding[] { const lines = source.split("\n"); const honored = honoredIn(lines, declared.states); const slots = slotsIn(source); const firstSeen = firstOf(slots); const firstUnmentioned = firstOf(slots.filter((slot) => !enumeratesVocabulary(lines[slot.line - 1] ?? ""))); const unstated = (slot: Slot): Finding[] => { const state = declared.states.get(slot.name); const open = state !== undefined && state !== "RESOLVED" && !honored.has(slot.name); return open && firstUnmentioned.has(slot) ? [unhonoured(path, slot.line, slot.name, state)] : []; }; return slots.flatMap((slot) => [ ...unstated(slot), ...(firstSeen.has(slot) ? undeclaredFinding(path, slot, declared) : []), ]); }; const healBinding = function healBinding(context: RuleContext, rendered: string, fix: boolean): string[] { const onDisk = context.exists(BINDING_PATH) ? context.read(BINDING_PATH) : null; const drift = onDisk === null || !sameDocument(onDisk, rendered); if (!drift || !fix) { return []; } writeBinding(context.repoRoot, rendered); return [BINDING_PATH]; }; export const rule: RuleDeclaration = { check(context: RuleContext, fix: boolean): RuleResult { const rendered = renderBinding(); const healed = healBinding(context, rendered, fix); const drift = healed.length > 0 ? [drifted(BINDING_PATH)] : []; if (!context.paths.includes(BINDING_PATH)) { return { derivations: { binding: "rendered" }, findings: drift, healed }; } const declared = declaredIn(rendered); const scoped = underRoots(context.paths, SLOT_CONSUMERS); const findings = [ ...scoped .filter((path) => path !== BINDING_PATH) .flatMap((path) => consumerFindings(path, context.read(path), declared)), ...drift, ]; const names = [...declared.names].toSorted((left, right) => left.localeCompare(right, "en")); return { derivations: { consumers: scoped.length, declared: names }, findings, healed }; }, extensions: [".md"], heals: true, invariant: "every abstract slot a consumer names resolves through the adapter binding, and the binding is rendered from the configuration rather than authored beside it", jurisdiction: "taxonomy", kinds: ["unresolvedSlot", "slotInWrongSection", "stateNotHonoured", "bindingDrift"], reads: [BINDING_PATH], stage: "content", };