/** * recon.ts — shared Q0 reconnaissance phase. * * Runs once per hygiene run (before any check's analysis phase) and writes a * project snapshot to `/.pygienium/recon.json`. Each check can read this * snapshot so the recon work isn't repeated per check. The snapshot is minimal * and dependency-free (git state + source-file inventory) — real checks layer * their own analysis on top via sub-agents. * * @module pygienium/recon */ import { exec } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; import { join } from "node:path"; import { stateDir, RECON_FILENAME } from "./run-state.js"; import { SCOPE_EXTENSIONS } from "./checks/scope.js"; const execAsync = promisify(exec); export interface ReconSnapshot { cwd: string; createdAt: number; gitBranch?: string; gitDirty?: boolean; /** Count of source files by extension. */ fileCounts: Record; totalSourceFiles: number; } /** Run `git ls-files` when possible to get a clean source inventory. */ async function listSourceFiles(cwd: string): Promise { try { const { stdout } = await execAsync( `git -C ${JSON.stringify(cwd)} ls-files --cached --others --exclude-standard`, { maxBuffer: 64 * 1024 * 1024 }, ); return stdout .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0) .filter((l) => { const dot = l.lastIndexOf("."); if (dot === -1) return false; return SCOPE_EXTENSIONS.has(l.slice(dot).toLowerCase()); }); } catch { /* not a git repo or git unavailable — empty inventory */ return []; } } /** Run the shared recon phase for `cwd`, writing the snapshot if missing. */ export async function runRecon(cwd: string): Promise { const dir = stateDir(cwd); const path = join(dir, RECON_FILENAME); // Reuse a fresh-enough snapshot (< 5 min) when present. try { const raw = await readFile(path, "utf8"); const existing = JSON.parse(raw) as ReconSnapshot; if ( existing.createdAt && Date.now() - existing.createdAt < 5 * 60 * 1000 && existing.cwd === cwd ) { return existing; } } catch { /* no existing snapshot */ } const files = await listSourceFiles(cwd); const fileCounts: Record = {}; for (const f of files) { const dot = f.lastIndexOf("."); const ext = dot === -1 ? "" : f.slice(dot).toLowerCase(); fileCounts[ext] = (fileCounts[ext] ?? 0) + 1; } let gitBranch: string | undefined; let gitDirty: boolean | undefined; try { const branchOut = await execAsync( `git -C ${JSON.stringify(cwd)} rev-parse --abbrev-ref HEAD`, ); gitBranch = branchOut.stdout.trim() || undefined; const statusOut = await execAsync( `git -C ${JSON.stringify(cwd)} status --porcelain`, ); gitDirty = statusOut.stdout.trim().length > 0; } catch { /* not a git repo */ } const snapshot: ReconSnapshot = { cwd, createdAt: Date.now(), gitBranch, gitDirty, fileCounts, totalSourceFiles: files.length, }; await mkdir(dir, { recursive: true }); await writeFile(path, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); return snapshot; }