Single source of truth for what pygienium inspects (implementation-code extensions, exclude dirs, .d.ts/.min.* exclusions) shared by recon, dead-code, deep-modules, defensive-guards, comments, and complexity. Every scan task and agent prompt injects the shared scope rules instead of copy-pasted per-check lists.
110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
/**
|
|
* recon.ts — shared Q0 reconnaissance phase.
|
|
*
|
|
* Runs once per hygiene run (before any check's analysis phase) and writes a
|
|
* project snapshot to `<cwd>/.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<string, number>;
|
|
totalSourceFiles: number;
|
|
}
|
|
|
|
/** Run `git ls-files` when possible to get a clean source inventory. */
|
|
async function listSourceFiles(cwd: string): Promise<string[]> {
|
|
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<ReconSnapshot> {
|
|
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<string, number> = {};
|
|
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;
|
|
}
|