import { CHECK_ABSORPTION, CHECK_DIRECTIVES, CHECK_DURABLE, CHECK_INHERITANCE, CHECK_NEEDS, CHECK_SIGNATURES, CHECK_SUCCESSOR, DIRECTIVES_HOLD, INHERITANCE_HOLDS, NOBODY_TAKING_PART, NO_SUCCESSOR, SIGNATURES_HOLD, absorptionDuplicated, absorptionHolds, absorptionNoCloses, absorptionOpen, absorptionUndistributed, deferredUnanswered, deferredUndeclared, durableHold, durableUndeclared, durableUnresolved, needsHold, needsMissing, successorArrived, successorMissing, successorScheduled, unarrivedClauses, undischarged, unoriginated as unoriginatedMessage, unsigned, } from "../strings/converge.strings.ts"; import { activeSeats, boardRecords } from "../analyzers/board.analyzer.ts"; import { basename, resolve } from "node:path"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { BLOCKING_SUFFIX } from "../constants/blocking.constants.ts"; import { fencedFlags } from "../predicates/fence.predicate.ts"; import { surfacePath } from "../../../config/surface.config.ts"; const SIGN_OFF_ABSENT = "—"; const NEEDS_FIELD = "Needs"; const DURABLE_FIELD = "Durable"; const INHERITED_HEADING = "INHERITED"; const PLACEHOLDER = "<"; const LIST_ITEM = "- "; const AGENT_LABEL = "Agent "; const SIGN_OFF_COLON_LIMIT = 4; const SIGN_OFF_LETTER_LIMIT = 2; const EMPHASIS = ["*", "`", "_"]; const isDigit = function isDigit(character: string): boolean { return character >= "0" && character <= "9"; }; const isHeadingCharacter = function isHeadingCharacter(character: string): boolean { return (character >= "a" && character <= "z") || isDigit(character) || character === "-"; }; export const declaredHeading = function declaredHeading(field: string): string { return field.trim(); }; export const headingIsWhole = function headingIsWhole(field: string): boolean { const trimmed = field.trim(); for (const character of trimmed) { if (!isHeadingCharacter(character)) { return false; } } return trimmed.length > 0; }; export const CONVERGENCE_STEPS = [ "needs", "signatures", "durable", "directives", "absorption", "inheritance", "successor", ] as const; export const ARCHIVE_STEP = "archive"; export const DESTRUCTIVE_STEPS: readonly string[] = [...CONVERGENCE_STEPS, ARCHIVE_STEP]; export type ConvergenceStep = (typeof CONVERGENCE_STEPS)[number]; export interface Edge { readonly step: string; readonly edge: string; readonly holds: boolean; readonly detail: string; } const SUCCESSOR_FIELD = "SUCCESSOR:"; const DEFERRED_HEADING = "DEFERRED"; const DIRECTIVES_HEADING = "DIRECTIVES"; export const declaredSuccessor = function declaredSuccessor(venue: string): string { const fenced = fencedFlags(venue); const line = venue.split("\n").find((text, index) => fenced[index] !== true && text.trim().startsWith(SUCCESSOR_FIELD)); const value = line === undefined ? "" : line.trim().slice(SUCCESSOR_FIELD.length).trim(); return value.startsWith(PLACEHOLDER) ? "" : value; }; const CLAUSE_ARROW = "→"; interface DeferredSection { readonly declared: boolean; readonly answered: boolean; readonly clauses: readonly string[]; } interface ClauseLine { readonly clause: string; readonly receiver: string; } interface ClauseSection { readonly declared: boolean; readonly answered: boolean; readonly lines: readonly ClauseLine[]; } export interface StrandedDeferral { readonly clause: string; readonly receiver: string; } const receiverOf = function receiverOf(line: string): string { const arrow = line.indexOf(CLAUSE_ARROW); if (arrow === -1) { return ""; } const rest = line.slice(arrow + CLAUSE_ARROW.length).trim(); const comma = rest.indexOf(","); return (comma === -1 ? rest : rest.slice(0, comma)).trim(); }; const unemphasised = function unemphasised(text: string): string { let held = text; for (const mark of EMPHASIS) { held = held.replaceAll(mark, ""); } return held.trim(); }; const BANNER = "═"; const isSectionBoundary = function isSectionBoundary(trimmed: string): boolean { return trimmed.startsWith("#") || trimmed.startsWith(BANNER); }; export const DEFERRED_SECTION = DEFERRED_HEADING; export const DEFERRAL_ARROW = CLAUSE_ARROW; export const sectionBound = function sectionBound(venue: string, heading: string): { from: number; to: number } | null { const lines = venue.split("\n"); const boundaries = [...lines.keys()].filter((index) => isSectionBoundary((lines[index] ?? "").trim())); const from = boundaries.find((index) => (lines[index] ?? "").includes(heading)); if (from === undefined) { return null; } return { from, to: boundaries.find((index) => index > from) ?? lines.length }; }; const clauseOf = function clauseOf(trimmed: string): string { const arrow = trimmed.indexOf(CLAUSE_ARROW); return unemphasised(arrow === -1 ? trimmed.slice(LIST_ITEM.length) : trimmed.slice(LIST_ITEM.length, arrow)); }; const isStatedClause = function isStatedClause(clause: string): boolean { return clause.length > 0 && !clause.startsWith(PLACEHOLDER) && clause !== SIGN_OFF_ABSENT; }; const opensSection = function opensSection(trimmed: string, heading: string): boolean { return isSectionBoundary(trimmed) && trimmed.includes(heading); }; const insideFlags = function insideFlags(lines: readonly string[], heading: string): boolean[] { const out: boolean[] = []; let inside = false; for (const trimmed of lines) { out.push(inside); inside = opensSection(trimmed, heading) || (inside && !isSectionBoundary(trimmed)); } return out; }; const clauseSection = function clauseSection(venue: string, heading: string): ClauseSection { const lines = venue.split("\n").map((line) => line.trim()); const inside = insideFlags(lines, heading); const items = lines.filter( (trimmed, index) => inside[index] === true && !isSectionBoundary(trimmed) && trimmed.startsWith(LIST_ITEM), ); return { answered: items.some((trimmed) => { const clause = clauseOf(trimmed); return isStatedClause(clause) || clause === SIGN_OFF_ABSENT; }), declared: lines.some((trimmed) => opensSection(trimmed, heading)), lines: items.flatMap((trimmed) => { const clause = clauseOf(trimmed); return isStatedClause(clause) ? [{ clause, receiver: receiverOf(trimmed) }] : []; }), }; }; const deferredClauses = function deferredClauses(venue: string): DeferredSection { const section = clauseSection(venue, DEFERRED_HEADING); return { answered: section.answered, clauses: section.lines.map((entry) => entry.clause), declared: section.declared, }; }; export const clauseLines = function clauseLines(venue: string, heading: string): readonly ClauseLine[] { return clauseSection(venue, heading).lines; }; export const DISTRIBUTES_FIELD = "DISTRIBUTES:"; const CLOSES_FIELD = "CLOSES:"; const OPEN_TASK = "- [ ]"; export interface Absorption { readonly checklist: string; readonly open: readonly string[]; readonly closes: string; readonly declaring: readonly string[]; } interface PlannedSurface { readonly entry: string; readonly declares: boolean; readonly closes: string; readonly open: readonly string[]; } const isAllDigits = function isAllDigits(text: string): boolean { for (const character of text) { if (!isDigit(character)) { return false; } } return text.length > 0; }; const isTaskId = function isTaskId(token: string): boolean { return token.includes(".") && isAllDigits(token.replaceAll(".", "")); }; const taskIdOf = function taskIdOf(trimmed: string): string { return trimmed.slice(OPEN_TASK.length).trim().split(" ")[0] ?? ""; }; const plannedSurface = function plannedSurface(planning: string, entry: string, venueName: string): PlannedSurface { const lines = readFileSync(resolve(planning, entry), "utf8") .split("\n") .map((line) => line.trim()); const closesLine = lines.findLast((line) => line.startsWith(CLOSES_FIELD)); return { closes: closesLine === undefined ? "" : closesLine.slice(CLOSES_FIELD.length).trim(), declares: lines.some((line) => line.startsWith(DISTRIBUTES_FIELD) && line.includes(venueName)), entry, open: lines .filter((line) => line.startsWith(OPEN_TASK)) .map(taskIdOf) .filter(isTaskId), }; }; export const absorptionState = function absorptionState(repoRoot: string, venueName: string): Absorption | null { const planning = resolve(repoRoot, surfacePath("planning")); if (!existsSync(planning)) { return null; } const declaring = readdirSync(planning) .map((entry) => plannedSurface(planning, entry, venueName)) .filter((surface) => surface.declares); const [first] = declaring; if (first === undefined) { return null; } return { checklist: first.entry, closes: first.closes, declaring: declaring.map((surface) => surface.entry), open: first.open.filter((id) => id !== first.closes), }; }; export interface Distribution { readonly path: string; readonly line: number; readonly declares: string; } export const declaredDistributions = function declaredDistributions( surfaces: readonly string[], read: (path: string) => string, ): Distribution[] { return surfaces.flatMap((path) => read(path) .split("\n") .flatMap((line, index) => { const trimmed = line.trim(); const declares = trimmed.startsWith(DISTRIBUTES_FIELD) ? trimmed.slice(DISTRIBUTES_FIELD.length).trim() : ""; return declares.length === 0 || declares.startsWith(PLACEHOLDER) ? [] : [{ declares, line: index + 1, path }]; }), ); }; const AWAITING_LINE = "READ AND AWAITING:"; export const hasReadMark = function hasReadMark(venue: string): boolean { return venue .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith(AWAITING_LINE)) .some((line) => line .slice(AWAITING_LINE.length) .split(" ") .map((token) => token.trim()) .some((letter) => letter.length > 0 && letter !== SIGN_OFF_ABSENT), ); }; export const venueInvariant = function venueInvariant(name: string): string { const base = basename(name); const stem = base.endsWith(BLOCKING_SUFFIX) ? base.slice(0, base.length - BLOCKING_SUFFIX.length) : base; const dot = stem.lastIndexOf("."); return dot === -1 ? stem : stem.slice(0, dot); }; export const addressesSuccessor = function addressesSuccessor(receiver: string, successorName: string): boolean { return receiver.length > 0 && successorName.length > 0 && venueInvariant(successorName) === receiver; }; export const unarrivedDeferrals = function unarrivedDeferrals( venue: string, successorText: string, successorName = "", ): readonly ClauseLine[] { return clauseLines(venue, DEFERRED_HEADING) .filter((entry) => addressesSuccessor(entry.receiver, successorName)) .filter((entry) => !successorText.includes(entry.clause)); }; export const unoriginatedInheritance = function unoriginatedInheritance(venue: string, successorText: string): string[] { const deferred = clauseLines(venue, DEFERRED_HEADING).map((entry) => entry.clause); return clauseLines(successorText, INHERITED_HEADING) .map((entry) => entry.clause) .filter((clause) => !deferred.some((name) => name.includes(clause) || clause.includes(name))); }; export const receiverResolves = function receiverResolves( receiver: string, board: string, index: string, venueNames: readonly string[], planned: readonly string[] = [], ): boolean { if (receiver.length === 0) { return false; } if (new Set(activeSeats(board, index)).has(receiver)) { return true; } return venueNames.some((name) => venueInvariant(name) === receiver) || planned.includes(receiver); }; export const strandedDeferrals = function strandedDeferrals( venue: string, board: string, index: string, venueNames: readonly string[], planned: readonly string[] = [], ): StrandedDeferral[] { return clauseLines(venue, DEFERRED_HEADING) .filter((entry) => entry.receiver === entry.clause || !receiverResolves(entry.receiver, board, index, venueNames, planned)) .map((entry) => ({ clause: entry.clause, receiver: entry.receiver })); }; export const SIGN_OFF_BANNER = "═══════════════════ SIGN-OFF"; export const signOffSection = function signOffSection(venue: string): string[] { const lines = venue.split("\n"); const opens = lines.findIndex((line) => line.startsWith(SIGN_OFF_BANNER)); return opens === -1 ? [] : lines.slice(opens + 1); }; const isSignOffLetter = function isSignOffLetter(letter: string): boolean { const first = letter.charAt(0); return letter.length > 0 && letter.length <= SIGN_OFF_LETTER_LIMIT && first >= "A" && first <= "Z"; }; const signOffEntry = function signOffEntry(line: string): [string, string] | null { const colon = line.indexOf(":"); const letter = colon <= 0 || colon > SIGN_OFF_COLON_LIMIT ? "" : line.slice(0, colon).trim(); return isSignOffLetter(letter) ? [letter, line.slice(colon + 1).trim()] : null; }; const signOffLines = function signOffLines(venue: string): Map { const entries = signOffSection(venue) .map(signOffEntry) .filter((entry): entry is [string, string] => entry !== null); return new Map(entries.toReversed()); }; export const venueRecords = function venueRecords(venue: string): Map> { return new Map( boardRecords(venue) .filter((record) => record.kind === "agent") .map((record): [string, ReadonlyMap] => [record.label.slice(AGENT_LABEL.length).trim(), record.fields]), ); }; const successorOf = function successorOf(target: string): string { const stem = target.slice(0, target.length - BLOCKING_SUFFIX.length); const dot = stem.lastIndexOf("."); const ordinal = dot === -1 ? Number.NaN : Number(stem.slice(dot + 1)); return Number.isFinite(ordinal) ? `${String(ordinal + 1)}${BLOCKING_SUFFIX}` : ""; }; type Records = ReadonlyMap>; interface SuccessorView { readonly declared: string; readonly successor: string; readonly successorText: string; readonly deferred: DeferredSection; readonly unarrived: readonly string[]; readonly elsewhere: readonly string[]; readonly scheduled: boolean; } const fieldOf = function fieldOf(records: Records, letter: string, field: string): string { return (records.get(letter)?.get(field) ?? "").trim(); }; const needsEdge = function needsEdge(letters: readonly string[], uncovened: readonly string[], records: Records): Edge { const unstated = letters.filter((letter) => fieldOf(records, letter, NEEDS_FIELD).length === 0); const vacuous = letters.length === 0; const holds = !vacuous && unstated.length === 0; const failing = vacuous ? NOBODY_TAKING_PART : needsMissing(unstated); return { detail: holds ? needsHold(letters.length, uncovened) : failing, edge: CHECK_NEEDS, holds, step: "needs" }; }; const signaturesEdge = function signaturesEdge(letters: readonly string[], venue: string): Edge { const signatures = signOffLines(venue); const outstanding = letters.filter((letter) => { const signed = (signatures.get(letter) ?? "").trim(); return signed.length === 0 || signed === SIGN_OFF_ABSENT; }); const vacuous = letters.length === 0; const holds = !vacuous && outstanding.length === 0; const failing = vacuous ? NOBODY_TAKING_PART : unsigned(outstanding); return { detail: holds ? SIGNATURES_HOLD : failing, edge: CHECK_SIGNATURES, holds, step: "signatures" }; }; const durableDeclared = function durableDeclared(records: Records, letter: string): boolean { return records.get(letter)?.has(DURABLE_FIELD) === true && fieldOf(records, letter, DURABLE_FIELD).length > 0; }; const durableFailure = function durableFailure(undeclared: readonly string[], unresolved: readonly string[]): string { return [ undeclared.length === 0 ? "" : durableUndeclared(undeclared), unresolved.length === 0 ? "" : durableUnresolved(unresolved), ] .filter((part) => part.length > 0) .join("; "); }; const durableEdge = function durableEdge(letters: readonly string[], records: Records, archive: string): Edge { const undeclared = letters.filter((letter) => !durableDeclared(records, letter)); const stated = letters.filter((letter) => durableDeclared(records, letter)); const headingOf = (letter: string): string => declaredHeading(fieldOf(records, letter, DURABLE_FIELD)); const headings = stated.map((letter) => (headingOf(letter) === SIGN_OFF_ABSENT ? `${letter} → none` : headingOf(letter))); const unresolved = stated .filter((letter) => headingOf(letter) !== SIGN_OFF_ABSENT) .filter((letter) => !headingIsWhole(headingOf(letter)) || !archive.includes(headingOf(letter))) .map((letter) => `${letter} → ${headingOf(letter)}`); const vacuous = letters.length === 0; const holds = !vacuous && undeclared.length === 0 && unresolved.length === 0; const failing = vacuous ? NOBODY_TAKING_PART : durableFailure(undeclared, unresolved); return { detail: holds ? durableHold(headings) : failing, edge: CHECK_DURABLE, holds, step: "durable" }; }; const successorName = function successorName(target: string, declared: string, siblings: readonly string[]): string { const wanted = successorOf(target); const matches = (name: string): boolean => declared.length > 0 ? basename(name).startsWith(declared) : name.endsWith(wanted); return siblings.find((name) => matches(name) && name !== target) ?? ""; }; const successorView = function successorView( repoRoot: string, target: string, venue: string, siblings: readonly string[], planned: readonly string[], ): SuccessorView { const declared = declaredSuccessor(venue); const successor = successorName(target, declared, siblings); const absolute = successor.length === 0 ? "" : resolve(repoRoot, successor); const successorText = absolute.length > 0 && existsSync(absolute) ? readFileSync(absolute, "utf8") : ""; return { declared, deferred: deferredClauses(venue), elsewhere: clauseLines(venue, DEFERRED_HEADING) .filter((entry) => successor.length > 0 && !addressesSuccessor(entry.receiver, successor)) .map((entry) => `${entry.clause} → ${entry.receiver}`), scheduled: declared.length > 0 && planned.includes(declared), successor, successorText, unarrived: unarrivedDeferrals(venue, successorText, successor).map((entry) => entry.clause), }; }; const inheritsIntoSuccessor = function inheritsIntoSuccessor(view: SuccessorView): boolean { return view.successor.length > 0 && view.successorText.includes(INHERITED_HEADING) && view.unarrived.length === 0; }; const successorHolds = function successorHolds(view: SuccessorView): boolean { return view.deferred.declared && view.deferred.answered && (view.scheduled || inheritsIntoSuccessor(view)); }; const successorDetail = function successorDetail(view: SuccessorView, holds: boolean): string { if (holds && view.scheduled && view.successor.length === 0) { return successorScheduled(view.declared); } if (holds) { return successorArrived(view.successor, view.elsewhere, view.deferred.clauses); } if (!view.deferred.declared) { return deferredUndeclared(DEFERRED_HEADING); } if (!view.deferred.answered) { return deferredUnanswered(DEFERRED_HEADING); } const inherited = view.successor.length > 0 && view.successorText.includes(INHERITED_HEADING); return inherited ? unarrivedClauses(view.unarrived) : successorMissing(INHERITED_HEADING, view.declared); }; const successorEdge = function successorEdge(view: SuccessorView): Edge { const holds = successorHolds(view); return { detail: successorDetail(view, holds), edge: CHECK_SUCCESSOR, holds, step: "successor" }; }; const directivesEdge = function directivesEdge(venue: string): Edge { const live = clauseLines(venue, DIRECTIVES_HEADING).map((entry) => entry.clause); const holds = live.length === 0; return { detail: holds ? DIRECTIVES_HOLD : undischarged(live), edge: CHECK_DIRECTIVES, holds, step: "directives" }; }; const absorbed = function absorbed(absorption: Absorption | null): boolean { return absorption?.declaring.length === 1 && absorption.closes.length > 0 && absorption.open.length === 0; }; const absorptionDetail = function absorptionDetail(absorption: Absorption | null, target: string): string { if (absorption === null) { return absorptionUndistributed(basename(target), DISTRIBUTES_FIELD); } if (absorption.declaring.length > 1) { return absorptionDuplicated(absorption.declaring); } if (absorption.closes.length === 0) { return absorptionNoCloses(absorption.checklist, CLOSES_FIELD); } return absorption.open.length === 0 ? absorptionHolds(absorption.checklist, absorption.closes) : absorptionOpen(absorption.checklist, absorption.open); }; const absorptionEdge = function absorptionEdge(repoRoot: string, target: string): Edge { const absorption = absorptionState(repoRoot, basename(target)); return { detail: absorptionDetail(absorption, target), edge: CHECK_ABSORPTION, holds: absorbed(absorption), step: "absorption", }; }; const inheritanceDetail = function inheritanceDetail(successor: string, unoriginated: readonly string[]): string { if (successor.length === 0) { return NO_SUCCESSOR; } return unoriginated.length === 0 ? INHERITANCE_HOLDS : unoriginatedMessage(unoriginated); }; const inheritanceEdge = function inheritanceEdge(view: SuccessorView, venue: string): Edge { const unoriginated = unoriginatedInheritance(venue, view.successorText); return { detail: inheritanceDetail(view.successor, unoriginated), edge: CHECK_INHERITANCE, holds: view.successor.length === 0 || unoriginated.length === 0, step: "inheritance", }; }; export const convergenceEdges = function convergenceEdges( repoRoot: string, target: string, venue: string, board: string, index: string, archive: string, siblings: readonly string[], plannedInvariants: readonly string[] = [], ): Edge[] { const records = venueRecords(venue); const convened = activeSeats(board, index); const letters = convened.filter((letter) => records.has(letter)); const uncovened = convened.filter((letter) => !records.has(letter)); const view = successorView(repoRoot, target, venue, siblings, plannedInvariants); return [ needsEdge(letters, uncovened, records), signaturesEdge(letters, venue), durableEdge(letters, records, archive), successorEdge(view), directivesEdge(venue), absorptionEdge(repoRoot, target), inheritanceEdge(view, venue), ]; }; export const successorExists = function successorExists( repoRoot: string, target: string, siblings: readonly string[], ): boolean { const wanted = successorOf(target); return siblings.some((name) => name.endsWith(wanted) && name !== target && existsSync(resolve(repoRoot, name))); };