Initial commit: pygenium as git submodule

This commit is contained in:
2026-08-07 14:54:45 -04:00
commit 581436ed23
61 changed files with 9331 additions and 0 deletions

128
src/recon.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 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";
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;
}
/** Source extensions worth inventorying for hygiene checks. */
const SOURCE_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/** 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 SOURCE_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;
}