diff --git a/README.md b/README.md index 8d78490..8c55394 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,9 @@ registerCheck(def) ← checks/*.ts self-register on load ├─ Q0 recon (shared, run once per run) (src/recon.ts) │ git state + source-file inventory → .pygienium/recon.json ├─ analysis sub-agent (buildScanTask) ← scanner/ agent - │ writes pygienium/checks//findings.md + │ writes .pygienium/checks//findings.md ├─ fix sub-agent (buildFixTask) ← fixer, only with --fix - │ writes pygienium/checks//changes.md + │ writes .pygienium/checks//changes.md ├─ verify gate (re-runs check.gate) └─ cleanup (drops transient scratch artifacts) ``` diff --git a/src/checks/comments.ts b/src/checks/comments.ts index 63c8fea..9a735f1 100644 --- a/src/checks/comments.ts +++ b/src/checks/comments.ts @@ -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 `/pygienium/checks/comments/`): + * Artifacts (under `/.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: `/pygienium/checks/comments/`. Based on `scope.cwd` (the + * artifacts: `/.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, diff --git a/src/checks/complexity.ts b/src/checks/complexity.ts index fb94908..16af1d7 100644 --- a/src/checks/complexity.ts +++ b/src/checks/complexity.ts @@ -28,11 +28,11 @@ import { scopeRulesMarkdown } from "./scope.js"; export const COMPLEXITY_PHASE_ID = "C4"; /** - * Artifact directory: `/pygienium/checks/complexity/`. + * Artifact directory: `/.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 35–49 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, diff --git a/src/checks/dead-code.ts b/src/checks/dead-code.ts index db24844..45f54bb 100644 --- a/src/checks/dead-code.ts +++ b/src/checks/dead-code.ts @@ -950,14 +950,14 @@ export async function applyDeadCodeFixes( // Artifact paths // --------------------------------------------------------------------------- -const CHECK_DIRNAME = "pygienium/checks/dead-code"; +const CHECK_DIRNAME = ".pygienium/checks/dead-code"; -/** `/pygienium/checks/dead-code/findings.md` */ +/** `/.pygienium/checks/dead-code/findings.md` */ export function findingsPath(target: string): string { return join(target, CHECK_DIRNAME, "findings.md"); } -/** `/pygienium/checks/dead-code/changes.md` */ +/** `/.pygienium/checks/dead-code/changes.md` */ export function changesPath(target: string): string { return join(target, CHECK_DIRNAME, "changes.md"); } diff --git a/src/checks/deep-modules.ts b/src/checks/deep-modules.ts index da40831..8271ebd 100644 --- a/src/checks/deep-modules.ts +++ b/src/checks/deep-modules.ts @@ -11,7 +11,7 @@ * * Lifecycle: * gate (need source files) → recon (shared) → scan sub-agent writes - * `/pygienium/checks/deep-modules/findings.md` → [with --fix] fix + * `/.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. */ diff --git a/src/checks/defensive-guards.ts b/src/checks/defensive-guards.ts index ea63225..818ced2 100644 --- a/src/checks/defensive-guards.ts +++ b/src/checks/defensive-guards.ts @@ -30,7 +30,7 @@ * * Lifecycle: * gate (need source files) → recon (shared) → scan sub-agent writes - * `/pygienium/checks/defensive-guards/findings.md` separating redundant + * `/.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. */ diff --git a/src/commands.ts b/src/commands.ts index d2c494d..3db4aa6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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 { - 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; } diff --git a/src/export.ts b/src/export.ts index 3f8e8b5..eed4954 100644 --- a/src/export.ts +++ b/src/export.ts @@ -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: `/pygienium/checks//` — the single canonical + * Artifact root: `/.pygienium/checks//` — 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 `/pygienium/` (the artifact root). */ +/** Resolve `/.pygienium/` (the artifact root). */ export function pygieniumArtifactDir(cwd: string): string { return join(cwd, PYGIENIUM_ARTIFACT_DIR); } -/** Resolve `/pygienium/checks/`. */ +/** Resolve `/.pygienium/checks/`. */ export function canonicalChecksRoot(cwd: string): string { return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR); } -/** Resolve `/pygienium/export.`. */ +/** Resolve `/.pygienium/export.`. */ 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 `/pygienium/checks/` root, + * Gather artifact entries from the canonical `/.pygienium/checks/` root, * one entry per check directory. Entries are sorted alphabetically. Marks an * entry `unknown` when its check is absent from `state`. */ diff --git a/src/help.ts b/src/help.ts index d8dcb2b..41541f4 100644 --- a/src/help.ts +++ b/src/help.ts @@ -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: ", 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", }, ]; diff --git a/src/modes/all.ts b/src/modes/all.ts index 0e4f74a..9e28855 100644 --- a/src/modes/all.ts +++ b/src/modes/all.ts @@ -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 `/pygienium/all-summary.md`. */ +/** Resolve `/.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 `/.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); diff --git a/src/modes/check-runner.ts b/src/modes/check-runner.ts index 56b9388..9424cfa 100644 --- a/src/modes/check-runner.ts +++ b/src/modes/check-runner.ts @@ -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). */ diff --git a/src/run-state.ts b/src/run-state.ts index fbb5a2f..65c34cb 100644 --- a/src/run-state.ts +++ b/src/run-state.ts @@ -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 { ); } +/** + * 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(); + +/** + * Make sure `/.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 { + 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, diff --git a/tests/all-integration.test.ts b/tests/all-integration.test.ts index 5d1abce..e54999d 100644 --- a/tests/all-integration.test.ts +++ b/tests/all-integration.test.ts @@ -35,9 +35,9 @@ function fakeCheck(name: string): CheckDefinition { fixAgentName: "fixer", phaseId: "scan", 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) => - `!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, }; } diff --git a/tests/all.test.ts b/tests/all.test.ts index 8879849..3eb5ebe 100644 --- a/tests/all.test.ts +++ b/tests/all.test.ts @@ -4,7 +4,7 @@ * Mirrors the spec scenario: run `/pygienium-all` on a small repo and assert: * - every registered check runs exactly once in registry order; * - 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; * - interrupted/resumed runs re-dispatch non-terminal checks while skipping * terminal ones, unless `--fresh` resets everything. @@ -57,9 +57,9 @@ function fakeCheck(name: string): CheckDefinition { fixAgentName: "fixer", phaseId: "scan", 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) => - `!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, }; } @@ -132,12 +132,16 @@ describe("/pygienium-all orchestrator (task 12)", () => { 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); expect(p.target).toBe(join(cwd, "subdir")); expect(p.fix).toBe(true); expect(p.fresh).toBe(true); + expect(p.gitignore).toBe(true); // default: keep the .gitignore guard on 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", () => { @@ -176,7 +180,7 @@ describe("/pygienium-all orchestrator (task 12)", () => { 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("beta")); @@ -193,7 +197,7 @@ describe("/pygienium-all orchestrator (task 12)", () => { expect(summary).toContain("changes:"); // Artifacts actually exist on disk. const alphaFindings = await readFile( - join(cwd, "pygienium", "checks", "alpha", "findings.md"), + join(cwd, ".pygienium", "checks", "alpha", "findings.md"), "utf8", ); expect(alphaFindings).toContain("alpha findings"); @@ -233,7 +237,7 @@ describe("/pygienium-all orchestrator (task 12)", () => { await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd))); expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases const firstAlphaFix = await readFile( - join(cwd, "pygienium", "checks", "alpha", "changes.md"), + join(cwd, ".pygienium", "checks", "alpha", "changes.md"), "utf8", ); @@ -255,7 +259,7 @@ describe("/pygienium-all orchestrator (task 12)", () => { expect(state3?.status).toBe("complete"); // The fresh re-run overwrote alpha's changes.md (still valid content). const alphaFix2 = await readFile( - join(cwd, "pygienium", "checks", "alpha", "changes.md"), + join(cwd, ".pygienium", "checks", "alpha", "changes.md"), "utf8", ); expect(alphaFix2).toContain("alpha changes"); @@ -278,7 +282,7 @@ describe("/pygienium-all orchestrator (task 12)", () => { finishedAt: Date.now(), }; // 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 writeFile( join(alphaDir, "findings.md"), diff --git a/tests/deep-modules.test.ts b/tests/deep-modules.test.ts index 2a54131..ae34688 100644 --- a/tests/deep-modules.test.ts +++ b/tests/deep-modules.test.ts @@ -145,15 +145,15 @@ describe("deep-modules check", () => { 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); const check = getCheck("deep-modules")!; await handleCheckCommand(check, "--fix", stubCtx(cwd)); expect(findingsPath(cwd)).toBe( - join(cwd, "pygienium", "checks", "deep-modules", "findings.md"), + join(cwd, ".pygienium", "checks", "deep-modules", "findings.md"), ); expect(changesPath(cwd)).toBe( - join(cwd, "pygienium", "checks", "deep-modules", "changes.md"), + join(cwd, ".pygienium", "checks", "deep-modules", "changes.md"), ); }); diff --git a/tests/defensive-guards.test.ts b/tests/defensive-guards.test.ts index 7fa54d6..f8b2529 100644 --- a/tests/defensive-guards.test.ts +++ b/tests/defensive-guards.test.ts @@ -208,16 +208,16 @@ describe("defensive-guards check", () => { 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 seedBoundary(cwd); const check = getCheck("defensive-guards")!; await handleCheckCommand(check, "--fix", stubCtx(cwd)); expect(findingsPath(cwd)).toBe( - join(cwd, "pygienium", "checks", "defensive-guards", "findings.md"), + join(cwd, ".pygienium", "checks", "defensive-guards", "findings.md"), ); expect(changesPath(cwd)).toBe( - join(cwd, "pygienium", "checks", "defensive-guards", "changes.md"), + join(cwd, ".pygienium", "checks", "defensive-guards", "changes.md"), ); }); diff --git a/tests/status-resume-export.test.ts b/tests/status-resume-export.test.ts index 763d00d..0eff4bd 100644 --- a/tests/status-resume-export.test.ts +++ b/tests/status-resume-export.test.ts @@ -71,9 +71,9 @@ function fakeCheck(name: string): CheckDefinition { fixAgentName: "fixer", phaseId: "scan", 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) => - `!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, }; } @@ -86,7 +86,7 @@ function stubCtx(cwd: string): PygieniumCtx { function trackingRunner(): { runner: AgentRunner; dispatched: string[] } { const dispatched: string[] = []; const runner: AgentRunner = async (opts) => { - // Tag by check name from the task text (`!write pygienium/checks//`). + // Tag by check name from the task text (`!write .pygienium/checks//`). const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task); if (m) dispatched.push(m[1] as string); return fakeAgentRunner(opts); @@ -297,7 +297,7 @@ describe("status / resume / export (task 13)", () => { void state; 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("## alpha (complete)"); expect(bundle).toContain("## beta (complete)"); @@ -314,7 +314,7 @@ describe("status / resume / export (task 13)", () => { await captureStdout(() => 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).not.toContain("## alpha"); }); @@ -330,7 +330,7 @@ describe("status / resume / export (task 13)", () => { await captureStdout(() => 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).not.toContain("## beta"); }); @@ -345,7 +345,7 @@ describe("status / resume / export (task 13)", () => { handleExportCommand("--out=json", stubCtx(cwd)), ); 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 { checks: Array<{ name: string; status: string; findings: string }>; }; @@ -370,20 +370,22 @@ describe("status / resume / export (task 13)", () => { expect(f.out).toBe("json"); }); - it("gatherExportEntries reads only the canonical pygienium/checks/ root", async () => { - await mkdir(join(cwd, "pygienium", "checks", "alpha"), { recursive: true }); - 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"), { + it("gatherExportEntries reads only the canonical .pygienium/checks/ root", async () => { + await mkdir(join(cwd, ".pygienium", "checks", "alpha"), { recursive: true, }); 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", "utf8", ); @@ -393,7 +395,7 @@ describe("status / resume / export (task 13)", () => { expect(alpha?.findings).toContain("alpha findings"); expect(alpha?.status).toBe("unknown"); 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(); }); @@ -415,6 +417,6 @@ describe("status / resume / export (task 13)", () => { it("exportRun writes nothing useful and reports zero entries cleanly", async () => { const result = await exportRun(cwd, undefined, {}); 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")); }); });