import { statSync } from "node:fs"; import { resolve } from "node:path"; import { clearPendingImpactForPath, formatMultiImpactSummary, getChangedPathsFromDotdotgodImpactCommand, isBroadVerificationCommand, isCommitLikeCommand, mergeImpactCheckPaths, normalizeImpactPath, pendingImpactSummary, shouldTrackImpactPath, upsertPendingImpact, type PendingImpactItem, } from "../impact.ts"; export interface ImpactCliResult { ok: boolean; data?: unknown; stdout?: string; error?: string; } export interface GateSnapshot { pendingImpactItems: PendingImpactItem[]; } export class GateController { pendingImpactItems: PendingImpactItem[] = []; private fingerprintPath(cwd: string, path: string): string | undefined { try { const stat = statSync(resolve(cwd, path)); return `${stat.size}:${Math.round(stat.mtimeMs)}`; } catch { return undefined; } } trackPendingImpact( cwd: string, path: string, reason: PendingImpactItem["reason"], now = new Date(), ): boolean { const normalized = normalizeImpactPath(cwd, path); if (!normalized || !shouldTrackImpactPath(normalized)) return false; const fingerprint = this.fingerprintPath(cwd, normalized); this.pendingImpactItems = upsertPendingImpact(this.pendingImpactItems, { path: normalized, ...(fingerprint ? { fingerprint } : {}), reason, touchedAt: now.toISOString(), }); return true; } clearPendingImpact( cwd: string, path: string, checkedFingerprint?: string, ): boolean { const normalized = normalizeImpactPath(cwd, path); if (!normalized) return false; const fingerprint = this.fingerprintPath(cwd, normalized); if (!checkedFingerprint || !fingerprint || checkedFingerprint === fingerprint) { this.pendingImpactItems = clearPendingImpactForPath(this.pendingImpactItems, normalized); return true; } return false; } mergeImpactCheckPaths(cwd: string, gitChangedPaths: readonly string[]): string[] { return mergeImpactCheckPaths(cwd, this.pendingImpactItems, gitChangedPaths); } runImpactChecks( cwd: string, paths: readonly string[], runImpact: (paths: readonly string[]) => ImpactCliResult, batchSize = 20, ): { summary: string; checked: string[]; failed: string[] } { const normalizedPaths = [ ...new Set( paths .map((path) => normalizeImpactPath(cwd, path)) .filter((path): path is string => Boolean(path)) .filter(shouldTrackImpactPath), ), ]; const results: Array<{ path: string; data?: unknown; error?: string; summary?: string }> = []; const checked: string[] = []; const failed: string[] = []; const checkedFingerprints = new Map(); const boundedBatchSize = Math.max(1, Math.min(20, Math.trunc(batchSize) || 20)); for (let offset = 0; offset < normalizedPaths.length; offset += boundedBatchSize) { const batch = normalizedPaths.slice(offset, offset + boundedBatchSize); for (const path of batch) checkedFingerprints.set(path, this.fingerprintPath(cwd, path)); const result = runImpact(batch); const label = batch.join(", "); if (result.ok) { results.push({ path: label, data: result.data, ...(result.stdout ? { summary: result.stdout } : {}), }); checked.push(...batch); } else { results.push({ path: label, error: result.error ?? "unknown error" }); failed.push(...batch); } } const summary = formatMultiImpactSummary(results); for (const path of checked) { this.clearPendingImpact(cwd, path, checkedFingerprints.get(path)); } return { summary, checked, failed }; } buildPendingImpactReminder(): string | undefined { if (this.pendingImpactItems.length === 0) return undefined; return `[DOTDOTGOD IMPACT CHECK PENDING]\nRun dotdotgod graph impact for these task-relevant changed files:\n${pendingImpactSummary(this.pendingImpactItems)}\nBefore broad tests, further edits, commit, push, or publish, use dotdotgod_graph_impact or /impact-check and review related specs, tests, docs, and source.`; } buildCommitBlockReason(command: string): string | undefined { if (this.pendingImpactItems.length === 0 || !isCommitLikeCommand(command)) return undefined; return `Blocked: graph impact is pending for task-relevant changed files.\nRun /impact-check or dotdotgod_graph_impact first, then review related specs, tests, docs, and source.\nPending:\n${pendingImpactSummary(this.pendingImpactItems)}`; } buildBroadVerificationPrompt(command: string): string | undefined { if (this.pendingImpactItems.length === 0 || !isBroadVerificationCommand(command)) return undefined; return `Dotdotgod graph impact remains pending for these task-relevant changed files:\n${pendingImpactSummary(this.pendingImpactItems)}\n\nRun this broad verification command without the impact review?`; } clearFromImpactCommandResult(cwd: string, command: string, output: string): boolean { const changed = getChangedPathsFromDotdotgodImpactCommand(command); if (changed.length === 0 || output.includes('"ok": false')) return false; return changed.map((path) => this.clearPendingImpact(cwd, path)).some(Boolean); } snapshot(): GateSnapshot { return { pendingImpactItems: [...this.pendingImpactItems] }; } restore(snapshot: GateSnapshot | undefined): void { if (!snapshot) return; this.pendingImpactItems = [...snapshot.pendingImpactItems]; } }