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

@@ -13,7 +13,7 @@
* - "why" comments that explain intent, rationale, or gotchas → KEEP
* - 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
* - `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`
* 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
* 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 {
const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/comments`;
return `${base}/.pygienium/checks/comments`;
}
/** 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
* 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 {
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
* 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(
_cwd: string,

View File

@@ -28,11 +28,11 @@ import { scopeRulesMarkdown } from "./scope.js";
export const COMPLEXITY_PHASE_ID = "C4";
/**
* Artifact directory: `<cwd>/pygienium/checks/complexity/`.
* Artifact directory: `<cwd>/.pygienium/checks/complexity/`.
*/
export function complexityArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/complexity`;
return `${base}/.pygienium/checks/complexity`;
}
/** 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:
* 1. Compute cyclomatic complexity per function
* 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(
_cwd: string,
scope: CheckScope,
): string {
const outDir = complexityArtifactDir(scope);
const findingsFile = findingsPath(scope);
return `# Task: excessive complexity scan
@@ -173,7 +172,7 @@ Always create findings.md so the run has an artifact.
* 1. Split 50+ complexity functions
* 2. Refactor or justify 3549 functions
* 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(
_cwd: string,

View File

@@ -950,14 +950,14 @@ export async function applyDeadCodeFixes(
// 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 {
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 {
return join(target, CHECK_DIRNAME, "changes.md");
}

View File

@@ -11,7 +11,7 @@
*
* Lifecycle:
* 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
* 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. */
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. */

View File

@@ -30,7 +30,7 @@
*
* Lifecycle:
* 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, preserves boundary guards, and writes `changes.md` distinguishing
* removed vs kept-with-reason.
@@ -52,7 +52,7 @@ import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */
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. */

View File

@@ -60,12 +60,24 @@ function resolveCwd(args: string, ctxCwd: string): string {
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 fix = tokens.includes("--fix");
const rest = tokens.filter((t) => t !== "--fix").join(" ");
return { fix, rest };
const fresh = tokens.includes("--fresh");
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(
args: string,
ctxCwd: string,
): { cwd: string; fresh: boolean } {
): { cwd: string; fresh: boolean; gitignore: boolean } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fresh = tokens.includes("--fresh");
const gitignore = !tokens.includes("--no-gitignore");
const positional = tokens.find((t) => !t.startsWith("--"));
const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd;
return { cwd, fresh };
return { cwd, fresh, gitignore };
}
/** `/pygienium-help` */
@@ -116,9 +129,12 @@ export async function handleCheckCommand(
hasUI: ctx.hasUI,
});
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print(
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,
fix: parsed.fix,
fresh: parsed.fresh,
gitignore: parsed.gitignore,
only: parsed.only,
ui: ctx.ui,
hasUI: ctx.hasUI,
});
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print(
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,
ctx: PygieniumCtx,
): Promise<void> {
const { cwd, fresh } = parseResumeArgs(args, ctx.cwd);
const { cwd, fresh, gitignore } = parseResumeArgs(args, ctx.cwd);
let state = await loadRunState(cwd);
if (!state) {
print(ctx, "pygienium: no run state to resume.");
@@ -202,6 +222,7 @@ export async function handleResumeCommand(
// unless --fresh.
let ran = 0;
let skipped = 0;
let giAppended = false;
for (const entry of Object.values(state.checks)) {
const def = getCheck(entry.name);
if (!def) {
@@ -258,7 +279,7 @@ export async function handleExportCommand(
if (result.entries.length === 0) {
print(
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;
}

View File

@@ -3,9 +3,9 @@
*
* `/pygienium-export` walks each check's artifact directory (where
* `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.
*
* 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";
/** 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`. */
export const CHECKS_SUBDIR = "checks";
/** Base filename for the bundle (`export.md` / `export.json`). */
export const EXPORT_FILENAME_BASE = "export";
/** Resolve `<cwd>/pygienium/` (the artifact root). */
/** Resolve `<cwd>/.pygienium/` (the artifact root). */
export function pygieniumArtifactDir(cwd: string): string {
return join(cwd, PYGIENIUM_ARTIFACT_DIR);
}
/** Resolve `<cwd>/pygienium/checks/`. */
/** Resolve `<cwd>/.pygienium/checks/`. */
export function canonicalChecksRoot(cwd: string): string {
return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR);
}
/** Resolve `<cwd>/pygienium/export.<format>`. */
/** Resolve `<cwd>/.pygienium/export.<format>`. */
export function exportBundlePath(cwd: string, format: ExportFormat): string {
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
* entry `unknown` when its check is absent from `state`.
*/

View File

@@ -62,6 +62,12 @@ export const CLI_FLAGS: HelpFlag[] = [
scope: "all",
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=",
scope: "export",
@@ -118,7 +124,7 @@ export const COMMANDS: HelpCommand[] = [
{
usage: "pygienium-export [path] [--check=] [--status=] [--out=md|json]",
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",
},
];

View File

@@ -10,7 +10,7 @@
* init single run (mode "all") → run shared recon once →
* for each registered check (in registry order): call `runCheck` with the
* 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;
* `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 {
applyPhaseStatus,
ensureRunStateIgnored,
initRunState,
loadRunState,
markRunStatus,
@@ -42,11 +43,11 @@ import {
} from "../run-state.js";
/** 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. */
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 {
return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME);
}
@@ -62,10 +63,17 @@ export interface AllRunOptions {
only?: string[];
/** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */
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?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
/** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage;
}
/** Outcome of {@link runAllChecks}. */
@@ -80,6 +88,8 @@ export interface AllRunOutcome {
ran: string[];
/** Checks skipped because they were already terminal. */
skipped: string[];
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
gitignoreAppended?: boolean;
}
/**
@@ -104,11 +114,13 @@ export function parseAllArgs(
target: string;
fix: boolean;
fresh: boolean;
gitignore: boolean;
only: string[];
} {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
let fix = false;
let fresh = false;
let gitignore = true;
let target = cwd;
const only: string[] = [];
for (const tok of tokens) {
@@ -116,6 +128,8 @@ export function parseAllArgs(
fix = true;
} else if (tok === "--fresh") {
fresh = true;
} else if (tok === "--no-gitignore") {
gitignore = false;
} else if (tok.startsWith("--only=")) {
for (const name of tok.slice("--only=".length).split(",")) {
const trimmed = name.trim();
@@ -125,7 +139,7 @@ export function parseAllArgs(
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. */
@@ -219,6 +233,11 @@ export async function runAllChecks(
const fresh = opts.fresh ?? 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);
if (selected.length === 0) {
// `--only` selected nothing (or no checks registered). Still produce a
@@ -227,7 +246,14 @@ export async function runAllChecks(
markRunStatus(state, reconcileRunStatus(state));
await saveRunState(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 --------------------------
@@ -312,6 +338,7 @@ export async function runAllChecks(
ui: opts.ui,
hasUI,
existingState: state,
gitignore: opts.gitignore,
});
state = outcome.state;
ran.push(check.name);

View File

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

View File

@@ -9,8 +9,8 @@
* @module pygienium/run-state
*/
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
export const RUN_STATE_DIRNAME = ".pygienium";
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). */
export function applyPhaseStatus(
state: RunState,