feat: keep run artifacts under hidden .pygienium/ and out of git

All per-check artifacts, all-summary, and export move from pygienium/ to
.pygienium/; ensureRunStateIgnored appends .pygienium/ to the target
repo's .gitignore on first run (opt out with --no-gitignore), so a run
never stages its own output. Export now reads a single canonical root.
This commit is contained in:
2026-08-09 16:45:29 -04:00
parent 288506e84d
commit d0e8ad5571
17 changed files with 183 additions and 82 deletions

View File

@@ -126,9 +126,9 @@ registerCheck(def) ← checks/*.ts self-register on load
├─ Q0 recon (shared, run once per run) (src/recon.ts) ├─ Q0 recon (shared, run once per run) (src/recon.ts)
│ git state + source-file inventory → .pygienium/recon.json │ git state + source-file inventory → .pygienium/recon.json
├─ analysis sub-agent (buildScanTask) ← scanner/<check> agent ├─ analysis sub-agent (buildScanTask) ← scanner/<check> agent
│ writes pygienium/checks/<name>/findings.md │ writes .pygienium/checks/<name>/findings.md
├─ fix sub-agent (buildFixTask) ← fixer, only with --fix ├─ fix sub-agent (buildFixTask) ← fixer, only with --fix
│ writes pygienium/checks/<name>/changes.md │ writes .pygienium/checks/<name>/changes.md
├─ verify gate (re-runs check.gate) ├─ verify gate (re-runs check.gate)
└─ cleanup (drops transient scratch artifacts) └─ cleanup (drops transient scratch artifacts)
``` ```

View File

@@ -13,7 +13,7 @@
* - "why" comments that explain intent, rationale, or gotchas → KEEP * - "why" comments that explain intent, rationale, or gotchas → KEEP
* - code self-explanatory with no comment → no comment needed (don't add one) * - code self-explanatory with no comment → no comment needed (don't add one)
* *
* Artifacts (under `<cwd>/pygienium/checks/comments/`): * Artifacts (under `<cwd>/.pygienium/checks/comments/`):
* - `findings.md` — per-file line refs for each smell * - `findings.md` — per-file line refs for each smell
* - `changes.md` — summary of edits + human-review items * - `changes.md` — summary of edits + human-review items
* *
@@ -28,14 +28,14 @@ export const COMMENTS_PHASE_ID = "C1";
/** /**
* Directory where this check writes its `findings.md` and `changes.md` * Directory where this check writes its `findings.md` and `changes.md`
* artifacts: `<cwd>/pygienium/checks/comments/`. Based on `scope.cwd` (the * artifacts: `<cwd>/.pygienium/checks/comments/`. Based on `scope.cwd` (the
* project root, always a directory) so the path is valid whether the scan * project root, always a directory) so the path is valid whether the scan
* target is a single file or a directory. Matches the spec's * target is a single file or a directory. Matches the spec's
* `pygienium/checks/comments/findings.md` relative-path notation. * `.pygienium/checks/comments/findings.md` relative-path notation.
*/ */
export function commentsArtifactDir(scope: CheckScope): string { export function commentsArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, ""); const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/comments`; return `${base}/.pygienium/checks/comments`;
} }
/** Absolute path to the findings artifact for this check. */ /** Absolute path to the findings artifact for this check. */
@@ -71,7 +71,7 @@ Short + high value is the goal. Evaluate every comment in the target:
/** /**
* Build the analysis sub-agent task. Instructs the agent to read candidate * Build the analysis sub-agent task. Instructs the agent to read candidate
* source files, identify comment smells per the rubric, and write per-file line * source files, identify comment smells per the rubric, and write per-file line
* references to `pygienium/comments/findings.md`. * references to `.pygienium/comments/findings.md`.
*/ */
export function buildCommentsScanTask(_cwd: string, scope: CheckScope): string { export function buildCommentsScanTask(_cwd: string, scope: CheckScope): string {
const outDir = commentsArtifactDir(scope); const outDir = commentsArtifactDir(scope);
@@ -119,7 +119,7 @@ Write the report under \`${outDir}\` (create directories as needed).
/** /**
* Build the fix sub-agent task from the scan findings. Instructs the agent to * Build the fix sub-agent task from the scan findings. Instructs the agent to
* apply safe removals/tightenings, leave "why" comments, and write a summary * apply safe removals/tightenings, leave "why" comments, and write a summary
* of edits plus anything needing human review to `pygienium/comments/changes.md`. * of edits plus anything needing human review to `.pygienium/comments/changes.md`.
*/ */
export function buildCommentsFixTask( export function buildCommentsFixTask(
_cwd: string, _cwd: string,

View File

@@ -28,11 +28,11 @@ import { scopeRulesMarkdown } from "./scope.js";
export const COMPLEXITY_PHASE_ID = "C4"; export const COMPLEXITY_PHASE_ID = "C4";
/** /**
* Artifact directory: `<cwd>/pygienium/checks/complexity/`. * Artifact directory: `<cwd>/.pygienium/checks/complexity/`.
*/ */
export function complexityArtifactDir(scope: CheckScope): string { export function complexityArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, ""); const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/complexity`; return `${base}/.pygienium/checks/complexity`;
} }
/** Absolute path to findings artifact. */ /** Absolute path to findings artifact. */
@@ -84,13 +84,12 @@ count decision points (if/else if/for/while/case/&&/||/catch) per function.
* Build the analysis sub-agent task. Instructs the agent to: * Build the analysis sub-agent task. Instructs the agent to:
* 1. Compute cyclomatic complexity per function * 1. Compute cyclomatic complexity per function
* 2. Identify structural complexity smells * 2. Identify structural complexity smells
* 3. Write findings to pygienium/checks/complexity/findings.md * 3. Write findings to .pygienium/checks/complexity/findings.md
*/ */
export function buildComplexityScanTask( export function buildComplexityScanTask(
_cwd: string, _cwd: string,
scope: CheckScope, scope: CheckScope,
): string { ): string {
const outDir = complexityArtifactDir(scope);
const findingsFile = findingsPath(scope); const findingsFile = findingsPath(scope);
return `# Task: excessive complexity scan return `# Task: excessive complexity scan
@@ -173,7 +172,7 @@ Always create findings.md so the run has an artifact.
* 1. Split 50+ complexity functions * 1. Split 50+ complexity functions
* 2. Refactor or justify 3549 functions * 2. Refactor or justify 3549 functions
* 3. Apply safe refactors for structural smells * 3. Apply safe refactors for structural smells
* 4. Write changes summary to pygienium/checks/complexity/changes.md * 4. Write changes summary to .pygienium/checks/complexity/changes.md
*/ */
export function buildComplexityFixTask( export function buildComplexityFixTask(
_cwd: string, _cwd: string,

View File

@@ -950,14 +950,14 @@ export async function applyDeadCodeFixes(
// Artifact paths // Artifact paths
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const CHECK_DIRNAME = "pygienium/checks/dead-code"; const CHECK_DIRNAME = ".pygienium/checks/dead-code";
/** `<target>/pygienium/checks/dead-code/findings.md` */ /** `<target>/.pygienium/checks/dead-code/findings.md` */
export function findingsPath(target: string): string { export function findingsPath(target: string): string {
return join(target, CHECK_DIRNAME, "findings.md"); return join(target, CHECK_DIRNAME, "findings.md");
} }
/** `<target>/pygienium/checks/dead-code/changes.md` */ /** `<target>/.pygienium/checks/dead-code/changes.md` */
export function changesPath(target: string): string { export function changesPath(target: string): string {
return join(target, CHECK_DIRNAME, "changes.md"); return join(target, CHECK_DIRNAME, "changes.md");
} }

View File

@@ -11,7 +11,7 @@
* *
* Lifecycle: * Lifecycle:
* gate (need source files) → recon (shared) → scan sub-agent writes * gate (need source files) → recon (shared) → scan sub-agent writes
* `<cwd>/pygienium/checks/deep-modules/findings.md` → [with --fix] fix * `<cwd>/.pygienium/checks/deep-modules/findings.md` → [with --fix] fix
* sub-agent writes `changes.md`, inlines safe pass-throughs, and lists * sub-agent writes `changes.md`, inlines safe pass-throughs, and lists
* risky consolidations (external importers / public API) for human review. * risky consolidations (external importers / public API) for human review.
* *
@@ -32,7 +32,7 @@ import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */ /** Output directory for this check's persistent reports. */
export function deepModulesOutputDir(cwd: string): string { export function deepModulesOutputDir(cwd: string): string {
return join(cwd, "pygienium", "checks", "deep-modules"); return join(cwd, ".pygienium", "checks", "deep-modules");
} }
/** `findings.md` path for this check. */ /** `findings.md` path for this check. */

View File

@@ -30,7 +30,7 @@
* *
* Lifecycle: * Lifecycle:
* gate (need source files) → recon (shared) → scan sub-agent writes * gate (need source files) → recon (shared) → scan sub-agent writes
* `<cwd>/pygienium/checks/defensive-guards/findings.md` separating redundant * `<cwd>/.pygienium/checks/defensive-guards/findings.md` separating redundant
* guards from boundary guards → [with --fix] fix sub-agent removes redundant * guards from boundary guards → [with --fix] fix sub-agent removes redundant
* guards, preserves boundary guards, and writes `changes.md` distinguishing * guards, preserves boundary guards, and writes `changes.md` distinguishing
* removed vs kept-with-reason. * removed vs kept-with-reason.
@@ -52,7 +52,7 @@ import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */ /** Output directory for this check's persistent reports. */
export function defensiveGuardsOutputDir(cwd: string): string { export function defensiveGuardsOutputDir(cwd: string): string {
return join(cwd, "pygienium", "checks", "defensive-guards"); return join(cwd, ".pygienium", "checks", "defensive-guards");
} }
/** `findings.md` path for this check. */ /** `findings.md` path for this check. */

View File

@@ -60,12 +60,24 @@ function resolveCwd(args: string, ctxCwd: string): string {
return resolve(ctxCwd, tok); return resolve(ctxCwd, tok);
} }
/** Strip a leading flag token (`--fix`) from args, returning the remainder. */ /**
function splitFlags(args: string): { fix: boolean; rest: string } { * Strip leading flag tokens (`--fix`, `--fresh`, `--no-gitignore`) from args,
* returning the remainder (the positional `[path]`).
*/
function splitFlags(args: string): {
fix: boolean;
fresh: boolean;
rest: string;
noGitignore: boolean;
} {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fix = tokens.includes("--fix"); const fix = tokens.includes("--fix");
const rest = tokens.filter((t) => t !== "--fix").join(" "); const fresh = tokens.includes("--fresh");
return { fix, rest }; const noGitignore = tokens.includes("--no-gitignore");
const rest = tokens
.filter((t) => t !== "--fix" && t !== "--fresh" && t !== "--no-gitignore")
.join(" ");
return { fix, fresh, rest, noGitignore };
} }
/** /**
@@ -76,12 +88,13 @@ function splitFlags(args: string): { fix: boolean; rest: string } {
function parseResumeArgs( function parseResumeArgs(
args: string, args: string,
ctxCwd: string, ctxCwd: string,
): { cwd: string; fresh: boolean } { ): { cwd: string; fresh: boolean; gitignore: boolean } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fresh = tokens.includes("--fresh"); const fresh = tokens.includes("--fresh");
const gitignore = !tokens.includes("--no-gitignore");
const positional = tokens.find((t) => !t.startsWith("--")); const positional = tokens.find((t) => !t.startsWith("--"));
const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd; const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd;
return { cwd, fresh }; return { cwd, fresh, gitignore };
} }
/** `/pygienium-help` */ /** `/pygienium-help` */
@@ -116,9 +129,12 @@ export async function handleCheckCommand(
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
}); });
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print( print(
ctx, ctx,
`pygienium ${check.label}: ${outcome.status}${outcome.error ? `${outcome.error}` : ""}`, `pygienium ${check.label}: ${outcome.status}${outcome.error ? `${outcome.error}` : ""}${giNote}`,
); );
} }
@@ -142,13 +158,17 @@ export async function handleAllCommand(
target: parsed.target, target: parsed.target,
fix: parsed.fix, fix: parsed.fix,
fresh: parsed.fresh, fresh: parsed.fresh,
gitignore: parsed.gitignore,
only: parsed.only, only: parsed.only,
ui: ctx.ui, ui: ctx.ui,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
}); });
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print( print(
ctx, ctx,
`pygienium: all-run ${outcome.status}${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})`, `pygienium: all-run ${outcome.status}${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})${giNote}`,
); );
} }
@@ -178,7 +198,7 @@ export async function handleResumeCommand(
args: string, args: string,
ctx: PygieniumCtx, ctx: PygieniumCtx,
): Promise<void> { ): Promise<void> {
const { cwd, fresh } = parseResumeArgs(args, ctx.cwd); const { cwd, fresh, gitignore } = parseResumeArgs(args, ctx.cwd);
let state = await loadRunState(cwd); let state = await loadRunState(cwd);
if (!state) { if (!state) {
print(ctx, "pygienium: no run state to resume."); print(ctx, "pygienium: no run state to resume.");
@@ -202,6 +222,7 @@ export async function handleResumeCommand(
// unless --fresh. // unless --fresh.
let ran = 0; let ran = 0;
let skipped = 0; let skipped = 0;
let giAppended = false;
for (const entry of Object.values(state.checks)) { for (const entry of Object.values(state.checks)) {
const def = getCheck(entry.name); const def = getCheck(entry.name);
if (!def) { if (!def) {
@@ -258,7 +279,7 @@ export async function handleExportCommand(
if (result.entries.length === 0) { if (result.entries.length === 0) {
print( print(
ctx, ctx,
`pygienium: nothing to export (no findings.md/changes.md under ${cwd}/pygienium/checks/).`, `pygienium: nothing to export (no findings.md/changes.md under ${cwd}/.pygienium/checks/).`,
); );
return; return;
} }

View File

@@ -3,9 +3,9 @@
* *
* `/pygienium-export` walks each check's artifact directory (where * `/pygienium-export` walks each check's artifact directory (where
* `findings.md` and `changes.md` live), applies `--check=` / `--status=` * `findings.md` and `changes.md` live), applies `--check=` / `--status=`
* filters, and writes a single bundle to `pygienium/export.{md|json}`. * filters, and writes a single bundle to `.pygienium/export.{md|json}`.
* *
* Artifact root: `<cwd>/pygienium/checks/<name>/` — the single canonical * Artifact root: `<cwd>/.pygienium/checks/<name>/` — the single canonical
* location every shipped check writes to. * location every shipped check writes to.
* *
* Statuses for `--status=` filtering come from the run-state; a check dir * Statuses for `--status=` filtering come from the run-state; a check dir
@@ -21,23 +21,23 @@ import type { RunState } from "./run-state.js";
export type ExportFormat = "md" | "json"; export type ExportFormat = "md" | "json";
/** Directory name (relative to cwd) that holds `checks/` and `export.md`. */ /** Directory name (relative to cwd) that holds `checks/` and `export.md`. */
export const PYGIENIUM_ARTIFACT_DIR = "pygienium"; export const PYGIENIUM_ARTIFACT_DIR = ".pygienium";
/** Subdirectory holding per-check `findings.md`/`changes.md`. */ /** Subdirectory holding per-check `findings.md`/`changes.md`. */
export const CHECKS_SUBDIR = "checks"; export const CHECKS_SUBDIR = "checks";
/** Base filename for the bundle (`export.md` / `export.json`). */ /** Base filename for the bundle (`export.md` / `export.json`). */
export const EXPORT_FILENAME_BASE = "export"; export const EXPORT_FILENAME_BASE = "export";
/** Resolve `<cwd>/pygienium/` (the artifact root). */ /** Resolve `<cwd>/.pygienium/` (the artifact root). */
export function pygieniumArtifactDir(cwd: string): string { export function pygieniumArtifactDir(cwd: string): string {
return join(cwd, PYGIENIUM_ARTIFACT_DIR); return join(cwd, PYGIENIUM_ARTIFACT_DIR);
} }
/** Resolve `<cwd>/pygienium/checks/`. */ /** Resolve `<cwd>/.pygienium/checks/`. */
export function canonicalChecksRoot(cwd: string): string { export function canonicalChecksRoot(cwd: string): string {
return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR); return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR);
} }
/** Resolve `<cwd>/pygienium/export.<format>`. */ /** Resolve `<cwd>/.pygienium/export.<format>`. */
export function exportBundlePath(cwd: string, format: ExportFormat): string { export function exportBundlePath(cwd: string, format: ExportFormat): string {
return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`); return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`);
} }
@@ -153,7 +153,7 @@ async function gatherFromRoot(
} }
/** /**
* Gather artifact entries from the canonical `<cwd>/pygienium/checks/` root, * Gather artifact entries from the canonical `<cwd>/.pygienium/checks/` root,
* one entry per check directory. Entries are sorted alphabetically. Marks an * one entry per check directory. Entries are sorted alphabetically. Marks an
* entry `unknown` when its check is absent from `state`. * entry `unknown` when its check is absent from `state`.
*/ */

View File

@@ -62,6 +62,12 @@ export const CLI_FLAGS: HelpFlag[] = [
scope: "all", scope: "all",
description: "Comma-separated check names to run (subset of the registry).", description: "Comma-separated check names to run (subset of the registry).",
}, },
{
name: "--no-gitignore",
scope: "<check>, all, resume",
description:
"Don't add `.pygienium/` to the target repo's .gitignore (added by default so runs never stage their own output).",
},
{ {
name: "--check=", name: "--check=",
scope: "export", scope: "export",
@@ -118,7 +124,7 @@ export const COMMANDS: HelpCommand[] = [
{ {
usage: "pygienium-export [path] [--check=] [--status=] [--out=md|json]", usage: "pygienium-export [path] [--check=] [--status=] [--out=md|json]",
description: description:
"Bundle every check's findings.md + changes.md into pygienium/export.{md|json}.", "Bundle every check's findings.md + changes.md into .pygienium/export.{md|json}.",
example: "/pygienium-export --out=json", example: "/pygienium-export --out=json",
}, },
]; ];

View File

@@ -10,7 +10,7 @@
* init single run (mode "all") → run shared recon once → * init single run (mode "all") → run shared recon once →
* for each registered check (in registry order): call `runCheck` with the * for each registered check (in registry order): call `runCheck` with the
* SHARED run-state record (not a fresh one per check) → reconcile run * SHARED run-state record (not a fresh one per check) → reconcile run
* status → write `pygienium/all-summary.md`. * status → write `.pygienium/all-summary.md`.
* *
* Resumability: terminal checks (`complete`/`skipped`) are skipped on resume; * Resumability: terminal checks (`complete`/`skipped`) are skipped on resume;
* `in_progress`/`failed`/`pending` checks re-run. `--fresh` resets every check * `in_progress`/`failed`/`pending` checks re-run. `--fresh` resets every check
@@ -29,6 +29,7 @@ import { createPhaseStrip } from "../phases.js";
import { runRecon } from "../recon.js"; import { runRecon } from "../recon.js";
import { import {
applyPhaseStatus, applyPhaseStatus,
ensureRunStateIgnored,
initRunState, initRunState,
loadRunState, loadRunState,
markRunStatus, markRunStatus,
@@ -42,11 +43,11 @@ import {
} from "../run-state.js"; } from "../run-state.js";
/** Artifact directory name (relative to cwd) that holds `all-summary.md`. */ /** Artifact directory name (relative to cwd) that holds `all-summary.md`. */
export const ALL_ARTIFACT_DIR = "pygienium"; export const ALL_ARTIFACT_DIR = ".pygienium";
/** Filename for the unified per-check summary report. */ /** Filename for the unified per-check summary report. */
export const ALL_SUMMARY_FILENAME = "all-summary.md"; export const ALL_SUMMARY_FILENAME = "all-summary.md";
/** Resolve `<cwd>/pygienium/all-summary.md`. */ /** Resolve `<cwd>/.pygienium/all-summary.md`. */
export function allSummaryPath(cwd: string): string { export function allSummaryPath(cwd: string): string {
return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME); return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME);
} }
@@ -62,10 +63,17 @@ export interface AllRunOptions {
only?: string[]; only?: string[];
/** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */ /** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */
fresh?: boolean; fresh?: boolean;
/**
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
* state/artifacts (default true). Set false with `--no-gitignore`.
*/
gitignore?: boolean;
/** UI context (optional; null in print mode). */ /** UI context (optional; null in print mode). */
ui?: ExtensionUIContext; ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */ /** Whether dialog-capable UI is available. */
hasUI?: boolean; hasUI?: boolean;
/** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage;
} }
/** Outcome of {@link runAllChecks}. */ /** Outcome of {@link runAllChecks}. */
@@ -80,6 +88,8 @@ export interface AllRunOutcome {
ran: string[]; ran: string[];
/** Checks skipped because they were already terminal. */ /** Checks skipped because they were already terminal. */
skipped: string[]; skipped: string[];
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
gitignoreAppended?: boolean;
} }
/** /**
@@ -104,11 +114,13 @@ export function parseAllArgs(
target: string; target: string;
fix: boolean; fix: boolean;
fresh: boolean; fresh: boolean;
gitignore: boolean;
only: string[]; only: string[];
} { } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
let fix = false; let fix = false;
let fresh = false; let fresh = false;
let gitignore = true;
let target = cwd; let target = cwd;
const only: string[] = []; const only: string[] = [];
for (const tok of tokens) { for (const tok of tokens) {
@@ -116,6 +128,8 @@ export function parseAllArgs(
fix = true; fix = true;
} else if (tok === "--fresh") { } else if (tok === "--fresh") {
fresh = true; fresh = true;
} else if (tok === "--no-gitignore") {
gitignore = false;
} else if (tok.startsWith("--only=")) { } else if (tok.startsWith("--only=")) {
for (const name of tok.slice("--only=".length).split(",")) { for (const name of tok.slice("--only=".length).split(",")) {
const trimmed = name.trim(); const trimmed = name.trim();
@@ -125,7 +139,7 @@ export function parseAllArgs(
target = tok; target = tok;
} }
} }
return { target: resolve(cwd, target), fix, fresh, only }; return { target: resolve(cwd, target), fix, fresh, gitignore, only };
} }
/** Count non-empty lines in captured findings/changes text. */ /** Count non-empty lines in captured findings/changes text. */
@@ -219,6 +233,11 @@ export async function runAllChecks(
const fresh = opts.fresh ?? false; const fresh = opts.fresh ?? false;
const hasUI = opts.hasUI ?? false; const hasUI = opts.hasUI ?? false;
// Keep pygienium's own output out of the scanned repo's git index unless
// the caller opted out with --no-gitignore.
const gitignoreAppended =
opts.gitignore === false ? false : await ensureRunStateIgnored(cwd);
const selected = selectChecks(opts.only); const selected = selectChecks(opts.only);
if (selected.length === 0) { if (selected.length === 0) {
// `--only` selected nothing (or no checks registered). Still produce a // `--only` selected nothing (or no checks registered). Still produce a
@@ -227,7 +246,14 @@ export async function runAllChecks(
markRunStatus(state, reconcileRunStatus(state)); markRunStatus(state, reconcileRunStatus(state));
await saveRunState(state); await saveRunState(state);
const summaryPath = await writeAllSummary(state, []); const summaryPath = await writeAllSummary(state, []);
return { status: state.status, state, summaryPath, ran: [], skipped: [] }; return {
status: state.status,
state,
summaryPath,
ran: [],
skipped: [],
gitignoreAppended,
};
} }
// --- Init / resume the single shared run-state -------------------------- // --- Init / resume the single shared run-state --------------------------
@@ -312,6 +338,7 @@ export async function runAllChecks(
ui: opts.ui, ui: opts.ui,
hasUI, hasUI,
existingState: state, existingState: state,
gitignore: opts.gitignore,
}); });
state = outcome.state; state = outcome.state;
ran.push(check.name); ran.push(check.name);

View File

@@ -82,12 +82,14 @@ export interface RunCheckOptions {
hasUI?: boolean; hasUI?: boolean;
/** Pre-existing run state to update (for `/pygienium-all` and resume). */ /** Pre-existing run state to update (for `/pygienium-all` and resume). */
existingState?: RunState; existingState?: RunState;
gitignore?: boolean;
} }
/** Outcome of a single check run. */ /** Outcome of a single check run. */
export interface CheckRunOutcome { export interface CheckRunOutcome {
/** Final check status. */ /** Final check status. */
status: "complete" | "failed" | "skipped"; status: "complete" | "failed" | "skipped";
gitignoreAppended?: boolean;
/** Findings text from the analysis phase. */ /** Findings text from the analysis phase. */
findings?: string; findings?: string;
/** Changes text from the fix phase (when run with --fix). */ /** Changes text from the fix phase (when run with --fix). */

View File

@@ -9,8 +9,8 @@
* @module pygienium/run-state * @module pygienium/run-state
*/ */
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { join } from "node:path";
export const RUN_STATE_DIRNAME = ".pygienium"; export const RUN_STATE_DIRNAME = ".pygienium";
export const RUN_STATE_FILENAME = "run-state.json"; export const RUN_STATE_FILENAME = "run-state.json";
@@ -152,6 +152,46 @@ export async function saveRunState(state: RunState): Promise<void> {
); );
} }
/**
* Memo of cwds whose `.gitignore` was already ensured this process, so the
* check runs at most once per target per session.
*/
const gitIgnoreMemo = new Set<string>();
/**
* Make sure `<cwd>/.gitignore` excludes `.pygienium/` (run-state + artifacts)
* so a run never stages its own output into the scanned repo's git index.
* Best-effort and idempotent: no-op outside a git work tree or when the entry
* already exists. Returns true when it appended the entry (or created the file).
*/
export async function ensureRunStateIgnored(cwd: string): Promise<boolean> {
if (gitIgnoreMemo.has(cwd)) return false;
gitIgnoreMemo.add(cwd);
try {
// Only act inside a git work tree (works for worktrees too: .git is a file).
await stat(join(cwd, ".git"));
const ignorePath = join(cwd, ".gitignore");
const marker = ".pygienium/";
let content: string;
try {
content = await readFile(ignorePath, "utf8");
} catch {
await writeFile(ignorePath, `${marker}\n`, "utf8");
return true;
}
if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false;
const prefix = content.endsWith("\n") ? "" : "\n";
await appendFile(
ignorePath,
`${prefix}# pygienium run-state and check artifacts\n${marker}\n`,
"utf8",
);
return true;
} catch {
return false; // not a git work tree, or a best-effort write failed
}
}
/** Mark a phase's status (and optionally an error message). */ /** Mark a phase's status (and optionally an error message). */
export function applyPhaseStatus( export function applyPhaseStatus(
state: RunState, state: RunState,

View File

@@ -35,9 +35,9 @@ function fakeCheck(name: string): CheckDefinition {
fixAgentName: "fixer", fixAgentName: "fixer",
phaseId: "scan", phaseId: "scan",
buildScanTask: (_cwd, scope) => buildScanTask: (_cwd, scope) =>
`!write pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`, `!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
buildFixTask: (_cwd, _scope, findings) => buildFixTask: (_cwd, _scope, findings) =>
`!write pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`, `!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
gate: () => undefined, gate: () => undefined,
}; };
} }

View File

@@ -4,7 +4,7 @@
* Mirrors the spec scenario: run `/pygienium-all` on a small repo and assert: * Mirrors the spec scenario: run `/pygienium-all` on a small repo and assert:
* - every registered check runs exactly once in registry order; * - every registered check runs exactly once in registry order;
* - run-state shows all checks complete and the overall run complete; * - run-state shows all checks complete and the overall run complete;
* - `pygienium/all-summary.md` is present and lists per-check outcomes; * - `.pygienium/all-summary.md` is present and lists per-check outcomes;
* - `--only=alpha,gamma` narrows the candidate set preserving order; * - `--only=alpha,gamma` narrows the candidate set preserving order;
* - interrupted/resumed runs re-dispatch non-terminal checks while skipping * - interrupted/resumed runs re-dispatch non-terminal checks while skipping
* terminal ones, unless `--fresh` resets everything. * terminal ones, unless `--fresh` resets everything.
@@ -57,9 +57,9 @@ function fakeCheck(name: string): CheckDefinition {
fixAgentName: "fixer", fixAgentName: "fixer",
phaseId: "scan", phaseId: "scan",
buildScanTask: (_cwd, scope) => buildScanTask: (_cwd, scope) =>
`!write pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`, `!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
buildFixTask: (_cwd, _scope, findings) => buildFixTask: (_cwd, _scope, findings) =>
`!write pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`, `!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
gate: () => undefined, gate: () => undefined,
}; };
} }
@@ -132,12 +132,16 @@ describe("/pygienium-all orchestrator (task 12)", () => {
await rm(cwd, { recursive: true, force: true }); await rm(cwd, { recursive: true, force: true });
}); });
it("parseAllArgs parses path, --fix, --fresh, and --only", () => { it("parseAllArgs parses path, --fix, --fresh, --no-gitignore, and --only", () => {
const p = parseAllArgs("subdir --fix --only=alpha,beta --fresh", cwd); const p = parseAllArgs("subdir --fix --only=alpha,beta --fresh", cwd);
expect(p.target).toBe(join(cwd, "subdir")); expect(p.target).toBe(join(cwd, "subdir"));
expect(p.fix).toBe(true); expect(p.fix).toBe(true);
expect(p.fresh).toBe(true); expect(p.fresh).toBe(true);
expect(p.gitignore).toBe(true); // default: keep the .gitignore guard on
expect(p.only).toEqual(["alpha", "beta"]); expect(p.only).toEqual(["alpha", "beta"]);
const noGi = parseAllArgs("--no-gitignore", cwd);
expect(noGi.gitignore).toBe(false);
expect(noGi.target).toBe(cwd);
}); });
it("selectChecks preserves registry order for the --only subset", () => { it("selectChecks preserves registry order for the --only subset", () => {
@@ -176,7 +180,7 @@ describe("/pygienium-all orchestrator (task 12)", () => {
expect(state?.recon.complete).toBe(true); expect(state?.recon.complete).toBe(true);
}); });
it("writes pygienium/all-summary.md listing per-check outcomes", async () => { it("writes .pygienium/all-summary.md listing per-check outcomes", async () => {
registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta")); registerCheck(fakeCheck("beta"));
@@ -193,7 +197,7 @@ describe("/pygienium-all orchestrator (task 12)", () => {
expect(summary).toContain("changes:"); expect(summary).toContain("changes:");
// Artifacts actually exist on disk. // Artifacts actually exist on disk.
const alphaFindings = await readFile( const alphaFindings = await readFile(
join(cwd, "pygienium", "checks", "alpha", "findings.md"), join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
"utf8", "utf8",
); );
expect(alphaFindings).toContain("alpha findings"); expect(alphaFindings).toContain("alpha findings");
@@ -233,7 +237,7 @@ describe("/pygienium-all orchestrator (task 12)", () => {
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd))); await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases
const firstAlphaFix = await readFile( const firstAlphaFix = await readFile(
join(cwd, "pygienium", "checks", "alpha", "changes.md"), join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
"utf8", "utf8",
); );
@@ -255,7 +259,7 @@ describe("/pygienium-all orchestrator (task 12)", () => {
expect(state3?.status).toBe("complete"); expect(state3?.status).toBe("complete");
// The fresh re-run overwrote alpha's changes.md (still valid content). // The fresh re-run overwrote alpha's changes.md (still valid content).
const alphaFix2 = await readFile( const alphaFix2 = await readFile(
join(cwd, "pygienium", "checks", "alpha", "changes.md"), join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
"utf8", "utf8",
); );
expect(alphaFix2).toContain("alpha changes"); expect(alphaFix2).toContain("alpha changes");
@@ -278,7 +282,7 @@ describe("/pygienium-all orchestrator (task 12)", () => {
finishedAt: Date.now(), finishedAt: Date.now(),
}; };
// Pre-create alpha's on-disk artifacts so its completed entry has artifacts. // Pre-create alpha's on-disk artifacts so its completed entry has artifacts.
const alphaDir = join(cwd, "pygienium", "checks", "alpha"); const alphaDir = join(cwd, ".pygienium", "checks", "alpha");
await mkdir(alphaDir, { recursive: true }); await mkdir(alphaDir, { recursive: true });
await writeFile( await writeFile(
join(alphaDir, "findings.md"), join(alphaDir, "findings.md"),

View File

@@ -145,15 +145,15 @@ describe("deep-modules check", () => {
expect(state?.checks["deep-modules"]?.status).toBe("complete"); expect(state?.checks["deep-modules"]?.status).toBe("complete");
}); });
it("findings.md and changes.md live under pygienium/checks/deep-modules/", async () => { it("findings.md and changes.md live under .pygienium/checks/deep-modules/", async () => {
await seedPassThrough(cwd); await seedPassThrough(cwd);
const check = getCheck("deep-modules")!; const check = getCheck("deep-modules")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd)); await handleCheckCommand(check, "--fix", stubCtx(cwd));
expect(findingsPath(cwd)).toBe( expect(findingsPath(cwd)).toBe(
join(cwd, "pygienium", "checks", "deep-modules", "findings.md"), join(cwd, ".pygienium", "checks", "deep-modules", "findings.md"),
); );
expect(changesPath(cwd)).toBe( expect(changesPath(cwd)).toBe(
join(cwd, "pygienium", "checks", "deep-modules", "changes.md"), join(cwd, ".pygienium", "checks", "deep-modules", "changes.md"),
); );
}); });

View File

@@ -208,16 +208,16 @@ describe("defensive-guards check", () => {
expect(state?.checks["defensive-guards"]?.status).toBe("complete"); expect(state?.checks["defensive-guards"]?.status).toBe("complete");
}); });
it("findings.md and changes.md live under pygienium/checks/defensive-guards/", async () => { it("findings.md and changes.md live under .pygienium/checks/defensive-guards/", async () => {
await seedNoise(cwd); await seedNoise(cwd);
await seedBoundary(cwd); await seedBoundary(cwd);
const check = getCheck("defensive-guards")!; const check = getCheck("defensive-guards")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd)); await handleCheckCommand(check, "--fix", stubCtx(cwd));
expect(findingsPath(cwd)).toBe( expect(findingsPath(cwd)).toBe(
join(cwd, "pygienium", "checks", "defensive-guards", "findings.md"), join(cwd, ".pygienium", "checks", "defensive-guards", "findings.md"),
); );
expect(changesPath(cwd)).toBe( expect(changesPath(cwd)).toBe(
join(cwd, "pygienium", "checks", "defensive-guards", "changes.md"), join(cwd, ".pygienium", "checks", "defensive-guards", "changes.md"),
); );
}); });

View File

@@ -71,9 +71,9 @@ function fakeCheck(name: string): CheckDefinition {
fixAgentName: "fixer", fixAgentName: "fixer",
phaseId: "scan", phaseId: "scan",
buildScanTask: (_cwd, scope) => buildScanTask: (_cwd, scope) =>
`!write pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`, `!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
buildFixTask: (_cwd, _scope, findings) => buildFixTask: (_cwd, _scope, findings) =>
`!write pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`, `!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
gate: () => undefined, gate: () => undefined,
}; };
} }
@@ -86,7 +86,7 @@ function stubCtx(cwd: string): PygieniumCtx {
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } { function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
const dispatched: string[] = []; const dispatched: string[] = [];
const runner: AgentRunner = async (opts) => { const runner: AgentRunner = async (opts) => {
// Tag by check name from the task text (`!write pygienium/checks/<name>/`). // Tag by check name from the task text (`!write .pygienium/checks/<name>/`).
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task); const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
if (m) dispatched.push(m[1] as string); if (m) dispatched.push(m[1] as string);
return fakeAgentRunner(opts); return fakeAgentRunner(opts);
@@ -297,7 +297,7 @@ describe("status / resume / export (task 13)", () => {
void state; void state;
await captureStdout(() => handleExportCommand("", stubCtx(cwd))); await captureStdout(() => handleExportCommand("", stubCtx(cwd)));
const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
expect(bundle).toContain("# Pygienium export"); expect(bundle).toContain("# Pygienium export");
expect(bundle).toContain("## alpha (complete)"); expect(bundle).toContain("## alpha (complete)");
expect(bundle).toContain("## beta (complete)"); expect(bundle).toContain("## beta (complete)");
@@ -314,7 +314,7 @@ describe("status / resume / export (task 13)", () => {
await captureStdout(() => await captureStdout(() =>
handleExportCommand("--check=beta", stubCtx(cwd)), handleExportCommand("--check=beta", stubCtx(cwd)),
); );
const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
expect(bundle).toContain("## beta (complete)"); expect(bundle).toContain("## beta (complete)");
expect(bundle).not.toContain("## alpha"); expect(bundle).not.toContain("## alpha");
}); });
@@ -330,7 +330,7 @@ describe("status / resume / export (task 13)", () => {
await captureStdout(() => await captureStdout(() =>
handleExportCommand("--status=failed", stubCtx(cwd)), handleExportCommand("--status=failed", stubCtx(cwd)),
); );
const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
expect(bundle).toContain("## alpha (failed)"); expect(bundle).toContain("## alpha (failed)");
expect(bundle).not.toContain("## beta"); expect(bundle).not.toContain("## beta");
}); });
@@ -345,7 +345,7 @@ describe("status / resume / export (task 13)", () => {
handleExportCommand("--out=json", stubCtx(cwd)), handleExportCommand("--out=json", stubCtx(cwd)),
); );
expect(out.join("\n")).toContain("export.json"); expect(out.join("\n")).toContain("export.json");
const raw = await readFile(join(cwd, "pygienium", "export.json"), "utf8"); const raw = await readFile(join(cwd, ".pygienium", "export.json"), "utf8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
checks: Array<{ name: string; status: string; findings: string }>; checks: Array<{ name: string; status: string; findings: string }>;
}; };
@@ -370,20 +370,22 @@ describe("status / resume / export (task 13)", () => {
expect(f.out).toBe("json"); expect(f.out).toBe("json");
}); });
it("gatherExportEntries reads only the canonical pygienium/checks/ root", async () => { it("gatherExportEntries reads only the canonical .pygienium/checks/ root", async () => {
await mkdir(join(cwd, "pygienium", "checks", "alpha"), { recursive: true }); await mkdir(join(cwd, ".pygienium", "checks", "alpha"), {
await writeFile(
join(cwd, "pygienium", "checks", "alpha", "findings.md"),
"# alpha findings\n",
"utf8",
);
// A stray .pygienium/checks/ dir (the removed legacy root) is ignored now
// that all checks write to the single canonical `pygienium/checks/` root.
await mkdir(join(cwd, ".pygienium", "checks", "ghost"), {
recursive: true, recursive: true,
}); });
await writeFile( await writeFile(
join(cwd, ".pygienium", "checks", "ghost", "findings.md"), join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
"# alpha findings\n",
"utf8",
);
// A stray pygienium/checks/ dir (the old non-hidden root) is ignored now
// that all checks write to the single canonical `.pygienium/checks/` root.
await mkdir(join(cwd, "pygienium", "checks", "ghost"), {
recursive: true,
});
await writeFile(
join(cwd, "pygienium", "checks", "ghost", "findings.md"),
"# ghost findings\n", "# ghost findings\n",
"utf8", "utf8",
); );
@@ -393,7 +395,7 @@ describe("status / resume / export (task 13)", () => {
expect(alpha?.findings).toContain("alpha findings"); expect(alpha?.findings).toContain("alpha findings");
expect(alpha?.status).toBe("unknown"); expect(alpha?.status).toBe("unknown");
expect(alpha?.findingsPath).toBe( expect(alpha?.findingsPath).toBe(
join(cwd, "pygienium", "checks", "alpha", "findings.md"), join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
); );
expect(entries.find((e) => e.name === "ghost")).toBeUndefined(); expect(entries.find((e) => e.name === "ghost")).toBeUndefined();
}); });
@@ -415,6 +417,6 @@ describe("status / resume / export (task 13)", () => {
it("exportRun writes nothing useful and reports zero entries cleanly", async () => { it("exportRun writes nothing useful and reports zero entries cleanly", async () => {
const result = await exportRun(cwd, undefined, {}); const result = await exportRun(cwd, undefined, {});
expect(result.entries).toHaveLength(0); expect(result.entries).toHaveLength(0);
expect(relative(cwd, result.path)).toBe(join("pygienium", "export.md")); expect(relative(cwd, result.path)).toBe(join(".pygienium", "export.md"));
}); });
}); });