import { createHash } from "node:crypto"; import type { FactKind, RepositoryFact, RepositoryScan } from "./repository.ts"; const FINDING_KINDS = new Set(["layout", "constraint", "reference"]); const MAX_FINDINGS = 64; const MAX_VALUE_LENGTH = 240; const MAX_EVIDENCE_LENGTH = 512; export interface ExplorationFinding { readonly scope: string; readonly kind: "layout" | "constraint" | "reference"; readonly value: string; readonly sourcePath: string; readonly evidence: string; } export interface ExplorationFactResult { readonly facts: readonly RepositoryFact[]; readonly issues: readonly string[]; } export function buildExplorationFacts( scan: RepositoryScan, reads: ReadonlyMap, input: unknown, ): ExplorationFactResult { if (!Array.isArray(input) || input.length === 0) { return failure("Provide at least one repository finding."); } if (input.length > MAX_FINDINGS) { return failure(`Provide no more than ${MAX_FINDINGS} repository findings.`); } const issues: string[] = []; const facts: RepositoryFact[] = []; const identities = new Set( scan.facts.map( (fact) => `${fact.kind}\u0000${fact.scope}\u0000${fact.value}`, ), ); const ids = new Set(scan.facts.map((fact) => fact.id)); for (const [index, candidate] of input.entries()) { const finding = parseFinding(candidate, index + 1, reads, issues); if (!finding) continue; const identity = `${finding.kind}\u0000${finding.scope}\u0000${finding.value}`; if (identities.has(identity)) continue; identities.add(identity); const source = reads.get(finding.sourcePath); if (source === undefined) continue; const evidenceOffset = source.indexOf(finding.evidence); if (evidenceOffset === -1) continue; const startLine = source.slice(0, evidenceOffset).split(/\r?\n/).length; const endLine = startLine + finding.evidence.split(/\r?\n/).length - 1; const id = uniqueId(finding, ids); ids.add(id); facts.push( Object.freeze({ id, kind: finding.kind, scope: finding.scope, priority: finding.kind === "constraint" ? 82 : 72, value: finding.value, source: Object.freeze({ path: finding.sourcePath, startLine, endLine, kind: "boundary", }), }), ); } return Object.freeze({ facts: Object.freeze(facts), issues: Object.freeze(issues), }); } function parseFinding( candidate: unknown, position: number, reads: ReadonlyMap, issues: string[], ): ExplorationFinding | undefined { if (!isRecord(candidate)) { issues.push(`Finding ${position} must be an object.`); return undefined; } const scope = normalizePath(candidate.scope); const kind = candidate.kind; const value = normalizeText(candidate.value); const sourcePath = normalizePath(candidate.sourcePath); const evidence = normalizeEvidence(candidate.evidence); if (!isSafeScope(scope)) { issues.push(`Finding ${position} has an invalid scope.`); return undefined; } if (typeof kind !== "string" || !FINDING_KINDS.has(kind as FactKind)) { issues.push(`Finding ${position} has an invalid kind.`); return undefined; } if (!isSingleLine(value) || value.length > MAX_VALUE_LENGTH) { issues.push(`Finding ${position} must have one concise value.`); return undefined; } if (!isSafePath(sourcePath) || !reads.has(sourcePath)) { issues.push( `Finding ${position} must cite a file read during this initialization.`, ); return undefined; } if (evidence.length === 0 || evidence.length > MAX_EVIDENCE_LENGTH) { issues.push(`Finding ${position} must include a bounded source excerpt.`); return undefined; } const source = reads.get(sourcePath); if (source === undefined || !source.includes(evidence)) { issues.push( `Finding ${position} source excerpt was not present in the recorded read.`, ); return undefined; } if (!isScopeAncestor(scope, scopeForPath(sourcePath))) { issues.push(`Finding ${position} scope must contain its source file.`); return undefined; } return { scope, kind: kind as ExplorationFinding["kind"], value, sourcePath, evidence, }; } function uniqueId( finding: ExplorationFinding, ids: ReadonlySet, ): string { const base = `X${createHash("sha256") .update( `${finding.kind}\u0000${finding.scope}\u0000${finding.value}\u0000${finding.sourcePath}`, "utf8", ) .digest("hex") .slice(0, 12)}`; if (!ids.has(base)) return base; let suffix = 2; while (ids.has(`${base}-${suffix}`)) suffix += 1; return `${base}-${suffix}`; } function failure(message: string): ExplorationFactResult { return Object.freeze({ facts: Object.freeze([]), issues: Object.freeze([message]), }); } function normalizeText(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function normalizeEvidence(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function normalizePath(value: unknown): string { if (typeof value !== "string") return ""; return ( value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "") || "." ); } function isSingleLine(value: string): boolean { return value.length > 0 && !/[\r\n\u0000]/.test(value); } function isSafeScope(value: string): boolean { return value === "." || isSafePath(value); } function isSafePath(value: string): boolean { return ( value.length > 0 && !value.startsWith("/") && !value.includes("//") && value .split("/") .every((part) => part.length > 0 && part !== "." && part !== "..") ); } function scopeForPath(path: string): string { const index = path.lastIndexOf("/"); return index === -1 ? "." : path.slice(0, index); } function isScopeAncestor(scope: string, pathScope: string): boolean { return ( scope === "." || scope === pathScope || pathScope.startsWith(`${scope}/`) ); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }