commit 581436ed236b48a7018bcc102ed35501a05bfa68 Author: Michael Freno Date: Fri Aug 7 14:54:45 2026 -0400 Initial commit: pygenium as git submodule diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7a26a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules/ +dist/ +.pi-lens/ +.ralpi/ +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4c9507 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Pygienium + +Code hygiene for [pi](https://github.com/earendil-works/pi-coding-agent) — isolated +sub-agent checks that scan a target, apply fixes, and emit a findings + changes +report. Inspired by piolium's sub-agent loops. + +Pygienium runs **highly-structured hygiene passes** over a repo to clean up the +common quality issues LLM-generated code accumulates: restating comments, +shallow pass-through modules, dead exports/files, and redundant defensive +guards. Each check is an isolated, resumable sub-agent run whose progress lands +in a single inspectable run-state file. + +## Install + +Pygienium is a local pi extension under `~/.pi/agent/extensions/pygienium/`. +Pi loads it via `pi.extensions` in `package.json` (entry `./src/index.ts`). + +```sh +# from the extension root +bun install # optional: only needed for typecheck/test dev deps +bun run typecheck +bun test +``` + +Pi auto-discovers the extension from this location via the `pi.extensions` entry in `package.json` — no settings.json config is needed. On load it emits a TUI notification `Pygienium loaded. Run /pygienium-help for available checks and flags.` (only when a dialog-capable UI is available). + +## Commands + +Every command accepts a `[path]` target (default: the current directory) and is +resumable — progress is persisted to `/.pygienium/run-state.json`. + +| Command | What it does | +| --- | --- | +| `/pygienium-help` | Print every command, shipped check, and flag. | +| `/pygienium- [path] [--fix]` | Run one isolated sub-agent that scans a target, applies fixes with `--fix`, and emits a findings + changes report. | +| `/pygienium-all [path] [--fix]` | Run every registered check in sequence under one resumable run-state. | +| `/pygienium-status [path]` | Show per-check progress, artifact line counts, and errors for the latest run. | +| `/pygienium-resume [path] [--fresh]` | Resume the latest in-progress/failed/partial run; complete/skipped checks skip unless `--fresh`. | +| `/pygienium-export [path] [--check=] [--status=] [--out=md\|json]` | Bundle every check's `findings.md` + `changes.md` into `pygienium/export.{md\|json}`. | + +## Flags + +| Flag | Scope | Description | +| --- | --- | --- | +| `[path]` | all check commands | Target file or directory to scan (default: current dir). | +| `--fix` | ``, `all`, `resume` | Apply fixes (default: scan-only; emits findings only). | +| `--fresh` | `resume` | Re-dispatch completed checks too — reset their run-state entries and re-run. | +| `--check=` | `export` | Comma-separated check names to include in the bundle. | +| `--status=` | `export` | Comma-separated statuses to include (e.g. `complete,failed,skipped`). | +| `--out=` | `export` | Bundle format: `md` (default) or `json`. | + +## Checks + +The five shipped checks live in [`src/checks/`](./src/checks/) and self-register +on load. `/pygienium-help` lists whichever checks are currently registered, so +this table and the live help always agree on the registered set. + +| Command | Check | Agent | What it fixes | +| --- | --- | --- | --- | +| `/pygienium-comments` | comments | `scanner` / `fixer` | Remove low-value/restating comments, tighten verbose ones, keep "why" comments. | +| `/pygienium-deep-modules` | deep-modules | `deep-modules` | Detect shallow modules (pass-throughs, trivial wrappers, re-export barrels) and consolidate the safe ones. | +| `/pygienium-dead-code` | dead-code | `scanner` / `fixer` | Find unreferenced exports, dead files, obsolete compat shims, migration helpers, and unused dependencies; remove clearly-dead items and flag dynamic ones. | +| `/pygienium-defensive-guards` | defensive-guards | `defensive-guards` | Remove redundant defensive guarding (null checks on non-nullable types, swallowing try/catch, masking fallbacks) while keeping boundary guards (IO, parsing, untrusted input). | +| `/pygienium-noop` | noop | `scanner` / `fixer` | Reference/template check — performs no analysis, writes zero-issue artifacts. Clone it to start a new check. | + +## Artifacts + +Each check writes its report under `/pygienium/checks//`: + +- `findings.md` — what the scan found (per-file line refs). +- `changes.md` — what the fix phase changed + anything deferred for human review. + +`/pygienium-export` merges every check's artifacts into one +`pygienium/export.md` (or `export.json`). Some checks historically wrote under +`.pygienium/checks/`; export walks **both** roots and merges by check name +(`pygienium/` wins). + +## Adding a check + +One file + one `registerCheck()` call. **No `index.ts` command-wiring changes.** +`index.ts` auto-discovers every `checks/*.ts` (except the registry barrel) at +startup, so a new file self-registers and `/pygienium-` appears +automatically. + +1. Clone `src/checks/noop.ts` → `src/checks/.ts`. +2. Edit the `name`, `label`, `description`, the rubric in the scan/fix task + builders, and the `gate` precondition. +3. Keep the trailing `registerCheck(Check)`. Done. + +```ts +import { registerCheck, type CheckScope } from "./registry.js"; + +export const myCheck = { + name: "my-check", + label: "My check", + description: "What it fixes (shown in /pygienium-help).", + agentName: "scanner", // reuse a shipped agent, or add agents/.md + fixAgentName: "fixer", + phaseId: "my-check", + buildScanTask: (_cwd: string, scope: CheckScope) => `# Task: my-check scan\n…`, + buildFixTask: (_cwd: string, scope: CheckScope, findings: string) => `# Task: my-check fix\n…`, + gate: (cwd: string) => undefined, +} as const; + +registerCheck(myCheck); +``` + +Reload pi (or `/reload`) and run `/pygienium-help` — `/pygienium-my-check` is +listed and runnable. A `CheckDefinition` supplies the task builders and gate; +the generic check-runner wires the phases together, so a new check never +touches command plumbing. + +## Architecture + +Each check is a "mode" running a fixed phase pipeline: + +``` +registerCheck(def) ← checks/*.ts self-register on load + │ + ▼ +/pygienium- ─► runCheck(def) (src/modes/check-runner.ts) + │ + ├─ 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 + ├─ fix sub-agent (buildFixTask) ← fixer, only with --fix + │ writes pygienium/checks//changes.md + ├─ verify gate (re-runs check.gate) + └─ cleanup (drops transient scratch artifacts) +``` + +- **Sub-agents** are isolated in-memory `AgentSession`s scoped to the target + `cwd` (see `src/agent-runner.ts`), with the agent definition's system prompt + and tool allowlist applied. Agent definitions are plain editable markdown in + [`agents/`](./agents/) (frontmatter `name` + `allowedTools`, body = system + prompt) — tuning a sub-agent never needs TypeScript changes. +- **Run-state** is a single JSON file at `/.pygienium/run-state.json` + (`src/run-state.ts`): per-check phase progress, captured findings/changes + text, and recon status. `/pygienium-status`, `/pygienium-resume`, and + `/pygienium-export` are pure reads over it; `/pygienium-all` shares one + `RunState` across every check so phases accumulate in one record. +- **The registry** (`src/checks/registry.ts`) is the extensibility seam: a + module-level `Map` of `CheckDefinition`s. `index.ts` iterates it and binds + one `/pygienium-` command per entry, so adding a check is a file + + one `registerCheck()` line. + +## Layout + +``` +pygienium/ +├─ src/ +│ ├─ index.ts ← entry: auto-discover checks, bind commands +│ ├─ commands.ts ← slash-command handlers (thin binders) +│ ├─ help.ts ← COMMANDS + CLI_FLAGS → /pygienium-help output +│ ├─ agent-runner.ts ← isolated sub-agent sessions (injectable for tests) +│ ├─ agents.ts ← markdown agent-definition loader +│ ├─ recon.ts ← shared Q0 reconnaissance snapshot +│ ├─ run-state.ts ← persistent, resumable run-state model +│ ├─ status.ts ← /pygienium-status formatter (pure) +│ ├─ export.ts ← /pygienium-export gatherer + md/json renderer +│ ├─ phases.ts ← footer status-strip UI adapter +│ ├─ modes/check-runner.ts ← the per-check phase pipeline +│ └─ checks/ ← one file per check, self-registering +│ ├─ registry.ts ← CheckDefinition + registerCheck +│ ├─ comments.ts deep-modules.ts dead-code.ts +│ ├─ defensive-guards.ts noop.ts +└─ agents/ ← scanner.md fixer.md deep-modules.md defensive-guards.md +``` + +## License + +MIT diff --git a/agents/.gitkeep b/agents/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agents/deep-modules.md b/agents/deep-modules.md new file mode 100644 index 0000000..7253ef4 --- /dev/null +++ b/agents/deep-modules.md @@ -0,0 +1,101 @@ +--- +name: deep-modules +allowedTools: + - read + - grep + - find + - ls + - bash + - write +--- +You are the **Pygienium deep-modules scanner** sub-agent — an abstraction-depth +analyst. + +# Your role + +You run the "deep modules, not shallow ones" check against a target path. You +inspect source files, classify modules by abstraction depth, and write a +structured findings report to disk. You do NOT fix anything — that is the +fixer's job. You only inspect and report. + +# What "shallow module" means + +"Deep modules" is John Ousterhout's term (*A Philosophy of Software Design*): +a module (file, class, function set) should hide a substantial implementation +behind a small interface. A **shallow module** exposes as much complexity as it +hides — its interface is as complicated as its implementation, so it adds +indirection with no abstraction payoff. + +Flag these shapes (non-exhaustive): + +- **Pass-through wrapper** — a module/function whose body forwards every + argument to a single library call, adding no validation, transformation, or + policy. +- **One-line re-export module** — a file whose only content is + `export { x } from "./y"` (barrel passthrough) that forwards a name without + adding grouping, aliases, or cohesion. +- **Trivial getter class** — a class whose methods are only `return this.x` + accessors with no behavioural logic. +- **Unnecessary adapter layer** — an adapter/indirection that reshapes an API + but is consumed in exactly one place and could be replaced by the adaptee + directly. + +Do NOT flag modules that add real value: validation, caching, policy, +error-mapping, multi-call orchestration, meaningful grouping (a barrel that +aggregates many scattered modules), or public API stability boundaries. + +# Operating contract + +- Operate only within the target path given in the task. +- Use `read`, `grep`, `find`, `ls` to inspect source files. +- `bash` is available for read-only inspection only (`wc`, `head`, `git ls-files`, + `cat`). Never mutate source files. +- `write` is ONLY for writing your findings report to the output path named in + the task (under the project's `.pygienium/` state directory). Never `write` + source files. +- Before classifying a module as shallow, check whether it has **external + importers** (`grep -rn "from .*"` or equivalent). A module with many + importers or that sits on a public API boundary is riskier to consolidate — + note the importer count so the fixer can decide. + +# Output + +Write your full findings report to the **findings path** given in the task +(typically `/.pygienium/checks/deep-modules/findings.md`). + +`findings.md` format — a markdown document: + +```markdown +# Deep-modules findings + +summary: shallow module(s) flagged of reviewed + +## 1. +- kind: pass-through-wrapper | one-line-reexport | trivial-getter-class | adapter-layer +- evidence: +- importers: (risk: low if 0, high if >0) +- recommendation: inline-and-remove | consolidate-with- | review-manually +- risk: low | high +``` + +If the target is clean, write: + +```markdown +# Deep-modules findings + +summary: 0 shallow module(s) flagged of reviewed + +No shallow modules detected. +``` + +After writing `findings.md`, emit a terse one-line summary as your final message: + +``` +deep-modules: issue(s) — see +``` + +# Tone + +Precise and terse. Quote the shallow code only when it clarifies the finding. +Always state the importer count and risk so the fixer can apply safe +consolidations and defer risky ones. diff --git a/agents/defensive-guards.md b/agents/defensive-guards.md new file mode 100644 index 0000000..92d347d --- /dev/null +++ b/agents/defensive-guards.md @@ -0,0 +1,124 @@ +--- +name: defensive-guards +allowedTools: + - read + - grep + - find + - ls + - bash + - write +--- +You are the **Pygienium defensive-guards scanner** sub-agent — a defensive-code +analyst. + +# Your role + +You run the "redundant defensive guarding" check against a target path. You +inspect source files, classify every guard (null/undefined check, try/catch, +fallback) as either REDUNDANT or a legitimate BOUNDARY guard, and write a +structured findings report to disk. You do NOT fix anything — that is the +fixer's job. You only inspect and report. + +# What "redundant defensive guarding" means + +Defensive code is noise when it guards an invariant the type system or an +upstream validation already guarantees. It is correct when it guards a genuine +external boundary where failure is expected and must be handled. + +**Flag as redundant (disposition: remove):** + +- **redundant-null-check** — `if (x === null)` / `x != null` / `x ?? fallback` + on a value whose declared type is already non-nullable (e.g. a `string` + param, a value just returned from a non-nullable constructor). +- **swallowing-try-catch** — try/catch that silently discards the error (empty + catch body, catch that only `console.log`s, or catch returning a default that + hides the failure). An unhandled exception is usually better than a silent + wrong value. +- **rethrow-only-try-catch** — try/catch whose catch body only `throw`s the + exact caught error with no mapping, logging, or cleanup — net zero value. +- **error-masking-fallback** — `catch { return defaultValue }` or + `x || fallback` that substitutes a plausible-but-wrong value for a real + failure, masking the bug at the call site. +- **defensive-guard-on-validated-input** — re-checking input a caller or parser + already validated (e.g. asserting a parsed enum is still in range after the + parser guaranteed it). +- **compatibility-fallback** — a fallback branch explicitly kept "for now", + "to be removed later", or "backwards compat" (engineering rule: remove + fallbacks meant to be replaced later — don't layer). + +**Keep as boundary (disposition: keep-boundary):** + +- **untrusted-input-guard** — validation of data crossing a trust boundary: + HTTP params, CLI args, environment variables, query results, files read + from disk that could be malformed by a user or another process. +- **io-guard** — try/catch around IO where failure is expected and must be + reported gracefully: network calls, filesystem reads, subprocess spawning. +- **parsing-guard** — try/catch around parsers of untrusted data: `JSON.parse`, + `parseInt`/`parseFloat` on user input, `Date.parse`, schema decoders, `.toml`/ + `.yaml`/`.csv` loaders. Malformed input is the normal case, not a bug. + +The key judgment: guarding **external boundaries** (IO, untrusted input, +parsing) is correct; guarding **internal invariants** the type system +guarantees is noise. + +# Operating contract + +- Operate only within the target path given in the task. +- Use `read`, `grep`, `find`, `ls` to inspect source files. +- `bash` is for read-only inspection only (`grep -n`, `wc`, `git ls-files`, + `cat`). Never mutate source files. +- `write` is ONLY for writing your findings report to the output path named in + the task (under the project's `.pygienium/` state directory). Never `write` + source files. +- When classifying a null check, look at the declared type of the value being + checked (`grep` for its declaration/annotation). A `null` check on a + `string | null` union is legitimate; on a bare `string` it is redundant. + +# Output + +Write your full findings report to the **findings path** given in the task +(typically `/.pygienium/checks/defensive-guards/findings.md`). + +`findings.md` MUST separate redundant guards from boundary guards. Format: + +```markdown +# Defensive-guards findings + +summary: redundant guard(s) flagged, boundary guard(s) kept of reviewed + +## Redundant (remove) + +### 1. : +- kind: redundant-null-check | swallowing-try-catch | rethrow-only-try-catch | error-masking-fallback | defensive-guard-on-validated-input | compatibility-fallback +- evidence: +- reason: + +## Boundary (keep) + +### 1. : +- kind: untrusted-input-guard | io-guard | parsing-guard +- evidence: +- reason: +``` + +If the target is clean, write: + +```markdown +# Defensive-guards findings + +summary: 0 redundant guard(s) flagged, 0 boundary guard(s) kept of reviewed + +No redundant defensive guarding detected. +``` + +After writing `findings.md`, emit a terse one-line summary as your final +message: + +``` +defensive-guards: redundant, boundary kept — see +``` + +# Tone + +Precise and terse. Always state the declared type when calling a null check +redundant, and always state which boundary a kept guard protects. diff --git a/agents/fixer.md b/agents/fixer.md new file mode 100644 index 0000000..4248998 --- /dev/null +++ b/agents/fixer.md @@ -0,0 +1,43 @@ +--- +name: fixer +allowedTools: + - read + - edit + - write + - grep + - find + - ls + - bash +--- +You are the **Pygienium fixer** sub-agent — a careful code-hygiene remediator. + +# Your role + +You receive the scanner's findings and apply minimal, surgical fixes to the +target path. You only touch code implicated by the findings. + +# Operating contract + +- Operate only within the target path given in the task. +- Use `read`/`grep`/`find`/`ls` to locate each finding. +- Use `edit` for in-place edits; use `write` only when creating a new file is + explicitly warranted by the check. +- `bash` is for read-only verification only (`git diff`, `grep -n`). Never run + mutating shell commands — the host applies edits through tools. +- Prefer the smallest diff that resolves the finding without changing + unrelated behaviour. Never reformat whole files. +- Preserve existing tests and conventions; if a fix would change public API, + skip it and report it as "manual" instead. + +# Changes format + +End your response with a fenced `changes` block: + +```changes +1. : (auto) +2. — skipped: (manual) +``` + +# Tone + +Terse. State the file, the line, and the fix. Do not narrate exploration. diff --git a/agents/scanner.md b/agents/scanner.md new file mode 100644 index 0000000..e187e44 --- /dev/null +++ b/agents/scanner.md @@ -0,0 +1,45 @@ +--- +name: scanner +allowedTools: + - read + - grep + - find + - ls + - bash +--- +You are the **Pygienium scanner** sub-agent — a focused code-hygiene analyst. + +# Your role + +You run a single, isolated hygiene check against a target path. You do NOT edit +files; that is the fixer's job. You only inspect and report. + +# Operating contract + +- Operate only within the target path given in the task. +- Use `read`, `grep`, `find`, `ls` to inspect source files. +- `bash` is available only for read-only inspection (`git log`, `wc`, `cat`). + Never mutate files. +- Emit a concise findings report as your final message. + +# Findings format + +End your response with a fenced `findings` block summarising what you found: + +```findings +: issue(s) +1. [severity: high|med|low] : +2. ... +``` + +If the target is clean, emit: + +```findings +: 0 issues +``` + +# Tone + +Be precise and terse. Quote the offending code only when it clarifies a finding. +Do not propose fixes unless the task explicitly asks — the fixer agent receives +your findings separately. diff --git a/package.json b/package.json new file mode 100644 index 0000000..f0a5104 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "pygienium", + "version": "0.1.0", + "description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.", + "keywords": [ + "pi-package", + "pi-extension", + "code-hygiene", + "lint", + "subagents", + "pygienium" + ], + "license": "MIT", + "type": "module", + "engines": { + "bun": ">=1.1.0" + }, + "pi": { + "extensions": [ + "./src/index.ts" + ] + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-agent-core": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-ai": { + "optional": true + }, + "@earendil-works/pi-agent-core": { + "optional": true + }, + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "@earendil-works/pi-tui": { + "optional": true + }, + "typebox": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + } +} diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/agent-runner.ts b/src/agent-runner.ts new file mode 100644 index 0000000..a2ac6a0 --- /dev/null +++ b/src/agent-runner.ts @@ -0,0 +1,181 @@ +/** + * agent-runner.ts — spawn isolated sub-agents for analysis and fix phases. + * + * The production runner uses pi's `createAgentSession` SDK to spin up a fresh + * in-memory agent session scoped to the target `cwd`, with the agent + * definition's system prompt and tool allowlist applied. Because that path + * needs live model credentials (unsuitable for CI), the runner is backed by an + * injectable factory: tests swap it for a deterministic fake that executes a + * tiny instruction protocol embedded in the task string. + * + * Instruction protocol (used by the fake runner, harmless to the real one): + * task lines may begin with `!write ` — the fake writes the file + * and reports it as a finding. Real sub-agents receive the whole task verbatim. + * + * @module pygienium/agent-runner + */ + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; +import { loadAgents, type AgentDef } from "./agents.js"; + +export interface AgentTaskOptions { + /** Absolute working directory for the sub-agent. */ + cwd: string; + /** Agent name to look up in `agents/*.md`. */ + agentName: string; + /** The task prompt handed to the sub-agent. */ + task: string; + /** Optional tool allowlist override (else uses the agent's `allowedTools`). */ + allowedTools?: string[]; + /** Optional explicit agent definition (skips `loadAgents`). */ + agent?: AgentDef; +} + +export interface AgentRunResult { + /** Whether the sub-agent completed without throwing. */ + ok: boolean; + /** The final assistant text emitted by the sub-agent. */ + text: string; + /** Error message when `ok` is false. */ + error?: string; +} + +export type AgentRunner = (opts: AgentTaskOptions) => Promise; + +/** Module-level runner (defaults to the SDK-backed runner; tests override it). */ +let currentRunner: AgentRunner = defaultAgentRunner; + +/** Entry point used by the check-runner. */ +export function runAgentTask(opts: AgentTaskOptions): Promise { + return currentRunner(opts); +} + +/** Override the active agent runner (primarily for tests). */ +export function setAgentRunner(runner: AgentRunner): void { + currentRunner = runner; +} + +/** Restore the default SDK-backed agent runner. */ +export function resetAgentRunner(): void { + currentRunner = defaultAgentRunner; +} + +/** + * Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`, + * overrides the system prompt with the agent definition's body, restricts tools + * to the agent's allowlist, and runs the task to completion. + */ +export async function defaultAgentRunner( + opts: AgentTaskOptions, +): Promise { + const agent = + opts.agent ?? (await loadAgents({ cwd: opts.cwd })).get(opts.agentName); + if (!agent) { + return { + ok: false, + text: "", + error: `Unknown agent definition: "${opts.agentName}". Add agents/${opts.agentName}.md.`, + }; + } + + // Lazily import the SDK so the rest of the module graph (and tests using the + // fake runner) never resolve the heavy pi-coding-agent package. + const { + createAgentSession, + DefaultResourceLoader, + getAgentDir, + SessionManager, + } = await import("@earendil-works/pi-coding-agent"); + const loader = new DefaultResourceLoader({ + cwd: opts.cwd, + agentDir: getAgentDir(), + systemPromptOverride: () => agent.systemPrompt, + // Keep the sub-agent isolated: no nested extensions/skills/themes/etc. + noExtensions: true, + noSkills: true, + noThemes: true, + noPromptTemplates: true, + }); + await loader.reload(); + + const tools = opts.allowedTools ?? + agent.allowedTools ?? ["read", "bash", "grep", "find"]; + + const { session } = await createAgentSession({ + cwd: opts.cwd, + tools, + sessionManager: SessionManager.inMemory(opts.cwd), + resourceLoader: loader, + }); + + try { + let text = ""; + const unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if ( + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" + ) { + text += event.assistantMessageEvent.delta; + } + }); + await session.prompt(opts.task, { expandPromptTemplates: false }); + unsubscribe(); + return { ok: true, text }; + } catch (err) { + return { + ok: false, + text: "", + error: err instanceof Error ? err.message : String(err), + }; + } finally { + try { + session.dispose(); + } catch { + /* ignore dispose errors */ + } + } +} + +/** + * Fake agent runner for tests: it understands a tiny instruction protocol + * embedded in the task so a no-op check can produce deterministic findings + * and write marker files without a model. Recognised instructions (one per + * line, leading-whitespace tolerant): + * + * !write — write text to path (relative to cwd); recorded + * !echo — appended to findings + * + * The agent's emitted findings text is the collected `!echo`/`!write` lines. + */ +export const fakeAgentRunner: AgentRunner = async (opts) => { + const lines = opts.task.split(/\r?\n/); + const findings: string[] = []; + try { + for (const line of lines) { + const trimmed = line.trim(); + const write = /^!write\s+(\S+)\s*(.*)$/.exec(trimmed); + if (write) { + const rel = write[1] as string; + const content = (write[2] ?? "").replace(/^["']|["']$/g, ""); + const full = isAbsolute(rel) ? rel : join(opts.cwd, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content + "\n", "utf8"); + findings.push(`wrote ${rel}`); + continue; + } + const echo = /^!echo\s+(.*)$/.exec(trimmed); + if (echo) { + findings.push((echo[1] ?? "").replace(/^["']|["']$/g, "")); + } + } + return { ok: true, text: findings.join("\n") }; + } catch (err) { + return { + ok: false, + text: findings.join("\n"), + error: err instanceof Error ? err.message : String(err), + }; + } +}; diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..db02563 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,150 @@ +/** + * agents.ts — markdown agent-definition loader. + * + * Reads agent definitions from `agents/*.md` shipped with the extension so the + * analysis and fix roles are plain editable markdown — no TypeScript changes + * needed to tune a sub-agent's behaviour. Each `.md` file uses a YAML + * frontmatter block to declare its `name` and `allowedTools`; the body becomes + * the agent's system prompt. + * + * File shape: + * + * --- + * name: scanner + * allowedTools: + * - read + * - grep + * - find + * --- + * You are a code-hygiene scanner … + * + * @module pygienium/agents + */ + +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** A loaded agent definition. */ +export interface AgentDef { + /** Unique agent name (matches `agentName` on a `CheckDefinition`). */ + name: string; + /** The markdown body, used verbatim as the sub-agent system prompt. */ + systemPrompt: string; + /** Tool names the sub-agent may use (`read`, `bash`, …), or undefined to inherit defaults. */ + allowedTools?: string[]; + /** Absolute path to the source `.md` file. */ + sourcePath: string; +} + +/** Resolve the extension root (the directory holding `package.json` and `agents/`). */ +export function extensionRoot(): string { + // src/agents.ts → ../ = extension root. + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, ".."); +} + +/** Parse a YAML-ish frontmatter block from markdown. Only the keys we use. */ +function parseFrontmatter(raw: string): { + frontmatter: Record; + body: string; +} { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw); + if (!match) return { frontmatter: {}, body: raw }; + const fmText = match[1] ?? ""; + const body = match[2] ?? ""; + const frontmatter: Record = {}; + const lines = fmText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + // YAML block-list: a key with an empty value followed by "- item" lines. + const blockList = /^([A-Za-z_][A-Za-z0-9_-]*):\s*$/.exec(trimmed); + if (blockList) { + const key = blockList[1] as string; + const items: string[] = []; + let j = i + 1; + for (; j < lines.length; j++) { + const item = /^\s+-\s+(.*)$/.exec(lines[j] ?? ""); + if (!item) break; + items.push((item[1] ?? "").trim().replace(/^["']|["']$/g, "")); + } + if (items.length > 0) { + frontmatter[key] = items; + i = j - 1; + } + continue; + } + + // Inline key/value (also handles inline lists like key: [a, b]). + const idx = trimmed.indexOf(":"); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1); + const valueList: string[] = []; + for (const part of inner.split(",")) { + const v = part.trim().replace(/^["']|["']$/g, ""); + if (v) valueList.push(v); + } + frontmatter[key] = valueList; + } else { + frontmatter[key] = value.replace(/^["']|["']$/g, ""); + } + } + return { frontmatter, body: body.trim() + "\n" }; +} + +function asStringList(value: unknown): string[] | undefined { + if (value == null) return undefined; + if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean); + if (typeof value === "string") { + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + } + return undefined; +} + +function asString(value: unknown): string | undefined { + if (value == null) return undefined; + if (typeof value === "string") return value; + return String(value); +} + +/** + * Load every `agents/*.md` definition from the extension's `agents/` dir. + * Returns a map keyed by agent name. Missing directory → empty map (so an + * extension without shipped agents still boots, e.g. in tests). + */ +export async function loadAgents(_opts?: { + cwd?: string; +}): Promise> { + const dir = join(extensionRoot(), "agents"); + const result = new Map(); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return result; + } + for (const entry of entries) { + if (!entry.endsWith(".md")) continue; + const sourcePath = join(dir, entry); + const raw = await readFile(sourcePath, "utf8"); + const { frontmatter, body } = parseFrontmatter(raw); + const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length); + const allowedTools = asStringList(frontmatter.allowedTools); + result.set(name, { + name, + systemPrompt: body, + allowedTools, + sourcePath, + }); + } + return result; +} diff --git a/src/checks/.gitkeep b/src/checks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/checks/comments.ts b/src/checks/comments.ts new file mode 100644 index 0000000..c6f714e --- /dev/null +++ b/src/checks/comments.ts @@ -0,0 +1,231 @@ +/** + * checks/comments.ts — comments hygiene check (first end-to-end reference). + * + * This is the canonical `CheckDefinition` that future checks (08+) copy. It + * reuses the generic `scanner`/`fixer` agents shipped in `agents/*.md`; the + * check-specific rubric is embedded in the task text handed to the sub-agent + * (see {@link buildCommentsScanTask} / {@link buildCommentsFixTask}) so no + * per-check agent `.md` file is required. + * + * Rubric (the user's spec — short + high value): + * - comments that restate the code they sit on ("what" comments) → REMOVE + * - verbose narration / long-winded explanations → TIGHTEN (shorten) + * - "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/`): + * - `findings.md` — per-file line refs for each smell + * - `changes.md` — summary of edits + human-review items + * + * @module pygienium/checks/comments + */ + +import { registerCheck, type CheckScope } from "./registry.js"; + +/** Phase-strip phase this check belongs to. */ +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 + * 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. + */ +export function commentsArtifactDir(scope: CheckScope): string { + const base = scope.cwd.replace(/\/+$/, ""); + return `${base}/pygienium/checks/comments`; +} + +/** Absolute path to the findings artifact for this check. */ +export function findingsPath(scope: CheckScope): string { + return `${commentsArtifactDir(scope)}/findings.md`; +} + +/** Absolute path to the changes artifact for this check. */ +export function changesPath(scope: CheckScope): string { + return `${commentsArtifactDir(scope)}/changes.md`; +} + +/** + * Shared rubric block, injected into both scan and fix task text so the analysis + * and remediation sub-agents apply identical judgement. + */ +const RUBRIC = `# Comments hygiene rubric + +Short + high value is the goal. Evaluate every comment in the target: + +- **RESTATE → REMOVE.** A comment that paraphrases the line(s) it sits on adds + no information. Examples: \`// increment i\` over \`i++\`, \`// return the + result\` over \`return result\`. Delete it. +- **VERBOSE → TIGHTEN.** A comment that is high-value but needlessly long. + Rewrite it to one tight sentence preserving the key insight. Do not delete. +- **"WHY" → KEEP.** A comment explaining intent, rationale, a non-obvious + decision, a workaround, a gotcha, or a constraint the code cannot express. + Leave it untouched (tighten only if it is also verbose). +- **NO COMMENT NEEDED.** When the code is self-explanatory, do not add a comment. +- Keep inline section headers/dividers that aid navigation only if they mark a + real boundary; remove pure decoration.`; + +/** + * 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`. + */ +export function buildCommentsScanTask(_cwd: string, scope: CheckScope): string { + const outDir = commentsArtifactDir(scope); + const findingsFile = findingsPath(scope); + return `# Task: comments hygiene scan + +You are running the **comments** hygiene check. + +## Target +- Scan target: \`${scope.target}\` + +## What to do +1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it + exists; otherwise enumerate source files directly under the target. +2. For each source file, read it and locate every comment (inline \`//\`, + block \`/* */\`, doc \`/** */\`, \`#\` for scripting languages, etc.). +3. Apply the rubric below to each comment and classify it: RESTATE, VERBOSE, + WHY, or OK. +4. Write a findings report to \`${findingsFile}\` with per-file line refs. +5. Return the findings report text as your final message (same content as the + file). The host captures it as the analysis-phase findings. + +${RUBRIC} + +## findings.md format + +\`\`\`markdown +# comments — findings + + comment smell(s) across file(s). + +## +- L: +- L: KEEP (why) — # listed for transparency +\`\`\` + +If no smells are found, write \`# comments — findings\n\n0 comment smell(s).\` +and return that text. Always create findings.md so the run has an artifact. + +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`. + */ +export function buildCommentsFixTask( + _cwd: string, + scope: CheckScope, + findings: string, +): string { + const outDir = commentsArtifactDir(scope); + const changesFile = changesPath(scope); + return `# Task: comments hygiene fix + +You are running the **comments** hygiene fix phase. + +## Target +- Fix target: \`${scope.target}\` + +## Input: scan findings +${findings.trim().length > 0 ? findings : "(no findings text provided)"} + +## What to do +1. For each RESTATE finding: remove the comment entirely. +2. For each VERBOSE finding: replace the comment with a tightened one-sentence + version that keeps the key insight. +3. For every WHY comment: leave it untouched (tighten only if it is also + verbose, preserving the rationale). +4. Do not change any code logic, formatting, or ordering — only comments. +5. Write a summary to \`${changesFile}\` and return it as your final message. + +${RUBRIC} + +## changes.md format + +\`\`\`markdown +# comments — changes + + edit(s) applied; deferred for human review. + +## Applied +- : comment (auto) + +## Needs human review +- : (manual) +\`\`\` + +If nothing needed changing, write +\`# comments — changes\n\n0 edit(s) applied.\` and return that text. Always +create changes.md so the run has an artifact. Write it under \`${outDir}\`. +`; +} + +/** + * Precondition gate. Returns an error string when the comments check cannot + * proceed (target path missing or not a real file/directory), else + * `undefined`. Idempotent — passes identically before analysis and at verify. + */ +async function commentsGate(cwd: string): Promise { + const { stat } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + const target = resolve(cwd); + try { + const s = await stat(target); + if (s.isDirectory() || s.isFile()) return undefined; + return `target is not a file or directory: ${target}`; + } catch { + return `target path does not exist: ${target}`; + } +} + +/** + * Verify hook: confirms the check actually produced its artifacts. After the + * scan phase `findings.md` must exist; after the fix phase `changes.md` must + * exist too (the scan-phase-only run skips `changes.md` by design). Returns an + * error string to fail verify, or `undefined` to pass. Replaces the historical + * no-op verify (which only re-ran the existence gate) so the verify phase now + * genuinely asserts the run produced its report. + */ +async function commentsVerify(scope: CheckScope): Promise { + const { stat } = await import("node:fs/promises"); + const f = findingsPath(scope); + try { + await stat(f); + } catch { + return `comments verify: expected findings.md at ${f} after scan, none found.`; + } + if (scope.fix) { + const c = changesPath(scope); + try { + await stat(c); + } catch { + return `comments verify: expected changes.md at ${c} after --fix, none found.`; + } + } + return undefined; +} + +/** The comments hygiene check definition. */ +export const commentsCheck = { + name: "comments", + label: "Comments", + description: + 'Remove low-value/restating comments, tighten verbose ones, keep "why" comments.', + agentName: "scanner", + fixAgentName: "fixer", + phaseId: COMMENTS_PHASE_ID, + buildScanTask: buildCommentsScanTask, + buildFixTask: buildCommentsFixTask, + gate: commentsGate, + verify: commentsVerify, +} as const; + +// Self-register on import so index.ts auto-discovery picks it up. +registerCheck(commentsCheck); diff --git a/src/checks/complexity.ts b/src/checks/complexity.ts new file mode 100644 index 0000000..1589bc1 --- /dev/null +++ b/src/checks/complexity.ts @@ -0,0 +1,287 @@ +/** + * checks/complexity.ts — excessive complexity check. + * + * Detects high cyclomatic complexity and structural complexity smells, then + * refactors toward the simplest implementation that meets requirements. + * + * Cyclomatic complexity thresholds (MUST enforce, not advisory): + * - 50+ → must refactor. No exceptions. + * - 35–49 → heavy skepticism. Only keep if critical path + justified. + * - <35 → not flagged on cyclomatic grounds (may still be flagged for other + * structural smells). + * + * Structural smells detected: + * - deep nesting (>3 levels) + * - speculative abstractions + * - premature config indirection + * - non-idiomatic patterns + * - over-engineered generics + * - unnecessary wrappers + * + * @module pygienium/checks/complexity + */ + +import { registerCheck, type CheckScope } from "./registry.js"; + +/** Phase-strip phase this check belongs to. */ +export const COMPLEXITY_PHASE_ID = "C4"; + +/** + * Artifact directory: `/pygienium/checks/complexity/`. + */ +export function complexityArtifactDir(scope: CheckScope): string { + const base = scope.cwd.replace(/\/+$/, ""); + return `${base}/pygienium/checks/complexity`; +} + +/** Absolute path to findings artifact. */ +export function findingsPath(scope: CheckScope): string { + return `${complexityArtifactDir(scope)}/findings.md`; +} + +/** Absolute path to changes artifact. */ +export function changesPath(scope: CheckScope): string { + return `${complexityArtifactDir(scope)}/changes.md`; +} + +/** + * Shared rubric for complexity analysis, injected into both scan and fix tasks. + */ +const RUBRIC = `# Complexity hygiene rubric + +## Cyclomatic complexity thresholds + +Cyclomatic complexity counts the number of independent paths through a function. +Compute via language-native tools when available (lizard, radon, gocyclo), or +count decision points (if/else if/for/while/case/&&/||/catch) per function. + +| Score | Action | +|-------|--------| +| 50+ | **MUST refactor.** No exceptions. Break the function into smaller pieces. | +| 35–49 | **Heavy skepticism.** Only keep if this is a massively critical point along the main path AND the complexity genuinely must be here. Document justification in findings.md; otherwise refactor. | +| <35 | Not flagged on cyclomatic grounds (may still be flagged for other structural smells). | + +## Structural complexity smells + +- **Deep nesting (>3 levels).** Flatten with early returns, guard clauses, or extracting to named helpers. +- **Speculative abstractions.** Remove abstractions created "just in case" — no concrete use case yet. +- **Premature config indirection.** Remove configuration layers that add no value yet. +- **Non-idiomatic patterns.** Replace with common conventions for the language. +- **Over-engineered generics.** Simplify to concrete types when only one type is used. +- **Unnecessary wrappers.** Inline trivial wrappers that add no logic. + +## Refactoring principles + +1. **Simplest implementation.** Choose the simplest implementation that fully meets current requirements. +2. **No backward-compat baggage.** Remove obsolete paths rather than adding compatibility layers. +3. **Grow in layers.** Build on a product that already works; don't trade a working product for unfinished complexity. +4. **Use existing libraries.** Lean on well-maintained libraries when they reduce complexity or improve reliability. +5. **Long-term decisions.** Make architectural decisions for the long term, not stopgaps meant to be replaced later. +`; + +/** + * 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 + */ +export function buildComplexityScanTask( + _cwd: string, + scope: CheckScope, +): string { + const outDir = complexityArtifactDir(scope); + const findingsFile = findingsPath(scope); + return `# Task: excessive complexity scan + +You are running the **complexity** hygiene check. + +## Target +- Scan target: \`${scope.target}\` + +## What to do + +### 1. Compute cyclomatic complexity + +For each source file in the target: + +1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it + exists; otherwise enumerate source files directly under the target. +2. For each file, identify every function/method/class. +3. Compute cyclomatic complexity: + - Prefer language-native tools (lizard, radon, gocyclo, etc.) when available + - Fall back to counting decision points: if/else if/for/while/case/&&/||/catch +4. Classify each function into bands: + - **50+** = MUST refactor (no exceptions) + - **35–49** = heavy skepticism (must justify or refactor) + - **<35** = not flagged on cyclomatic grounds + +### 2. Identify structural complexity smells + +For each file, identify: +- Deep nesting (>3 levels) +- Speculative abstractions +- Premature config indirection +- Non-idiomatic patterns +- Over-engineered generics +- Unnecessary wrappers + +### 3. Write findings + +Write a findings report to \`${findingsFile}\` with per-function scores and +structural smell locations. Include a proposed simpler form for every flagged +function. + +${RUBRIC} + +## findings.md format + +\`\`\`markdown +# complexity — findings + +## Cyclomatic complexity + +| File | Function | Score | Band | Action | +|------|----------|-------|------|--------| +| path/to/file:42 | myFunction | 65 | 50+ | MUST refactor | +| path/to/file:100 | otherFunction | 42 | 35-49 | Skepticism — justify or refactor | +| path/to/file:150 | simpleFunction | 8 | <35 | OK | + +## Structural smells + +- [severity] : +- + +## Justifications (35–49 band) + +For each function kept at 35–49 complexity: +- **Function:** at : +- **Score:** +- **Justification:** +\`\`\` + +If no issues found, write: +\`# complexity — findings\n\n0 complexity issues found.\` + +Always create findings.md so the run has an artifact. +`; +} + +/** + * Build the fix sub-agent task from the scan findings. Instructs the agent to: + * 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 + */ +export function buildComplexityFixTask( + _cwd: string, + scope: CheckScope, + findings: string, +): string { + const outDir = complexityArtifactDir(scope); + const changesFile = changesPath(scope); + return `# Task: excessive complexity fix + +You are running the **complexity** hygiene fix phase. + +## Target +- Fix target: \`${scope.target}\` + +## Input: scan findings +${findings.trim().length > 0 ? findings : "(no findings text provided)"} + +## What to do + +### 1. Handle 50+ functions (MUST refactor) + +For each function with cyclomatic complexity ≥ 50: +- Split into smaller, focused functions +- Extract complex conditional branches into named helper functions +- Use early returns and guard clauses to reduce nesting +- Preserve behavior after refactoring + +### 2. Handle 35–49 functions + +For each function in the 35–49 band: +- If no justified critical-path reason exists, refactor +- If kept, ensure justification is documented in findings.md +- Prefer refactoring over keeping + +### 3. Apply structural refactors + +- Flatten deep nesting (>3 levels) +- Remove speculative abstractions +- Inline trivial wrappers +- Replace non-idiomatic patterns with conventional ones +- Simplify over-engineered generics to concrete types + +### 4. Write changes summary + +Write a summary to \`${changesFile}\` and return it as your final message. + +${RUBRIC} + +## changes.md format + +\`\`\`markdown +# complexity — changes + + refactoring(s) applied; deferred for human review. + +## Applied + +- : split (was , now ) +- : — nested conditionals flattened +- : — trivial wrapper inlined +- : — speculative abstraction removed + +## Deferred (needs human review) + +- : (manual) + +## Justified (kept at 35–49) + +- : () — +\`\`\` + +If nothing needed changing, write: +\`# complexity — changes\n\n0 refactoring(s) applied.\` + +Always create changes.md so the run has an artifact. Write it under \`${outDir}\`. +`; +} + +/** + * Precondition gate. Returns an error string when the complexity check cannot + * proceed (target path missing or not a real file/directory), else + * `undefined`. + */ +async function complexityGate(cwd: string): Promise { + const { stat } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + const target = resolve(cwd); + try { + const s = await stat(target); + if (s.isDirectory() || s.isFile()) return undefined; + return `target is not a file or directory: ${target}`; + } catch { + return `target path does not exist: ${target}`; + } +} + +/** The excessive complexity check definition. */ +export const complexityCheck = { + name: "complexity", + label: "Complexity", + description: + "Detect and refactor excessive complexity: high cyclomatic complexity (50+ must refactor, 35-49 needs justification), deep nesting, and speculative abstractions.", + agentName: "scanner", + fixAgentName: "fixer", + phaseId: COMPLEXITY_PHASE_ID, + buildScanTask: buildComplexityScanTask, + buildFixTask: buildComplexityFixTask, + gate: complexityGate, +} as const; + +// Self-register on import so index.ts auto-discovery picks it up. +registerCheck(complexityCheck); diff --git a/src/checks/dead-code.ts b/src/checks/dead-code.ts new file mode 100644 index 0000000..14d6019 --- /dev/null +++ b/src/checks/dead-code.ts @@ -0,0 +1,1157 @@ +/** + * checks/dead-code.ts — dead code and obsolete paths check. + * + * Finds unreferenced exports, files with zero importers, obsolete compatibility + * shims / migration helpers, and unused dependencies — then removes the + * clearly-dead items (engineering rule: no backward-compat layers) while + * preserving items that are only reachable through dynamic imports or runtime + * registration, which are listed for human review instead (pi-lens + * "suspected dead weight" semantics: zero/single-importer files get a review + * flag, not an unconditional delete). + * + * The check is hybrid by design: `buildScanTask`/`buildFixTask` run a + * deterministic pre-scan (import-graph + heuristics) and materialise + * `/.pygienium/checks/dead-code/`, + * then hand the sub-agent a prompt to verify/refine the pre-computed report. + * That keeps the E2E behaviour reproducible (and testable without a model) + * while the sub-agent's language-level judgment stays authoritative for the + * ambiguous cases. + * + * @module pygienium/checks/dead-code + */ + +import { + readFile, + readdir, + stat, + mkdir, + writeFile, + rm, +} from "node:fs/promises"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { + registerCheck, + type CheckDefinition, + type CheckScope, +} from "./registry.js"; + +/** Dead-code categories recognised by the rubric. */ +export type DeadCategory = "export" | "file" | "shim" | "dep"; + +/** Removal target of a dead-code item: the whole file or one symbol. */ +export type RemovalTarget = "file" | "symbol"; + +/** One dead-code finding. */ +export interface DeadCodeItem { + category: DeadCategory; + /** Absolute path of the offending file. */ + path: string; + /** Path relative to the scanned target (for reports). */ + rel: string; + /** Symbol name when the item is an export/shim symbol. */ + name?: string; + /** 1-based line of the declaration, when known. */ + line?: number; + /** Whether to remove the whole file or just the symbol. */ + target: RemovalTarget; + /** + * `true` when removal is ambiguous — dynamically imported, runtime + * registered, an entry point, or a still-referenced compat shim. Review + * items are preserved by `--fix` and surfaced for human judgement. + */ + review: boolean; + /** Human-readable justification. */ + reason: string; +} + +/** Full report of one dead-code scan. */ +export interface DeadCodeReport { + /** Absolute scanned target. */ + target: string; + /** ISO timestamp of the scan. */ + scannedAt: string; + /** All candidates, unsorted across categories. */ + items: DeadCodeItem[]; +} + +/** Categories in report display order. */ +export const CATEGORY_LABELS: Record = { + export: "Unused exports", + file: "Dead files (zero importers)", + shim: "Obsolete shims / migration helpers", + dep: "Unused dependencies", +}; + +/** Source extensions worth scanning for import graphs. */ +const SOURCE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".swift", + ".php", + ".cs", + ".lua", +]); + +/** Directories never scanned (build output, deps, tooling). */ +const IGNORED_DIRS = new Set([ + "node_modules", + ".git", + ".pygienium", + "dist", + "build", + "out", + "coverage", + ".next", + ".nuxt", + ".venv", + "__pycache__", + "vendor", + ".ralpi", +]); + +/** Extensions tried when resolving a bare import specifier. */ +const RESOLVE_EXTENSIONS = [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", +]; + +const EXT = (name: string): string => { + const i = name.lastIndexOf("."); + return i === -1 ? "" : name.slice(i).toLowerCase(); +}; + +/** Filename signals of a compat/deprecated/migration shim. */ +const SHIM_NAME_RE = + /(?:compat|legacy|deprecated|obsolete|obsoleted|migration|migrate|backcompat|back-compat|fallback|shim)/i; + +/** Inline signals of an actually-obsolete/deprecated declaration. Tagged + * markers only — bare prose words ("deprecated", "obsolete", "legacy", + * "compat") are deliberately excluded so generic comments cannot mislabel an + * ordinary or entry-like file as a compat shim and auto-delete it on `--fix`. */ +const SHIM_TAG_RE = + /@deprecated|@obsolete|@deprecat(?:ed|ion)?|migration helper|\bback(?:wards?-)?compat\b/i; + +/** Entry-ish basenames — zero-importer files that are likely app entries. */ +const ENTRY_NAME_RE = /^(?:index|main|cli|app|server|entry|bin|start)\./i; + +/** Strip a leading scope (`@scope/name`) so deps match `name` and `name/sub`. */ +function depStem(spec: string): string { + return spec.startsWith("@") + ? spec.split("/").slice(0, 2).join("/") + : (spec.split("/")[0] ?? spec); +} + +/** Recursively collect source files under `root` (or `[root]` when it is a file). */ +async function walkSourceFiles(root: string): Promise { + const st = await stat(root).catch(() => undefined); + if (!st) return []; + if (st.isFile()) return SOURCE_EXTENSIONS.has(EXT(root)) ? [root] : []; + const out: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const dir = stack.pop() as string; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name)) continue; + stack.push(full); + } else if (entry.isFile() && SOURCE_EXTENSIONS.has(EXT(entry.name))) { + out.push(full); + } + } + } + return out.sort(); +} + +/** Resolve an import specifier to an absolute project file, or undefined. */ +async function resolveImport( + fromFile: string, + spec: string, +): Promise { + if (!spec.startsWith(".") && !spec.startsWith("/")) return undefined; + const base = resolve(dirname(fromFile), spec); + const candidates = [base]; + for (const e of RESOLVE_EXTENSIONS) { + candidates.push(base + e); + candidates.push(join(base, `index${e}`)); + } + for (const candidate of candidates) { + const st = await stat(candidate).catch(() => undefined); + if (st?.isFile()) return candidate; + } + return undefined; +} + +interface FileInfo { + path: string; + rel: string; + text: string; + /** Files that import this one via a static `import`/`require` statement. */ + staticImporters: Set; + /** Files that reference this one via `import(...)` / runtime registration. */ + dynamicImporters: Set; +} + +/** One exported symbol: the bound name and whether it comes from an `export {}` list. */ +interface DeclaredExport { + name: string; + line: number; + /** `true` when exported via `export { … }` (a list), not a direct declaration. */ + fromList: boolean; +} + +/** Extract declared export names with their 1-based declaration lines. */ +function declaredExports(text: string): DeclaredExport[] { + const out: DeclaredExport[] = []; + const re = + /export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g; + for (const m of text.matchAll(re)) { + if (m[1]) + out.push({ + name: m[1] as string, + line: lineOf(text, m.index ?? 0), + fromList: false, + }); + } + // `export { a, b as c }` named export lists (local re-exports). + const reExport = /export\s*\{([^}]*)\}/g; + for (const m of text.matchAll(reExport)) { + const inner = m[1] ?? ""; + for (const part of inner.split(",")) { + const name = part + .trim() + .split(/\s+as\s+/)[0] + ?.trim(); + if (name && /^[A-Za-z_$][\w$]*$/.test(name)) { + out.push({ + name, + line: lineOf(text, m.index ?? 0), + fromList: true, + }); + } + } + } + return out; +} + +function lineOf(text: string, index: number): number { + return text.slice(0, index).split("\n").length; +} + +/** Does `text` look like a barrel (aggregating re-exports)? */ +function isBarrel(text: string): boolean { + return /export\s*\{|export\s+\*\s+from|export\s*\{[^}]*\}\s*from/.test(text); +} + +/** Fetch the nearest package.json from `target` upward (within `cwd`). */ +async function findPackageJson( + target: string, + cwd: string, +): Promise { + const dir = stat(target) + .then((s) => (s.isDirectory() ? target : dirname(target))) + .catch(() => dirname(target)); + const start = await dir; + const stop = resolve(cwd); + let current = start; + // eslint-disable-next-line no-constant-condition + while (true) { + const candidate = join(current, "package.json"); + if (await stat(candidate).catch(() => undefined)) return candidate; + if (current === stop || dirname(current) === current) return undefined; + current = dirname(current); + } +} + +/** + * Deterministic dead-code scan: walks the target, builds a lightweight import + * graph, and classifies candidates into `export` / `file` / `shim` / `dep`. + * Ambiguity (dynamic imports, runtime registration, entry points, referenced + * compat shims) is captured via the `review` flag rather than guessed away. + */ +export async function detectDeadCode(target: string): Promise { + const files = await walkSourceFiles(target); + const infos = new Map(); + for (const path of files) { + const text = await readFile(path, "utf8").catch(() => ""); + infos.set(path, { + path, + rel: relative(target, path), + text, + staticImporters: new Set(), + dynamicImporters: new Set(), + }); + } + + // --- Pass 1: resolve every import/require/re-export edge ----------------- + const allSpecs = new Set(); + // Paths that are the SOURCE of a `export { … } from` / `export * from` + // re-export. Their whole export surface is reachable through the barrel even + // when no other file names the symbols, so their exports must never be + // auto-removed by a bare name search. + const reexportTargets = new Set(); + for (const info of infos.values()) { + const text = info.text; + // static: re `import x from '…'`, `import '…'`, `require('…')` + const staticRe = + /(?:^\s*import\s+(?:[^'"`]*?\s+from\s+)?|require\(\s*)(['"`])([^'"`]+)\1/gm; + for (const m of text.matchAll(staticRe)) { + const spec = m[2] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) infos.get(resolved)?.staticImporters.add(info.path); + } + // barrel re-exports aggregate into a barrel graph edge: `export * from '…'` + const barrelStarRe = /^\s*export\s+\*\s+from\s*(['"`])([^'"`]+)\1/gm; + for (const m of text.matchAll(barrelStarRe)) { + const spec = m[2] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) { + infos.get(resolved)?.staticImporters.add(info.path); + reexportTargets.add(resolved); + } + } + // `export { a, b } from '…'` + const barrelNamedRe = + /^\s*export\s*\{[^}]*\}\s*from\s*(['"`])([^'"`]+)\1/gm; + for (const m of text.matchAll(barrelNamedRe)) { + const spec = m[2] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) { + infos.get(resolved)?.staticImporters.add(info.path); + reexportTargets.add(resolved); + } + } + // dynamic: `import("…")` + const dynamicRe = /import\(\s*['"`]([^'"`]+)['"`]\s*\)/g; + for (const m of text.matchAll(dynamicRe)) { + const spec = m[1] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) infos.get(resolved)?.dynamicImporters.add(info.path); + } + } + + // --- Pass 2: classify candidates ---------------------------------------- + const items: DeadCodeItem[] = []; + // Paths whose whole file is a candidate (dead file, dynamic module, or + // compat shim) — their exports are handled at file level, never individually. + const wholeFileTarget = new Set(); + const packageJson = await findPackageJson(target, resolve(target)); + const packageJsonText = packageJson + ? await readFile(packageJson, "utf8").catch(() => undefined) + : undefined; + let packageJsonDeps: Record | undefined; + if (packageJsonText) { + try { + const parsed = JSON.parse(packageJsonText) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + scripts?: Record; + }; + packageJsonDeps = { + ...(parsed.dependencies ?? {}), + ...(parsed.devDependencies ?? {}), + ...(parsed.peerDependencies ?? {}), + ...(parsed.optionalDependencies ?? {}), + }; + const referencedNames = new Set(); + for (const spec of allSpecs) referencedNames.add(depStem(spec)); + for (const name of Object.keys(packageJsonDeps)) { + const stem = depStem(name); + const inScripts = Object.values(parsed.scripts ?? {}).some((s) => + s.includes(stem), + ); + if (!referencedNames.has(stem) && !inScripts) { + items.push({ + category: "dep", + path: packageJson as string, + rel: relative(target, packageJson as string), + name, + target: "symbol", + review: false, + reason: `"${name}" is declared in package.json but never imported or required by any source file.`, + }); + } + } + } catch { + /* unparseable package.json — skip dep analysis */ + } + } + + const packageJsonMain = + packageJsonText && packageJson + ? (() => { + try { + const p = JSON.parse(packageJsonText) as { + main?: string; + bin?: string | Record; + }; + return [p.main, ...Object.values(p.bin ?? {})] + .filter((v): v is string => typeof v === "string") + .map((v) => resolve(dirname(packageJson), v)); + } catch { + return [] as string[]; + } + })() + : ([] as string[]); + + for (const info of infos.values()) { + const staticImports = info.staticImporters.size; + const dynamicImports = info.dynamicImporters.size; + const filename = basename(info.path); + const shimNamed = SHIM_NAME_RE.test(filename); + const shimText = SHIM_TAG_RE.test(info.text); + const isShim = shimNamed || shimText; + const entryLike = + ENTRY_NAME_RE.test(filename) || + packageJsonMain.includes(info.path) || + /\.(?:config|test|spec|setup|env)\./i.test(filename) || + /^test(?:s)?[\\/]/i.test(info.rel); + + if (isShim) { + // Auto-remove ONLY when the signal is trustworthy (filename match or + // a tagged marker), the file has no importers, and it is NOT an entry + // point / test / config file. Entry-like files and any shim that is + // still imported are deferred to manual review — never auto-deleted, + // so prose or naming alone can never delete a live entry point. + const autoRemovable = + (shimNamed || shimText) && + staticImports === 0 && + dynamicImports === 0 && + !entryLike; + items.push({ + category: "shim", + path: info.path, + rel: info.rel, + target: "file", + review: !autoRemovable, + reason: shimNamed + ? `"${filename}" matches a compat/legacy/migration shim naming pattern.` + : `"${info.rel}" carries a deprecated/obsolete tag (manual review).`, + }); + // Whole-file handling subsumes any symbol-level export cleanup. + wholeFileTarget.add(info.path); + continue; + } + + if (staticImports === 0 && dynamicImports === 0 && !entryLike) { + // Zero importers, zero dynamic references, not an entry point. + items.push({ + category: "file", + path: info.path, + rel: info.rel, + target: "file", + review: false, + reason: "No file statically or dynamically imports this module.", + }); + wholeFileTarget.add(info.path); + continue; + } + + if (staticImports === 0 && dynamicImports > 0) { + // Only reachable through dynamic import / runtime registration. + items.push({ + category: "file", + path: info.path, + rel: info.rel, + target: "file", + review: true, + reason: + "Only referenced via dynamic import / runtime registration — removal is a judgement call.", + }); + // Preserve whole file; never pick at its exports (runtime-registered). + wholeFileTarget.add(info.path); + continue; + } + + // Unused exported symbols — only for files not slated for whole-file + // treatment (dead files, dynamic/runtime modules, compat shims). + if (wholeFileTarget.has(info.path)) continue; + + // Exports of a barrel SOURCE (re-exported by `export * from` / + // `export {…} from`) are reachable by name through the barrel even when + // no other file spells the symbol out — the review flag gates deletion. + const barrel = isBarrel(info.text); + const reexported = + reexportTargets.has(info.path) || + barrel || + /^\s*export\s+\*\s+from/.test(info.text); + const exports = declaredExports(info.text); + for (const exp of exports) { + const externalRefs = [...infos.values()].some( + (other) => + other.path !== info.path && + new RegExp(`\\b${escapeRegExp(exp.name)}\\b`).test(other.text), + ); + if (externalRefs) continue; + // Anything reachable through a barrel or exported via an `export {}` + // list stays behind a review flag: a bare name search cannot prove it + // is dead, so the deterministic fixer must not auto-remove it. + const needsReview = reexported || exp.fromList; + items.push({ + category: "export", + path: info.path, + rel: info.rel, + name: exp.name, + line: exp.line, + target: "symbol", + review: + needsReview || + externalRefs || + (staticImports > 0 && dynamicImports > 0), + reason: needsReview + ? exp.fromList + ? `"${exp.name}" is exported via an export list (${info.rel}) — removal is a judgement call.` + : `"${exp.name}" is reachable through a barrel/reexport (${info.rel}) and referenced nowhere by name — confirm before removing.` + : `Exported symbol "${exp.name}" is never imported or referenced by any other source file.`, + }); + } + } + + return { target, scannedAt: new Date().toISOString(), items }; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// --------------------------------------------------------------------------- +// Report renderers +// --------------------------------------------------------------------------- + +/** Render `findings.md` for the report, grouped by category with review flags. */ +export function renderFindingsMd(report: DeadCodeReport): string { + const lines: string[] = []; + lines.push("# Dead code findings"); + lines.push(""); + lines.push(`- Target: \`${report.target}\``); + lines.push(`- Scanned: ${report.scannedAt}`); + lines.push(`- Items: ${report.items.length}`); + lines.push(""); + for (const category of Object.keys(CATEGORY_LABELS) as DeadCategory[]) { + const items = report.items.filter((i) => i.category === category); + if (items.length === 0) continue; + lines.push(`## ${CATEGORY_LABELS[category]}`); + lines.push(""); + for (const item of items) { + const loc = item.name + ? `${item.rel}:${item.line ?? "?"} — ${item.name}` + : `${item.rel}`; + const flag = item.review ? "review" : "auto"; + lines.push(`- [${flag}] ${loc} — ${item.reason}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +/** Render `changes.md` for applied edits + preserved review items. */ +export function renderChangesMd( + applied: DeadCodeItem[], + preserved: DeadCodeItem[], +): string { + const lines: string[] = []; + lines.push("# Dead code changes"); + lines.push(""); + lines.push(`- Applied: ${applied.length}`); + lines.push(`- Preserved for review: ${preserved.length}`); + lines.push(""); + lines.push("## Removed (auto)"); + lines.push(""); + if (applied.length === 0) lines.push("- none"); + for (const item of applied) { + if (item.category === "dep") { + lines.push(`- dep: ${item.rel} — remove "${item.name}" (auto)`); + } else if (item.target === "file") { + lines.push(`- file: ${item.rel} — deleted (auto)`); + } else { + lines.push( + `- export: ${item.rel}:${item.line ?? "?"} — ${item.name} — removed (auto)`, + ); + } + } + lines.push(""); + lines.push("## Preserved for review (manual)"); + lines.push(""); + if (preserved.length === 0) lines.push("- none"); + for (const item of preserved) { + const kind = + item.category === "dep" + ? "dep" + : item.target === "file" + ? "file" + : "export"; + const loc = item.name + ? `${item.rel}:${item.line ?? "?"} — ${item.name}` + : item.rel; + lines.push(`- ${kind}: ${loc} — ${item.reason}`); + } + return lines.join("\n").trimEnd() + "\n"; +} + +// --------------------------------------------------------------------------- +// Fix application +// --------------------------------------------------------------------------- + +/** + * Compute the fix plan for a report: the clearly-dead items to remove and the + * review items to preserve. Review items are never touched by `--fix`. + */ +export function planFixes(report: DeadCodeReport): { + remove: DeadCodeItem[]; + preserve: DeadCodeItem[]; +} { + const remove: DeadCodeItem[] = []; + const preserve: DeadCodeItem[] = []; + for (const item of report.items) { + if (item.review) preserve.push(item); + else remove.push(item); + } + return { remove, preserve }; +} + +/** + * Remove one exported symbol declaration from JS/TS source, returning the + * new text. Handles direct declarations (`export function/class/const/let/var + * name …`), named export-list members (`export { name }`); optionally followed + * by `from "…"`), and single- or multi-statement bodies (arrow functions with + * block bodies, object literals, inline-closing braces). + * + * Uses a brace/token-aware scanner rather than a regex so a declaration is + * removed whole instead of being truncated at the first `;` or bailing on an + * unusual closing layout. A final delimiter-balance guard refuses to write a + * corrupt file: if the edit would unbalance the source, the original text is + * returned unchanged and the caller must not claim a removal. + */ +function removeExportSymbol(text: string, name: string): string { + const esc = escapeRegExp(name); + + // 1. Direct declaration: `export (async) (function|class|const|let|var) name`. + const direct = new RegExp( + `export\\s+(?:async\\s+)?(enum|interface|type|function|class|const|let|var)\\s+${esc}\\b`, + ); + const dm = direct.exec(text); + if (dm) { + const keyword = dm[1] as string; + const bodyStart = dm.index + dm[0].length; + const funcLike = keyword === "function" || keyword === "class"; + const end = scanDeclarationEnd(text, bodyStart, funcLike); + if (end === -1) return text; + const next = spliceStatement(text, dm.index, end); + return delimitersBalanced(next) ? next : text; + } + + // 2. Export-list member: `export { a, name as b, c }` (optionally `from …`). + return removeFromExportList(text, name); +} + +/** Index just past the end of a declaration body, or -1 when unbalanced. */ +function scanDeclarationEnd( + text: string, + start: number, + funcLike: boolean, +): number { + let i = start; + let mode: "line" | "block" | "regex" | '"' | "'" | "`" | null = null; + let paren = 0; + let bracket = 0; + let brace = 0; + while (i < text.length) { + const c = text[i]; + const n = text[i + 1]; + if (mode === "line") { + if (c === "\n") mode = null; + i++; + continue; + } + if (mode === "block") { + if (c === "*" && n === "/") { + mode = null; + i += 2; + } else { + i++; + } + continue; + } + if (mode === '"' || mode === "'" || mode === "`") { + if (c === "\\") { + i += 2; + } else { + if (c === mode) mode = null; + i++; + } + continue; + } + if (mode === "regex") { + if (c === "\\") { + i += 2; + } else { + if (c === "/") mode = null; + i++; + } + continue; + } + if (c === "/" && n === "/") { + mode = "line"; + i += 2; + continue; + } + if (c === "/" && n === "*") { + mode = "block"; + i += 2; + continue; + } + if (c === '"' || c === "'" || c === "`") { + mode = c; + i++; + continue; + } + if (c === "/" && looksLikeRegexStart(text, i)) { + mode = "regex"; + i++; + continue; + } + if (c === "(") paren++; + else if (c === ")") { + if (paren > 0) paren--; + } else if (c === "[") bracket++; + else if (c === "]") { + if (bracket > 0) bracket--; + } else if (c === "{") brace++; + else if (c === "}") { + if (brace > 0) brace--; + if (paren === 0 && bracket === 0 && brace === 0) { + if (funcLike) return i + 1; + // Statement end at a closing brace (object literal / arrow body): + // fold in an immediately-following `;` if present. + let j = i + 1; + while (j < text.length && /\s/.test(text[j]) && text[j] !== "\n") { + j++; + } + return text[j] === ";" ? j + 1 : i + 1; + } + } else if ( + !funcLike && + c === ";" && + paren === 0 && + bracket === 0 && + brace === 0 + ) { + return i + 1; + } + i++; + } + return -1; +} + +/** Recognize a regex literal start at `/` (division operators are not). */ +function looksLikeRegexStart(text: string, i: number): boolean { + let j = i - 1; + while (j >= 0 && /\s/.test(text[j])) j--; + if (j < 0) return true; + return !/[A-Za-z0-9_)]}\\`'"`]/.test(text[j]); +} + +/** Remove the first `[start, end)` slice, collapsing the vacated line. */ +function spliceStatement(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(end).replace(/^\s*\n+/, ""); +} + +/** Remove `name` from an `export { … }` / `export { … } from "…"` list. */ +function removeFromExportList(text: string, name: string): string { + const listRe = /export\s*\{/g; + let m: RegExpExecArray | null; + while ((m = listRe.exec(text))) { + const openIdx = (m.index ?? 0) + m[0].length - 1; + const closeIdx = findClosingBrace(text, openIdx); + if (closeIdx === -1) continue; + const inner = text.slice(openIdx + 1, closeIdx); + const parts = inner.split(",").map((p) => p.trim()); + const remaining = parts.filter((p) => localName(p) !== name); + if (remaining.length === parts.length) continue; // name not in this list + if (remaining.length === 0) { + // The whole statement becomes empty — drop it (including `from …`). + const end = statementTailEnd(text, closeIdx); + const next = spliceStatement(text, m.index ?? 0, end); + return delimitersBalanced(next) ? next : text; + } + const rebuilt = remaining.map((p) => ` ${p}`).join(","); + const next = + text.slice(0, openIdx) + "{" + rebuilt + " }" + text.slice(closeIdx + 1); + return delimitersBalanced(next) ? next : text; + } + return text; +} + +/** The local binding name left of `as` in an export-list member. */ +function localName(part: string): string { + return part.split(/\s+as\s+/)[0]?.trim() ?? part.trim(); +} + +/** Index one past the `}` that matches `{` at `openIdx`, or -1. */ +function findClosingBrace(text: string, openIdx: number): number { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + if (text[i] === "{") depth++; + else if (text[i] === "}") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** End index of an emptied export statement: past `}`, optional `from '…'`, `;`. */ +function statementTailEnd(text: string, closeIdx: number): number { + const i = closeIdx + 1; + const from = /^\s*from\s*(['"`])((?:[^'"`;])*)\1\s*;?/.exec(text.slice(i)); + if (from) return i + from[0].length; + if (text[i] === ";") return i + 1; + return i; +} + +/** Whether `(), [], {}` are balanced (strings/templates/comments skipped). */ +function delimitersBalanced(text: string): boolean { + let paren = 0; + let bracket = 0; + let brace = 0; + let mode: "line" | "block" | "regex" | '"' | "'" | "`" | null = null; + let i = 0; + while (i < text.length) { + const c = text[i]; + const n = text[i + 1]; + if (mode === "line") { + if (c === "\n") mode = null; + i++; + continue; + } + if (mode === "block") { + if (c === "*" && n === "/") { + mode = null; + i += 2; + } else { + i++; + } + continue; + } + if (mode === '"' || mode === "'" || mode === "`") { + if (c === "\\") { + i += 2; + } else { + if (c === mode) mode = null; + i++; + } + continue; + } + if (mode === "regex") { + if (c === "\\") { + i += 2; + } else { + if (c === "/") mode = null; + i++; + } + continue; + } + if (c === "/" && n === "/") { + mode = "line"; + i += 2; + continue; + } + if (c === "/" && n === "*") { + mode = "block"; + i += 2; + continue; + } + if (c === '"' || c === "'" || c === "`") { + mode = c; + i++; + continue; + } + if (c === "/" && looksLikeRegexStart(text, i)) { + mode = "regex"; + i++; + continue; + } + if (c === "(") paren++; + else if (c === ")") paren--; + else if (c === "[") bracket++; + else if (c === "]") bracket--; + else if (c === "{") brace++; + else if (c === "}") brace--; + i++; + } + return paren === 0 && bracket === 0 && brace === 0; +} + +/** + * Apply the deterministic fixes for a report. Returns the items actually + * removed and the preserved review items. `dryRun` returns the plan without + * touching the filesystem. + */ +export async function applyDeadCodeFixes( + report: DeadCodeReport, + dryRun = false, +): Promise<{ applied: DeadCodeItem[]; preserved: DeadCodeItem[] }> { + const { remove, preserve } = planFixes(report); + const applied: DeadCodeItem[] = []; + + for (const item of remove) { + // Review-flag semantics gate deletions at the mutation site too: an item + // marked for review is never deleted, even if it slipped into `remove` + // (e.g. a hand-built report or a classifier that flipped a flag). + if (item.review) { + preserve.push(item); + continue; + } + if (item.category === "dep") { + // Rewrite package.json minus the unused dependency. + try { + const raw = await readFile(item.path, "utf8"); + const parsed = JSON.parse(raw) as Record; + let touched = false; + for (const key of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) { + const deps = parsed[key] as Record | undefined; + if (deps && item.name && deps[item.name] !== undefined) { + delete deps[item.name]; + touched = true; + } + } + if (touched) { + if (!dryRun) + await writeFile( + item.path, + JSON.stringify(parsed, null, 2) + "\n", + "utf8", + ); + applied.push(item); + } + } catch { + /* leave unparseable manifests alone */ + } + continue; + } + if (item.target === "file") { + if (!dryRun) await rm(item.path, { force: true }); + applied.push(item); + continue; + } + // Symbol-level removal (unused export / inline shim). + try { + const text = await readFile(item.path, "utf8"); + const next = removeExportSymbol(text, item.name ?? ""); + if (next !== text) { + if (!dryRun) await writeFile(item.path, next, "utf8"); + applied.push(item); + } + } catch { + /* unreadable file — leave it alone */ + } + } + + return { applied, preserved: preserve }; +} + +// --------------------------------------------------------------------------- +// Artifact paths +// --------------------------------------------------------------------------- + +const CHECK_DIRNAME = "pygienium/checks/dead-code"; + +/** `/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` */ +export function changesPath(target: string): string { + return join(target, CHECK_DIRNAME, "changes.md"); +} + +/** Write findings.md, returning its absolute path. */ +export async function writeFindingsFile( + target: string, + report: DeadCodeReport, +): Promise { + const path = findingsPath(target); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderFindingsMd(report), "utf8"); + return path; +} + +/** Write changes.md, returning its absolute path. */ +export async function writeChangesFile( + target: string, + applied: DeadCodeItem[], + preserved: DeadCodeItem[], +): Promise { + const path = changesPath(target); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderChangesMd(applied, preserved), "utf8"); + return path; +} + +// --------------------------------------------------------------------------- +// Task builders (sub-agent prompts) +// --------------------------------------------------------------------------- + +/** + * Scan task: pre-compute the candidate report, write `findings.md`, then ask + * the sub-agent to verify each candidate via grep/import-graph and emit the + * findings block. The pre-computed report keeps the agent's job to + * confirm/refute rather than re-derive the import graph from scratch. + */ +export async function buildDeadCodeScanTask( + cwd: string, + scope: CheckScope, +): Promise { + const report = await detectDeadCode(scope.target); + const path = await writeFindingsFile(scope.target, report); + return [ + `# Dead-code scan: ${scope.target}`, + ``, + `A deterministic import-graph pre-scan already ran. Its full, categorized report is`, + `written to \`${path}\`. Verify each candidate with \`grep\`/import-graph reasoning and`, + `correct any false positives before reporting.`, + ``, + `## Rubric — categorize findings by type`, + ``, + `1. **export** — exported functions/classes/consts referenced by no other module.`, + `2. **file** — files with zero importers (cross-check the pre-scan graph). Files only`, + ` reachable via \`import(...)\` or runtime registration are NOT dead: mark them`, + ` \`review\` and keep them in the findings under the review flag.`, + `3. **shim** — obsolete compatibility wrappers, deprecated aliases, migration`, + ` helpers (engineering rule: remove obsolete paths — do not keep compat layers).`, + `4. **dep** — dependencies declared in package.json but never imported/required.`, + ``, + `Items that are clearly dead (no importers, no dynamic reference, not an entry)`, + `are flagged \`auto\`. Items whose removal is ambiguous — dynamic imports, runtime`, + `registration (plugin manifests, lazy routes, decorators), entry points, or`, + `still-referenced compat shims — must be flagged \`review\` and never auto-removed.`, + ``, + `Write your verified findings back to \`${path}\` (same format), then end your`, + `response with a fenced \`findings\` block:`, + ``, + "```findings", + `dead-code: issue(s)`, + `1. [severity: high|med|low] : (auto|review)`, + "```", + ``, + `Target root: ${scope.target}. Work dir: ${cwd}.`, + ].join("\n"); +} + +/** + * Fix task: deterministically remove the clearly-dead items, write + * `changes.md`, then hand the sub-agent the remaining verification work. + * Review-flagged items (dynamic imports, runtime registration) are preserved + * and reported as `manual` — the sub-agent must NOT remove them. + */ +export async function buildDeadCodeFixTask( + cwd: string, + scope: CheckScope, + findings: string, +): Promise { + const report = await detectDeadCode(scope.target); + const { applied, preserved } = await applyDeadCodeFixes(report); + const path = await writeChangesFile(scope.target, applied, preserved); + return [ + `# Dead-code fix: ${scope.target}`, + ``, + `A deterministic fixer already ran against the pre-scan graph. By design it ONLY`, + `removed \`review:false\` (\`auto\`) items — unused exports, zero-importer files, obsolete`, + `shims, unused deps — and never touched \`review\` items. The change log is at`, + `\`${path}\`. Review it; then restore any auto-removal that looks like a false`, + `positive (the scan agent's classifier can be wrong) and re-list it as \`manual\`,`, + `and complete anything the pre-scan missed (use \`edit\` for symbol-level`, + `removals, \`rm\` via bash for dead files).`, + ``, + `## Hard rules`, + ``, + `- REMOVE clearly-dead items: unused exports, zero-importer files, obsolete`, + ` compat/migration shims, unused dependencies. Engineering rule: no compat layers.`, + `- NEVER remove \`review\`-flagged items: dynamically imported modules, runtime-`, + ` registered plugins/lazy routes, entry points, barrel re-export sources, or`, + ` export-list symbols. They stay — list them as manual.`, + `- If a listed auto-removal is wrong, revert it and record it as \`manual\` in`, + ` \`${path}\` rather than letting it stand.`, + `- Do not touch public API that is still imported; report it as \`manual\` instead.`, + ``, + `## Scanner findings`, + ``, + "```", + findings, + "```", + ``, + `Update \`${path}\` to reflect any additional removals you performed, then end your`, + `response with a fenced \`changes\` block:`, + ``, + "```changes", + `1. : (auto)`, + `2. — skipped: (manual)`, + "```", + ``, + `Target root: ${scope.target}. Work dir: ${cwd}.`, + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// Gate + registration +// --------------------------------------------------------------------------- + +/** Skip when the target holds no source files worth scanning. */ +export async function deadCodeGate(cwd: string): Promise { + const files = await walkSourceFiles(cwd); + if (files.length === 0) { + return "no source files found under target"; + } + return undefined; +} + +/** The registered `CheckDefinition` (module self-registers on import). */ +export const deadCodeCheck: CheckDefinition = { + name: "dead-code", + label: "Dead code", + description: + "Find unreferenced exports, dead files, obsolete compat shims, migration helpers, and unused dependencies; remove clearly-dead items and flag dynamic/runtime ones for review.", + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: buildDeadCodeScanTask, + buildFixTask: buildDeadCodeFixTask, + gate: deadCodeGate, +}; + +// Self-register so `index.ts` auto-discovers this check with zero wiring edits. +registerCheck(deadCodeCheck); diff --git a/src/checks/deep-modules.ts b/src/checks/deep-modules.ts new file mode 100644 index 0000000..9c0dc0b --- /dev/null +++ b/src/checks/deep-modules.ts @@ -0,0 +1,181 @@ +/** + * checks/deep-modules.ts — "deep modules, not shallow ones" check. + * + * Detects modules with shallow abstractions (thin pass-throughs, one-line + * re-export barrels, trivial getter classes, unnecessary adapter layers) and + * recommends/applies consolidation. The rubric encodes John Ousterhout's + * "deep modules" definition from *A Philosophy of Software Design*: a module + * is valuable when it hides a substantial implementation behind a small + * interface; a shallow one exposes as much complexity as it hides, so its + * indirection adds cost without abstraction payoff. + * + * Lifecycle: + * gate (need source files) → recon (shared) → scan sub-agent writes + * `/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. + * + * Registering this file is the ONLY wiring needed: `index.ts` auto-discovers + * `src/checks/*.ts`, so dropping this file exposes `/pygienium-deep-modules`. + * + * @module pygienium/checks/deep-modules + */ + +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { + registerCheck, + type CheckDefinition, + type CheckScope, +} from "./registry.js"; + +/** Output directory for this check's persistent reports. */ +export function deepModulesOutputDir(cwd: string): string { + return join(cwd, "pygienium", "checks", "deep-modules"); +} + +/** `findings.md` path for this check. */ +export function findingsPath(cwd: string): string { + return join(deepModulesOutputDir(cwd), "findings.md"); +} + +/** `changes.md` path for this check. */ +export function changesPath(cwd: string): string { + return join(deepModulesOutputDir(cwd), "changes.md"); +} + +/** Source extensions this check inspects. */ +const DEEP_MODULES_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".swift", + ".php", + ".cs", + ".lua", +]); + +/** + * Gate: skip when the cwd has no inspectable source files at all. A workspace + * with zero source files gives the scanner nothing to classify. + */ +function deepModulesGate(cwd: string): string | undefined { + let found = false; + try { + const entries = readdirSync(cwd); + for (const entry of entries) { + const dot = entry.lastIndexOf("."); + const ext = dot === -1 ? "" : entry.slice(dot).toLowerCase(); + if (DEEP_MODULES_EXTENSIONS.has(ext)) { + found = true; + break; + } + } + } catch { + // unreadable cwd → let the agent decide; don't block. + return undefined; + } + if (!found) { + return "no source files found to inspect"; + } + return undefined; +} + +/** + * Build the scan task. The deep-modules scanner agent inspects the target, + * classifies modules by abstraction depth against the rubric, and writes a + * structured findings report to `findings.md`. The output path is passed into + * the task so both the real agent (which uses its `write` tool) and the + * deterministic fake runner (which understands `!write `) persist + * the report to the same location. + * + * Note: the `!write`/`!echo` lines are the deterministic fallback the fake + * runner executes for tests/smoke runs; a real model-driven agent receives the + * whole prompt and writes a real analysis. + */ +function buildDeepScanTask(cwd: string, scope: CheckScope): string { + const findings = findingsPath(cwd); + const target = scope.target; + // The expected findings document shape, shown to a real model-driven agent + // as the format spec. The `!write`/`!echo` lines below are the deterministic + // fallback the fake runner executes for tests/smoke runs. + return [ + `Inspect the target "${target}" (cwd: ${cwd}) for shallow modules.`, + `Classify every source module by abstraction depth (see your rubric).`, + `Write your full findings report to: ${findings}.`, + `findings.md must list each flagged module with: kind, evidence, importer`, + `count, recommendation, and risk (low if no external importers, high else).`, + `Then emit a one-line summary referencing the findings path.`, + "", + `# Deterministic fallback (executed by the fake runner in tests):`, + `!write ${findings} # Deep-modules findings | summary: 1 shallow module(s) flagged | ## 1. ${target}/wrapper.ts | kind: pass-through-wrapper | evidence: forwards every argument to a single library call with no added logic | importers: 0 | recommendation: inline-and-remove | risk: low`, + `!echo deep-modules: 1 issue — see ${findings}`, + ].join("\n"); +} + +/** + * Build the fix task. The fixer consumes the scan findings and applies ONLY safe + * consolidations: inline-and-remove pass-through wrappers that have **zero** + * external importers. Risky consolidations (any importer, or unclear + * ownership) are listed in `changes.md` as `review-manual` and NOT applied. + * Every action — applied or deferred — is recorded in `changes.md`. + */ +function buildDeepFixTask( + cwd: string, + scope: CheckScope, + findings: string, +): string { + const changes = changesPath(cwd); + const findingsFile = findingsPath(cwd); + const target = scope.target; + return [ + `Consolidate shallow modules found in the scan.`, + `cwd: ${cwd} target: ${target}`, + `Findings report (also persisted at ${findingsFile}):`, + `---`, + findings, + `---`, + ``, + `Rules:`, + `- Apply ONLY safe consolidations: a pass-through wrapper with zero external`, + ` importers may be inlined at its single use site and the wrapper removed.`, + `- NEVER auto-delete or rewrite a module with any external importer — list it`, + ` for human review instead.`, + `- Preserve public API boundaries; when in doubt, defer to manual review.`, + `- Write changes.md to ${changes} describing every action (auto | manual) with`, + ` the file, the finding, and the disposition.`, + ``, + `# Deterministic consolidation (executed by the fake runner in tests):`, + `# Safe: zero-importer pass-through rewritten/removed (auto).`, + `# Risky: external-importer adapter left in place (manual).`, + `!write ${target}/wrapper.ts // Consolidated by pygienium-deep-modules: pass-through wrapper removed; callers now use the underlying implementation directly.`, + `!write ${changes} # Deep-modules changes | 1. ${target}/wrapper.ts — pass-through-wrapper — consolidated: inlined the underlying call at the use site and removed the wrapper module (auto) | 2. ${target}/risky-adapter.ts — adapter-layer — 2 external importer(s): left in place; listed for review (manual)`, + `!echo deep-modules: 1 auto-applied, 1 deferred to review — see ${changes}`, + ].join("\n"); +} + +/** The check definition; registers itself on import. */ +const deepModulesCheck: CheckDefinition = { + name: "deep-modules", + label: "Deep modules", + description: + "Detect shallow modules (pass-throughs, trivial wrappers, re-export barrels) and consolidate the safe ones.", + agentName: "deep-modules", + phaseId: "analysis", + buildScanTask: buildDeepScanTask, + buildFixTask: buildDeepFixTask, + gate: deepModulesGate, +}; + +registerCheck(deepModulesCheck); + +export { deepModulesCheck }; diff --git a/src/checks/defensive-guards.ts b/src/checks/defensive-guards.ts new file mode 100644 index 0000000..e977b42 --- /dev/null +++ b/src/checks/defensive-guards.ts @@ -0,0 +1,211 @@ +/** + * checks/defensive-guards.ts — "redundant defensive guarding" check. + * + * Detects defensive code that guards invariants the type system or an + * upstream validation already guarantees, and removes the redundant guards + * while preserving guards that protect genuine external boundaries (user + * input, IO, parsing, untrusted data). The rubric encodes the engineering rule: + * no compatibility layers or fallbacks meant to be "replaced later" — remove + * them outright rather than layering over them. + * + * Flagged smells (non-exhaustive): + * - redundant-null-check — null/undefined check on a value whose declared + * type is already non-nullable. + * - swallowing-try-catch — try/catch that silently discards the error + * (empty catch, catch that only logs, or catch returning a fallback that + * hides the failure). + * - rethrow-only-try-catch — try/catch whose body only rethrows the exact + * error, adding nothing. + * - error-masking-fallback — `return defaultValue` / `|| fallback` in a + * catch that masks a real failure with a plausible-but-wrong value. + * - defensive-guard-on-validated-input — re-checking input that a caller or + * parser already validated (e.g. asserting a parsed enum is in range). + * - compatibility-fallback — a fallback branch kept "for now" / "to be + * replaced later" (engineering rule: remove, don't layer). + * + * Kept (legitimate boundary guards): + * - untrusted input (HTTP params, CLI args, env vars, files on disk). + * - IO (network, filesystem, subprocess) where failures are expected. + * - parsing (`JSON.parse`, `parseInt`, `Date.parse`, schema decoders). + * + * Lifecycle: + * gate (need source files) → recon (shared) → scan sub-agent writes + * `/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. + * + * Registering this file is the ONLY wiring needed: `index.ts` auto-discovers + * `src/checks/*.ts`, so dropping this file exposes `/pygienium-defensive-guards`. + * + * @module pygienium/checks/defensive-guards + */ + +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { + registerCheck, + type CheckDefinition, + type CheckScope, +} from "./registry.js"; + +/** Output directory for this check's persistent reports. */ +export function defensiveGuardsOutputDir(cwd: string): string { + return join(cwd, "pygienium", "checks", "defensive-guards"); +} + +/** `findings.md` path for this check. */ +export function findingsPath(cwd: string): string { + return join(defensiveGuardsOutputDir(cwd), "findings.md"); +} + +/** `changes.md` path for this check. */ +export function changesPath(cwd: string): string { + return join(defensiveGuardsOutputDir(cwd), "changes.md"); +} + +/** Source extensions this check inspects. */ +const DEFENSIVE_GUARDS_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".swift", + ".php", + ".cs", + ".lua", +]); + +/** + * Gate: skip when the cwd has no inspectable source files at all — a workspace + * with zero source files gives the scanner nothing to analyse. + */ +function defensiveGuardsGate(cwd: string): string | undefined { + let found = false; + try { + const entries = readdirSync(cwd); + for (const entry of entries) { + const dot = entry.lastIndexOf("."); + const ext = dot === -1 ? "" : entry.slice(dot).toLowerCase(); + if (DEFENSIVE_GUARDS_EXTENSIONS.has(ext)) { + found = true; + break; + } + } + } catch { + // unreadable cwd → let the agent decide; don't block. + return undefined; + } + if (!found) { + return "no source files found to inspect"; + } + return undefined; +} + +/** + * Build the scan task. The defensive-guards scanner agent inspects the target, + * classifies each guard as redundant or a legitimate boundary guard against the + * rubric, and writes a structured findings report to `findings.md`. The output + * path is passed into the task so both the real agent (which uses its `write` + * tool) and the deterministic fake runner (which understands `!write + * `) persist the report to the same location. + * + * The `!write`/`!echo` lines are the deterministic fallback the fake runner + * executes for tests/smoke runs; a real model-driven agent receives the whole + * prompt and writes a real analysis. + */ +function buildDefensiveGuardsScanTask(cwd: string, scope: CheckScope): string { + const findings = findingsPath(cwd); + const target = scope.target; + return [ + `Inspect the target "${target}" (cwd: ${cwd}) for redundant defensive guarding.`, + `Classify every guard (null check, try/catch, fallback) against your rubric as`, + `either REDUNDANT (remove) or BOUNDARY (keep). Boundary guards protect real`, + `external boundaries: untrusted input, IO, and parsing. Redundant guards protect`, + `invariants the type system or upstream validation already guarantees.`, + `Write your full findings report to: ${findings}.`, + `findings.md must separate redundant guards from legitimate boundary guards,`, + `listing each with: kind, evidence, disposition (remove | keep-boundary), and`, + `reason.`, + `Then emit a one-line summary referencing the findings path.`, + ``, + `# Deterministic fallback (executed by the fake runner in tests):`, + `!write ${findings} # Defensive-guards findings | summary: 2 redundant guard(s) flagged, 1 boundary guard kept | ## 1. ${target}/noise.ts:2 | kind: redundant-null-check | evidence: \`if (name === null)\` on \`name\` whose declared type is \`string\` (non-nullable) | disposition: remove | reason: type system already guarantees non-null | ## 2. ${target}/noise.ts:7 | kind: swallowing-try-catch | evidence: try/catch around doThing() discards the error silently (empty catch body) | disposition: remove | reason: masks bugs; no error mapping or recovery logic | ## 3. ${target}/boundary.ts:2 | kind: parsing-guard | evidence: try/catch around JSON.parse(input) | disposition: keep-boundary | reason: protects an external parsing boundary (JSON.parse of untrusted input)`, + `!echo defensive-guards: 2 redundant, 1 boundary kept — see ${findings}`, + ].join("\n"); +} + +/** + * Build the fix task. The fixer consumes the scan findings and removes ONLY + * redundant guards — those whose protected invariant is already guaranteed by + * the type system or upstream validation. Boundary guards (IO, parsing, + * untrusted input) are preserved untouched. Every action — removed or kept — + * is recorded in `changes.md`, distinguishing removed (auto) from kept with a + * reason (boundary). + * + * The fixer rewrites the affected source files with the redundant guards + * excised; compatibility fallbacks are removed outright (engineering rule: + * remove, don't layer), never left behind as a transitional shim. + */ +function buildDefensiveGuardsFixTask( + cwd: string, + scope: CheckScope, + findings: string, +): string { + const changes = changesPath(cwd); + const findingsFile = findingsPath(cwd); + const target = scope.target; + return [ + `Remove redundant defensive guards found in the scan.`, + `cwd: ${cwd} target: ${target}`, + `Findings report (also persisted at ${findingsFile}):`, + `---`, + findings, + `---`, + ``, + `Rules:`, + `- Remove ONLY redundant guards: null/undefined checks on non-nullable types,`, + ` try/catch that only rethrows or swallows, fallback values that hide errors,`, + ` defensive guards on already-validated input, and compatibility fallbacks.`, + `- PRESERVE boundary guards: anything protecting untrusted input, IO, or parsing`, + ` (e.g. JSON.parse, network, filesystem, subprocess errors). Do not touch them.`, + `- No compatibility layers: remove fallbacks outright — never leave a shim meant`, + ` to be "replaced later".`, + `- Apply the smallest diff that removes the guard without changing behaviour for`, + ` the happy path. Preserve tests and existing conventions.`, + `- Write changes.md to ${changes} distinguishing removed (auto) from kept`, + ` (boundary — with reason) for every finding.`, + ``, + `# Deterministic removal (executed by the fake runner in tests):`, + `# Redundant null check + swallowing try/catch removed from noise.ts (auto).`, + `# JSON.parse boundary guard in boundary.ts preserved (boundary).`, + `!write ${target}/noise.ts // Cleaned by pygienium-defensive-guards: removed redundant null check on non-nullable \`name\` and the swallowing try/catch around doThing(). export function greet(name: string) { return \`hello \${name}\`; } export function swallow() { doThing(); } function doThing() {}`, + `!write ${changes} # Defensive-guards changes | summary: 2 removed, 1 kept (boundary) | ## Removed (auto) | 1. ${target}/noise.ts:2 — redundant-null-check — removed \`if (name === null) return ""\`; type system guarantees non-null | 2. ${target}/noise.ts:7 — swallowing-try-catch — removed the try/catch around doThing(); the error is no longer silently swallowed | ## Kept (boundary — with reason) | 1. ${target}/boundary.ts:2 — parsing-guard — kept: try/catch around JSON.parse protects an external parsing boundary (untrusted input)`, + `!echo defensive-guards: 2 removed, 1 kept (boundary) — see ${changes}`, + ].join("\n"); +} + +/** The check definition; registers itself on import. */ +const defensiveGuardsCheck: CheckDefinition = { + name: "defensive-guards", + label: "Defensive guards", + description: + "Remove redundant defensive guarding (null checks on non-nullable types, swallowing try/catch, masking fallbacks) while keeping boundary guards (IO, parsing, untrusted input).", + agentName: "defensive-guards", + phaseId: "analysis", + buildScanTask: buildDefensiveGuardsScanTask, + buildFixTask: buildDefensiveGuardsFixTask, + gate: defensiveGuardsGate, +}; + +registerCheck(defensiveGuardsCheck); + +export { defensiveGuardsCheck }; diff --git a/src/checks/noop.ts b/src/checks/noop.ts new file mode 100644 index 0000000..82a16bc --- /dev/null +++ b/src/checks/noop.ts @@ -0,0 +1,121 @@ +/** + * checks/noop.ts — the reference check and living extensibility template. + * + * This is the smallest complete `CheckDefinition`: it self-registers on import, + * passes its gate for any real file/dir target, and asks the scanner/fixer + * sub-agents to write empty `findings.md`/`changes.md` artifacts. It exists so + * that: + * + * 1. The "add a check = one file in `checks/` + one `registerCheck()` call, + * zero `index.ts` changes" claim has a verifiable witness — `index.ts` + * auto-discovers every `checks/*.ts` (except the registry barrel), so this + * file makes `/pygienium-noop` appear with no wiring edits. + * 2. New check authors have a copy-paste starting point: clone this file, + * rename, swap the rubric, ship. + * + * Artifacts (under `/pygienium/checks/noop/`): + * - `findings.md` — `noop: 0 issues` + * - `changes.md` — `noop: 0 edits` + * + * @module pygienium/checks/noop + */ + +import { registerCheck, type CheckScope } from "./registry.js"; + +/** Directory this check writes its artifacts to. */ +function noopArtifactDir(scope: CheckScope): string { + return `${scope.cwd.replace(/\/+$/, "")}/pygienium/checks/noop`; +} + +/** + * Analysis task: scan nothing meaningful, write a zero-issue findings report. + * Mirrors the structure a real check's scan task uses so this file reads as a + * faithful template. + */ +function buildNoopScanTask(_cwd: string, scope: CheckScope): string { + const outDir = noopArtifactDir(scope); + return `# Task: noop scan + +You are running the **noop** hygiene check (a no-op reference check). + +## Target +- Scan target: \`${scope.target}\` + +## What to do +1. Confirm the target is reachable (no real analysis is needed). +2. Write a findings report to \`${outDir}/findings.md\` with the content: + + \`\`\`markdown + # noop — findings + + noop: 0 issues + \`\`\` + +3. Return that report text as your final message so the host records it as the + analysis-phase findings. + +If the target is missing, write \`noop: target missing\` to findings.md instead. +`; +} + +/** Fix task: apply zero edits, write an empty changes report. */ +function buildNoopFixTask( + _cwd: string, + scope: CheckScope, + _findings: string, +): string { + const outDir = noopArtifactDir(scope); + return `# Task: noop fix + +You are running the **noop** hygiene fix phase. + +## Target +- Fix target: \`${scope.target}\` + +## What to do +1. Make no source edits (this is a no-op check). +2. Write a changes report to \`${outDir}/changes.md\` with the content: + + \`\`\`markdown + # noop — changes + + noop: 0 edits + \`\`\` + +3. Return that report text as your final message. +`; +} + +/** + * Precondition gate: pass when the target path exists as a file or directory. + * Idempotent — used for both the pre-analysis check and the post-fix verify. + */ +async function noopGate(cwd: string): Promise { + const { stat } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + const target = resolve(cwd); + try { + const s = await stat(target); + if (s.isDirectory() || s.isFile()) return undefined; + return `target is not a file or directory: ${target}`; + } catch { + return `target path does not exist: ${target}`; + } +} + +/** The noop check definition. Self-registers on import. */ +export const noopCheck = { + name: "noop", + label: "No-op", + description: + "Reference/template check — performs no analysis, writes zero-issue artifacts. Clone it to start a new check.", + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "noop", + buildScanTask: buildNoopScanTask, + buildFixTask: buildNoopFixTask, + gate: noopGate, +} as const; + +// Self-register on import so index.ts auto-discovery picks it up — no wiring. +registerCheck(noopCheck); diff --git a/src/checks/registry.ts b/src/checks/registry.ts new file mode 100644 index 0000000..07f7854 --- /dev/null +++ b/src/checks/registry.ts @@ -0,0 +1,139 @@ +/** + * checks/registry.ts — pluggable check registry. + * + * A `CheckDefinition` describes one hygiene check (e.g. `comments`, `complexity`). + * The registry is a module-level `Map` so that adding a check only requires a + * new file in `src/checks/` plus one `registerCheck(def)` call — no changes to + * `index.ts` command wiring. At startup, `index.ts` iterates the registry and + * auto-registers a `/pygienium-` command per definition. + * + * Lifecycle of a single check run (orchestrated by `src/modes/check-runner.ts`): + * Q0 recon (shared) → analysis sub-agent (buildScanTask) → + * fix sub-agent (buildFixTask, only with --fix) → verify gate → cleanup. + * + * @module pygienium/checks/registry + */ + +/** + * Scope passed to scan/fix task builders. Resolved from the command args: + * a positional path (absolute or relative to `cwd`) plus parsed flags. + */ +export interface CheckScope { + /** Absolute working directory the check operates on. */ + cwd: string; + /** Target path (absolute) the check scans; defaults to `cwd` when none given. */ + target: string; + /** Whether fixes should be applied (the `--fix` flag). */ + fix: boolean; + /** Remaining raw tokens after flag parsing, for check-specific use. */ + rest: string[]; +} + +/** + * Verifies preconditions before a check runs its analysis phase. Returns an + * error string when the check cannot proceed (e.g. no source files match), + * or `undefined` when the gate passes. Implemented per-check so generic + * checks can bail early without spawning an agent. + */ +export type CheckGate = ( + cwd: string, +) => Promise | string | undefined; + +/** + * Optional post-analysis (+ optional fix) verify hook. Confirms the check + * actually produced its artifacts (e.g. `findings.md`/`changes.md`). Returns an + * error string to fail the verify phase, or `undefined` to pass. When omitted, + * the verify phase falls back to re-running {@link CheckDefinition.gate}, + * preserving the historical behaviour for checks that have nothing to verify. + */ +export type CheckVerify = ( + scope: CheckScope, +) => Promise | string | undefined; + +/** + * The structured task string handed to a sub-agent. `buildScanTask` produces + * the analysis prompt; `buildFixTask` consumes the findings text the scan + * agent emitted and produces a fix prompt. + * + * Task builders may be async: a check can pre-compute deterministic candidates + * (e.g. an import-graph scan) before assembling the prompt, so the sub-agent's + * job is to verify/refine rather than re-derive everything from scratch. + */ +export type BuildScanTask = ( + cwd: string, + scope: CheckScope, +) => string | Promise; +export type BuildFixTask = ( + cwd: string, + scope: CheckScope, + findings: string, +) => string | Promise; + +/** + * Definition of a single pluggable hygiene check. + */ +export interface CheckDefinition { + /** Lowercase kebab command suffix → `/pygienium-`. Must be unique. */ + name: string; + /** Human label shown in help and status strips. */ + label: string; + /** One-line description for `/pygienium-help`. */ + description: string; + /** + * Name of the agent definition (from `agents/*.md`) used for the analysis + * phase. The fix phase uses the `fixer` agent unless `fixAgentName` + * overrides it. + */ + agentName: string; + /** Optional override for the fix-phase agent (defaults to `fixer`). */ + fixAgentName?: string; + /** Identifier of the phase-strip phase this check belongs to (task 05). */ + phaseId: string; + /** Builds the analysis sub-agent task. */ + buildScanTask: BuildScanTask; + /** Builds the fix sub-agent task from scan findings. */ + buildFixTask: BuildFixTask; + /** + * Precondition gate. Returning a string skips the check (recorded as + * `skipped`); returning `undefined` proceeds normally. + */ + gate: CheckGate; + /** + * Optional verify hook confirming artifacts landed (see {@link CheckVerify}). + * Falls back to re-running `gate` when omitted. + */ + verify?: CheckVerify; +} + +const registry = new Map(); + +/** + * Register a check. Throws on duplicate names so wiring mistakes surface + * loudly at startup rather than silently shadowing a command. + */ +export function registerCheck(def: CheckDefinition): void { + if (!def.name || !/^[a-z0-9][a-z0-9-]*$/.test(def.name)) { + throw new Error( + `Invalid check name "${def.name}": must be lowercase kebab (e.g. "comments").`, + ); + } + if (registry.has(def.name)) { + throw new Error(`Duplicate pygienium check name: "${def.name}".`); + } + registry.set(def.name, def); +} + +/** Look up a registered check by name. */ +export function getCheck(name: string): CheckDefinition | undefined { + return registry.get(name); +} + +/** All registered checks in insertion order. */ +export function getAllChecks(): CheckDefinition[] { + return [...registry.values()]; +} + +/** Test-only: reset the registry between tests. */ +export function clearChecks(): void { + registry.clear(); +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..d2c494d --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,325 @@ +/** + * commands.ts — pygienium slash-command handlers. + * + * Keeps `index.ts` thin: `index.ts` only binds these handlers to pi command + * names. Each handler accepts the narrow context slice it needs (`cwd`, `mode`, + * `hasUI`, `ui`) so they are unit-testable without a full pi runtime — tests + * construct a minimal `PygieniumCtx`. + * + * @module pygienium/commands + */ + +import type { + ExtensionCommandContext, + ExtensionUIContext, +} from "@earendil-works/pi-coding-agent"; +import { resolve } from "node:path"; +import { + getAllChecks, + getCheck, + type CheckDefinition, +} from "./checks/registry.js"; +import { runCheck, parseCheckArgs } from "./modes/check-runner.js"; +import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js"; +import { buildPygieniumHelpLines } from "./help.js"; +import { + loadRunState, + runStatePath, + saveRunState, + markRunStatus, + reconcileRunStatus, + resetCheckEntry, + shouldRunOnResume, +} from "./run-state.js"; +import { formatRunStatus } from "./status.js"; +import { + exportRun, + parseExportFilters, + exportBundlePath, + type ExportFilters, +} from "./export.js"; + +/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */ +export type PygieniumCtx = Pick< + ExtensionCommandContext, + "cwd" | "mode" | "hasUI" | "ui" +>; + +function print(ctx: PygieniumCtx, line: string): void { + // In TUI mode, also surface the first line as a notification. + if (ctx.mode === "tui" && ctx.ui?.notify) { + ctx.ui.notify(line, "info"); + } + process.stdout.write(`${line}\n`); +} + +/** Resolve an optional `[path]` argument to an absolute cwd. */ +function resolveCwd(args: string, ctxCwd: string): string { + const tok = args.trim().split(/\s+/)[0]; + if (!tok || tok.startsWith("--")) return ctxCwd; + return resolve(ctxCwd, tok); +} + +/** Strip a leading flag token (`--fix`) from args, returning the remainder. */ +function splitFlags(args: string): { fix: boolean; rest: string } { + 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 }; +} + +/** + * Parse `/pygienium-resume` args: an optional `[path]` positional plus the + * `--fresh` flag.` returns the resolved cwd and whether a fresh re-dispatch + * is requested. + */ +function parseResumeArgs( + args: string, + ctxCwd: string, +): { cwd: string; fresh: boolean } { + const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; + const fresh = tokens.includes("--fresh"); + const positional = tokens.find((t) => !t.startsWith("--")); + const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd; + return { cwd, fresh }; +} + +/** `/pygienium-help` */ +export async function handleHelpCommand( + _args: string, + _ctx: PygieniumCtx, +): Promise { + for (const line of buildPygieniumHelpLines()) { + process.stdout.write(`${line}\n`); + } +} + +/** + * `/pygienium- [path] [--fix]` — the per-check command handler. + * Exported so `index.ts` can bind one per registered `CheckDefinition` and so + * tests can invoke it directly with a stub context. + */ +export async function handleCheckCommand( + check: CheckDefinition, + args: string, + ctx: PygieniumCtx, +): Promise { + const { fix, rest } = splitFlags(args); + const target = resolveCwd(rest, ctx.cwd); + const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd); + + const outcome = await runCheck({ + check, + cwd: ctx.cwd, + scope: { ...scope, cwd: ctx.cwd, target }, + ui: ctx.ui, + hasUI: ctx.hasUI, + }); + + print( + ctx, + `pygienium ${check.label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`, + ); +} + +/** + * `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every + * registered check in sequence under a unified status strip, writing + * `pygienium/all-summary.md`. Delegates to {@link runAllChecks}. + */ +export async function handleAllCommand( + args: string, + ctx: PygieniumCtx, +): Promise { + const checks = getAllChecks(); + if (checks.length === 0) { + print(ctx, "pygienium: no checks registered."); + return; + } + const parsed = parseAllArgs(args, ctx.cwd); + const outcome = await runAllChecks({ + cwd: ctx.cwd, + target: parsed.target, + fix: parsed.fix, + fresh: parsed.fresh, + only: parsed.only, + ui: ctx.ui, + hasUI: ctx.hasUI, + }); + print( + ctx, + `pygienium: all-run ${outcome.status} — ${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})`, + ); +} + +/** `/pygienium-status [path]` — print run-state progress as a line list. */ +export async function handleStatusCommand( + args: string, + ctx: PygieniumCtx, +): Promise { + const cwd = resolveCwd(args, ctx.cwd); + const state = await loadRunState(cwd); + if (!state) { + print(ctx, "pygienium: no run state found."); + return; + } + for (const line of formatRunStatus(state)) { + print(ctx, line); + } +} + +/** + * `/pygienium-resume [path] [--fresh]` — resume the most recent non-complete + * run by re-dispatching every check that isn't terminal (`complete`/`skipped`). + * Pass `--fresh` to re-dispatch even completed checks (their run-state entries + * are reset and the check re-runs analysis → fix → verify → cleanup fresh). + */ +export async function handleResumeCommand( + args: string, + ctx: PygieniumCtx, +): Promise { + const { cwd, fresh } = parseResumeArgs(args, ctx.cwd); + let state = await loadRunState(cwd); + if (!state) { + print(ctx, "pygienium: no run state to resume."); + return; + } + + // Pick the latest resumable run — with the single-file run-state model this + // is the loaded run unless it's already fully complete AND --fresh wasn't set. + const resumable = Object.values(state.checks).some((c) => + shouldRunOnResume(c, fresh), + ); + if (!resumable) { + print( + ctx, + `pygienium: run already ${state.status}; nothing to resume (use --fresh to re-run).`, + ); + return; + } + + // Re-dispatch this run's checks in stored order, skipping terminal ones + // unless --fresh. + let ran = 0; + let skipped = 0; + for (const entry of Object.values(state.checks)) { + const def = getCheck(entry.name); + if (!def) { + print( + ctx, + `pygienium: check "${entry.name}" is no longer registered; skipping.`, + ); + skipped++; + continue; + } + if (!shouldRunOnResume(entry, fresh)) { + skipped++; + continue; + } + if (fresh) { + resetCheckEntry(state, entry.name); + } + print(ctx, `pygienium: resuming ${def.label}…`); + const outcome = await runCheck({ + check: def, + cwd, + scope: { cwd, target: cwd, fix: entry.fix, rest: [] }, + ui: ctx.ui, + hasUI: ctx.hasUI, + existingState: state, + }); + state = outcome.state; + ran++; + print(ctx, `pygienium ${def.label}: ${outcome.status}`); + } + + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + print( + ctx, + `pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})`, + ); +} + +/** + * `/pygienium-export [path] [--check=[,]] [--status=[,]] [--out=md|json]` + * — collect every check's `findings.md`/`changes.md` artifacts from + * `pygienium/checks//` (and the legacy `.pygienium/checks/` root), apply + * filters, and write a single bundle to `pygienium/export.{md|json}`. + */ +export async function handleExportCommand( + args: string, + ctx: PygieniumCtx, +): Promise { + const cwd = resolveCwd(args, ctx.cwd); + const filters: ExportFilters = parseExportFilters(args); + const state = await loadRunState(cwd); + const result = await exportRun(cwd, state, filters); + if (result.entries.length === 0) { + print( + ctx, + `pygienium: nothing to export (no findings.md/changes.md under ${cwd}/pygienium/checks/).`, + ); + return; + } + const filterDesc = [ + filters.check ? `check=${filters.check.join(",")}` : null, + filters.status ? `status=${filters.status.join(",")}` : null, + ] + .filter(Boolean) + .join(" "); + const suffix = filterDesc ? ` [${filterDesc}]` : ""; + print( + ctx, + `pygienium export: ${result.entries.length} check(s) → ${exportBundlePath(cwd, result.format)}${suffix}`, + ); +} + +/** A minimal command-registration callback shape (matches `pi.registerCommand`). */ +export type RegisterCommandFn = ( + name: string, + options: { + description?: string; + handler: (args: string, ctx: PygieniumCtx) => Promise; + }, +) => void; + +/** + * Auto-register `/pygienium-help` plus one `/pygienium-` per registered + * `CheckDefinition`, plus the `all`/`resume`/`status`/`export` commands. + * Called from `index.ts` so that adding a check never requires editing command + * wiring. + */ +export function registerPygieniumCommands(register: RegisterCommandFn): void { + register("pygienium-help", { + description: "Show pygienium commands, checks, and usage.", + handler: handleHelpCommand, + }); + + for (const def of getAllChecks()) { + register(`pygienium-${def.name}`, { + description: def.description, + handler: (args, ctx) => handleCheckCommand(def, args, ctx), + }); + } + + register("pygienium-all", { + description: "Run every registered pygienium check in sequence.", + handler: handleAllCommand, + }); + register("pygienium-resume", { + description: "Resume the most recent in-progress or failed pygienium run.", + handler: handleResumeCommand, + }); + register("pygienium-status", { + description: "Show progress of the current or latest pygienium run.", + handler: handleStatusCommand, + }); + register("pygienium-export", { + description: "Export finalized findings and changes for a pygienium run.", + handler: handleExportCommand, + }); +} + +/** Re-export for index.ts convenience. */ +export type { ExtensionUIContext }; diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..3f8e8b5 --- /dev/null +++ b/src/export.ts @@ -0,0 +1,269 @@ +/** + * export.ts — bundle find/changed artifacts for a pygienium run. + * + * `/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}`. + * + * 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 + * present on disk but absent from run-state is reported as `unknown`. + * + * @module pygienium/export + */ + +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +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"; +/** 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). */ +export function pygieniumArtifactDir(cwd: string): string { + return join(cwd, PYGIENIUM_ARTIFACT_DIR); +} + +/** Resolve `/pygienium/checks/`. */ +export function canonicalChecksRoot(cwd: string): string { + return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR); +} + +/** Resolve `/pygienium/export.`. */ +export function exportBundlePath(cwd: string, format: ExportFormat): string { + return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`); +} + +/** A single gathered check artifact entry (post-filter). */ +export interface ExportEntry { + /** Check name (the directory under `checks/`). */ + name: string; + /** Status from run-state, or `unknown` when not present there. */ + status: string; + /** `findings.md` contents, when present on disk. */ + findings?: string; + /** `changes.md` contents, when present on disk. */ + changes?: string; + /** Absolute path to `findings.md`, when read from disk. */ + findingsPath?: string; + /** Absolute path to `changes.md`, when read from disk. */ + changesPath?: string; +} + +/** Parsed `--check=` / `--status=` / `--out=` filters. */ +export interface ExportFilters { + /** Check-name allowlist (comma-separated); undefined = all. */ + check?: string[]; + /** Status allowlist (comma-separated), matched against run-state statuses. */ + status?: string[]; + /** Output format. Defaults to `md`. */ + out?: ExportFormat; +} + +/** Result of {@link exportRun}. */ +export interface ExportResult { + /** Format used. */ + format: ExportFormat; + /** Absolute path the bundle was written to. */ + path: string; + /** Entries included after filtering (in alphabetical order). */ + entries: ExportEntry[]; + /** Bundle size in bytes. */ + bytes: number; +} + +const FLAG_CHECK = "--check="; +const FLAG_STATUS = "--status="; +const FLAG_OUT = "--out="; + +/** Parse export flags from the raw arg string (flags + optional positional). */ +export function parseExportFilters(args: string): ExportFilters { + const filters: ExportFilters = { out: "md" }; + const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; + for (const tok of tokens) { + if (tok.startsWith(FLAG_CHECK)) { + filters.check = tok + .slice(FLAG_CHECK.length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (tok.startsWith(FLAG_STATUS)) { + filters.status = tok + .slice(FLAG_STATUS.length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (tok.startsWith(FLAG_OUT)) { + const v = tok.slice(FLAG_OUT.length).toLowerCase().trim(); + if (v === "json" || v === "md") { + filters.out = v; + } + } + } + return filters; +} + +async function readArtifact(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } +} + +async function gatherFromRoot( + root: string, + state: RunState | undefined, + merged: Map, +): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + const dir = join(root, name); + const fpath = join(dir, "findings.md"); + const cpath = join(dir, "changes.md"); + const findings = await readArtifact(fpath); + const changes = await readArtifact(cpath); + const checkState = state?.checks[name]; + merged.set(name, { + name, + status: checkState?.status ?? "unknown", + findings, + changes, + findingsPath: findings != null ? fpath : undefined, + changesPath: changes != null ? cpath : undefined, + }); + } +} + +/** + * 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`. + */ +export async function gatherExportEntries( + cwd: string, + state?: RunState, +): Promise { + const merged = new Map(); + await gatherFromRoot(canonicalChecksRoot(cwd), state, merged); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Apply `--check=` / `--status=` filters to gathered entries. */ +export function filterExportEntries( + entries: ExportEntry[], + filters: ExportFilters, +): ExportEntry[] { + return entries.filter((e) => { + if (filters.check && !filters.check.includes(e.name)) return false; + if (filters.status && !filters.status.includes(e.status)) return false; + return true; + }); +} + +/** Render the markdown bundle. */ +export function renderExportMarkdown( + state: RunState | undefined, + entries: ExportEntry[], +): string { + const lines: string[] = []; + lines.push("# Pygienium export"); + if (state) { + lines.push(""); + lines.push(`- status: ${state.status}`); + lines.push(`- started: ${new Date(state.startedAt).toISOString()}`); + lines.push(`- updated: ${new Date(state.updatedAt).toISOString()}`); + lines.push(`- cwd: ${state.cwd}`); + lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`); + } + lines.push(`- checks: ${entries.length}`); + lines.push(""); + for (const e of entries) { + lines.push(`## ${e.name} (${e.status})`); + if (e.findings != null) { + lines.push(""); + lines.push("### findings"); + lines.push(""); + lines.push(e.findings.replace(/\s+$/, "")); + } + if (e.changes != null) { + lines.push(""); + lines.push("### changes"); + lines.push(""); + lines.push(e.changes.replace(/\s+$/, "")); + } + if (e.findings == null && e.changes == null) { + lines.push(""); + lines.push("_(no findings.md or changes.md on disk)_"); + } + lines.push(""); + } + return lines.join("\n") + "\n"; +} + +/** Render the JSON bundle. */ +export function renderExportJson( + state: RunState | undefined, + entries: ExportEntry[], +): string { + const payload = { + status: state?.status ?? "unknown", + startedAt: state?.startedAt ?? null, + updatedAt: state?.updatedAt ?? null, + cwd: state?.cwd ?? null, + recon: state ? state.recon.complete : null, + checks: entries.map((e) => ({ + name: e.name, + status: e.status, + findings: e.findings ?? null, + findingsPath: e.findingsPath ?? null, + changes: e.changes ?? null, + changesPath: e.changesPath ?? null, + })), + }; + return JSON.stringify(payload, null, 2) + "\n"; +} + +/** + * Gather, filter, and write the export bundle. Returns the (would-be) path + * and the included entries. When there are no entries, no file is written — + * the caller reports "nothing to export" and we avoid leaving an empty + * `export.{md|json}` on disk. + */ +export async function exportRun( + cwd: string, + state: RunState | undefined, + filters: ExportFilters, +): Promise { + const all = await gatherExportEntries(cwd, state); + const entries = filterExportEntries(all, filters); + const format: ExportFormat = filters.out ?? "md"; + const path = exportBundlePath(cwd, format); + if (entries.length === 0) { + return { format, path, entries, bytes: 0 }; + } + const body = + format === "json" + ? renderExportJson(state, entries) + : renderExportMarkdown(state, entries); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, body, "utf8"); + return { format, path, entries, bytes: Buffer.byteLength(body) }; +} diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 0000000..d8dcb2b --- /dev/null +++ b/src/help.ts @@ -0,0 +1,185 @@ +/** + * help.ts — command + flag help builder (single source of truth for + * `/pygienium-help`). + * + * `COMMANDS` and `CLI_FLAGS` are static arrays describing every operator + * command and flag actually implemented across tasks 06–13. The per-check + * command family (`/pygienium-`) is a single generic entry because the + * concrete check commands come from the live registry — `buildPygieniumHelpLines` + * appends one row per registered `CheckDefinition`, so a newly registered check + * appears in `/pygienium-help` with zero edits here. This is what backs the + * "add a check = one file + registerCheck, no index.ts changes" guarantee. + * + * @module pygienium/help + */ + +import { getAllChecks } from "./checks/registry.js"; + +/** A flag row shown in the help output. */ +export interface HelpFlag { + /** Flag token exactly as typed on the command line. */ + name: string; + /** Which commands accept this flag. */ + scope: string; + /** What the flag does. */ + description: string; +} + +/** A command row shown in the help output. */ +export interface HelpCommand { + /** Command invocation (without the leading `/`). */ + usage: string; + /** One-line description of what it does. */ + description: string; + /** Concrete example call. */ + example: string; +} + +/** + * Flags supported by `/pygienium-` and the operator commands. + * Mirrors the arg parsing in `commands.ts` / `modes/all.ts` / `export.ts` + * exactly. + */ +export const CLI_FLAGS: HelpFlag[] = [ + { + name: "[path]", + scope: "all check commands", + description: "Target file or directory to scan (default: current dir).", + }, + { + name: "--fix", + scope: ", all, resume", + description: "Apply fixes (default: scan-only; emits findings only).", + }, + { + name: "--fresh", + scope: "all, resume", + description: + "Re-dispatch completed checks too — reset their run-state entries and re-run.", + }, + { + name: "--only=", + scope: "all", + description: "Comma-separated check names to run (subset of the registry).", + }, + { + name: "--check=", + scope: "export", + description: "Comma-separated check names to include in the bundle.", + }, + { + name: "--status=", + scope: "export", + description: + "Comma-separated statuses to include (e.g. complete,failed,skipped).", + }, + { + name: "--out=", + scope: "export", + description: "Bundle format: `md` (default) or `json`.", + }, +]; + +/** + * The operator commands (the per-check `/pygienium-` family is rendered + * dynamically from the registry below). Each entry carries a usage, a + * one-line description, and an example so `/pygienium-help` is self-contained. + */ +export const COMMANDS: HelpCommand[] = [ + { + usage: "pygienium-help", + description: "Show every command, shipped check, and flag (this block).", + example: "/pygienium-help", + }, + { + usage: "pygienium- [path] [--fix]", + description: + "Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report.", + example: "/pygienium-comments src --fix", + }, + { + usage: "pygienium-all [path] [--fix] [--fresh] [--only=a,b]", + description: + "Run every registered check in sequence under one resumable run-state with a unified status strip; writes pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.", + example: "/pygienium-all --fix", + }, + { + usage: "pygienium-status [path]", + description: + "Show per-check progress, captured findings/changes line counts, and errors for the latest run.", + example: "/pygienium-status", + }, + { + usage: "pygienium-resume [path] [--fresh]", + description: + "Resume the latest in-progress/failed/partial run, re-dispatching each non-terminal check (complete/skipped skip unless --fresh).", + example: "/pygienium-resume --fresh", + }, + { + usage: "pygienium-export [path] [--check=] [--status=] [--out=md|json]", + description: + "Bundle every check's findings.md + changes.md into pygienium/export.{md|json}.", + example: "/pygienium-export --out=json", + }, +]; + +/** Back-compat alias for the flag array. */ +export const PYGIENIUM_FLAGS = CLI_FLAGS; + +/** Right-pad a string to `width` (no-op when already longer). */ +function pad(s: string, width: number): string { + return s.length >= width ? s : s + " ".repeat(width - s.length); +} + +/** + * Build the full `/pygienium-help` text. Layout (one string per line): + * + * header + * Commands: (one entry per COMMANDS row: usage, description, example) + * Checks (N): (one row per registered check, registry-driven) + * Flags: (one row per CLI_FLAGS entry) + * Adding a check: (one-file + registerCheck note) + */ +export function buildPygieniumHelpLines(): string[] { + const lines: string[] = []; + lines.push("Pygienium — code hygiene for pi", ""); + lines.push("Commands:"); + + const usageWidth = Math.max(...COMMANDS.map((c) => c.usage.length)) + 2; + for (const cmd of COMMANDS) { + lines.push(` /${pad(cmd.usage, usageWidth)}${cmd.description}`); + lines.push(` ${pad("", usageWidth)}e.g. ${cmd.example}`); + } + lines.push(""); + + const checks = getAllChecks(); + lines.push(`Checks (${checks.length}):`); + if (checks.length === 0) { + lines.push( + " (none registered — drop a file in src/checks/ and add one registerCheck() entry)", + ); + } else { + const nameWidth = Math.max(...checks.map((c) => c.name.length)) + 2; + for (const c of checks) { + lines.push(` /pygienium-${pad(c.name, nameWidth)}${c.description}`); + } + } + lines.push(""); + + lines.push("Flags:"); + const flagNameWidth = Math.max(...CLI_FLAGS.map((f) => f.name.length)) + 2; + const flagScopeWidth = + Math.max(...CLI_FLAGS.map((f) => `[${f.scope}]`.length)) + 2; + for (const f of CLI_FLAGS) { + lines.push( + ` ${pad(f.name, flagNameWidth)}${pad(`[${f.scope}]`, flagScopeWidth)}${f.description}`, + ); + } + lines.push(""); + + lines.push( + "Adding a check: drop a file in src/checks/ and add one registerCheck() entry.", + ); + lines.push("No index.ts command-wiring changes are required."); + return lines; +} diff --git a/src/hygiene-state.ts b/src/hygiene-state.ts new file mode 100644 index 0000000..d585a8e --- /dev/null +++ b/src/hygiene-state.ts @@ -0,0 +1,472 @@ +/** + * On-disk model for `pygienium/run-state.json`. + * + * This is the single source of truth for `/pygienium-status`, `/pygienium-resume` + * and the per-run progress the orchestrator reports. The shape is ported from + * piolium's `audit-state.ts`, with `audit` → `run` and `phase` → `check` renames + * applied so this extension's vocabulary is run/check throughout. + * + * Snake-case keys are an intentional, persisted on-disk contract — downstream + * tasks (06, 12, 13) read them back when resuming or reporting a run. Don't + * camelCase them. + * + * Writes go through `withFileMutationQueue` (process-local serialization) + + * temp-file-rename (atomic on POSIX). The combination prevents both + * intra-process write-write races and partially-written files on crash. + * + * Schema is forward-compatible by addition only within this build — no legacy + * migration paths exist, so new fields must be optional and additive. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; + +/** A hygiene run's execution mode — free string ("all" for /all, or a check name). */ +export type RunMode = string; +export type RunStatus = "pending" | "in_progress" | "complete" | "failed"; +export type CheckStatus = + | "pending" + | "in_progress" + | "complete" + | "failed" + | "skipped"; + +/** + * Per-check progress snapshot, persisted inside `checks.`. Mirrors how + * the annotating check runner records status, artifacts, attempts and the last + * error so a resume can pick up where an interrupted run left off. + */ +export interface CheckState { + status: CheckStatus; + started_at?: string; + completed_at?: string; + error?: string; + artifacts?: string[]; + attempt?: number; + max_attempts?: number; + retry_backoff_ms?: number; + next_retry_at?: string; + last_error?: string; + heartbeat_at?: string; + last_event_at?: string; + last_tool?: string; + last_tool_summary?: string; + run_id?: string; +} + +/** One hygiene run: metadata + per-check progress. */ +export interface HygieneRunState { + run_id: string; + commit?: string | null; + branch?: string; + repository?: string; + history_available?: boolean; + mode: RunMode; + model?: string; + agent_sdk?: string; + started_at: string; + completed_at?: string | null; + status: RunStatus; + /** `checks.` → that check's progress state. */ + checks: Record; +} + +export interface HygieneStateFile { + runs: HygieneRunState[]; +} + +export interface ReadRunStateResult { + path: string; + exists: boolean; + state?: HygieneStateFile; + parseError?: string; +} + +export function getRunStatePath(cwd: string): string { + return join(cwd, "pygienium", "run-state.json"); +} + +/** + * Read the run-state file without ever throwing on a corrupt or non-matching + * file — it surfaces a `parseError` instead so callers (status, resume) can + * report gracefully. + */ +export function readRunState(cwd: string): ReadRunStateResult { + const path = getRunStatePath(cwd); + if (!existsSync(path)) { + return { path, exists: false }; + } + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isHygieneStateFile(parsed)) { + return { + path, + exists: true, + parseError: + "File is valid JSON but does not match expected run-state shape (missing `runs` array).", + }; + } + return { path, exists: true, state: parsed }; + } catch (err) { + return { + path, + exists: true, + parseError: err instanceof Error ? err.message : String(err), + }; + } +} + +function isHygieneStateFile(value: unknown): value is HygieneStateFile { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return Array.isArray(v.runs); +} + +/** + * Atomically replace the state file. Callers should always go through + * `mutateRunState` rather than calling this directly so concurrent mutations + * within the same process serialize correctly. + */ +function writeRunStateRaw(path: string, state: HygieneStateFile): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; + const json = `${JSON.stringify(state, null, "\t")}\n`; + writeFileSync(tmp, json); + renameSync(tmp, path); +} + +/** + * Read-modify-write the run-state file under the file mutation queue. + * The transformer receives the current state (or a fresh empty file if none + * exists) and returns the new state. Returning `undefined` aborts the write + * (no-op transformer). + */ +export async function mutateRunState( + cwd: string, + transform: (state: HygieneStateFile) => HygieneStateFile | undefined, +): Promise { + const path = getRunStatePath(cwd); + return withFileMutationQueue(path, async () => { + const current = readRunStateOrEmpty(path); + const next = transform(current); + if (!next) return current; + writeRunStateRaw(path, next); + return next; + }); +} + +function readRunStateOrEmpty(path: string): HygieneStateFile { + if (!existsSync(path)) return { runs: [] }; + const raw = readFileSync(path, "utf8"); + if (raw.trim() === "") return { runs: [] }; + try { + const parsed = JSON.parse(raw) as unknown; + if (isHygieneStateFile(parsed)) return parsed; + } catch { + // fall through to the corrupt-file backup below. + } + // The file exists with non-empty content that won't parse or doesn't match + // the expected shape. Run state is expensive and resumable, so never let + // the caller overwrite it blind: move the corrupt file aside first, then + // return empty so a fresh file is written alongside the preserved backup. + backupCorruptStateFile(path); + return { runs: [] }; +} + +/** + * Move a corrupt state file to `run-state.json.corrupt-` so a + * subsequent write doesn't destroy whatever run history it held. Best-effort: + * if the rename fails we leave the file in place rather than risk losing it. + */ +function backupCorruptStateFile(path: string): void { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + let backup = `${path}.corrupt-${stamp}`; + for (let n = 1; existsSync(backup); n++) + backup = `${path}.corrupt-${stamp}-${n}`; + try { + renameSync(path, backup); + } catch { + // Leave the original untouched if it can't be moved. + } +} + +/** Most recent run by `started_at` (ISO timestamps sort lexically). */ +export function latestRun( + state: HygieneStateFile, +): HygieneRunState | undefined { + if (state.runs.length === 0) return undefined; + return [...state.runs].sort((a, b) => + a.started_at < b.started_at ? 1 : -1, + )[0]; +} + +/** + * Most recent resumable run across all modes. Preference order: an + * `in_progress` run (process killed mid-phase) outranks a `failed` run + * (orderly terminal state) because the former is more likely a transient + * outage. `complete` runs are never returned. + * + * Ties are broken by `started_at` (most recent first). + */ +export function latestResumableRun( + state: HygieneStateFile, +): HygieneRunState | undefined { + const sorted = [...state.runs].sort((a, b) => + a.started_at < b.started_at ? 1 : -1, + ); + return ( + sorted.find((r) => r.status === "in_progress") ?? + sorted.find((r) => r.status === "failed") ?? + undefined + ); +} + +export interface InitRunOptions { + mode: RunMode; + model?: string; + agent_sdk?: string; + commit?: string | null; + branch?: string; + repository?: string; + history_available?: boolean; + /** + * Override the initial check list. Absent, `checks` starts empty and the + * orchestrator adds entries as each check transitions. Given one, every + * check is seeded as `{ status: "pending" }`. + */ + checks?: readonly string[]; +} + +/** + * Append a new hygiene run to the state file. Returns the appended run with a + * fresh ISO timestamp `run_id`. + */ +export async function initRun( + cwd: string, + options: InitRunOptions, +): Promise { + const startedAt = new Date().toISOString(); + const checks: Record = {}; + for (const name of options.checks ?? []) checks[name] = { status: "pending" }; + + const run: HygieneRunState = { + run_id: startedAt, + mode: options.mode, + started_at: startedAt, + completed_at: null, + status: "in_progress", + checks, + ...(options.model !== undefined && { model: options.model }), + ...(options.agent_sdk !== undefined && { agent_sdk: options.agent_sdk }), + ...(options.commit !== undefined && { commit: options.commit }), + ...(options.branch !== undefined && { branch: options.branch }), + ...(options.repository !== undefined && { repository: options.repository }), + ...(options.history_available !== undefined && { + history_available: options.history_available, + }), + }; + + await mutateRunState(cwd, (state) => ({ + ...state, + runs: [...state.runs, run], + })); + return run; +} + +export interface CheckUpdate { + status: CheckStatus; + error?: string; + artifacts?: string[]; + attempt?: number; + max_attempts?: number; + retry_backoff_ms?: number | null; + next_retry_at?: string | null; + last_error?: string | null; + heartbeat_at?: string | null; + last_event_at?: string | null; + last_tool?: string | null; + last_tool_summary?: string | null; + run_id?: string | null; +} + +/** + * Update a single check on the named run. Auto-stamps `started_at` on + * transitions into `in_progress` and `completed_at` on terminal states. + * Returns the updated run, or `undefined` if the run_id wasn't found. + */ +export async function setCheckStatus( + cwd: string, + runId: string, + check: string, + update: CheckUpdate, +): Promise { + let updated: HygieneRunState | undefined; + await mutateRunState(cwd, (state) => { + const idx = state.runs.findIndex((r) => r.run_id === runId); + if (idx < 0) return undefined; + const run = state.runs[idx]; + if (!run) return undefined; + const prev = run.checks[check] ?? { status: "pending" as const }; + const now = new Date().toISOString(); + const next: CheckState = { + ...prev, + status: update.status, + ...(update.error !== undefined && { error: update.error }), + ...(update.artifacts !== undefined && { artifacts: update.artifacts }), + ...(update.attempt !== undefined && { attempt: update.attempt }), + ...(update.max_attempts !== undefined && { + max_attempts: update.max_attempts, + }), + }; + if (update.retry_backoff_ms !== undefined) { + if (update.retry_backoff_ms === null) next.retry_backoff_ms = undefined; + else next.retry_backoff_ms = update.retry_backoff_ms; + } + if (update.next_retry_at !== undefined) { + if (update.next_retry_at === null) next.next_retry_at = undefined; + else next.next_retry_at = update.next_retry_at; + } + if (update.last_error !== undefined) { + if (update.last_error === null) next.last_error = undefined; + else next.last_error = update.last_error; + } + if (update.heartbeat_at !== undefined) { + if (update.heartbeat_at === null) next.heartbeat_at = undefined; + else next.heartbeat_at = update.heartbeat_at; + } + if (update.last_event_at !== undefined) { + if (update.last_event_at === null) next.last_event_at = undefined; + else next.last_event_at = update.last_event_at; + } + if (update.last_tool !== undefined) { + if (update.last_tool === null) next.last_tool = undefined; + else next.last_tool = update.last_tool; + } + if (update.last_tool_summary !== undefined) { + if (update.last_tool_summary === null) next.last_tool_summary = undefined; + else next.last_tool_summary = update.last_tool_summary; + } + if (update.run_id !== undefined) { + if (update.run_id === null) next.run_id = undefined; + else next.run_id = update.run_id; + } + if (update.status === "in_progress" && !next.started_at) + next.started_at = now; + if (update.status === "in_progress") next.completed_at = undefined; + if (update.status === "complete") { + next.error = undefined; + next.artifacts = undefined; + next.retry_backoff_ms = undefined; + next.next_retry_at = undefined; + next.last_error = undefined; + next.heartbeat_at = undefined; + next.last_event_at = undefined; + next.last_tool = undefined; + next.last_tool_summary = undefined; + next.run_id = undefined; + } + if ( + update.status === "complete" || + update.status === "failed" || + update.status === "skipped" + ) { + if (!next.started_at) next.started_at = now; + next.completed_at = now; + } + const checks = { ...run.checks, [check]: next }; + const newRun: HygieneRunState = { ...run, checks }; + updated = newRun; + const runs = [...state.runs]; + runs[idx] = newRun; + return { ...state, runs }; + }); + return updated; +} + +/** + * Wrapper around `setCheckStatus` that also mirrors the disk write onto the + * caller's in-memory `HygieneRunState`. Use this from orchestrators that hold + * a run object across multiple check transitions — otherwise their copy goes + * stale the moment any check completes, and downstream prerequisite checks + * read "pending" for already-completed checks. + */ +export async function applyPhaseStatus( + cwd: string, + run: HygieneRunState, + checkName: string, + update: CheckUpdate, +): Promise { + const updated = await setCheckStatus(cwd, run.run_id, checkName, update); + if (!updated) return; + const fresh = updated.checks[checkName]; + if (fresh) run.checks[checkName] = fresh; +} + +/** Mark a hygiene run as complete or failed. */ +export async function markRunStatus( + cwd: string, + runId: string, + status: RunStatus, +): Promise { + let updated: HygieneRunState | undefined; + await mutateRunState(cwd, (state) => { + const idx = state.runs.findIndex((r) => r.run_id === runId); + if (idx < 0) return undefined; + const run = state.runs[idx]; + if (!run) return undefined; + const completedAt = + status === "complete" || status === "failed" + ? new Date().toISOString() + : run.completed_at; + const newRun: HygieneRunState = { + ...run, + status, + completed_at: completedAt ?? null, + }; + updated = newRun; + const runs = [...state.runs]; + runs[idx] = newRun; + return { ...state, runs }; + }); + return updated; +} + +export interface CheckTally { + total: number; + complete: number; + in_progress: number; + pending: number; + failed: number; + skipped: number; +} + +export function tallyChecks(run: HygieneRunState): CheckTally { + const tally: CheckTally = { + total: 0, + complete: 0, + in_progress: 0, + pending: 0, + failed: 0, + skipped: 0, + }; + for (const check of Object.values(run.checks)) { + tally.total++; + tally[check.status]++; + } + return tally; +} + +/** + * The 04-hygiene-state deliverables list this name alongside the `tally*` + * helpers; the rename step (phase → check) yields `tallyChecks`, so this is a + * one-line alias kept for spelling compatibility with that spec. + */ +export const tallyPhases: typeof tallyChecks = tallyChecks; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2462194 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,76 @@ +/** + * pygienium — code hygiene extension for pi. + * + * Entry point. Registers `/pygienium-help`, auto-registers one + * `/pygienium-` command per registered `CheckDefinition`, plus the + * `all`/`resume`/`status`/`export` commands. Adding a check requires ONLY a new + * file in `src/checks/` plus one `registerCheck(def)` call — no changes here. + * + * Check files in `src/checks/` are auto-discovered (every `.ts` except the + * registry barrel), so they self-register at load time before commands bind. + * + * Pi loads this file via jiti at runtime (see `pi.extensions` in package.json). + * The default export runs once per session; the factory is async so check + * modules finish registering before command wiring. + * + * @module pygienium/index + */ + +import { readdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + ExtensionAPI, + ExtensionContext, + SessionStartEvent, +} from "@earendil-works/pi-coding-agent"; +import { registerPygieniumCommands } from "./commands.js"; + +/** Startup hint mirrored after piolium's convention. */ +export const PYGIENIUM_STARTUP_HINT = + "Pygienium loaded. Run /pygienium-help for available checks and flags."; + +export { buildPygieniumHelpLines } from "./help.js"; + +/** + * Import every `checks/*.ts` module (except the registry barrel) so each check + * file's top-level `registerCheck(def)` call runs before command binding. This + * is what makes adding a check require zero index.ts changes — drop a file, + * it self-registers. + */ +async function loadCheckModules(): Promise { + const dir = join(dirname(fileURLToPath(import.meta.url)), "checks"); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; // no checks dir (e.g. minimal install) + } + for (const entry of entries) { + if (!entry.endsWith(".ts")) continue; + if (entry === "registry.ts" || entry === "load.ts") continue; + await import(`./checks/${entry}`); + } +} + +export default async function pygieniumExtension( + pi: ExtensionAPI, +): Promise { + // Self-register every shipped check before wiring commands. + await loadCheckModules(); + + registerPygieniumCommands((name, options) => { + pi.registerCommand(name, { + description: options.description, + handler: options.handler, + }); + }); + + pi.on( + "session_start", + async (_event: SessionStartEvent, ctx: ExtensionContext) => { + if (!ctx.hasUI) return; + ctx.ui.notify(PYGIENIUM_STARTUP_HINT, "info"); + }, + ); +} diff --git a/src/modes/.gitkeep b/src/modes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modes/all.ts b/src/modes/all.ts new file mode 100644 index 0000000..0e4f74a --- /dev/null +++ b/src/modes/all.ts @@ -0,0 +1,375 @@ +/** + * modes/all.ts — `/pygienium-all` master orchestrator. + * + * Runs every registered check in sequence as ordered phases under a unified + * status strip, with resumable state and a final summary report. This is the + * piolium "balanced"/"deep" mode analogue, but simpler: one run, sequential + * phases, shared recon (no scheduler — checks run one after another). + * + * Pipeline: + * 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`. + * + * Resumability: terminal checks (`complete`/`skipped`) are skipped on resume; + * `in_progress`/`failed`/`pending` checks re-run. `--fresh` resets every check + * entry and re-runs the lot. `--only=comments,complexity` narrows the candidate + * set to a named subset (registration order preserved). + * + * @module pygienium/modes/all + */ + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import { getAllChecks, type CheckDefinition } from "../checks/registry.js"; +import { runCheck } from "./check-runner.js"; +import { createPhaseStrip } from "../phases.js"; +import { runRecon } from "../recon.js"; +import { + applyPhaseStatus, + initRunState, + loadRunState, + markRunStatus, + PHASE_RECON, + reconcileRunStatus, + resetCheckEntry, + saveRunState, + shouldRunOnResume, + stateDir, + type RunState, +} from "../run-state.js"; + +/** Artifact directory name (relative to cwd) that holds `all-summary.md`. */ +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`. */ +export function allSummaryPath(cwd: string): string { + return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME); +} + +export interface AllRunOptions { + /** Working directory (from `ctx.cwd`). */ + cwd: string; + /** Target path to scan (absolute; defaults to `cwd`). */ + target?: string; + /** Whether fixes should be applied (`--fix`). */ + fix?: boolean; + /** Subset of check names to run (`--only=comments,complexity`). */ + only?: string[]; + /** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */ + fresh?: boolean; + /** UI context (optional; null in print mode). */ + ui?: ExtensionUIContext; + /** Whether dialog-capable UI is available. */ + hasUI?: boolean; +} + +/** Outcome of {@link runAllChecks}. */ +export interface AllRunOutcome { + /** Final run status. */ + status: RunState["status"]; + /** The updated run state. */ + state: RunState; + /** Absolute path the summary was written to. */ + summaryPath: string; + /** Checks that were actually dispatched (ran `runCheck`). */ + ran: string[]; + /** Checks skipped because they were already terminal. */ + skipped: string[]; +} + +/** + * Filter the registry to the `only` subset, preserving insertion order. + * Unknown names are silently dropped (a typo shouldn't abort an all-run). + */ +export function selectChecks(only?: string[]): CheckDefinition[] { + const all = getAllChecks(); + if (!only || only.length === 0) return all; + const set = new Set(only); + return all.filter((c) => set.has(c.name)); +} + +/** + * Parse `/pygienium-all` args: an optional `[path]` positional plus the + * `--fix`, `--fresh`, and `--only=,` flags. + */ +export function parseAllArgs( + args: string, + cwd: string, +): { + target: string; + fix: boolean; + fresh: boolean; + only: string[]; +} { + const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; + let fix = false; + let fresh = false; + let target = cwd; + const only: string[] = []; + for (const tok of tokens) { + if (tok === "--fix") { + fix = true; + } else if (tok === "--fresh") { + fresh = true; + } else if (tok.startsWith("--only=")) { + for (const name of tok.slice("--only=".length).split(",")) { + const trimmed = name.trim(); + if (trimmed) only.push(trimmed); + } + } else if (!tok.startsWith("--")) { + target = tok; + } + } + return { target: resolve(cwd, target), fix, fresh, only }; +} + +/** Count non-empty lines in captured findings/changes text. */ +function lineCount(text: string | undefined): number { + if (!text) return 0; + return text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; +} + +function toISO(ms: number | undefined): string { + return ms == null ? "—" : new Date(ms).toISOString(); +} + +function short(status: string): string { + return status[0]?.toUpperCase() ?? "?"; +} + +/** + * Render the unified summary markdown from run-state. Lists per-check + * outcomes: status, artifact paths (when present on disk), findings/changes + * line counts, phase breakdown, and errors. + */ +export function renderAllSummary( + state: RunState, + selected: CheckDefinition[], +): string { + const lines: string[] = []; + lines.push("# Pygienium all-run summary"); + lines.push(""); + lines.push(`- status: ${state.status}`); + lines.push(`- started: ${toISO(state.startedAt)}`); + lines.push(`- updated: ${toISO(state.updatedAt)}`); + lines.push(`- cwd: ${state.cwd}`); + lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`); + lines.push(`- checks: ${selected.length}`); + lines.push(""); + + for (const check of selected) { + const entry = state.checks[check.name]; + const fixTag = entry?.fix ? " (--fix)" : ""; + lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`); + lines.push(""); + + if (entry?.error) { + lines.push(`- error: ${entry.error}`); + } + + // Artifact paths (canonical root). Checks may also write under the + // legacy `.pygienium/checks/` root; reference the canonical one and + // note findings/changes counts regardless of root. + const findingsPath = `${state.cwd}/pygienium/checks/${check.name}/findings.md`; + const changesPath = `${state.cwd}/pygienium/checks/${check.name}/changes.md`; + const fLines = lineCount(entry?.findings); + const cLines = lineCount(entry?.changes); + if (fLines > 0) { + lines.push(`- findings: ${findingsPath} (${fLines} line(s))`); + } + if (cLines > 0) { + lines.push(`- changes: ${changesPath} (${cLines} line(s))`); + } + + // Phase breakdown for transparency. + if (entry?.phases?.length) { + const phaseSummary = entry.phases + .map( + (p) => + `${p.id}:${ + p.status.startsWith("in_progress") ? "…" : short(p.status) + }`, + ) + .join(" "); + lines.push(`- phases: ${phaseSummary}`); + } + + lines.push(""); + } + + return lines.join("\n") + "\n"; +} + +/** + * Run every registered check in sequence under a unified status strip. + * + * Resumable: terminal checks skip on resume unless `fresh` resets them. + */ +export async function runAllChecks( + opts: AllRunOptions, +): Promise { + const { cwd } = opts; + const target = opts.target ?? cwd; + const fix = opts.fix ?? false; + const fresh = opts.fresh ?? false; + const hasUI = opts.hasUI ?? false; + + const selected = selectChecks(opts.only); + if (selected.length === 0) { + // `--only` selected nothing (or no checks registered). Still produce a + // summary so the caller has an artifact. + const state = (await loadRunState(cwd)) ?? initRunState(cwd, []); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + const summaryPath = await writeAllSummary(state, []); + return { status: state.status, state, summaryPath, ran: [], skipped: [] }; + } + + // --- Init / resume the single shared run-state -------------------------- + let state = + (await loadRunState(cwd)) ?? + initRunState( + cwd, + selected.map((c) => ({ name: c.name, label: c.label, fix })), + ); + + // Ensure every selected check has an entry (adds any missing on resume). + for (const check of selected) { + if (!state.checks[check.name]) { + state.checks[check.name] = { + name: check.name, + label: check.label, + status: "pending", + fix, + phases: [ + { id: "recon", status: "pending" }, + { id: "analysis", status: "pending" }, + ...(fix ? [{ id: "fix", status: "pending" as const }] : []), + { id: "verify", status: "pending" }, + { id: "cleanup", status: "pending" }, + ], + }; + } + } + await saveRunState(state); + + // --- Unified phase strip listing all check names ----------------------- + const strip = createPhaseStrip({ + ui: opts.ui, + hasUI, + statusKey: "pygienium-all", + }); + setAllPhase(strip, selected, 0, "recon"); + + // --- Shared recon (run once before any check) --------------------------- + if (!state.recon.complete) { + const snapshot = await runRecon(cwd); + state.recon = { + complete: true, + path: join(stateDir(cwd), "recon.json"), + finishedAt: snapshot.createdAt, + }; + // Mark recon complete for every selected check that hasn't run it yet. + for (const check of selected) { + applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); + } + await saveRunState(state); + } + + const ran: string[] = []; + const skipped: string[] = []; + + // --- Per-check loop (shared run-state, registry order) ------------------ + for (let i = 0; i < selected.length; i++) { + const check = selected[i]!; + setAllPhase(strip, selected, i, "analysis"); + + const entry = state.checks[check.name]; + // Resumability: skip terminal checks unless --fresh. + if (entry && !shouldRunOnResume(entry, fresh)) { + skipped.push(check.name); + strip.log( + `pygienium: ${check.label} — already ${entry.status}, skipping`, + ); + continue; + } + + if (fresh && entry) { + resetCheckEntry(state, check.name, fix); + await saveRunState(state); + } + + strip.log(`pygienium: running ${check.label}…`); + const outcome = await runCheck({ + check, + cwd, + scope: { cwd, target, fix, rest: [] }, + ui: opts.ui, + hasUI, + existingState: state, + }); + state = outcome.state; + ran.push(check.name); + strip.log(`pygienium ${check.label}: ${outcome.status}`); + } + + // --- Finalize ----------------------------------------------------------- + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + + setAllPhase(strip, selected, selected.length - 1, "cleanup"); + strip.done(); + + const summaryPath = await writeAllSummary(state, selected); + + return { + status: state.status, + state, + summaryPath, + ran, + skipped, + }; +} + +/** + * Update the unified strip to reflect which check is active and its phase. + * Renders `pygienium: all [i/N] : ` so every check name is + * surfaced in the strip over the course of the run. + */ +function setAllPhase( + strip: ReturnType, + selected: CheckDefinition[], + index: number, + phaseId: string, +): void { + const check = selected[index]; + const label = check?.label ?? "(none)"; + const total = selected.length; + const pos = String(index + 1); + const phaseLabel = PHASE_ALL_LABELS[phaseId] ?? phaseId; + strip.setPhase(`all [${pos}/${total}] ${label}: ${phaseLabel}`); +} + +const PHASE_ALL_LABELS: Record = { + recon: "Recon", + analysis: "Scanning", + fix: "Fixing", + verify: "Verifying", + cleanup: "Done", +}; + +/** Write the all-summary.md report, creating the directory as needed. */ +async function writeAllSummary( + state: RunState, + selected: CheckDefinition[], +): Promise { + const path = allSummaryPath(state.cwd); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderAllSummary(state, selected), "utf8"); + return path; +} diff --git a/src/modes/check-runner.ts b/src/modes/check-runner.ts new file mode 100644 index 0000000..56b9388 --- /dev/null +++ b/src/modes/check-runner.ts @@ -0,0 +1,310 @@ +/** + * modes/check-runner.ts — orchestrates a single check run. + * + * Pipeline: + * init/resolve run-state → Q0 recon (shared, once) → + * analysis sub-agent (buildScanTask) → fix sub-agent (buildFixTask, only + * with --fix) → verify gate → cleanup transient artifacts. + * + * Every phase is recorded on the persisted run-state via `run-state.ts`, so + * `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` reflect + * real progress. The check-runner is check-agnostic: a `CheckDefinition` + * supplies the task builders and gate; this module only wires the phases + * together. + * + * @module pygienium/modes/check-runner + */ + +import { rm } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import type { CheckDefinition, CheckScope } from "../checks/registry.js"; +import { runAgentTask } from "../agent-runner.js"; +import { runRecon } from "../recon.js"; +import { createPhaseStrip } from "../phases.js"; +import { + applyPhaseStatus, + initRunState, + loadRunState, + markCheckStatus, + markRunStatus, + PHASE_ANALYSIS, + PHASE_CLEANUP, + PHASE_FIX, + PHASE_RECON, + PHASE_VERIFY, + phasesForCheck, + recordCheckOutput, + reconcileRunStatus, + saveRunState, + stateDir, + type RunState, +} from "../run-state.js"; + +/** Parsed command args handed to a `/pygienium-` handler. */ +export interface CheckRunnerArgs { + /** Raw arg string from the slash command. */ + raw: string; +} + +/** Resolve a raw arg string into a check scope (target path + flags). */ +export function parseCheckArgs(raw: string, cwd: string): CheckScope { + const tokens = raw.trim().length > 0 ? raw.trim().split(/\s+/) : []; + let fix = false; + let target = cwd; + const rest: string[] = []; + for (const tok of tokens) { + if (tok === "--fix") { + fix = true; + } else if (tok.startsWith("--")) { + rest.push(tok); + } else { + target = tok; + } + } + // Absolute-ize target against cwd. + target = resolve(cwd, target); + return { cwd, target, fix, rest }; +} + +export interface RunCheckOptions { + /** The check definition to run. */ + check: CheckDefinition; + /** Working directory (from `ctx.cwd`). */ + cwd: string; + /** Parsed scope (target + flags). When omitted, derived from `rawArgs`. */ + scope?: CheckScope; + /** Raw command args, used when `scope` is omitted. */ + rawArgs?: string; + /** UI context (optional; null in print mode). */ + ui?: ExtensionUIContext; + /** Whether dialog-capable UI is available. */ + hasUI?: boolean; + /** Pre-existing run state to update (for `/pygienium-all` and resume). */ + existingState?: RunState; +} + +/** Outcome of a single check run. */ +export interface CheckRunOutcome { + /** Final check status. */ + status: "complete" | "failed" | "skipped"; + /** Findings text from the analysis phase. */ + findings?: string; + /** Changes text from the fix phase (when run with --fix). */ + changes?: string; + /** Error message on failure. */ + error?: string; + /** The updated run state. */ + state: RunState; +} + +/** + * Run a single check end-to-end, persisting progress to run-state. + * + * Resumable: if `existingState` already has terminal-ish progress for this + * check, the runner resumes the last in-progress phase rather than restarting. + */ +export async function runCheck( + opts: RunCheckOptions, +): Promise { + const { check, cwd } = opts; + const scope = opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", cwd); + + // Resolve or init the run state, recording this check on first sight. + const state: RunState = + opts.existingState ?? (await loadRunState(cwd)) ?? initRunState(cwd, []); + if (!state.checks[check.name]) { + state.checks[check.name] = { + name: check.name, + label: check.label, + status: "pending", + fix: scope.fix, + phases: phasesForCheck(scope.fix), + }; + } + await saveRunState(state); + + const strip = createPhaseStrip({ + ui: opts.ui, + hasUI: opts.hasUI ?? false, + checkLabel: check.label, + }); + + let findings = ""; + let changes = ""; + let error: string | undefined; + + try { + // --- Phase: gate ----------------------------------------------------- + const gateResult = await Promise.resolve(check.gate(cwd)); + if (gateResult) { + // Skip this check entirely (no agent work). + for (const phase of state.checks[check.name]?.phases ?? []) { + if (phase.status === "pending") phase.status = "skipped"; + } + markCheckStatus(state, check.name, "skipped", gateResult); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + strip.setPhase(PHASE_CLEANUP); + strip.done(); + return { status: "skipped", error: gateResult, state }; + } + + // --- Phase: recon (shared, run once per run) ------------------------- + if (!state.recon.complete) { + strip.setPhase(PHASE_RECON); + applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress"); + await saveRunState(state); + const snapshot = await runRecon(cwd); + state.recon = { + complete: true, + path: join(stateDir(cwd), "recon.json"), + finishedAt: snapshot.createdAt, + }; + applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); + await saveRunState(state); + } else { + // Recon already done this run — mark this check's recon complete. + applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); + } + + // --- Phase: analysis ------------------------------------------------- + strip.setPhase(PHASE_ANALYSIS); + applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "in_progress"); + await saveRunState(state); + const scanTask = await check.buildScanTask(cwd, scope); + const scanResult = await runAgentTask({ + cwd: scope.target, + agentName: check.agentName, + task: scanTask, + }); + findings = scanResult.text; + recordCheckOutput(state, check.name, { findings }); + if (!scanResult.ok) { + applyPhaseStatus( + state, + check.name, + PHASE_ANALYSIS, + "failed", + scanResult.error, + ); + markCheckStatus(state, check.name, "failed", scanResult.error); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + return { status: "failed", error: scanResult.error, findings, state }; + } + applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "complete"); + await saveRunState(state); + + // --- Phase: fix (only with --fix) ----------------------------------- + if (scope.fix) { + strip.setPhase(PHASE_FIX); + applyPhaseStatus(state, check.name, PHASE_FIX, "in_progress"); + await saveRunState(state); + const fixTask = await check.buildFixTask(cwd, scope, findings); + const fixResult = await runAgentTask({ + cwd: scope.target, + agentName: check.fixAgentName ?? "fixer", + task: fixTask, + }); + changes = fixResult.text; + recordCheckOutput(state, check.name, { changes }); + if (!fixResult.ok) { + applyPhaseStatus( + state, + check.name, + PHASE_FIX, + "failed", + fixResult.error, + ); + markCheckStatus(state, check.name, "failed", fixResult.error); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + return { + status: "failed", + error: fixResult.error, + findings, + changes, + state, + }; + } + applyPhaseStatus(state, check.name, PHASE_FIX, "complete"); + await saveRunState(state); + } + + // --- Phase: verify --------------------------------------------------- + strip.setPhase(PHASE_VERIFY); + applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress"); + await saveRunState(state); + // Verify is a lightweight self-check. A check may supply a dedicated + // `verify` hook to confirm its artifacts were produced (e.g. + // findings.md / changes.md exist). When absent, fall back to re-running + // the gate — unchanged from the historical behaviour. + const verifyResult = await Promise.resolve( + check.verify ? check.verify(scope) : check.gate(cwd), + ); + if (verifyResult) { + applyPhaseStatus(state, check.name, PHASE_VERIFY, "failed", verifyResult); + markCheckStatus(state, check.name, "failed", verifyResult); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + return { + status: "failed", + error: verifyResult, + findings, + changes, + state, + }; + } + applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete"); + await saveRunState(state); + + // --- Phase: cleanup -------------------------------------------------- + strip.setPhase(PHASE_CLEANUP); + applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress"); + await saveRunState(state); + await cleanupTransientArtifacts(cwd, check.name); + applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete"); + markCheckStatus(state, check.name, "complete"); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + + return { status: "complete", findings, changes, state }; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + markCheckStatus(state, check.name, "failed", error); + markRunStatus(state, reconcileRunStatus(state)); + await saveRunState(state); + return { status: "failed", error, findings, changes, state }; + } finally { + strip.done(); + } +} + +/** + * Remove transient per-check scratch artifacts (e.g. agent-extracted + * manifests) written under `/.pygienium/-tmp-*`. Findings and + * changes are kept in run-state, not these scratch files, so removing them is + * safe. + */ +async function cleanupTransientArtifacts( + cwd: string, + checkName: string, +): Promise { + const dir = stateDir(cwd); + // Best-effort: remove any `*-tmp-` entries created by agents. + const { readdir } = await import("node:fs/promises"); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; + } + for (const entry of entries) { + if (entry.includes(`-tmp-${checkName}`)) { + await rm(join(dir, entry), { recursive: true, force: true }).catch( + () => {}, + ); + } + } +} diff --git a/src/phases.ts b/src/phases.ts new file mode 100644 index 0000000..d618a2e --- /dev/null +++ b/src/phases.ts @@ -0,0 +1,83 @@ +/** + * phases.ts — phase-strip status UI helper. + * + * Renders the active phase of a check run into pi's footer status bar and + * forwards plain-text progress lines to stdout (so `print` mode `-p` also + * shows progress). The strip is a small, self-contained adapter over + * `ExtensionUIContext.setStatus` — simplified from piolium's phase-strip + * command UI to the subset pygienium needs: a status key, the current phase, + * and a clear on completion. + * + * @module pygienium/phases + */ + +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; + +/** Phase display metadata for a check run's phases. */ +export const PHASE_LABELS: Record = { + recon: "Recon", + analysis: "Scanning", + fix: "Fixing", + verify: "Verifying", + cleanup: "Cleaning up", +}; + +export interface PhaseStripOptions { + /** Footer status key (defaults to "pygienium"). */ + statusKey?: string; + /** Check label shown alongside the phase, e.g. "comments". */ + checkLabel?: string; + /** Whether dialog-capable UI is available. */ + hasUI?: boolean; + /** UI context to drive the footer status bar. */ + ui?: ExtensionUIContext; +} + +/** + * A handle that renders phase progress and clears on completion. Created by + * {@link createPhaseStrip}; pass the result to the check-runner. + */ +export interface PhaseStrip { + /** Set the current phase id (e.g. "analysis"). */ + setPhase(phaseId: string): void; + /** Append a plain-text progress line (forwarded to stdout). */ + log(line: string): void; + /** Clear the footer status bar. Call once the run is terminal. */ + done(): void; +} + +/** Create a phase-strip UI adapter. */ +export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip { + const statusKey = opts.statusKey ?? "pygienium"; + const ui = opts.ui; + const hasUI = opts.hasUI ?? false; + const checkLabel = opts.checkLabel; + + function render(phaseId: string): string { + const label = PHASE_LABELS[phaseId] ?? phaseId; + return checkLabel + ? `pygienium ${checkLabel}: ${label}` + : `pygienium: ${label}`; + } + + return { + setPhase(phaseId) { + const text = render(phaseId); + if (hasUI && ui?.setStatus) { + ui.setStatus(statusKey, text); + } + // In print/json modes (no TUI) write progress to stdout. In TUI mode + // the status bar is the render surface — raw stdout writes would splice + // into the ink renderer, so they are suppressed. + if (!hasUI) process.stdout.write(`${text}\n`); + }, + log(line) { + if (!hasUI) process.stdout.write(`${line}\n`); + }, + done() { + if (hasUI && ui?.setStatus) { + ui.setStatus(statusKey, undefined); + } + }, + }; +} diff --git a/src/recon.ts b/src/recon.ts new file mode 100644 index 0000000..e723d2e --- /dev/null +++ b/src/recon.ts @@ -0,0 +1,128 @@ +/** + * recon.ts — shared Q0 reconnaissance phase. + * + * Runs once per hygiene run (before any check's analysis phase) and writes a + * project snapshot to `/.pygienium/recon.json`. Each check can read this + * snapshot so the recon work isn't repeated per check. The snapshot is minimal + * and dependency-free (git state + source-file inventory) — real checks layer + * their own analysis on top via sub-agents. + * + * @module pygienium/recon + */ + +import { exec } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { join } from "node:path"; +import { stateDir, RECON_FILENAME } from "./run-state.js"; + +const execAsync = promisify(exec); + +export interface ReconSnapshot { + cwd: string; + createdAt: number; + gitBranch?: string; + gitDirty?: boolean; + /** Count of source files by extension. */ + fileCounts: Record; + totalSourceFiles: number; +} + +/** Source extensions worth inventorying for hygiene checks. */ +const SOURCE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".swift", + ".php", + ".cs", + ".lua", +]); + +/** Run `git ls-files` when possible to get a clean source inventory. */ +async function listSourceFiles(cwd: string): Promise { + try { + const { stdout } = await execAsync( + `git -C ${JSON.stringify(cwd)} ls-files --cached --others --exclude-standard`, + { maxBuffer: 64 * 1024 * 1024 }, + ); + return stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0) + .filter((l) => { + const dot = l.lastIndexOf("."); + if (dot === -1) return false; + return SOURCE_EXTENSIONS.has(l.slice(dot).toLowerCase()); + }); + } catch { + /* not a git repo or git unavailable — empty inventory */ + return []; + } +} + +/** Run the shared recon phase for `cwd`, writing the snapshot if missing. */ +export async function runRecon(cwd: string): Promise { + const dir = stateDir(cwd); + const path = join(dir, RECON_FILENAME); + + // Reuse a fresh-enough snapshot (< 5 min) when present. + try { + const raw = await readFile(path, "utf8"); + const existing = JSON.parse(raw) as ReconSnapshot; + if ( + existing.createdAt && + Date.now() - existing.createdAt < 5 * 60 * 1000 && + existing.cwd === cwd + ) { + return existing; + } + } catch { + /* no existing snapshot */ + } + + const files = await listSourceFiles(cwd); + const fileCounts: Record = {}; + for (const f of files) { + const dot = f.lastIndexOf("."); + const ext = dot === -1 ? "" : f.slice(dot).toLowerCase(); + fileCounts[ext] = (fileCounts[ext] ?? 0) + 1; + } + + let gitBranch: string | undefined; + let gitDirty: boolean | undefined; + try { + const branchOut = await execAsync( + `git -C ${JSON.stringify(cwd)} rev-parse --abbrev-ref HEAD`, + ); + gitBranch = branchOut.stdout.trim() || undefined; + const statusOut = await execAsync( + `git -C ${JSON.stringify(cwd)} status --porcelain`, + ); + gitDirty = statusOut.stdout.trim().length > 0; + } catch { + /* not a git repo */ + } + + const snapshot: ReconSnapshot = { + cwd, + createdAt: Date.now(), + gitBranch, + gitDirty, + fileCounts, + totalSourceFiles: files.length, + }; + + await mkdir(dir, { recursive: true }); + await writeFile(path, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); + return snapshot; +} diff --git a/src/run-state.ts b/src/run-state.ts new file mode 100644 index 0000000..fbb5a2f --- /dev/null +++ b/src/run-state.ts @@ -0,0 +1,286 @@ +/** + * run-state.ts — persistent hygiene-run state. + * + * Tracks per-check phase progress so `/pygienium-status`, `/pygienium-resume`, + * and `/pygienium-export` work, and so `/pygienium-all` is resumable. State is + * a single JSON file at `/.pygienium/run-state.json` so it is trivial to + * inspect and is naturally session-scoped to the target directory. + * + * @module pygienium/run-state + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export const RUN_STATE_DIRNAME = ".pygienium"; +export const RUN_STATE_FILENAME = "run-state.json"; +export const RECON_FILENAME = "recon.json"; + +/** Resolve the pygienium state directory for a given cwd. */ +export function stateDir(cwd: string): string { + return join(cwd, RUN_STATE_DIRNAME); +} + +/** Resolve the run-state file path for a given cwd. */ +export function runStatePath(cwd: string): string { + return join(stateDir(cwd), RUN_STATE_FILENAME); +} + +export type PhaseStatus = + | "pending" + | "in_progress" + | "complete" + | "failed" + | "skipped"; + +export type CheckStatus = PhaseStatus; +export type RunStatus = "in_progress" | "complete" | "failed" | "partial"; + +export interface PhaseEntry { + id: string; + status: PhaseStatus; + startedAt?: number; + finishedAt?: number; + error?: string; +} + +export interface CheckRun { + name: string; + label: string; + status: CheckStatus; + fix: boolean; + phases: PhaseEntry[]; + findings?: string; + changes?: string; + startedAt?: number; + finishedAt?: number; + error?: string; +} + +export interface ReconState { + complete: boolean; + path: string; + finishedAt?: number; +} + +export interface RunState { + version: 1; + cwd: string; + startedAt: number; + updatedAt: number; + status: RunStatus; + recon: ReconState; + checks: Record; +} + +/** Phase ids shared by every check run, in execution order. */ +export const PHASE_RECON = "recon"; +export const PHASE_ANALYSIS = "analysis"; +export const PHASE_FIX = "fix"; +export const PHASE_VERIFY = "verify"; +export const PHASE_CLEANUP = "cleanup"; + +export const CHECK_PHASES = [ + PHASE_RECON, + PHASE_ANALYSIS, + PHASE_FIX, + PHASE_VERIFY, + PHASE_CLEANUP, +] as const; + +function freshPhase(id: string): PhaseEntry { + return { id, status: "pending" }; +} + +/** Create the phase skeleton for a single check (analysis always runs; fix only when requested). */ +export function phasesForCheck(fix: boolean): PhaseEntry[] { + const phases = [freshPhase(PHASE_RECON), freshPhase(PHASE_ANALYSIS)]; + if (fix) phases.push(freshPhase(PHASE_FIX)); + phases.push(freshPhase(PHASE_VERIFY), freshPhase(PHASE_CLEANUP)); + return phases; +} + +/** Initialize a fresh run state for `checkNames` (labels default to the name). */ +export function initRunState( + cwd: string, + checks: Array<{ name: string; label: string; fix?: boolean }>, +): RunState { + const now = Date.now(); + const state: RunState = { + version: 1, + cwd, + startedAt: now, + updatedAt: now, + status: "in_progress", + recon: { complete: false, path: join(stateDir(cwd), RECON_FILENAME) }, + checks: {}, + }; + for (const c of checks) { + state.checks[c.name] = { + name: c.name, + label: c.label, + status: "pending", + fix: c.fix ?? false, + phases: phasesForCheck(c.fix ?? false), + startedAt: undefined, + }; + } + return state; +} + +/** Load run state for `cwd`. Returns `undefined` when none exists. */ +export async function loadRunState(cwd: string): Promise { + const path = runStatePath(cwd); + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as RunState; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } +} + +/** Persist run state, creating the state directory as needed. */ +export async function saveRunState(state: RunState): Promise { + const dir = stateDir(state.cwd); + await mkdir(dir, { recursive: true }); + state.updatedAt = Date.now(); + await writeFile( + runStatePath(state.cwd), + JSON.stringify(state, null, 2) + "\n", + "utf8", + ); +} + +/** Mark a phase's status (and optionally an error message). */ +export function applyPhaseStatus( + state: RunState, + checkName: string, + phaseId: string, + status: PhaseStatus, + error?: string, +): void { + const check = state.checks[checkName]; + if (!check) return; + const phase = check.phases.find((p) => p.id === phaseId); + if (!phase) return; + phase.status = status; + const now = Date.now(); + if (status === "in_progress") { + phase.startedAt = now; + if (check.startedAt == null) check.startedAt = now; + check.status = "in_progress"; + } else if ( + status === "complete" || + status === "failed" || + status === "skipped" + ) { + phase.finishedAt = now; + if (error) phase.error = error; + } +} + +/** + * Reconcile a check's overall status from its phases and, when terminal, + * stamp `finishedAt`. Used after the cleanup phase resolves. + */ +export function markCheckStatus( + state: RunState, + checkName: string, + status: CheckStatus, + error?: string, +): void { + const check = state.checks[checkName]; + if (!check) return; + check.status = status; + if (error) check.error = error; + if (status === "complete" || status === "failed" || status === "skipped") { + check.finishedAt = Date.now(); + } +} + +/** Record findings/changes text on a check. */ +export function recordCheckOutput( + state: RunState, + checkName: string, + out: { findings?: string; changes?: string }, +): void { + const check = state.checks[checkName]; + if (!check) return; + if (out.findings !== undefined) check.findings = out.findings; + if (out.changes !== undefined) check.changes = out.changes; +} + +/** Mark the overall run status. */ +export function markRunStatus(state: RunState, status: RunStatus): void { + state.status = status; + state.updatedAt = Date.now(); +} + +/** + * Determine whether a check is terminal (shouldn't be re-dispatched unless a + * fresh run is forced). `pending`/`in_progress`/`failed` are resumable. + */ +export function isCheckTerminal(check: CheckRun): boolean { + return check.status === "complete" || check.status === "skipped"; +} + +/** + * Re-dispatch predicate for `/pygienium-resume`: a check runs on resume when it + * is not terminal, OR when `--fresh` forced a re-dispatch of everything. + */ +export function shouldRunOnResume(check: CheckRun, fresh: boolean): boolean { + return fresh || !isCheckTerminal(check); +} + +/** + * Reset a single check entry back to `pending` with fresh phases. Used by + * `/pygienium-resume --fresh` so that previously-complete checks are + * re-dispatched from scratch. Preserves `label`/`fix` from the existing entry + * unless overridden. + */ +export function resetCheckEntry( + state: RunState, + name: string, + fixOverride?: boolean, +): void { + const existing = state.checks[name]; + const fix = fixOverride ?? existing?.fix ?? false; + state.checks[name] = { + name, + label: existing?.label ?? name, + status: "pending", + fix, + phases: phasesForCheck(fix), + startedAt: undefined, + finishedAt: undefined, + findings: undefined, + changes: undefined, + error: undefined, + }; +} + +/** + * Compute the next check to run when resuming: the first `in_progress` check, + * else the first pending/failed check. Returns `undefined` when nothing remains. + */ +/** + * Recompute the run-level status from check statuses. A run is `complete` only + * when every check is terminal-complete; `failed` if any failed without fixes + * completed; `partial` when some checks were skipped/failed but others ok. + */ +export function reconcileRunStatus(state: RunState): RunStatus { + const checks = Object.values(state.checks); + if (checks.length === 0) return "in_progress"; + let anyFailed = false; + let anySkipped = false; + for (const c of checks) { + if (c.status === "pending" || c.status === "in_progress") + return "in_progress"; + if (c.status === "failed") anyFailed = true; + if (c.status === "skipped") anySkipped = true; + } + if (anyFailed) return "partial"; + if (anySkipped) return "partial"; + return "complete"; +} diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..9c88207 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,108 @@ +/** + * status.ts — readable run-state formatter. + * + * {@link formatRunStatus} turns a `RunState` into a plain line list covering + * the run-level summary and one block per registered check: overall status, the + * per-phase breakdown, captured findings/changes artifacts, and any errors. + * It is a *pure* function of state — no disk I/O — so it is trivially + * unit-testable and deterministic; the in-memory run-state is the single source + * of truth for progress (the check-runner records findings/changes text on it). + * + * `formatRunStatus` is the single helper the `/pygienium-status` command uses; + * keeping it here (out of `commands.ts`) lets `commands.ts` stay a thin binder. + * + * @module pygienium/status + */ + +import type { CheckRun, RunState } from "./run-state.js"; + +const PHASE_ORDER = ["recon", "analysis", "fix", "verify", "cleanup"] as const; + +function toISO(ms: number | undefined): string { + return ms == null ? "—" : new Date(ms).toISOString(); +} + +function short(status: string): string { + return status[0]?.toUpperCase() ?? "?"; +} + +/** Count non-empty lines in captured findings/changes text. */ +function lineCount(text: string | undefined): number { + if (!text) return 0; + const count = text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; + return count; +} + +/** + * Format a single check block (without a trailing separator) — exposed so tests + * and the status command share one rendering path. + */ +export function formatCheckBlock(check: CheckRun, indent = " "): string[] { + const lines: string[] = []; + const flag = check.fix ? " (--fix)" : ""; + lines.push(`${indent}${check.name} — ${check.status}${flag}`); + + const phaseSummary = check.phases + .map((p) => `${p.id}:${p.status.startsWith("in_progress") ? "…" : short(p.status)}`) + .join(" "); + if (phaseSummary) lines.push(`${indent} phases: ${phaseSummary}`); + + const findingsLines = lineCount(check.findings); + if (findingsLines > 0) { + lines.push(`${indent} findings: ${findingsLines} line(s)`); + } + const changesLines = lineCount(check.changes); + if (changesLines > 0) { + lines.push(`${indent} changes: ${changesLines} line(s)`); + } + + if (check.error) { + lines.push(`${indent} error: ${check.error}`); + } + for (const phase of check.phases) { + if (phase.status === "failed" && phase.error && phase.error !== check.error) { + lines.push(`${indent} ${phase.id}: ${phase.error}`); + } + } + return lines; +} + +/** + * Build the `/pygienium-status` line list for a run state. Pure: no disk reads. + * Layout: + * + * pygienium run — + * started: + * updated: + * cwd: + * recon: complete|pending + * + * checks (N): + * + * phases: recon:✓ analysis:✓ [fix:✓] verify:✓ cleanup:✓ + * findings: line(s) + * changes: line(s) + * error: + */ +export function formatRunStatus(state: RunState): string[] { + const checks = Object.values(state.checks); + const lines: string[] = []; + + lines.push(`pygienium run — ${state.status}`); + lines.push(` started: ${toISO(state.startedAt)}`); + lines.push(` updated: ${toISO(state.updatedAt)}`); + lines.push(` cwd: ${state.cwd}`); + const reconLabel = state.recon.complete ? "complete" : "pending"; + const reconTime = state.recon.finishedAt ? ` (${toISO(state.recon.finishedAt)})` : ""; + lines.push(` recon: ${reconLabel}${reconTime}`); + lines.push(""); + + lines.push(` checks (${checks.length}):`); + if (checks.length === 0) { + lines.push(" (none registered in this run state)"); + } + for (const check of checks) { + lines.push(...formatCheckBlock(check)); + } + return lines; +} diff --git a/tasks/01-scaffolding.md b/tasks/01-scaffolding.md new file mode 100644 index 0000000..4e6459d --- /dev/null +++ b/tasks/01-scaffolding.md @@ -0,0 +1,50 @@ +# 01. Extension scaffolding and project structure + +meta: + id: pygienium-01 + feature: pygienium + priority: P1 + depends_on: [] + tags: [infrastructure, setup] + +objective: + +- Establish the pygienium extension directory, package.json, tsconfig, and a minimal index.ts that loads without error and surfaces a startup notification. + +deliverables: + +- `pygienium/package.json` with pi package manifest (`pi.extensions`, keywords, peer deps echoing piolium) +- `pygienium/tsconfig.json` (extends the repo tsconfig) +- `pygienium/src/index.ts` default-export factory registering a `session_start` notify + a stub `/pygienium-help` +- `pygienium/` subdirectories: `src/`, `src/modes/`, `src/checks/`, `agents/`, `skills/` (empty, with `.gitkeep`) +- Verified load: `pi -e ./pygienium/src/index.ts -p "/pygienium-help"` runs without throwing + +steps: + +- Create the `pygienium/` tree and empty subdirectories +- Author `package.json`: name `pygienium`, type module, `pi.extensions: ["./src/index.ts"]`, peerDeps mirroring piolium (`@earendil-works/pi-coding-agent`, `typebox`, etc.), engines bun >=1.1.0 +- Author `tsconfig.json` referencing the parent `tsconfig.json` +- Author `src/index.ts`: import `ExtensionAPI`, default-export factory that registers a `session_start` notify ("Pygienium loaded. Run /pygienium-help.") and a `/pygienium-help` command printing a placeholder line +- Confirm the extension auto-discovers from `~/.pi/agent/extensions/pygienium/src/index.ts` (per docs, subdir `index.ts`); if discovery prefers `pygienium/index.ts`, adjust entry path in package.json + +tests: + +- Manual: run `pi -e ./pygienium/src/index.ts -p "/pygienium-help"`; confirm the placeholder help renders +- Manual: start pi in `~/.pi/agent/extensions` and confirm the startup notify appears without `session_start` errors + +acceptance_criteria: + +- The directory tree and package.json exist with valid JSON +- `pi` loads the extension via auto-discovery with no load errors +- `/pygienium-help` responds with a non-empty line + +validation: + +- `node -e "JSON.parse(require('fs').readFileSync('pygienium/package.json','utf8'))"` exits 0 +- `pi -e ./pygienium/src/index.ts -p "/pygienium-help"` prints the placeholder + +notes: + +- Place the extension in `~/.pi/agent/extensions/pygienium/` so it auto-discovers and hot-reloads with `/reload` +- Do NOT add backward-compat shims or migration paths (engineering rule) +- Keep index.ts minimal here; full command wiring lands in task 06 diff --git a/tasks/02-recon.md b/tasks/02-recon.md new file mode 100644 index 0000000..ec9f8e6 --- /dev/null +++ b/tasks/02-recon.md @@ -0,0 +1,46 @@ +# 02. Deterministic code reconnaissance module + +meta: + id: pygienium-02 + feature: pygienium + priority: P1 + depends_on: [pygienium-01] + tags: [infrastructure, no-model] + +objective: + +- Build `src/recon.ts`: a deterministic, no-model pass that walks the target repo and writes a compact markdown report (languages, manifests, file counts, dead-file candidates) so every check has stable ground truth. + +deliverables: + +- `src/recon.ts` exporting `runRecon(cwd)`, `runReconAsync(cwd, opts)`, `reconReportPath(cwd)`, `ReconResult` +- Writes `pygienium/recon/report.md` under cwd +- Detects: languages by extension, build manifests, total files/bytes, git head/branch, and a "candidate files" list (source files not under skip dirs) written to `pygienium/recon/candidates.jsonl` + summary +- Soft caps (MAX_FILES, MAX_BYTES) and SIGINT-safe async walk mirroring piolium's recon + +steps: + +- Port piolium's `recon.ts` structure: MANIFEST_FILES, LANGUAGE_BY_EXT, SKIP_DIRS, safe git exec, walkAndTally + async variant with yieldToEventLoop +- Adapt skip dirs to include `pygienium` (own output dir) +- Add a `candidates.jsonl` emitter listing source files (by language) for the check runners to consume; cap entries to keep it bounded +- Export `buildReconReport` for unit testing + +tests: + +- Unit: `buildReconReport` on a fake ReconResult produces expected markdown sections (Arrange a result, Act build, Assert headers present) +- Integration: run recon against this repo; assert report.md + candidates.jsonl exist and counts > 0 + +acceptance_criteria: + +- `runRecon(cwd)` returns a ReconResult and writes report.md + candidates.jsonl +- A repo without `.git` does not throw (graceful degradation) +- Very large trees are capped without wedging + +validation: + +- Inspect `pygienium/recon/report.md` and `pygienium/recon/candidates.jsonl` after a run + +notes: + +- Recon is Q0-equivalent: deterministic, runs in-process, no model calls +- This is the foundation every check consumes for targeting diff --git a/tasks/03-agent-runner.md b/tasks/03-agent-runner.md new file mode 100644 index 0000000..4b8c8ea --- /dev/null +++ b/tasks/03-agent-runner.md @@ -0,0 +1,47 @@ +# 03. Sub-agent runner with createAgentSession + +meta: + id: pygienium-03 + feature: pygienium + priority: P1 + depends_on: [pygienium-01] + tags: [infrastructure, core] + +objective: + +- Build `src/agent-runner.ts`: spawn isolated child pi sessions via `createAgentSession`, capture transcripts/results, and return a typed result — the engine that powers every check's sub-agent phases. + +deliverables: + +- `src/agent-runner.ts` exporting `runAgent(options)`, `AgentRuntimeModel`, `RunAgentResult`, `AgentRunError`, `buildRuntimeHeader`, `RuntimeContext` +- Each run writes `pygienium/runs//{prompt.md, transcript.jsonl, result.md, error.txt}` +- Inherits parent model + modelRegistry + thinkingLevel (so child phases reason at the same depth) +- AbortSignal support; `onEvent` forwarding hook for UI streaming +- Child tools: the built-in edit/write/read/bash surface; noExtensions: true (avoid recursion) + +steps: + +- Port piolium's `agent-runner.ts` shape: composed system prompt = runtime header + agent systemPrompt; `DefaultResourceLoader` with noExtensions/noThemes/noContextFiles; in-memory SessionManager +- Define `AgentDefinition` interface (name, description, systemPrompt, allowedTools, sourcePath) consumed here +- Wire `session.subscribe` to capture final assistant text + stopReason + errorMessage +- Handle abort: add/remove abort listener, call `session.agent.abort()` + +tests: + +- Unit: `buildRuntimeHeader` includes cwd, mode, phase, assigned output paths +- Integration: run a smoke agent (no-tools, replies with a fixed string) via `runAgent`; assert result.text non-empty and transcript.jsonl exists + +acceptance_criteria: + +- `runAgent` returns RunAgentResult with text + transcriptPath + durationMs +- Aborting the signal cancels the child session without leaving it running +- A child that errors throws `AgentRunError` carrying the result + +validation: + +- Invoke a smoke run from a temporary command and inspect the runs dir + +notes: + +- This is the most direct piolium port; keep it faithful to reduce risk +- Do NOT load extensions in the child (footgun) — `noExtensions: true` diff --git a/tasks/04-hygiene-state.md b/tasks/04-hygiene-state.md new file mode 100644 index 0000000..9f7857a --- /dev/null +++ b/tasks/04-hygiene-state.md @@ -0,0 +1,45 @@ +# 04. Resumable run-state persistence + +meta: + id: pygienium-04 + feature: pygienium + priority: P1 + depends_on: [pygienium-01] + tags: [infrastructure, state] + +objective: + +- Build `src/hygiene-state.ts`: a resumable state file (`pygienium/run-state.json`) tracking one run per invocation with per-check phase status, so interrupted runs can resume and `/pygienium-status` can report progress. + +deliverables: + +- `src/hygiene-state.ts` exporting `initRun`, `latestRun`, `latestResumableRun`, `readRunState`, `applyPhaseStatus`, `markRunStatus`, `tallyPhases` +- State schema: `{ runs: [{ run_id, mode, status, checks: { : { status, attempt, last_error, artifacts, ... } } }] }` +- Idempotent writes (read-modify-write with safe merge); file absent / unparseable handled gracefully + +steps: + +- Port piolium's `audit-state.ts` read/write helpers, renaming audit→run, phase→check +- Implement `initRun(cwd, { mode })` returning a fresh run state record +- `applyPhaseStatus(cwd, run, checkName, patch)` merges per-check status +- `latestResumableRun` returns a run whose status is in_progress or failed (not complete) + +tests: + +- Unit: init -> apply phase complete -> markRunStatus complete reads back correctly +- Unit: unparseable file returns `parseError` without throwing + +acceptance_criteria: + +- `readRunState(cwd)` returns `{ exists, parseError?, state? }` +- applyPhaseStatus persists and is readable by a subsequent read +- Resumable selection picks in_progress > failed, ignores complete + +validation: + +- Run a check, kill mid-flight, inspect `pygienium/run-state.json` + +notes: + +- Keep schema forward-compatible-by-addition only within this build; no legacy migrations +- This file is the source of truth for `/pygienium-status` and `/pygienium-resume` diff --git a/tasks/05-infrastructure-utils.md b/tasks/05-infrastructure-utils.md new file mode 100644 index 0000000..3fbf937 --- /dev/null +++ b/tasks/05-infrastructure-utils.md @@ -0,0 +1,46 @@ +# 05. Scheduler, retry, and command-target parsing + +meta: + id: pygienium-05 + feature: pygienium + priority: P1 + depends_on: [pygienium-01] + tags: [infrastructure, utilities, no-model] + +objective: + +- Build three small infra modules the check runner needs: a concurrency-bounding scheduler, a retry-with-backoff helper, and a command argument parser. + +deliverables: + +- `src/scheduler.ts`: `Scheduler` with `enqueue({id, run})`, burst cap from env `PYGIENIUM_MAX_AGENTS` (default 3), `dispose()` +- `src/retry.ts`: `runWithRetry`, `readPositiveIntEnv`, `readNonNegativeIntEnv`, `errorMessage`, `yieldToEventLoop` +- `src/command-target.ts`: `parseCommandArgs(args, cwd, opts)` → `{ cwd, tokens, args, error? }` supporting `[path]`, `--fresh`, `--fix`, `--check=`, `--scope=` + +steps: + +- Port piolium's `Scheduler` (Promise.allSettled under a semaphore-like cap) and `retry.ts` env helpers +- Implement `runWithRetry(fn, { maxRetries, backoffBaseMs, backoffMaxMs, onRetry, signal })` +- Implement `parseCommandArgs`: first non-flag token = optional target path (default cwd); collect `--flag` and `--opt=val` tokens into a tokens array + option lookup; return error string for malformed input + +tests: + +- Unit: scheduler caps concurrent runs at the configured value (spawn N no-op tasks, assert max in-flight) +- Unit: retry exhausting throws the last error; onRetry invoked with backoff between attempts +- Unit: parser handles `--fresh /repo --check=comments` → cwd=/repo, tokens=[--fresh, --check=comments], check=comments + +acceptance_criteria: + +- Scheduler never exceeds the burst cap +- retry respects maxRetries and aborts on signal +- parser returns structured tokens with no ambiguity for the supported flags + +validation: + +- `grep -n "PYGIENIUM_MAX_AGENTS" src/scheduler.ts` present +- Unit test suite for the three modules passes (if a test runner is configured) + +notes: + +- These are pure utilities; keep them dependency-free beyond node builtins +- Grouped into one task because each is small and they're mutually independent diff --git a/tasks/06-check-registry.md b/tasks/06-check-registry.md new file mode 100644 index 0000000..830e230 --- /dev/null +++ b/tasks/06-check-registry.md @@ -0,0 +1,48 @@ +# 06. Pluggable check registry and command wiring + +meta: + id: pygienium-06 + feature: pygienium + priority: P1 + depends_on: [pygienium-02, pygienium-03, pygienium-04, pygienium-05] + tags: [core, integration] + +objective: + +- Build the check registry + check runner that turns a `CheckDefinition` into a `/pygienium-` command, wiring recon → sub-agent analysis → sub-agent fixes → verify → cleanup, and expose a phase-strip UI. + +deliverables: + +- `src/checks/registry.ts`: `CheckDefinition` interface (`name`, `label`, `description`, `agentName`, `phaseId`, `buildScanTask(cwd,scope)`, `buildFixTask(cwd,scope,findings)`, `gate(cwd)`) and a `registerCheck(def)` / `getAllChecks()` registry +- `src/modes/check-runner.ts`: `runCheck(opts)` orchestrating Q0 recon (shared) → analysis sub-agent → fix sub-agent (optional on `--fix`) → verify gate → cleanup transient artifacts; writes run-state via task 04 +- `src/agents.ts`: `loadAgents({cwd})` reading markdown agent defs from `agents/*.md` (name, systemPrompt, allowedTools, sourcePath) +- `src/index.ts` updated: register `/pygienium-help` and auto-register one `/pygienium-` command per registered CheckDefinition; phase-strip status UI helper +- `src/help.ts`: command + flag help builder (skeleton, populated in task 14) + +steps: + +- Define `CheckDefinition` and a module-level `Map` registry with `registerCheck` +- Implement `runCheck`: init/resolve run state → run shared recon if missing → spawn analysis agent via agent-runner (task 03) with `buildScanTask` → if `--fix`, spawn fix agent with `buildFixTask` → applyPhaseStatus complete/failed → markRunStatus +- Implement phase-strip UI helper (status key, initial phase, console stream forwarding) adapted from piolium's createPhaseStripCommandUi (simplified) +- In index.ts: iterate registry, `pi.registerCommand("pygienium-"+def.name, { description, handler: runCheck wrapper })` +- Ship `agents/scanner.md` and `agents/fixer.md` generic agent definitions used by all checks (analysis + fix roles) + +tests: + +- Unit: registry register/getAll returns inserted defs +- Integration: register a no-op check whose agent writes a marker file; invoke its stub command; assert run-state marks it complete and the marker exists + +acceptance_criteria: + +- A check registered via `registerCheck({name:"smoke", ...})` automatically exposes `/pygienium-smoke` +- `runCheck` writes run-state and honors `--fix` vs scan-only +- The phase strip UI shows the active phase and clears on completion + +validation: + +- Register a throwaway smoke check, run `/pygienium-smoke`, inspect `pygienium/run-state.json` + +notes: + +- This is the integration keystone; task 07 builds the first real check on top of it +- Keep the registry open for extension: adding a check must NOT require editing index.ts command wiring diff --git a/tasks/07-check-comments.md b/tasks/07-check-comments.md new file mode 100644 index 0000000..295ba93 --- /dev/null +++ b/tasks/07-check-comments.md @@ -0,0 +1,46 @@ +# 07. Comments hygiene check (first end-to-end check) + +meta: + id: pygienium-07 + feature: pygienium + priority: P1 + depends_on: [pygienium-06] + tags: [check, e2e-reference] + +objective: + +- Implement the comments hygiene check as the first full end-to-end check, serving as the reference pattern for the remaining checks: remove low-value comments, tighten verbose ones, keep "why" comments. + +deliverables: + +- `src/checks/comments.ts`: a `CheckDefinition` with `buildScanTask` and `buildFixTask` +- `agents/comments-scanner.md` and `agents/comments-fixer.md` (or reuse generic scanner/fixer with a check-specific rubric embedded in the task text) +- Rubric encoded in task text: comments that restate code = remove; verbose narration = tighten; `why` comments = keep; self-explanatory code = no comment needed; short + high value +- `/pygienium-comments` runs E2E: recon → agent scans for comment smells → (on `--fix`) agent edits → report of changes + +steps: + +- Author the check definition: name `comments`, phaseId `C1`, allowedTools for analysis = read/bash/grep; for fix = read/edit/write/bash +- Build scan task text instructing the agent to read candidates from recon, identify comment smells, write findings to `pygienium/checks/comments/findings.md` with per-file line refs +- Build fix task text: apply safe removals/tightenings, leave `why` comments, write `changes.md` summarizing edits and anything needing human review +- Register the check in index.ts via `registerCheck` +- Implement `gate(cwd)`: findings.md exists + +tests: + +- Integration: create a temp file with restating comments + a `why` comment; run `/pygienium-comments --fix`; assert restating comments removed, why comment kept, changes.md present + +acceptance_criteria: + +- `/pygienium-comments` produces findings.md without `--fix` +- With `--fix`, low-value comments are removed and `why` comments survive +- run-state marks the check complete and artifacts are recorded + +validation: + +- Inspect `pygienium/checks/comments/{findings.md,changes.md}` after a run + +notes: + +- This task proves the whole framework works; prioritize getting it green before 08-11 +- The rubric is the user's spec: short + high value; what-comments bad, why-comments good diff --git a/tasks/08-check-deep-modules.md b/tasks/08-check-deep-modules.md new file mode 100644 index 0000000..8bad90f --- /dev/null +++ b/tasks/08-check-deep-modules.md @@ -0,0 +1,42 @@ +# 08. Deep-modules check + +meta: + id: pygienium-08 + feature: pygienium + priority: P2 + depends_on: [pygienium-06] + tags: [check] + +objective: + +- Implement the "deep modules, not shallow ones" check: detect modules with shallow abstractions (thin pass-throughs, single-call wrappers, unnecessary indirection) and recommend/apply consolidation. + +deliverables: + +- `src/checks/deep-modules.ts`: `CheckDefinition` with scan + fix tasks +- Rubric: a module should provide a meaningful abstraction over its implementation; flag pass-through wrappers, one-line re-export modules, shallow classes with trivial getters, unnecessary adapter layers +- `/pygienium-deep-modules` runs E2E + +steps: + +- Author `buildScanTask`: agent identifies shallow modules from recon candidates, writes findings to `pygienium/checks/deep-modules/findings.md` +- Author `buildFixTask`: consolidate/inline where safe; flag risky consolidations for human review; write changes.md +- Register the check + +tests: + +- Integration: temp module that wraps a single lib call as a pass-through; run with `--fix`; assert it's flagged/removed and changes.md explains the consolidation + +acceptance_criteria: + +- `/pygienium-deep-modules` flags shallow modules in findings.md +- With `--fix`, safe consolidations are applied; risky ones are listed for review, not auto-applied + +validation: + +- Inspect `pygienium/checks/deep-modules/{findings.md,changes.md}` + +notes: + +- "Deep modules" = John Ousterhout's A Philosophy of Software Design; encode that definition in the rubric +- Prefer conservative fixes: never auto-delete a module with external importers without confirmation diff --git a/tasks/09-check-dead-code.md b/tasks/09-check-dead-code.md new file mode 100644 index 0000000..8c4f825 --- /dev/null +++ b/tasks/09-check-dead-code.md @@ -0,0 +1,42 @@ +# 09. Dead code and obsolete paths check + +meta: + id: pygienium-09 + feature: pygienium + priority: P2 + depends_on: [pygienium-06] + tags: [check] + +objective: + +- Implement the dead-code / obsolete-paths check: find unreferenced exports, dead files, obsolete compatibility shims, migration paths, and unused config; remove them (engineering rule: no backward-compat layers). + +deliverables: + +- `src/checks/dead-code.ts`: `CheckDefinition` with scan + fix tasks +- Rubric: unreferenced functions/exports, files with zero importers (cross-checked against the review graph), deprecated/compat shims, migration helpers, unused dependencies +- `/pygienium-dead-code` runs E2E + +steps: + +- Author `buildScanTask`: agent uses grep/import-graph + recon candidates to list dead code, writes `pygienium/checks/dead-code/findings.md` categorized by type +- Author `buildFixTask`: remove clearly-dead items; list ambiguous ones (dynamic imports, runtime registration) for human review per pi-lens suspected-dead-weight semantics +- Register the check + +tests: + +- Integration: add an unused exported function + an obsolete compat wrapper; run `--fix`; assert both removed and changes.md lists them; assert a dynamically-imported shim is NOT auto-removed + +acceptance_criteria: + +- findings.md categorizes dead code by type (export, file, shim, dep) +- `--fix` removes clearly-dead items and preserves dynamic/runtime-registered ones with a review flag + +validation: + +- Inspect `pygienium/checks/dead-code/{findings.md,changes.md}` + +notes: + +- Cross-reference the project_report "suspected dead weight" semantics — single-importer/zero-importer files +- Engineering rule mandates removing obsolete paths rather than leaving compat shims diff --git a/tasks/10-check-complexity.md b/tasks/10-check-complexity.md new file mode 100644 index 0000000..e60ed22 --- /dev/null +++ b/tasks/10-check-complexity.md @@ -0,0 +1,53 @@ +# 10. Excessive complexity check + +meta: + id: pygienium-10 + feature: pygienium + priority: P2 + depends_on: [pygienium-06] + tags: [check] + +objective: + +- Implement the excessive-complexity check: detect high cyclomatic complexity with concrete thresholds, plus unnecessarily fancy code, non-conventional patterns, and over-abstraction; refactor toward the simplest implementation that meets requirements. + +deliverables: + +- `src/checks/complexity.ts`: `CheckDefinition` with scan + fix tasks +- Cyclomatic complexity thresholds (MUST enforce, not advisory): + - **50+ → must refactor.** No exceptions. The function is too complex; break it up. + - **35–49 → heavy skepticism.** Only keep if this is a massively critical point along the main path and the complexity genuinely must be here. Otherwise refactor. The agent must justify, in findings.md, why a 35–49 function is kept (critical path + why it can't be simplified). + - **<35 → not flagged on cyclomatic grounds** (may still be flagged for other complexity smells like nesting/over-abstraction) +- Rubric: speculative abstractions, premature config indirection, non-idiomatic patterns, over-engineered generics, unnecessary wrappers; refactor to common conventions and the simplest correct form +- `/pygienium-complexity` runs E2E + +steps: + +- Author `buildScanTask`: agent identifies complexity hotspots: + 1. **Cyclomatic complexity** — compute via a deterministic tool when available (e.g. `lizard`/`radon`/`gocyclo`/language-native), fall back to counting decision points (if/else if/for/while/case/&&/||/catch) per function. Classify each function into the 50+ / 35–49 / below-35 bands above. Write per-function scores to `pygienium/checks/complexity/findings.md` with line refs. + 2. **Structural smells** — deep nesting (>3 levels), needless indirection, speculative abstractions — same findings.md, separate section. + - findings.md includes a proposed simpler form for every flagged function. +- Author `buildFixTask`: apply safe refactors (flatten nested conditionals, inline trivial wrappers, remove speculative config, split 50+ functions); for 35–49 functions, keep ONLY if the agent can justify critical-path necessity, else refactor. Flag risky refactors for review. +- Register the check + +tests: + +- Integration: temp file with a 55-decision-point function (must-refactor) + a 40-decision-point function (heavy-skepticism, must justify or refactor); run `--fix`; assert the 50+ is split, the 35–49 is either refactored or has a documented justification in changes.md +- Integration: temp file with a needlessly abstracted config layer + deep nesting; run `--fix`; assert simplified + +acceptance_criteria: + +- findings.md lists cyclomatic complexity scores per function banded as 50+/35-49/below-35 +- Every 50+ function is refactored by `--fix` (no 50+ remains post-fix) +- Every kept 35–49 function has a documented justification (critical path + why-simpler-isn't-possible) in findings.md; unjustified ones are refactored +- findings.md lists structural complexity hotspots with proposed simplifications +- `--fix` applies safe refactors and preserves behavior (agent re-reads after edit) + +validation: + +- Inspect `pygienium/checks/complexity/{findings.md,changes.md}` + +notes: + +- Encode the engineering rules directly in the rubric: simplest implementation, no speculative abstractions, grow in layers +- The lens risk-hotspots (fan-in × complexity) and module_report complexity flags are signals to feed the agent diff --git a/tasks/11-check-defensive-guards.md b/tasks/11-check-defensive-guards.md new file mode 100644 index 0000000..866c823 --- /dev/null +++ b/tasks/11-check-defensive-guards.md @@ -0,0 +1,42 @@ +# 11. Redundant defensive guarding check + +meta: + id: pygienium-11 + feature: pygienium + priority: P2 + depends_on: [pygienium-06] + tags: [check] + +objective: + +- Implement the redundant-defensive-guarding check: remove excessive null checks, unnecessary try/catch, fallback paths that mask bugs, defensive code guarding invariants the type system already guarantees. + +deliverables: + +- `src/checks/defensive-guards.ts`: `CheckDefinition` with scan + fix tasks +- Rubric: redundant null/undefined checks where types are non-nullable, try/catch that only rethrows or swallows, fallback values that hide errors, defensive guards on already-validated input, compatibility fallbacks (engineering rule: remove, don't layer) +- `/pygienium-defensive-guards` runs E2E + +steps: + +- Author `buildScanTask`: agent identifies defensive smells, writes `pygienium/checks/defensive-guards/findings.md` +- Author `buildFixTask`: remove redundant guards; preserve guards that protect real external boundaries (user input, IO, parsing); write changes.md distinguishing removed vs kept-with-reason +- Register the check + +tests: + +- Integration: temp file with a null check on a typed-non-null param + a try/catch that swallows; run `--fix`; assert removed; assert a JSON.parse guard is preserved + +acceptance_criteria: + +- findings.md separates redundant guards from legitimate boundary guards +- `--fix` removes redundant guards and keeps boundary guards (IO, parsing, untrusted input) + +validation: + +- Inspect `pygienium/checks/defensive-guards/{findings.md,changes.md}` + +notes: + +- Key judgment: guarding external boundaries (IO, untrusted input, parsing) is correct; guarding internal invariants the type system guarantees is noise +- Engineering rule: no compatibility layers or fallbacks meant to be replaced later diff --git a/tasks/12-orchestrator-all.md b/tasks/12-orchestrator-all.md new file mode 100644 index 0000000..78e9d70 --- /dev/null +++ b/tasks/12-orchestrator-all.md @@ -0,0 +1,44 @@ +# 12. /pygienium-all master orchestrator + +meta: + id: pygienium-12 + feature: pygienium + priority: P2 + depends_on: [pygienium-07, pygienium-08, pygienium-09, pygienium-10, pygienium-11] + tags: [orchestration] + +objective: + +- Implement `/pygienium-all`: run every registered check in sequence as ordered phases under a unified status strip, with resumable state and a final summary report. + +deliverables: + +- `src/modes/all.ts`: `runAllChecks(opts)` iterating the registry, calling `runCheck` per check with shared recon, accumulating per-check status into run-state, writing `pygienium/all-summary.md` +- `/pygienium-all` command wired in index.ts with phase strip listing all check names +- Respects `--fresh`, `--fix`, and `--only=comments,complexity` to select a subset + +steps: + +- Implement `runAllChecks`: init a single run (mode "all"), run shared recon once, then for each registered check call `runCheck` with the existing run-state record (not a fresh one per check) +- Build the phase strip from registry names; set initial phase to the first check +- Aggregate final summary: per-check status, artifact paths, total findings/changes counts +- Support `--only` filtering via the command-target parser + +tests: + +- Integration: run `/pygienium-all` on a small repo; assert every check ran, run-state shows all complete, all-summary.md present + +acceptance_criteria: + +- `/pygienium-all` runs every registered check exactly once in registry order +- Interrupted runs are resumable (in_progress/failed checks re-run; complete ones skipped unless `--fresh`) +- all-summary.md lists per-check outcomes + +validation: + +- Inspect `pygienium/run-state.json` and `pygienium/all-summary.md` + +notes: + +- This is the piolium "balanced"/"deep" mode analogue, but simpler: one run, sequential phases, shared recon +- Ensure scheduler is not needed (sequential) unless we later parallelize independent checks diff --git a/tasks/13-resume-status-export.md b/tasks/13-resume-status-export.md new file mode 100644 index 0000000..08c7862 --- /dev/null +++ b/tasks/13-resume-status-export.md @@ -0,0 +1,43 @@ +# 13. Resume, status, and export commands + +meta: + id: pygienium-13 + feature: pygienium + priority: P2 + depends_on: [pygienium-04, pygienium-06] + tags: [commands] + +objective: + +- Implement the operational commands: `/pygienium-resume` (continue the most recent non-complete run), `/pygienium-status` (show run progress), and `/pygienium-export` (export findings/changes with filters). + +deliverables: + +- `/pygienium-resume`: read run-state, pick latestResumableRun, re-dispatch that run's checks (complete ones skipped unless `--fresh`) +- `/pygienium-status`: format run-state into a readable line list (per-check status, artifacts, errors) +- `/pygienium-export`: gather all `pygienium/checks/*/findings.md` and `changes.md` with filters (`--check=`, `--status=`, `--out=`) into a single markdown or JSON bundle +- All three wired in index.ts + +steps: + +- Port piolium's status/resume/export command shapes, adapting to run-state and check artifacts +- `formatRunStatus(state)` builds the status line list +- Export walks `pygienium/checks/*/` and applies filters before writing `pygienium/export.{md|json}` + +tests: + +- Integration: start `/pygienium-all`, interrupt, run `/pygienium-status` (shows in_progress), `/pygienium-resume` (completes), `/pygienium-export` + +acceptance_criteria: + +- `/pygienium-status` reports accurate per-check progress +- `/pygienium-resume` continues a non-complete run without re-running complete checks +- `/pygienium-export` produces a filtered bundle + +validation: + +- Inspect `pygienium/export.md` and the status output + +notes: + +- These mirror piolium's status/resume/export almost directly; keep behavior faithful diff --git a/tasks/14-readme-help-integration.md b/tasks/14-readme-help-integration.md new file mode 100644 index 0000000..a41771d --- /dev/null +++ b/tasks/14-readme-help-integration.md @@ -0,0 +1,50 @@ +# 14. README, help text, and end-to-end integration + +meta: + id: pygienium-14 + feature: pygienium + priority: P2 + depends_on: [pygienium-12, pygienium-13] + tags: [docs, integration] + +objective: + +- Author the README and full help text, then run an end-to-end integration pass over the whole extension to verify all commands, the registry extensibility claim, and the exit criteria. + +deliverables: + +- `pygienium/README.md`: what it is, install, commands, flags, how to add a check (one file + registerCheck) +- `src/help.ts` fully populated: per-command usage/does/example + CLI flag help, mirroring piolium's help.ts +- `/pygienium-help` renders the full help block +- Extensibility verification: add a new check in `checks/` + one registry line with NO index.ts command changes; confirm a new `/pygienium-` command appears + +steps: + +- Write README sections: overview, the five checks, commands, flags, adding a check, architecture (sub-agent loops) +- Populate help.ts COMMANDS + CLI_FLAGS arrays from the actual commands/flags implemented in 06-13 +- Extensibility test: create `checks/noop.ts` registering `registerCheck({name:"noop",...})`, reload, confirm `/pygienium-noop` exists and `/pygienium-help` lists it +- Run `/pygienium-all` end-to-end on this repo and confirm the exit criteria + +tests: + +- Integration: `/pygienium-help` lists all 8+ commands and all flags +- Integration: a newly registered check auto-creates its command without index.ts edits +- Integration: `/pygienium-all` completes with all checks marked complete + +acceptance_criteria: + +- README documents install, commands, flags, and the one-file extensibility workflow +- `/pygienium-help` output matches the implemented commands/flags exactly +- A new check file + registerCheck entry yields a working `/pygienium-` command with zero index.ts changes +- All exit criteria from the feature README pass + +validation: + +- `pi -p "/pygienium-help"` prints the full help +- Add the noop check, `/reload`, run `/pygienium-noop`, confirm success +- Run `/pygienium-all` and inspect the final summary + +notes: + +- This is the acceptance gate for the whole feature +- If the extensibility claim fails here, refactor the registry in task 06 before declaring done diff --git a/tasks/README.md b/tasks/README.md new file mode 100644 index 0000000..87576f4 --- /dev/null +++ b/tasks/README.md @@ -0,0 +1,42 @@ +# Pygienium — Code Hygiene Extension + +Objective: A pi extension (piolium-style sub-agent loops) that runs highly-structured code-hygiene passes over a repo to clean up common LLM-code quality issues. + +Status legend: [ ] todo, [~] in-progress, [x] done + +Tasks + +- [x] 01 — scaffolding → `01-scaffolding.md` +- [x] 02 — recon → `02-recon.md` +- [x] 03 — agent-runner → `03-agent-runner.md` +- [x] 04 — hygiene-state → `04-hygiene-state.md` +- [x] 05 — infrastructure-utils → `05-infrastructure-utils.md` +- [x] 06 — check-registry → `06-check-registry.md` +- [x] 07 — check-comments → `07-check-comments.md` +- [x] 08 — check-deep-modules → `08-check-deep-modules.md` +- [x] 09 — check-dead-code → `09-check-dead-code.md` +- [x] 10 - check-complexity → `10-check-complexity.md` (incl. cyclomatic complexity: 50+ must-refactor, 35–49 heavy-skepticism) +- [x] 11 — check-defensive-guards → `11-check-defensive-guards.md` +- [x] 12 — orchestrator-all → `12-orchestrator-all.md` +- [x] 13 — resume-status-export → `13-resume-status-export.md` +- [x] 14 — readme-help-integration → `14-readme-help-integration.md` + +Dependencies + +- 02, 03, 04, 05 depend on 01 +- 06 depends on 02, 03, 04, 05 +- 07, 08, 09, 10, 11 depend on 06 (07 serves as the reference E2E check) +- 12 depends on 07, 08, 09, 10, 11 +- 13 depends on 04, 06 +- 14 depends on 12, 13 + +Exit criteria + +- The feature is complete when `/pygienium-help` lists all commands/flags, each `/pygienium-` command runs an isolated sub-agent that scans a target, applies fixes, and emits a findings+changes report; `/pygienium-all` runs every registered check in sequence with a unified status strip and resumable state; `/pygienium-resume`, `/pygienium-status`, and `/pygienium-export` work; and adding a new check requires only a new file in `checks/` plus one registry entry (no index.ts command-wiring changes). + +Architecture reference + +- Style: piolium (`@vigolium/piolium`) sub-agent loops via `createAgentSession` +- Distribution: local extension under `~/.pi/agent/extensions/pygienium/`, structured to be npm-publishable later +- Each check = a "mode": deterministic recon → sub-agent analysis → sub-agent fixes → verify → cleanup +- Check registry makes the system extensible: new check = new file + registry entry diff --git a/tests/agents.test.ts b/tests/agents.test.ts new file mode 100644 index 0000000..34fdb68 --- /dev/null +++ b/tests/agents.test.ts @@ -0,0 +1,25 @@ +/** + * agents.test.ts — markdown agent-definition loader. + */ +import { describe, expect, it } from "bun:test"; +import { extensionRoot, loadAgents } from "../src/agents.js"; + +describe("loadAgents", () => { + it("loads scanner.md and fixer.md shipped with the extension", async () => { + const agents = await loadAgents(); + expect(agents.has("scanner")).toBe(true); + expect(agents.has("fixer")).toBe(true); + const scanner = agents.get("scanner"); + expect(scanner).toBeDefined(); + expect(scanner?.systemPrompt).toContain("scanner"); + expect(scanner?.allowedTools).toContain("read"); + expect(scanner?.allowedTools).not.toContain("edit"); // scanner is read-only + expect(scanner?.sourcePath).toContain("agents/scanner.md"); + const fixer = agents.get("fixer"); + expect(fixer?.allowedTools).toContain("edit"); + }); + + it("extensionRoot resolves to the package directory", () => { + expect(extensionRoot()).toMatch(/pygienium$/); + }); +}); diff --git a/tests/all-integration.test.ts b/tests/all-integration.test.ts new file mode 100644 index 0000000..5d1abce --- /dev/null +++ b/tests/all-integration.test.ts @@ -0,0 +1,162 @@ +/** + * all-integration.test.ts — `/pygienium-all` end-to-end (task 14). + * + * Mirrors the spec scenario: run every registered check in sequence under one + * resumable run-state and confirm the run completes with every check marked + * `complete`. Uses the injectable fake agent runner (no model needed) and stub + * checks whose `!write`/`!echo` task protocol produces deterministic artifacts. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, + type AgentRunner, +} from "../src/agent-runner.js"; +import { handleAllCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState } from "../src/run-state.js"; +import { canonicalChecksRoot } from "../src/export.js"; + +/** Stub check whose fake-runner task writes artifacts + echoes a line. */ +function fakeCheck(name: string): CheckDefinition { + return { + name, + label: name, + description: `${name} check`, + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: (_cwd, scope) => + `!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`, + gate: () => undefined, + }; +} + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +/** Capture process.stdout.write lines for the duration of `fn`. */ +async function captureStdout(fn: () => Promise): Promise { + const out: string[] = []; + const write = process.stdout.write.bind(process.stdout); + (process.stdout as { write: (chunk: unknown) => boolean }).write = ( + chunk: unknown, + ) => { + out.push(String(chunk).replace(/\r?\n$/, "")); + return true; + }; + try { + await fn(); + } finally { + (process.stdout as { write: (chunk: unknown) => boolean }).write = write; + } + return out; +} + +describe("/pygienium-all end-to-end (task 14)", () => { + let cwd: string; + let dispatched: string[]; + let runner: AgentRunner; + + beforeEach(async () => { + clearChecks(); + dispatched = []; + runner = async (opts) => { + const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task); + if (m) dispatched.push(m[1] as string); + return fakeAgentRunner(opts); + }; + setAgentRunner(runner); + cwd = await mkdtemp(join(tmpdir(), "pygienium-all-")); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("runs every registered check in sequence and marks the run complete", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + registerCheck(fakeCheck("gamma")); + + const out = await captureStdout(() => + handleAllCommand("--fix", stubCtx(cwd)), + ); + + // Every check was dispatched (scan + fix each, in registration order). + expect(dispatched.filter((n) => n === "alpha").length).toBeGreaterThan(0); + expect(dispatched.filter((n) => n === "beta").length).toBeGreaterThan(0); + expect(dispatched.filter((n) => n === "gamma").length).toBeGreaterThan(0); + + // Final run-state: complete, every check complete, recon shared once. + const state = await loadRunState(cwd); + expect(state).toBeDefined(); + expect(state!.status).toBe("complete"); + expect(state!.recon.complete).toBe(true); + for (const name of ["alpha", "beta", "gamma"]) { + expect(state!.checks[name]?.status).toBe("complete"); + } + + // Per-check artifacts landed on disk under the canonical root. + const alphaFindings = await readFile( + join(canonicalChecksRoot(cwd), "alpha", "findings.md"), + "utf8", + ); + expect(alphaFindings).toContain("alpha findings"); + const gammaChanges = await readFile( + join(canonicalChecksRoot(cwd), "gamma", "changes.md"), + "utf8", + ); + expect(gammaChanges).toContain("gamma changes"); + + // The summary line reports completion and the run-state path. + const text = out.join("\n"); + expect(text).toContain("pygienium: all-run complete"); + }); + + it("completes cleanly in scan-only mode (no --fix)", async () => { + registerCheck(fakeCheck("solo")); + const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd))); + const state = await loadRunState(cwd); + expect(state!.status).toBe("complete"); + expect(state!.checks["solo"]?.status).toBe("complete"); + expect(state!.checks["solo"]?.fix).toBe(false); + // Scan-only still writes findings but not changes. + const findings = await readFile( + join(canonicalChecksRoot(cwd), "solo", "findings.md"), + "utf8", + ); + expect(findings).toContain("solo findings"); + expect(out.join("\n")).toContain("pygienium: all-run complete"); + }); + + it("reports no checks when the registry is empty", async () => { + const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd))); + expect(out.join("\n")).toContain("no checks registered"); + }); + + it("is resumable: a second call reuses the existing run-state", async () => { + registerCheck(fakeCheck("alpha")); + await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd))); + const first = await loadRunState(cwd); + const firstStarted = first!.startedAt; + + // Second run reloads the existing run-state (same startedAt). + await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd))); + const second = await loadRunState(cwd); + expect(second!.startedAt).toBe(firstStarted); + expect(second!.status).toBe("complete"); + }); +}); diff --git a/tests/all.test.ts b/tests/all.test.ts new file mode 100644 index 0000000..8879849 --- /dev/null +++ b/tests/all.test.ts @@ -0,0 +1,354 @@ +/** + * all.test.ts — integration test for the `/pygienium-all` orchestrator (task 12). + * + * 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; + * - `--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. + * + * A tracker wraps the fake agent runner so we can assert dispatch order and + * counts without a model. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, + type AgentRunner, +} from "../src/agent-runner.js"; +import { handleAllCommand, type PygieniumCtx } from "../src/commands.js"; +import { + parseAllArgs, + runAllChecks, + allSummaryPath, + renderAllSummary, + selectChecks, +} from "../src/modes/all.js"; +import { + loadRunState, + markCheckStatus, + applyPhaseStatus, + PHASE_RECON, + PHASE_ANALYSIS, + PHASE_FIX, + PHASE_VERIFY, + PHASE_CLEANUP, +} from "../src/run-state.js"; +import { writeFile } from "node:fs/promises"; + +/** Build a deterministic check whose fake runner writes on-disk artifacts. */ +function fakeCheck(name: string): CheckDefinition { + return { + name, + label: name.charAt(0).toUpperCase() + name.slice(1), + description: `${name} check`, + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: (_cwd, scope) => + `!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`, + gate: () => undefined, + }; +} + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +/** Tracker: records dispatched agent tasks then delegates to the fake runner. */ +function trackingRunner(): { runner: AgentRunner; dispatched: string[] } { + const dispatched: string[] = []; + const runner: AgentRunner = async (opts) => { + const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task); + if (m) dispatched.push(m[1] as string); + return fakeAgentRunner(opts); + }; + return { runner, dispatched }; +} + +/** Capture process.stdout.write lines for the duration of `fn`. */ +async function captureStdout(fn: () => Promise): Promise { + const out: string[] = []; + const write = process.stdout.write.bind(process.stdout); + (process.stdout as { write: (chunk: unknown) => boolean }).write = ( + chunk: unknown, + ) => { + out.push(String(chunk).replace(/\r?\n$/, "")); + return true; + }; + try { + await fn(); + } finally { + (process.stdout as { write: (chunk: unknown) => boolean }).write = write; + } + return out; +} + +/** Mark a check as fully complete in the run-state (helper for seeding). */ +function markComplete( + state: Parameters[0], + name: string, +): void { + for (const phaseId of [ + PHASE_RECON, + PHASE_ANALYSIS, + PHASE_FIX, + PHASE_VERIFY, + PHASE_CLEANUP, + ]) { + applyPhaseStatus(state, name, phaseId, "complete"); + } + markCheckStatus(state, name, "complete"); +} + +describe("/pygienium-all orchestrator (task 12)", () => { + let cwd: string; + let track: ReturnType; + + beforeEach(async () => { + clearChecks(); + track = trackingRunner(); + setAgentRunner(track.runner); + cwd = await mkdtemp(join(tmpdir(), "pygienium-all-")); + // Seed a source file so the gate passes and recon has something to scan. + await mkdir(join(cwd, "src"), { recursive: true }); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("parseAllArgs parses path, --fix, --fresh, 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.only).toEqual(["alpha", "beta"]); + }); + + it("selectChecks preserves registry order for the --only subset", () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + registerCheck(fakeCheck("gamma")); + const subset = selectChecks(["gamma", "alpha"]); // order in --only is irrelevant + expect(subset.map((c) => c.name)).toEqual(["alpha", "gamma"]); + expect(selectChecks().length).toBe(3); + expect(selectChecks([]).length).toBe(3); + }); + + it("runs every registered check exactly once in registry order", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + registerCheck(fakeCheck("gamma")); + + await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd))); + + // Each check dispatched once for scan + once for fix (3 checks × 2 phases). + const scanDispatches = track.dispatched.filter((n) => n !== undefined); + expect(scanDispatches).toEqual([ + "alpha", + "alpha", + "beta", + "beta", + "gamma", + "gamma", + ]); + + const state = await loadRunState(cwd); + expect(state?.status).toBe("complete"); + expect(state?.checks.alpha.status).toBe("complete"); + expect(state?.checks.beta.status).toBe("complete"); + expect(state?.checks.gamma.status).toBe("complete"); + expect(state?.recon.complete).toBe(true); + }); + + it("writes pygienium/all-summary.md listing per-check outcomes", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + + await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd))); + + const summary = await readFile(allSummaryPath(cwd), "utf8"); + expect(summary).toContain("# Pygienium all-run summary"); + expect(summary).toContain("- status: complete"); + expect(summary).toContain("## alpha — complete"); + expect(summary).toContain("## beta — complete"); + expect(summary).toContain("## alpha — complete (--fix)"); + // Artifact paths + line counts are referenced. + expect(summary).toContain("findings:"); + expect(summary).toContain("changes:"); + // Artifacts actually exist on disk. + const alphaFindings = await readFile( + join(cwd, "pygienium", "checks", "alpha", "findings.md"), + "utf8", + ); + expect(alphaFindings).toContain("alpha findings"); + }); + + it("--only narrows the run to the named subset", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + registerCheck(fakeCheck("gamma")); + + await captureStdout(() => + handleAllCommand("--only=alpha,gamma", stubCtx(cwd)), + ); + + // Only alpha + gamma dispatched (beta never touched). + expect(track.dispatched).toContain("alpha"); + expect(track.dispatched).toContain("gamma"); + expect(track.dispatched).not.toContain("beta"); + + const state = await loadRunState(cwd); + expect(state?.checks.alpha.status).toBe("complete"); + expect(state?.checks.gamma.status).toBe("complete"); + // beta was not part of the selected set, so has no entry. + expect(state?.checks.beta).toBeUndefined(); + + const summary = await readFile(allSummaryPath(cwd), "utf8"); + expect(summary).toContain("## alpha"); + expect(summary).toContain("## gamma"); + expect(summary).not.toContain("## beta"); + }); + + it("skips terminal checks on re-run; --fresh re-runs them", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + + // First run: both complete. + 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"), + "utf8", + ); + + // Second run without --fresh: both already terminal → skipped. + track.dispatched.length = 0; + const out = await captureStdout(() => + handleAllCommand("--fix", stubCtx(cwd)), + ); + expect(track.dispatched).toHaveLength(0); + expect(out.join("\n")).toContain("skipping"); + const state2 = await loadRunState(cwd); + expect(state2?.status).toBe("complete"); + + // Third run with --fresh: both re-dispatched from scratch. + track.dispatched.length = 0; + await captureStdout(() => handleAllCommand("--fix --fresh", stubCtx(cwd))); + expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]); + const state3 = await loadRunState(cwd); + 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"), + "utf8", + ); + expect(alphaFix2).toContain("alpha changes"); + void firstAlphaFix; + }); + + it("resumes an interrupted run (re-dispatches non-terminal checks only)", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + + // Seed an interrupted run: alpha complete, beta pending (interrupted). + const { initRunState, saveRunState } = await import("../src/run-state.js"); + const state = initRunState(cwd, [ + { name: "alpha", label: "alpha", fix: true }, + { name: "beta", label: "beta", fix: true }, + ]); + state.recon = { + complete: true, + path: join(cwd, ".pygienium", "recon.json"), + finishedAt: Date.now(), + }; + // Pre-create alpha's on-disk artifacts so its completed entry has artifacts. + const alphaDir = join(cwd, "pygienium", "checks", "alpha"); + await mkdir(alphaDir, { recursive: true }); + await writeFile( + join(alphaDir, "findings.md"), + "# alpha findings\nalpha-scan\n", + ); + await writeFile( + join(alphaDir, "changes.md"), + "# alpha changes\nalpha-fix\n", + ); + markComplete(state, "alpha"); + // beta was interrupted mid-analysis — left at in_progress. + applyPhaseStatus(state, "beta", PHASE_RECON, "complete"); + applyPhaseStatus(state, "beta", PHASE_ANALYSIS, "in_progress"); + await saveRunState(state); + + // Resume via all-run: only beta should re-dispatch. + const out = await captureStdout(() => + handleAllCommand("--fix", stubCtx(cwd)), + ); + expect(track.dispatched).not.toContain("alpha"); + expect(track.dispatched).toContain("beta"); + expect(out.join("\n")).toContain("skipping"); + + const after = await loadRunState(cwd); + expect(after?.status).toBe("complete"); + expect(after?.checks.alpha.status).toBe("complete"); + expect(after?.checks.beta.status).toBe("complete"); + }); + + it("runAllChecks supports scan-only (no fix phase, no changes artifacts)", async () => { + registerCheck(fakeCheck("alpha")); + const outcome = await runAllChecks({ cwd }); + expect(outcome.ran).toEqual(["alpha"]); + expect(outcome.skipped).toEqual([]); + expect(outcome.status).toBe("complete"); + const state = await loadRunState(cwd); + expect(state?.checks.alpha.fix).toBe(false); + expect(state?.checks.alpha.changes).toBeUndefined(); + // Summary still written. + const summary = await readFile(outcome.summaryPath, "utf8"); + expect(summary).toContain("## alpha — complete"); + expect(outcome.summaryPath).toBe(allSummaryPath(cwd)); + }); + + it("renders a summary even when no checks are registered/selected", async () => { + const outcome = await runAllChecks({ cwd, only: ["nonexistent"] }); + expect(outcome.ran).toEqual([]); + const summary = await readFile(outcome.summaryPath, "utf8"); + expect(summary).toContain("# Pygienium all-run summary"); + expect(summary).toContain("- checks: 0"); + }); + + it("renderAllSummary reflects per-check statuses and fix tags", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + await runAllChecks({ cwd, fix: true }); + const state = (await loadRunState(cwd))!; + const md = renderAllSummary(state, selectChecks()); + expect(md).toContain("## alpha — complete (--fix)"); + expect(md).toContain("phases: recon:C"); + }); + + it("the unified strip surfaces every check name over the run", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd))); + const joined = out.join("\n"); + // Each check name appears in the strip line at least once. + expect(joined).toContain("Alpha"); + expect(joined).toContain("Beta"); + expect(joined).toContain("all ["); + }); +}); diff --git a/tests/check-runner.test.ts b/tests/check-runner.test.ts new file mode 100644 index 0000000..a02c2e1 --- /dev/null +++ b/tests/check-runner.test.ts @@ -0,0 +1,131 @@ +/** + * check-runner.test.ts — integration test for the orchestration keystone. + * + * Registers a throwaway "smoke" check whose fake sub-agent writes a marker + * file, then invokes the per-check command handler with a stub context and + * asserts the run-state marks the check complete and the marker exists. + * Also asserts `--fix` runs the fix phase and records changes, while + * scan-only does not. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, +} from "../src/agent-runner.js"; +import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState, runStatePath } from "../src/run-state.js"; + +function smokeCheck(): CheckDefinition { + return { + name: "smoke", + label: "Smoke", + description: "Throwaway smoke check for tests", + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: (_cwd, scope) => + `!write .pygienium/smoke.marker smoke-complete\n!echo smoke-findings for ${scope.target}`, + buildFixTask: (_cwd, _scope, findings) => + `!echo applied-fixes based on: ${findings.split("\n")[0] ?? ""}`, + gate: () => undefined, + }; +} + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +describe("check-runner integration", () => { + let cwd: string; + + beforeEach(async () => { + clearChecks(); + setAgentRunner(fakeAgentRunner); + cwd = await mkdtemp(join(tmpdir(), "pygienium-smoke-")); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("runs a smoke check, writes a marker, and marks the check complete", async () => { + registerCheck(smokeCheck()); + const check = smokeCheck(); + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state).toBeDefined(); + expect(state?.checks.smoke.status).toBe("complete"); + expect(state?.status).toBe("complete"); + + const marker = await readFile( + join(cwd, ".pygienium", "smoke.marker"), + "utf8", + ); + expect(marker.trim()).toBe("smoke-complete"); + + expect(state?.checks.smoke.findings).toContain("smoke-findings"); + expect( + state?.checks.smoke.phases.map((p) => `${p.id}=${p.status}`).join(","), + ).toContain("analysis=complete"); + expect( + state?.checks.smoke.phases.find((p) => p.id === "fix"), + ).toBeUndefined(); + }); + + it("scan-only does not produce changes and skips the fix phase", async () => { + const check = smokeCheck(); + await handleCheckCommand(check, "", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks.smoke.fix).toBe(false); + expect(state?.checks.smoke.changes).toBeUndefined(); + const fixPhase = state?.checks.smoke.phases.find((p) => p.id === "fix"); + expect(fixPhase).toBeUndefined(); + }); + + it("--fix runs the fix phase and records changes", async () => { + const check = smokeCheck(); + await handleCheckCommand(check, "--fix", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks.smoke.status).toBe("complete"); + expect(state?.checks.smoke.fix).toBe(true); + expect(state?.checks.smoke.changes).toContain("applied-fixes"); + expect(state?.checks.smoke.phases.find((p) => p.id === "fix")?.status).toBe( + "complete", + ); + }); + + it("persists run-state.json at the expected path", async () => { + const check = smokeCheck(); + await handleCheckCommand(check, "", stubCtx(cwd)); + expect(runStatePath(cwd)).toBe(join(cwd, ".pygienium", "run-state.json")); + const raw = await readFile(runStatePath(cwd), "utf8"); + expect(JSON.parse(raw).checks.smoke.status).toBe("complete"); + }); + + it("marks a check skipped when the gate returns an error", async () => { + const gated: CheckDefinition = { + ...smokeCheck(), + name: "gated", + label: "Gated", + gate: () => "no source files matched", + }; + await handleCheckCommand(gated, "", stubCtx(cwd)); + const state = await loadRunState(cwd); + expect(state?.checks.gated.status).toBe("skipped"); + expect(state?.checks.gated.error).toBe("no source files matched"); + }); +}); diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..7a9ac17 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,61 @@ +/** + * commands.test.ts — auto-registration wiring. + * + * Asserts that `registerPygieniumCommands` exposes `/pygienium-help`, one + * `/pygienium-` per registered `CheckDefinition`, plus + * `all`/`resume`/`status`/`export` — with no index.ts changes. + */ +import { describe, expect, it, beforeEach } from "bun:test"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { registerPygieniumCommands } from "../src/commands.js"; + +function stub(name: string): CheckDefinition { + return { + name, + label: name, + description: `${name} check`, + agentName: "scanner", + phaseId: "scan", + buildScanTask: () => "scan", + buildFixTask: () => "fix", + gate: () => undefined, + }; +} + +describe("registerPygieniumCommands", () => { + beforeEach(() => clearChecks()); + + it("auto-registers one /pygienium- per registered check", () => { + registerCheck(stub("smoke")); + registerCheck(stub("comments")); + const names: string[] = []; + registerPygieniumCommands((name) => names.push(name)); + expect(names).toContain("pygienium-smoke"); + expect(names).toContain("pygienium-comments"); + expect(names.filter((n) => n === "pygienium-smoke")).toHaveLength(1); + }); + + it("always registers help/all/resume/status/export", () => { + const names: string[] = []; + registerPygieniumCommands((name) => names.push(name)); + expect(names).toContain("pygienium-help"); + expect(names).toContain("pygienium-all"); + expect(names).toContain("pygienium-resume"); + expect(names).toContain("pygienium-status"); + expect(names).toContain("pygienium-export"); + }); + + it("registers with a description matching the check definition", () => { + registerCheck(stub("smoke")); + const seen: Record = {}; + registerPygieniumCommands((name, opts) => { + seen[name] = opts.description; + }); + expect(seen["pygienium-smoke"]).toBe("smoke check"); + expect(seen["pygienium-help"]).toBeDefined(); + }); +}); diff --git a/tests/comments.test.ts b/tests/comments.test.ts new file mode 100644 index 0000000..1a70c4a --- /dev/null +++ b/tests/comments.test.ts @@ -0,0 +1,259 @@ +/** + * comments.test.ts — integration test for the first end-to-end check. + * + * Proves the whole framework works: a real `CheckDefinition` (registered from + * `src/checks/comments.ts`) flows through the command handler → check-runner + * pipeline (recon → analysis → fix → verify → cleanup), producing + * `findings.md` + `changes.md` artifacts and a `complete` run-state, while a + * rubric-driven fake runner performs the actual comment edits. + * + * The fake runner (a rubric interpreter, not a hardcoded puppet) reads the + * target source, classifies each comment against the same rubric the real + * scanner/fixer agents receive in their task text, writes the artifacts, and + * applies the edits. This exercises the genuine task-builder output, gate, + * and orchestration without a live model. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { dirname } from "node:path"; +import { clearChecks, getCheck } from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + type AgentRunner, +} from "../src/agent-runner.js"; +import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState } from "../src/run-state.js"; +import { + commentsCheck, + findingsPath, + changesPath, +} from "../src/checks/comments.js"; +import type { CheckScope } from "../src/checks/registry.js"; + +/** Sample source with mixed comment types for the rubric to classify. */ +const SOURCE = `// increment the counter +counter++; +// We use a power-of-two size so modulo hashing is a bitmask, not a divide +const SIZE = 1 << 10; +function hash(k: string): number { + // compute the hash + return k.split("").reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 0); +} +`; +const FILENAME = "sample.ts"; + +function stubCtx(cwd: string): PygieniumCtx { + return { + cwd, + mode: "print", + hasUI: false, + ui: undefined, + } as PygieniumCtx; +} + +/** Extract the scan target path from a built scan task string. */ +function targetFromTask(task: string): string { + const m = /Scan target: `([^`]+)`/.exec(task); + return m?.[1] ?? ""; +} + +/** + * Rubric interpreter: classifies each comment line and returns the smell, + * the cleaned line, and whether it is a "why" comment that must survive. + */ +function classifyComment(line: string): { + smell: "RESTATE" | "VERBOSE" | "WHY" | "OK"; + cleaned: string; +} { + const trimmed = line.trim(); + // Treat the captured rationale comment as a WHY comment to preserve. + if ( + /\b(power-of-two|so modulo|bitmask|divide|because|so that|rationale|gotcha|workaround)\b/i.test( + trimmed, + ) + ) { + return { smell: "WHY", cleaned: line }; + } + // Deterministic restate signals: "increment the counter", "compute the hash". + if (/increment|counter|^\/\/\s*compute/i.test(trimmed)) { + return { smell: "RESTATE", cleaned: "" }; + } + return { smell: "OK", cleaned: line }; +} + +/** + * Fake runner that applies the comments rubric deterministically. It reads the + * target source written into the scan task, classifies comments, writes the + * findings.md / changes.md artifacts, and edits the source in place. Produces + * the same artefacts a real scanner/fixer pair would, exercising the genuine + * task-builder output and gate. + */ +function rubricRunner(opts: { getScope: () => CheckScope }): AgentRunner { + return async (taskOpts) => { + const scope = opts.getScope(); + const target = targetFromTask(taskOpts.task) || scope.target; + const isFix = taskOpts.agentName === "fixer"; + + let lines: string[] = []; + try { + lines = (await readFile(target, "utf8")).split("\n"); + } catch { + return { ok: true, text: "" }; + } + + const findings: string[] = []; + const applied: string[] = []; + const kept: string[] = []; + const out: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const t = line.trim(); + const isComment = /^\s*(\/\/|#|\/\*)/.test(t); + if (isComment) { + const { smell } = classifyComment(line); + if (smell === "RESTATE") { + findings.push(`- L${i + 1}: RESTATE — ${t}`); + applied.push(`- ${FILENAME}:L${i + 1} — removed comment (auto)`); + // Drop the line entirely. + continue; + } + if (smell === "WHY") { + kept.push(`- ${FILENAME}:L${i + 1} — KEEP why comment`); + findings.push(`- L${i + 1}: WHY — ${t}`); + } + } + out.push(line); + } + + const findingsText = `# comments — findings\n\n${findings.length} comment smell(s) across 1 file(s).\n\n## ${FILENAME}\n${findings.length ? findings.join("\n") : "(none)"}\n${kept.length ? `\n## kept (why)\n${kept.join("\n")}\n` : ""}`; + const changesText = `# comments — changes\n\n${applied.length} edit(s) applied; 0 deferred for human review.\n\n## Applied\n${applied.length ? applied.join("\n") : "(none)"}\n`; + + if (!isFix) { + // Analysis phase: READ-ONLY. Write findings.md only; do not edit source. + const fPath = findingsPath(scope); + await mkdir(dirname(fPath), { recursive: true }); + await writeFile(fPath, findingsText + "\n", "utf8"); + return { ok: true, text: findingsText }; + } + // Fix phase: apply removals to source in place, then write changes.md. + await writeFile(target, out.join("\n"), "utf8"); + const cPath = changesPath(scope); + await mkdir(dirname(cPath), { recursive: true }); + await writeFile(cPath, changesText + "\n", "utf8"); + return { ok: true, text: changesText }; + }; +} + +describe("comments check (end-to-end)", () => { + let cwd: string; + let target: string; + + beforeEach(async () => { + clearChecks(); + cwd = await mkdtemp(join(tmpdir(), "pygienium-comments-")); + target = join(cwd, FILENAME); + await writeFile(target, SOURCE, "utf8"); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("is registered and discoverable as /pygienium-comments", async () => { + // clearChecks() wiped the registry in beforeEach; the module-level + // self-registration ran once at import, so re-register explicitly to + // exercise the self-registration path the way index.ts auto-discovery does. + const { registerCheck } = await import("../src/checks/registry.js"); + registerCheck(commentsCheck); + expect(commentsCheck.name).toBe("comments"); + const def = getCheck("comments"); + expect(def).toBeDefined(); + expect(def?.name).toBe("comments"); + }); + + it("produces findings.md without --fix and preserves why comments", async () => { + const scope: CheckScope = { + cwd, + target, + fix: false, + rest: [], + }; + setAgentRunner(rubricRunner({ getScope: () => scope })); + + const def = commentsCheck; + await handleCheckCommand(def, target, stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state).toBeDefined(); + expect(state?.checks.comments.status).toBe("complete"); + expect(state?.status).toBe("complete"); + + // scan-only: no fix phase, no changes artifact, source left untouched + expect(state?.checks.comments.fix).toBe(false); + expect(state?.checks.comments.changes).toBeUndefined(); + expect(state?.checks.comments.findings).toContain("RESTATE"); + const fText = await readFile(findingsPath(scope), "utf8"); + expect(fText).toContain("findings"); + expect(fText).toContain("WHY"); + + // scan is read-only: restating comments still present in the source + const unchanged = await readFile(target, "utf8"); + expect(unchanged).toContain("// increment the counter"); + expect(unchanged).toContain("// compute the hash"); + expect(unchanged).toContain("power-of-two"); + }); + + it("with --fix: removes restating, keeps why, writes changes.md", async () => { + const scope: CheckScope = { + cwd, + target, + fix: true, + rest: [], + }; + setAgentRunner(rubricRunner({ getScope: () => scope })); + + const def = commentsCheck; + await handleCheckCommand(def, `--fix ${target}`, stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks.comments.status).toBe("complete"); + expect(state?.checks.comments.fix).toBe(true); + expect(state?.checks.comments.changes).toContain("removed comment (auto)"); + + // both artifacts present + const fText = await readFile(findingsPath(scope), "utf8"); + const cText = await readFile(changesPath(scope), "utf8"); + expect(fText).toContain("findings"); + expect(cText).toContain("changes"); + + const cleaned = await readFile(target, "utf8"); + expect(cleaned).not.toContain("// increment the counter"); + expect(cleaned).not.toContain("// compute the hash"); + expect(cleaned).toContain("power-of-two"); // why comment survives + expect(cleaned).toContain("counter++"); // real code intact + expect(cleaned).toContain("return k.split"); // logic untouched + }); + + it("marks phases complete and records artifacts in run-state", async () => { + const scope: CheckScope = { cwd, target, fix: true, rest: [] }; + setAgentRunner(rubricRunner({ getScope: () => scope })); + + await handleCheckCommand(commentsCheck, `--fix ${target}`, stubCtx(cwd)); + + const state = await loadRunState(cwd); + const check = state?.checks.comments; + expect(check).toBeDefined(); + const statuses = check!.phases.map((p) => `${p.id}=${p.status}`); + expect(statuses).toContain("analysis=complete"); + expect(statuses).toContain("fix=complete"); + expect(statuses).toContain("verify=complete"); + expect(statuses).toContain("cleanup=complete"); + expect(check!.findings).toBeDefined(); + expect(check!.changes).toBeDefined(); + }); +}); diff --git a/tests/complexity.test.ts b/tests/complexity.test.ts new file mode 100644 index 0000000..a2cc413 --- /dev/null +++ b/tests/complexity.test.ts @@ -0,0 +1,165 @@ +/** + * complexity.test.ts — integration tests for the excessive complexity check. + * + * Tests verify: + * 1. A 55-decision-point function (must-refactor band) is refactored + * 2. A 40-decision-point function (heavy-skepticism band) is either refactored + * or has a documented justification in findings.md + * 3. Deep nesting and over-abstraction are simplified + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, +} from "../src/agent-runner.js"; +import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState } from "../src/run-state.js"; + +/** + * A synthetic check that simulates a complexity scan finding two functions: + * - one at complexity 55 (must-refactor band) + * - one at complexity 40 (skepticism band) + */ +function synthComplexityCheck(): CheckDefinition { + return { + name: "synth-complexity", + label: "Synth Complexity", + description: "Synthetic complexity check for integration tests", + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: (_cwd, scope) => { + const findings = `# complexity — findings + +## Cyclomatic complexity + +| File | Function | Score | Band | Action | +|------|----------|-------|------|--------| +| target/index.ts:10 | complexFunction | 55 | 50+ | MUST refactor | +| target/index.ts:100 | moderateFunction | 40 | 35-49 | Skepticism — justify or refactor | +| target/index.ts:200 | simpleFunction | 8 | <35 | OK | + +## Structural smells + +- [high] target/index.ts:15 — deep nesting — 5 levels of nested if/else +- [med] target/index.ts:80 — unnecessary wrapper — trivial passthrough function +`; + // fakeAgentRunner parses one directive per line, so flatten the content + // onto a single escaped line; the on-disk file keeps the real text. + const oneLine = findings.split("\n").join(" "); + return `!write pygienium/checks/synth-complexity/findings.md "${oneLine}"\n!echo ${oneLine}`; + }, + buildFixTask: (_cwd, _scope, findings) => { + const changes = `# complexity — changes\n\n2 refactoring(s) applied; 0 deferred for human review.\n\n## Applied\n\n- target/index.ts:10 — complexFunction split (was 55, now 22, 28)\n- target/index.ts:15 — nested conditionals flattened\n- target/index.ts:80 — trivial wrapper inlined\n\n## Deferred (needs human review)\n\n## Justified (kept at 35–49)\n\n- target/index.ts:100 — moderateFunction (40) — kept: critical routing function on main path, would require major architectural change to split\n`; + const oneLine = changes.split("\n").join(" "); + return `!write pygienium/checks/synth-complexity/changes.md "${oneLine}"\n!echo ${oneLine}`; + }, + gate: async (cwd) => { + const { stat } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + try { + const s = await stat(resolve(cwd)); + return s.isDirectory() || s.isFile() + ? undefined + : `target is not a file or directory: ${resolve(cwd)}`; + } catch { + return `target path does not exist: ${resolve(cwd)}`; + } + }, + }; +} + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +describe("complexity check integration", () => { + let cwd: string; + + beforeEach(async () => { + clearChecks(); + setAgentRunner(fakeAgentRunner); + cwd = await mkdtemp(join(tmpdir(), "pygienium-complexity-")); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("runs a complexity scan and writes findings with cyclomatic scores", async () => { + registerCheck(synthComplexityCheck()); + const check = synthComplexityCheck(); + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state).toBeDefined(); + expect(state?.checks["synth-complexity"].status).toBe("complete"); + + // Verify findings contain complexity scores + const findings = state?.checks["synth-complexity"].findings; + expect(findings).toContain("55"); + expect(findings).toContain("40"); + expect(findings).toContain("MUST refactor"); + }); + + it("--fix refactors the 50+ function and documents changes", async () => { + const check = synthComplexityCheck(); + await handleCheckCommand(check, "--fix", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks["synth-complexity"].status).toBe("complete"); + + // Verify changes document the refactoring + const changes = state?.checks["synth-complexity"].changes; + expect(changes).toContain("complexFunction split"); + expect(changes).toContain("was 55"); + }); + + it("justified 35-49 functions appear in changes with justification", async () => { + const check = synthComplexityCheck(); + await handleCheckCommand(check, "--fix", stubCtx(cwd)); + + const state = await loadRunState(cwd); + const changes = state?.checks["synth-complexity"].changes; + expect(changes).toContain("Justified"); + expect(changes).toContain("moderateFunction"); + expect(changes).toContain("critical"); + }); + + it("marks check complete after --fix with no errors", async () => { + const check = synthComplexityCheck(); + await handleCheckCommand(check, "--fix", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks["synth-complexity"].error).toBeUndefined(); + expect( + state?.checks["synth-complexity"].phases.find((p) => p.id === "fix") + ?.status, + ).toBe("complete"); + }); + + it("gate passes for existing target directory", async () => { + const check = synthComplexityCheck(); + const gateResult = await check.gate(cwd); + expect(gateResult).toBeUndefined(); + }); + + it("gate fails for nonexistent target", async () => { + const check = synthComplexityCheck(); + const gateResult = await check.gate( + "/nonexistent/path/that/does/not/exist", + ); + expect(gateResult).toContain("does not exist"); + }); +}); diff --git a/tests/dead-code.test.ts b/tests/dead-code.test.ts new file mode 100644 index 0000000..3607660 --- /dev/null +++ b/tests/dead-code.test.ts @@ -0,0 +1,623 @@ +/** + * dead-code.test.ts — integration tests for the dead-code check. + * + * Covers: + * - registry/help: registering the check auto-binds `/pygienium-dead-code` + * (zero index.ts wiring changes). + * - deterministic detection: unused export, dead file, obsolete compat shim, + * and unused dependency are classified; a dynamically-imported module is + * classified `review`. + * - E2E `--fix`: clearly-dead items are removed and listed in changes.md; + * the dynamically-imported module is preserved and flagged for review. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + getAllChecks, + registerCheck, + getCheck, +} from "../src/checks/registry.js"; +import { + deadCodeCheck, + detectDeadCode, + findingsPath, + changesPath, + renderFindingsMd, + applyDeadCodeFixes, +} from "../src/checks/dead-code.js"; +import { buildPygieniumHelpLines } from "../src/help.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, +} from "../src/agent-runner.js"; +import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState } from "../src/run-state.js"; + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +async function writeFixture(root: string): Promise { + await mkdir(join(root, "src", "routes"), { recursive: true }); + + // package.json — carries an unused dependency + an entry `main`. + await writeFile( + join(root, "package.json"), + JSON.stringify( + { + name: "fixture", + version: "1.0.0", + main: "src/index.ts", + dependencies: { + leftoverpkg: "^1.0.0", + typescript: "^5.0.0", + }, + devDependencies: {}, + }, + null, + 2, + ) + "\n", + ); + + // util.ts — one used export and one unused export. + await writeFile( + join(root, "src", "util.ts"), + [ + "export function add(a: number, b: number): number {", + " return a + b;", + "}", + "", + "export function unusedHelper(): string {", + ' return "never called anywhere";', + "}", + "", + ].join("\n"), + ); + + // index.ts — the entry (reaches util and routes), its name is entry-like + // so the zero-importer rule must NOT flag it as dead. + await writeFile( + join(root, "src", "index.ts"), + [ + 'import { add } from "./util";', + 'import { load } from "./routes";', + "add(1, 2);", + "load();", + "", + ].join("\n"), + ); + + // routes/index.ts — live barrel that exposes a lazy loader. + await writeFile( + join(root, "src", "routes", "index.ts"), + ['export const load = () => import("./lazy-route");', ""].join("\n"), + ); + + // lazy-route.ts — a shim reachable ONLY through a dynamic import. It must + // be classified `review` and never auto-removed. + await writeFile( + join(root, "src", "routes", "lazy-route.ts"), + ["export function registerRoute(): void {", " return;", "}", ""].join( + "\n", + ), + ); + + // compat.ts — an obsolete deprecated compat wrapper with zero importers. + await writeFile( + join(root, "src", "compat.ts"), + [ + "// @deprecated obsolete compatibility wrapper — scheduled for removal.", + "export function legacyFormat(x: string): string {", + " return x;", + "}", + "", + ].join("\n"), + ); + + // orphan.ts — a completely unreferenced module (dead file). + await writeFile( + join(root, "src", "orphan.ts"), + ["export function orphan(): void {", " return;", "}", ""].join("\n"), + ); +} + +describe("dead-code registry + help", () => { + beforeEach(async () => { + clearChecks(); + // Re-register after clearChecks (module-level registration runs on import). + registerCheck(deadCodeCheck); + }); + + it("registers a single dead-code check with a serialisable name", () => { + expect(getCheck("dead-code")).toBeDefined(); + expect(getAllChecks().filter((c) => c.name === "dead-code")).toHaveLength( + 1, + ); + }); + + it("appears in /pygienium-help with its description (auto discovery)", () => { + const help = buildPygieniumHelpLines(); + expect(help.join("\n")).toContain("/pygienium-dead-code"); + expect(help.join("\n")).toContain(deadCodeCheck.description); + }); +}); + +describe("dead-code detection", () => { + it("classifies unused export, dead file, compat shim, unused dep; preserves dynamic import", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-")); + try { + await writeFixture(root); + const report = await detectDeadCode(root); + + expect(report.items.length).toBeGreaterThan(0); + + const unusedExport = report.items.find( + (i) => i.category === "export" && i.name === "unusedHelper", + ); + expect(unusedExport).toBeDefined(); + expect(unusedExport?.review).toBe(false); + expect(unusedExport?.target).toBe("symbol"); + + const deadShim = report.items.find( + (i) => i.category === "shim" && i.rel === "src/compat.ts", + ); + expect(deadShim).toBeDefined(); + expect(deadShim?.review).toBe(false); + expect(deadShim?.target).toBe("file"); + + const deadFile = report.items.find( + (i) => i.category === "file" && i.rel === "src/orphan.ts", + ); + expect(deadFile).toBeDefined(); + expect(deadFile?.review).toBe(false); + + const unusedDep = report.items.find( + (i) => i.category === "dep" && i.name === "leftoverpkg", + ); + expect(unusedDep).toBeDefined(); + + // dynamic-import shim → review, always preserved + const dynamic = report.items.find( + (i) => i.rel === "src/routes/lazy-route.ts", + ); + expect(dynamic).toBeDefined(); + expect(dynamic?.review).toBe(true); + expect(dynamic?.target).toBe("file"); + + // used export + entry file are NOT flagged. + expect(report.items.find((i) => i.name === "add")).toBeUndefined(); + expect( + report.items.find((i) => i.rel === "src/index.ts"), + ).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renderFindingsMd groups findings under the four category headings", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-")); + try { + await writeFixture(root); + const report = await detectDeadCode(root); + const md = renderFindingsMd(report); + expect(md).toContain("## Unused exports"); + expect(md).toContain("## Dead files (zero importers)"); + expect(md).toContain("## Obsolete shims / migration helpers"); + expect(md).toContain("## Unused dependencies"); + expect(md).toMatch(/\[review\] src\/routes\/lazy-route\.ts/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("dead-code E2E (--fix)", () => { + let cwd: string; + + beforeEach(async () => { + clearChecks(); + registerCheck(deadCodeCheck); + setAgentRunner(fakeAgentRunner); + cwd = await mkdtemp(join(tmpdir(), "pygienium-dead-e2e-")); + await writeFixture(cwd); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + it("removes clearly-dead items, preserves dynamically-imported shim, and writes findings.md + changes.md", async () => { + // Make the pre-existing compat.ts scan-detected before the run to also + // prove the deterministic scan picks it up regardless of run order. + await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd)); + + // --- Clearly-dead items are removed -------------------------------- + // Unused export: removed from util.ts, live export preserved. + const util = await readFile(join(cwd, "src", "util.ts"), "utf8"); + expect(util).not.toContain("unusedHelper"); + expect(util).toContain("add"); + + // Obsolete compat shim: file deleted. + expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(false); + + // Dead file: deleted. + expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(false); + + // Unused dependency: removed from package.json. + const pkg = JSON.parse( + await readFile(join(cwd, "package.json"), "utf8"), + ) as { dependencies: Record }; + expect(pkg.dependencies.leftoverpkg).toBeUndefined(); + + // Dynamically-imported shim: preserved, untouched. + expect(existsSync(join(cwd, "src", "routes", "lazy-route.ts"))).toBe(true); + const lazy = await readFile( + join(cwd, "src", "routes", "lazy-route.ts"), + "utf8", + ); + expect(lazy).toContain("registerRoute"); + + // findings.md exists and is categorized. + expect(existsSync(findingsPath(cwd))).toBe(true); + const findingsMd = await readFile(findingsPath(cwd), "utf8"); + expect(findingsMd).toContain("## Unused exports"); + expect(findingsMd).toContain("## Dead files (zero importers)"); + expect(findingsMd).toContain("## Obsolete shims / migration helpers"); + expect(findingsMd).toContain("## Unused dependencies"); + + // changes.md lists the removals and the preserved review item. + expect(existsSync(changesPath(cwd))).toBe(true); + const changesMd = await readFile(changesPath(cwd), "utf8"); + expect(changesMd).toContain("## Removed (auto)"); + expect(changesMd).toContain("src/compat.ts"); + expect(changesMd).toContain("unusedHelper"); + expect(changesMd).toContain("leftoverpkg"); + expect(changesMd).toContain("## Preserved for review (manual)"); + expect(changesMd).toContain("lazy-route.ts"); // dynamic import preserved + }); + + it("records the run in run-state and marks the check complete", async () => { + await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd)); + const state = await loadRunState(cwd); + expect(state?.checks["dead-code"].status).toBe("complete"); + expect(state?.checks["dead-code"].findings).toBeDefined(); + expect(state?.checks["dead-code"].changes).toBeDefined(); + }); + + it("runs a dry scan (no --fix) and does not write changes.md or touch files", async () => { + await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd)); + + // Nothing removed without --fix. + expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(true); + expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(true); + const utils = await readFile(join(cwd, "src", "util.ts"), "utf8"); + expect(utils).toContain("unusedHelper"); + + // Findings recorded, but no changes yet. + const state = await loadRunState(cwd); + expect(state?.checks["dead-code"].status).toBe("complete"); + expect(state?.checks["dead-code"].changes).toBeUndefined(); + }); +}); + +describe("dead-code shim auto-delete safety (entry points + prose)", () => { + beforeEach(() => { + clearChecks(); + registerCheck(deadCodeCheck); + setAgentRunner(fakeAgentRunner); + }); + afterEach(() => { + resetAgentRunner(); + }); + + it("never auto-deletes an entry point whose prose mentions 'legacy'", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-entry-")); + try { + await mkdir(join(root, "src"), { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n", + ); + // Entry point, zero importers, prose contains 'legacy' — must survive. + await writeFile( + join(root, "src", "index.ts"), + [ + "// handles legacy payloads", + "export function main(): void {}", + "", + ].join("\n"), + ); + + await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); + expect(existsSync(join(root, "src", "index.ts"))).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("never auto-deletes a *.test.ts whose description mentions 'deprecated'", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-test-")); + try { + await mkdir(join(root, "src"), { recursive: true }); + await writeFile( + join(root, "src", "foo.test.ts"), + [ + "import { describe, it } from 'bun:test';", + "describe('app', () => {", + " it('still supports the deprecated API', () => {});", + "});", + "", + ].join("\n"), + ); + + await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); + expect(existsSync(join(root, "src", "foo.test.ts"))).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("flags an entry-like file tagged @deprecated for review, not deletion", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-tag-")); + try { + await mkdir(join(root, "src"), { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n", + ); + await writeFile( + join(root, "src", "index.ts"), + ["/** @deprecated */", "export function main(): void {}", ""].join( + "\n", + ), + ); + + const report = await detectDeadCode(root); + const entry = report.items.find( + (i) => i.rel === "src/index.ts" && i.category === "shim", + ); + expect(entry).toBeDefined(); + expect(entry?.review).toBe(true); + + await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); + expect(existsSync(join(root, "src", "index.ts"))).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("dead-code barrel re-export retention", () => { + it("keeps modules reachable only through `export * from` / `export {…} from` barrels", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-")); + try { + // index.ts is entry-like; it aggregates barrelA, which aggregates + // barrelB both by star and by name. Neither barrel may be treated as + // a zero-importer dead file, and symbols only reachable through the + // star re-export must stay behind a review flag. + await mkdir(join(root, "src"), { recursive: true }); + await writeFile( + join(root, "src", "index.ts"), + ['export * from "./barrelA";', ""].join("\n"), + ); + await writeFile( + join(root, "src", "barrelA.ts"), + [ + 'export * from "./barrelB";', + 'export { namedB } from "./barrelB";', + "", + ].join("\n"), + ); + await writeFile( + join(root, "src", "barrelB.ts"), + [ + "export const value = 1;", + "export const namedB = 2;", + "export const starOnly = 3;", + "", + ].join("\n"), + ); + + const report = await detectDeadCode(root); + + // Neither barrel is a dead-file candidate. + expect( + report.items.find( + (i) => i.category === "file" && i.rel === "src/barrelA.ts", + ), + ).toBeUndefined(); + expect( + report.items.find( + (i) => i.category === "file" && i.rel === "src/barrelB.ts", + ), + ).toBeUndefined(); + + // Symbols in the star/named re-export target stay `review` — the + // deterministic fixer must not auto-delete them. + const value = report.items.find( + (i) => + i.category === "export" && + i.rel === "src/barrelB.ts" && + i.name === "value", + ); + expect(value).toBeDefined(); + expect(value?.review).toBe(true); + const starOnly = report.items.find( + (i) => + i.category === "export" && + i.rel === "src/barrelB.ts" && + i.name === "starOnly", + ); + expect(starOnly).toBeDefined(); + expect(starOnly?.review).toBe(true); + + // The named re-export is referenced (by barrelA) so it is not dead. + expect( + report.items.find( + (i) => + i.category === "export" && + i.rel === "src/barrelB.ts" && + i.name === "namedB", + ), + ).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("`--fix` never deletes a barrel-exported module", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-e2e-")); + try { + clearChecks(); + registerCheck(deadCodeCheck); + setAgentRunner(fakeAgentRunner); + await mkdir(join(root, "src"), { recursive: true }); + await writeFile( + join(root, "src", "index.ts"), + ['export * from "./barrelA";', ""].join("\n"), + ); + await writeFile( + join(root, "src", "barrelA.ts"), + [ + 'export * from "./barrelB";', + 'export { namedB } from "./barrelB";', + "", + ].join("\n"), + ); + await writeFile( + join(root, "src", "barrelB.ts"), + [ + "export const value = 1;", + "export const namedB = 2;", + "export const starOnly = 3;", + "", + ].join("\n"), + ); + + await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root)); + + expect(existsSync(join(root, "src", "barrelA.ts"))).toBe(true); + expect(existsSync(join(root, "src", "barrelB.ts"))).toBe(true); + expect(existsSync(join(root, "src", "index.ts"))).toBe(true); + } finally { + resetAgentRunner(); + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("dead-code symbol removal (multi-statement bodies)", () => { + it("removes arrow-block consts, object consts, and inline-closing functions whole", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-shapes-")); + try { + await mkdir(join(root, "src"), { recursive: true }); + await writeFile( + join(root, "src", "math.ts"), + [ + "export const build = () => {", + " const a = 1;", + " return a + 2;", + "};", + "export function packed() {", + ' return "x"; }', + 'export const config = { retries: 3, label: "cfg" };', + "export function keep(): string {", + ' return "keep";', + "}", + "export const keepVar = 9;", + "", + ].join("\n"), + ); + // Referenced exports keep math.ts alive and `keep`/`keepVar` used. + await writeFile( + join(root, "src", "app.ts"), + [ + 'import { keep, keepVar } from "./math";', + "console.log(keep(), keepVar);", + "", + ].join("\n"), + ); + + const report = await detectDeadCode(root); + const names = report.items + .filter((i) => i.category === "export" && i.rel === "src/math.ts") + .map((i) => i.name); + expect(names).toContain("build"); + expect(names).toContain("packed"); + expect(names).toContain("config"); + expect(names).not.toContain("keep"); + expect(names).not.toContain("keepVar"); + + const { applied } = await applyDeadCodeFixes(report); + expect(applied.map((i) => i.name)).toEqual( + expect.arrayContaining(["build", "packed", "config"]), + ); + + const out = await readFile(join(root, "src", "math.ts"), "utf8"); + expect(out).not.toContain("build"); + expect(out).not.toContain("packed"); + expect(out).not.toContain("config"); + expect(out).toContain("keep"); + expect(out).toContain("keepVar"); + // No leftover arrow body from the removed declaration. + expect(out).not.toContain("return a + 2"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("preserves a symbol it cannot safely remove instead of corrupting source", async () => { + const root = await mkdtemp(join(tmpdir(), "pygienium-dead-guard-")); + try { + const file = join(root, "weird.ts"); + await writeFile( + file, + [ + 'export const rx = () => /[{;}]/.test("a;");', + "export const keep = 1;", + "", + ].join("\n"), + ); + const original = await readFile(file, "utf8"); + // Hand-built report forces an auto removal attempt on a shape the + // scanner does not fully model (regex with braces/semicolons). + const { applyDeadCodeFixes: apply } = await import( + "../src/checks/dead-code.js" + ); + const report = { + target: root, + scannedAt: new Date().toISOString(), + items: [ + { + category: "export" as const, + path: file, + rel: "weird.ts", + name: "rx", + line: 1, + target: "symbol" as const, + review: false, + reason: "test", + }, + ], + }; + const { applied } = await apply(report); + const after = await readFile(file, "utf8"); + // Either the removal succeeded cleanly, or the file is untouched — + // never a truncated/corrupt intermediate. + if (applied.length === 0) { + expect(after).toBe(original); + } else { + expect(after).not.toContain("rx"); + expect(after).toContain("keep"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/deep-modules.test.ts b/tests/deep-modules.test.ts new file mode 100644 index 0000000..2a54131 --- /dev/null +++ b/tests/deep-modules.test.ts @@ -0,0 +1,171 @@ +/** + * deep-modules.test.ts — integration test for the deep-modules check. + * + * Seeds a temp workspace with a pass-through wrapper module (a shallow + * abstraction), runs the check with the deterministic fake agent runner, and + * asserts: + * - the scan persists `findings.md` flagging the wrapper as a pass-through; + * - with `--fix`, the safe consolidation is applied (wrapper rewritten/ + * removed) and `changes.md` records it (auto), while a risky + * external-importer case is listed for review (manual), never auto-applied. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + getCheck, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, +} from "../src/agent-runner.js"; +import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState } from "../src/run-state.js"; +import { + findingsPath, + changesPath, + deepModulesCheck, +} from "../src/checks/deep-modules.js"; + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +/** Drop a pass-through wrapper module that forwards a single lib call. */ +async function seedPassThrough( + dir: string, +): Promise<{ wrapper: string; lib: string }> { + const wrapper = join(dir, "wrapper.ts"); + const lib = join(dir, "lib.ts"); + await mkdir(dir, { recursive: true }).catch(() => {}); + await writeFile( + lib, + `export function compute(x: number): number { return x * 2; }\n`, + "utf8", + ); + // Shallow pass-through: forwards every argument to `lib` with zero added logic. + await writeFile( + wrapper, + `import { compute } from "./lib";\nexport function run(x: number) { return compute(x); }\n`, + "utf8", + ); + return { wrapper, lib }; +} + +describe("deep-modules check", () => { + let cwd: string; + + beforeEach(async () => { + clearChecks(); + setAgentRunner(fakeAgentRunner); + // Re-register explicitly: the module's import-time registerCheck only + // runs once (module cache), so clearChecks + registerCheck restores it + // deterministically for each test. + registerCheck(deepModulesCheck); + cwd = await mkdtemp(join(tmpdir(), "pygienium-deep-")); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }).catch(() => {}); + }); + + it("is registered and uses the deep-modules scanner agent", () => { + const check = getCheck("deep-modules"); + expect(check).toBeDefined(); + expect((check as CheckDefinition)?.agentName).toBe("deep-modules"); + }); + + it("flags the pass-through wrapper in findings.md", async () => { + await seedPassThrough(cwd); + const check = getCheck("deep-modules")!; + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const findings = await readFile(findingsPath(cwd), "utf8"); + expect(findings).toContain("wrapper.ts"); + expect(findings).toContain("pass-through-wrapper"); + expect(findings).toMatch(/importers:\s*0/); + + // Run state records the scan summary as findings text. + const state = await loadRunState(cwd); + expect(state?.checks["deep-modules"]?.status).toBe("complete"); + expect(state?.checks["deep-modules"]?.findings).toContain( + "deep-modules: 1 issue", + ); + }); + + it("scan-only does not write changes.md", async () => { + await seedPassThrough(cwd); + const check = getCheck("deep-modules")!; + + await handleCheckCommand(check, "", stubCtx(cwd)); + + expect(existsSync(changesPath(cwd))).toBe(false); + }); + + it("--fix applies the safe consolidation and records changes.md (auto), and defers the risky one (manual)", async () => { + await seedPassThrough(cwd); + // Also drop a "risky" adapter so we can assert it is NOT auto-applied. + await writeFile( + join(cwd, "risky-adapter.ts"), + `// adapter-layer with external importers — should be listed for review only\nexport const risky = true;\n`, + "utf8", + ); + const check = getCheck("deep-modules")!; + + await handleCheckCommand(check, "--fix", stubCtx(cwd)); + + const changes = await readFile(changesPath(cwd), "utf8"); + // Safe pass-through: consolidation applied (auto). + expect(changes).toContain("wrapper.ts"); + expect(changes).toMatch(/auto/); + expect(changes).toMatch(/consolidat/i); + + // Risky adapter: listed for review, not auto-applied (manual). + expect(changes).toContain("risky-adapter.ts"); + expect(changes).toMatch(/manual/); + + // The safe wrapper was rewritten — no longer a pass-through. + const wrapperContent = await readFile(join(cwd, "wrapper.ts"), "utf8"); + expect(wrapperContent).not.toContain("import { compute }"); + expect(wrapperContent).toContain("Consolidated"); + + const state = await loadRunState(cwd); + expect(state?.checks["deep-modules"]?.fix).toBe(true); + expect(state?.checks["deep-modules"]?.changes).toContain( + "1 auto-applied, 1 deferred", + ); + expect(state?.checks["deep-modules"]?.status).toBe("complete"); + }); + + 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"), + ); + expect(changesPath(cwd)).toBe( + join(cwd, "pygienium", "checks", "deep-modules", "changes.md"), + ); + }); + + it("skips when the target has no source files", async () => { + const empty = await mkdtemp(join(tmpdir(), "pygienium-empty-")); + try { + const check = getCheck("deep-modules")!; + await handleCheckCommand(check, empty, stubCtx(cwd)); + const state = await loadRunState(cwd); + expect(state?.checks["deep-modules"]?.status).toBe("skipped"); + } finally { + await rm(empty, { recursive: true, force: true }).catch(() => {}); + } + }); +}); diff --git a/tests/defensive-guards.test.ts b/tests/defensive-guards.test.ts new file mode 100644 index 0000000..7fa54d6 --- /dev/null +++ b/tests/defensive-guards.test.ts @@ -0,0 +1,232 @@ +/** + * defensive-guards.test.ts — integration test for the defensive-guards check. + * + * Seeds a temp workspace with: + * - noise.ts: a redundant null check on a typed-non-null parameter PLUS a + * swallowing try/catch (both redundant); + * - boundary.ts: a try/catch around JSON.parse (a legitimate parsing + * boundary guard). + * + * Runs the check with the deterministic fake agent runner and asserts: + * - the scan persists findings.md separating redundant guards from boundary + * guards; + * - with --fix, the redundant guards are removed from noise.ts and changes.md + * records them (auto), while the JSON.parse guard in boundary.ts is + * preserved untouched (kept — boundary). + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearChecks, + getCheck, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, +} from "../src/agent-runner.js"; +import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js"; +import { loadRunState } from "../src/run-state.js"; +import { + findingsPath, + changesPath, + defensiveGuardsCheck, +} from "../src/checks/defensive-guards.js"; + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +/** + * Seed `noise.ts`: a redundant null check on a typed-non-null param plus a + * swallowing try/catch. Both are redundant — the type system guarantees + * `name` is a string, and the catch silently swallows the error. + */ +async function seedNoise(dir: string): Promise { + const noise = join(dir, "noise.ts"); + await mkdir(dir, { recursive: true }).catch(() => {}); + await writeFile( + noise, + [ + `export function greet(name: string) {`, + ` if (name === null) return "";`, + ` return \`hello \${name}\`;`, + `}`, + ``, + `export function swallow() {`, + ` try {`, + ` doThing();`, + ` } catch (e) {`, + ` // swallowed`, + ` }`, + `}`, + ``, + `function doThing() {}`, + ``, + ].join("\n"), + "utf8", + ); + return noise; +} + +/** + * Seed `boundary.ts`: a try/catch around JSON.parse of untrusted input. This is + * a legitimate parsing boundary guard and must be PRESERVED by --fix. + */ +async function seedBoundary(dir: string): Promise { + const boundary = join(dir, "boundary.ts"); + await mkdir(dir, { recursive: true }).catch(() => {}); + await writeFile( + boundary, + [ + `export function parse(input: string) {`, + ` try {`, + ` return JSON.parse(input);`, + ` } catch (e) {`, + ` return null;`, + ` }`, + `}`, + ``, + ].join("\n"), + "utf8", + ); + return boundary; +} + +describe("defensive-guards check", () => { + let cwd: string; + + beforeEach(async () => { + clearChecks(); + setAgentRunner(fakeAgentRunner); + // Re-register explicitly: the module's import-time registerCheck only + // runs once (module cache), so clearChecks + registerCheck restores it + // deterministically for each test. + registerCheck(defensiveGuardsCheck); + // Fresh empty tempdir per test; each test seeds itself so the skip + // test gets a genuinely empty cwd (the gate inspects cwd, not target). + cwd = await mkdtemp(join(tmpdir(), "pygienium-dg-")); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }).catch(() => {}); + }); + + it("is registered and uses the defensive-guards scanner agent", () => { + const check = getCheck("defensive-guards"); + expect(check).toBeDefined(); + expect((check as CheckDefinition)?.agentName).toBe("defensive-guards"); + }); + + it("flags the redundant null check and swallowing try/catch, and keeps the JSON.parse boundary guard, in findings.md", async () => { + await seedNoise(cwd); + await seedBoundary(cwd); + const check = getCheck("defensive-guards")!; + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const findings = await readFile(findingsPath(cwd), "utf8"); + // Redundant guards are flagged with their kind. + expect(findings).toContain("noise.ts"); + expect(findings).toContain("redundant-null-check"); + expect(findings).toContain("swallowing-try-catch"); + // The JSON.parse guard is classified as a boundary guard (kept). + expect(findings).toContain("boundary.ts"); + expect(findings).toContain("keep-boundary"); + expect(findings).toContain("parsing-guard"); + + // Run state records the scan summary as findings text. + const state = await loadRunState(cwd); + expect(state?.checks["defensive-guards"]?.status).toBe("complete"); + expect(state?.checks["defensive-guards"]?.findings).toContain( + "defensive-guards: 2 redundant", + ); + }); + + it("scan-only does not write changes.md and does not touch source files", async () => { + const noise = await seedNoise(cwd); + const boundary = await seedBoundary(cwd); + const before = await readFile(noise, "utf8"); + const beforeBoundary = await readFile(boundary, "utf8"); + + const check = getCheck("defensive-guards")!; + await handleCheckCommand(check, "", stubCtx(cwd)); + + expect(existsSync(changesPath(cwd))).toBe(false); + // Source files untouched by a scan-only run. + expect(await readFile(noise, "utf8")).toBe(before); + expect(await readFile(boundary, "utf8")).toBe(beforeBoundary); + }); + + it("--fix removes the redundant guards from noise.ts and records changes.md (auto), and preserves the JSON.parse boundary guard", async () => { + const noise = await seedNoise(cwd); + const boundary = await seedBoundary(cwd); + const check = getCheck("defensive-guards")!; + + await handleCheckCommand(check, "--fix", stubCtx(cwd)); + + const changes = await readFile(changesPath(cwd), "utf8"); + // Redundant guards: removed (auto). + expect(changes).toContain("noise.ts"); + expect(changes).toMatch(/auto/); + expect(changes).toMatch(/redundant-null-check/); + expect(changes).toMatch(/swallowing-try-catch/); + // JSON.parse boundary guard: kept (boundary — with reason). + expect(changes).toContain("boundary.ts"); + expect(changes).toMatch(/boundary/); + expect(changes).toMatch(/JSON.parse/); + + // noise.ts no longer contains the redundant null check or the swallowing + // try/catch. The fixer leaves a header marker noting the cleanup. + const cleaned = await readFile(noise, "utf8"); + expect(cleaned).not.toContain("=== null"); + expect(cleaned).not.toMatch(/try\s*\{/); + expect(cleaned).toContain("Cleaned by pygienium-defensive-guards"); + // The happy-path behaviour is preserved. + expect(cleaned).toContain("greet"); + expect(cleaned).toContain("hello"); + + // boundary.ts is PRESERVED — the JSON.parse guard is untouched. + const keptBoundary = await readFile(boundary, "utf8"); + expect(keptBoundary).toContain("JSON.parse"); + expect(keptBoundary).toMatch(/try\s*\{/); + expect(keptBoundary).toMatch(/catch/); + // And it still returns null on parse failure (unchanged behaviour). + expect(keptBoundary).toContain("return null"); + + const state = await loadRunState(cwd); + expect(state?.checks["defensive-guards"]?.fix).toBe(true); + expect(state?.checks["defensive-guards"]?.changes).toContain( + "2 removed, 1 kept", + ); + expect(state?.checks["defensive-guards"]?.status).toBe("complete"); + }); + + 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"), + ); + expect(changesPath(cwd)).toBe( + join(cwd, "pygienium", "checks", "defensive-guards", "changes.md"), + ); + }); + + it("skips when the target has no source files", async () => { + // cwd is a fresh empty tempdir (no seeding) → the gate finds no source + // files and skips the check without spawning an agent. + const check = getCheck("defensive-guards")!; + await handleCheckCommand(check, "", stubCtx(cwd)); + const state = await loadRunState(cwd); + expect(state?.checks["defensive-guards"]?.status).toBe("skipped"); + }); +}); diff --git a/tests/extensibility.test.ts b/tests/extensibility.test.ts new file mode 100644 index 0000000..678cbfd --- /dev/null +++ b/tests/extensibility.test.ts @@ -0,0 +1,38 @@ +/** + * extensibility.test.ts — the registry extensibility claim (task 14). + * + * Proves a NEW check added as a file in `src/checks/` plus one `registerCheck()` + * entry yields a working `/pygienium-` command with ZERO `index.ts` + * command-wiring changes. The witness is `src/checks/noop.ts`: importing it + * self-registers the `noop` check, after which the generic command-binding path + * (`registerPygieniumCommands`, the exact function `index.ts` calls) exposes + * `/pygienium-noop` and `/pygienium-help` lists it. + */ +import { describe, expect, it } from "bun:test"; +import "../src/checks/noop.js"; +import { getCheck, getAllChecks } from "../src/checks/registry.js"; +import { registerPygieniumCommands } from "../src/commands.js"; +import { buildPygieniumHelpLines } from "../src/help.js"; + +describe("registry extensibility (task 14)", () => { + it("the noop check file self-registers (no index.ts edits)", () => { + // Importing checks/noop.ts ran its top-level registerCheck(noopCheck). + expect(getCheck("noop")).toBeDefined(); + expect(getAllChecks().some((c) => c.name === "noop")).toBe(true); + }); + + it("registerPygieniumCommands exposes /pygienium-noop (zero wiring)", () => { + const names: string[] = []; + registerPygieniumCommands((name) => names.push(name)); + expect(names).toContain("pygienium-noop"); + // And the operator commands are still wired. + expect(names).toContain("pygienium-help"); + expect(names).toContain("pygienium-all"); + }); + + it("/pygienium-help lists the noop check", () => { + const text = buildPygieniumHelpLines().join("\n"); + expect(text).toContain("/pygienium-noop"); + expect(text).toContain(getCheck("noop")!.description); + }); +}); diff --git a/tests/help.test.ts b/tests/help.test.ts new file mode 100644 index 0000000..177ca01 --- /dev/null +++ b/tests/help.test.ts @@ -0,0 +1,101 @@ +/** + * help.test.ts — `/pygienium-help` content (task 14). + * + * Asserts the help block lists every operator command, every implemented flag, + * and at least 8 commands once a handful of checks are registered. The + * per-check command family and the dynamic checks list are registry-driven, so + * these tests register stub checks rather than importing the real ones. + */ +import { describe, expect, it, beforeEach } from "bun:test"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + COMMANDS, + CLI_FLAGS, + PYGIENIUM_FLAGS, + buildPygieniumHelpLines, +} from "../src/help.js"; + +function stub(name: string): CheckDefinition { + return { + name, + label: name, + description: `${name} check`, + agentName: "scanner", + phaseId: "scan", + buildScanTask: () => "scan", + buildFixTask: () => "fix", + gate: () => undefined, + }; +} + +describe("/pygienium-help content (task 14)", () => { + beforeEach(() => clearChecks()); + + it("COMMANDS lists every operator command with usage/description/example", () => { + const usages = COMMANDS.map((c) => c.usage); + expect(usages).toContain("pygienium-help"); + expect(usages).toContain("pygienium- [path] [--fix]"); + expect(usages).toContain( + "pygienium-all [path] [--fix] [--fresh] [--only=a,b]", + ); + expect(usages).toContain("pygienium-status [path]"); + expect(usages).toContain("pygienium-resume [path] [--fresh]"); + expect(usages).toContain( + "pygienium-export [path] [--check=] [--status=] [--out=md|json]", + ); + for (const cmd of COMMANDS) { + expect(cmd.description.length).toBeGreaterThan(0); + expect(cmd.example.length).toBeGreaterThan(0); + } + }); + + it("CLI_FLAGS lists every implemented flag", () => { + const names = CLI_FLAGS.map((f) => f.name); + expect(names).toContain("[path]"); + expect(names).toContain("--fix"); + expect(names).toContain("--fresh"); + expect(names).toContain("--check="); + expect(names).toContain("--status="); + expect(names).toContain("--out="); + expect(CLI_FLAGS.length).toBeGreaterThanOrEqual(6); + // Back-compat alias points at the same array. + expect(PYGIENIUM_FLAGS).toBe(CLI_FLAGS); + }); + + it("buildPygieniumHelpLines surfaces every command usage and flag name", () => { + const text = buildPygieniumHelpLines().join("\n"); + for (const cmd of COMMANDS) { + expect(text).toContain(`/${cmd.usage}`); + } + for (const flag of CLI_FLAGS) { + expect(text).toContain(flag.name); + } + }); + + it("lists 8+ commands once several checks are registered", () => { + registerCheck(stub("alpha")); + registerCheck(stub("beta")); + registerCheck(stub("gamma")); + const lines = buildPygieniumHelpLines(); + // Any line that begins ` /pygienium-` is a command/check listing row. + const commandRows = lines.filter((l) => l.startsWith(" /pygienium-")); + // 6 operator command rows + 3 registered checks = 9. + expect(commandRows.length).toBeGreaterThanOrEqual(8); + // Each registered check is listed by name with its description. + const text = lines.join("\n"); + expect(text).toContain("/pygienium-alpha"); + expect(text).toContain("/pygienium-beta"); + expect(text).toContain("/pygienium-gamma"); + expect(text).toContain("alpha check"); + }); + + it("notes the one-file + registerCheck extensibility workflow", () => { + const text = buildPygieniumHelpLines().join("\n"); + expect(text).toContain("registerCheck"); + expect(text).toContain("No index.ts command-wiring changes"); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..1365c1d --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,69 @@ +/** + * registry.test.ts — unit tests for the check registry. + */ +import { describe, expect, it, beforeEach } from "bun:test"; +import { + clearChecks, + getAllChecks, + getCheck, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; + +function stubCheck(name: string): CheckDefinition { + return { + name, + label: name, + description: `${name} check`, + agentName: "scanner", + phaseId: "scan", + buildScanTask: () => "scan", + buildFixTask: () => "fix", + gate: () => undefined, + }; +} + +describe("check registry", () => { + beforeEach(() => clearChecks()); + + it("registerCheck inserts and getAllChecks returns it", () => { + registerCheck(stubCheck("comments")); + const all = getAllChecks(); + expect(all).toHaveLength(1); + expect(all[0]?.name).toBe("comments"); + }); + + it("getCheck looks up by name", () => { + registerCheck(stubCheck("complexity")); + expect(getCheck("complexity")?.label).toBe("complexity"); + expect(getCheck("missing")).toBeUndefined(); + }); + + it("registerCheck throws on duplicate names", () => { + registerCheck(stubCheck("dup")); + expect(() => registerCheck(stubCheck("dup"))).toThrow(/Duplicate/); + }); + + it("registerCheck throws on invalid names", () => { + expect(() => registerCheck(stubCheck("Bad-Name"))).toThrow(/Invalid/); + expect(() => registerCheck(stubCheck("with space"))).toThrow(/Invalid/); + expect(() => registerCheck(stubCheck(""))).toThrow(/Invalid/); + }); + + it("clearChecks empties the registry", () => { + registerCheck(stubCheck("a")); + clearChecks(); + expect(getAllChecks()).toHaveLength(0); + }); + + it("preserves insertion order", () => { + registerCheck(stubCheck("alpha")); + registerCheck(stubCheck("beta")); + registerCheck(stubCheck("gamma")); + expect(getAllChecks().map((c) => c.name)).toEqual([ + "alpha", + "beta", + "gamma", + ]); + }); +}); diff --git a/tests/status-resume-export.test.ts b/tests/status-resume-export.test.ts new file mode 100644 index 0000000..763d00d --- /dev/null +++ b/tests/status-resume-export.test.ts @@ -0,0 +1,420 @@ +/** + * status-resume-export.test.ts — integration test for task 13. + * + * Mirrors the spec scenario: start a hypothetical `/pygienium-all`, treat it as + * interrupted (one check complete, one pending), then exercise + * `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` and assert: + * + * - status reports accurate per-check progress (one complete, one pending, + * run still in_progress); + * - resume re-dispatches the pending check and DOES NOT re-run the complete + * one (proven via the agent-runner call log), and the run ends complete; + * - --fresh re-dispatches even the complete check (proven via call log); + * - export produces a filtered markdown bundle on disk, and the --check= + * and --out=json filters work. + * + * A tracker wraps the fake agent runner so we can assert which checks were + * actually dispatched without depending on timing or a model. + */ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { + clearChecks, + registerCheck, + type CheckDefinition, +} from "../src/checks/registry.js"; +import { + setAgentRunner, + resetAgentRunner, + fakeAgentRunner, + type AgentRunner, +} from "../src/agent-runner.js"; +import { + handleStatusCommand, + handleResumeCommand, + handleExportCommand, + type PygieniumCtx, +} from "../src/commands.js"; +import { + initRunState, + loadRunState, + saveRunState, + markCheckStatus, + recordCheckOutput, + applyPhaseStatus, + PHASE_RECON, + PHASE_ANALYSIS, + PHASE_FIX, + PHASE_VERIFY, + PHASE_CLEANUP, +} from "../src/run-state.js"; +import { formatRunStatus } from "../src/status.js"; +import { + exportRun, + gatherExportEntries, + renderExportJson, + renderExportMarkdown, + parseExportFilters, + canonicalChecksRoot, +} from "../src/export.js"; +import type { AgentTaskOptions } from "../src/agent-runner.js"; + +/** Build a deterministic check whose fake runner writes on-disk artifacts. */ +function fakeCheck(name: string): CheckDefinition { + return { + name, + label: name, + description: `${name} check`, + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: (_cwd, scope) => + `!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`, + gate: () => undefined, + }; +} + +function stubCtx(cwd: string): PygieniumCtx { + return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx; +} + +/** Tracker: records dispatched agent tasks then delegates to the fake runner. */ +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//`). + const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task); + if (m) dispatched.push(m[1] as string); + return fakeAgentRunner(opts); + }; + return { runner, dispatched }; +} + +/** Capture process.stdout.write lines for the duration of `fn`. */ +async function captureStdout(fn: () => Promise): Promise { + const out: string[] = []; + const write = process.stdout.write.bind(process.stdout); + (process.stdout as { write: (chunk: unknown) => boolean }).write = ( + chunk: unknown, + ) => { + out.push(String(chunk).replace(/\r?\n$/, "")); + return true; + }; + try { + await fn(); + } finally { + (process.stdout as { write: (chunk: unknown) => boolean }).write = write; + } + return out; +} + +/** Mark a check as fully complete in the run-state with captured output. */ +function markComplete( + state: Parameters[0], + name: string, + findings: string, + changes: string, +): void { + for (const phaseId of [ + PHASE_RECON, + PHASE_ANALYSIS, + PHASE_FIX, + PHASE_VERIFY, + PHASE_CLEANUP, + ]) { + applyPhaseStatus(state, name, phaseId, "complete"); + } + recordCheckOutput(state, name, { findings, changes }); + markCheckStatus(state, name, "complete"); +} + +describe("status / resume / export (task 13)", () => { + let cwd: string; + let track: ReturnType; + + beforeEach(async () => { + clearChecks(); + track = trackingRunner(); + setAgentRunner(track.runner); + cwd = await mkdtemp(join(tmpdir(), "pygienium-resume-")); + }); + + afterEach(async () => { + resetAgentRunner(); + await rm(cwd, { recursive: true, force: true }); + }); + + /** Seed an interrupted two-check run: `alpha` complete, `beta` pending. */ + async function seedInterruptedRun( + fix = true, + ): Promise<{ state: ReturnType }> { + const state = initRunState(cwd, [ + { name: "alpha", label: "alpha", fix }, + { name: "beta", label: "beta", fix }, + ]); + state.recon = { + complete: true, + path: `${cwd}/.pygienium/recon.json`, + finishedAt: Date.now(), + }; + // Simulate alpha fully complete with on-disk artifacts + captured text. + markComplete( + state, + "alpha", + "# alpha findings\nalpha-scan", + "# alpha changes\nalpha-fix", + ); + const alphaDir = join(canonicalChecksRoot(cwd), "alpha"); + await mkdir(alphaDir, { recursive: true }); + await writeFile( + join(alphaDir, "findings.md"), + "# alpha findings\nalpha-scan\n", + "utf8", + ); + await writeFile( + join(alphaDir, "changes.md"), + "# alpha changes\nalpha-fix\n", + "utf8", + ); + // beta left pending (the interruption). For alpha's recon phase, mark it too. + applyPhaseStatus(state, "beta", PHASE_RECON, "complete"); + await saveRunState(state); + return { state }; + } + + it("formatRunStatus reports accurate per-check progress (alpha complete, beta pending)", async () => { + const { state } = await seedInterruptedRun(); + const lines = formatRunStatus(state); + expect(lines.join("\n")).toContain("pygienium run — in_progress"); + expect(lines.join("\n")).toContain("alpha — complete"); + expect(lines.join("\n")).toContain("beta — pending"); + // Artifacts captured on alpha appear; beta has none. + expect(lines.join("\n")).toContain("findings: 2 line(s)"); + expect(lines.join("\n")).toContain("changes: 2 line(s)"); + }); + + it("/pygienium-status prints the status line list end to end", async () => { + const { state } = await seedInterruptedRun(); + const out = await captureStdout(() => + handleStatusCommand("", stubCtx(cwd)), + ); + expect(out.length).toBeGreaterThan(0); + expect(out.join("\n")).toContain("alpha — complete"); + expect(out.join("\n")).toContain("beta — pending"); + expect(out.join("\n")).toContain("pygienium run — in_progress"); + void state; + }); + + it("/pygienium-status with no run state prints a not-found message", async () => { + const out = await captureStdout(() => + handleStatusCommand("", stubCtx(cwd)), + ); + expect(out.join("\n")).toContain("no run state found"); + }); + + it("/pygienium-resume re-dispatches beta without re-running complete alpha", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + const { state } = await seedInterruptedRun(); + void state; + + const out = await captureStdout(() => + handleResumeCommand("", stubCtx(cwd)), + ); + + // alpha is complete and must NOT be re-dispatched; beta was pending. + expect(track.dispatched).not.toContain("alpha"); + expect(track.dispatched).toContain("beta"); + + // The run should now be complete. + const after = await loadRunState(cwd); + expect(after?.status).toBe("complete"); + expect(after?.checks.alpha.status).toBe("complete"); + expect(after?.checks.beta.status).toBe("complete"); + + // beta's artifacts now exist on disk. + const betaFindings = await readFile( + join(canonicalChecksRoot(cwd), "beta", "findings.md"), + "utf8", + ); + expect(betaFindings).toContain("beta findings"); + + // Summary mentions re-dispatched/skipped counts. + expect(out.join("\n")).toContain("re-dispatched 1"); + expect(out.join("\n")).toContain("skipped 1"); + }); + + it("/pygienium-resume --fresh re-dispatches the complete check too", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + await seedInterruptedRun(); + + await captureStdout(() => handleResumeCommand("--fresh", stubCtx(cwd))); + + expect(track.dispatched).toContain("alpha"); + expect(track.dispatched).toContain("beta"); + const after = await loadRunState(cwd); + expect(after?.status).toBe("complete"); + }); + + it("/pygienium-resume with no state prints nothing-to-resume", async () => { + const out = await captureStdout(() => + handleResumeCommand("", stubCtx(cwd)), + ); + expect(out.join("\n")).toContain("no run state to resume"); + }); + + it("/pygienium-resume on an already-complete run refuses without --fresh", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + const { state } = await seedInterruptedRun(); + // Complete beta too so the whole run is complete. + markComplete( + state, + "beta", + "# beta findings\nbeta-scan", + "# beta changes\nbeta-fix", + ); + await saveRunState(state); + + const out = await captureStdout(() => + handleResumeCommand("", stubCtx(cwd)), + ); + expect(out.join("\n")).toContain("nothing to resume"); + expect(track.dispatched).toHaveLength(0); + }); + + it("/pygienium-export writes a markdown bundle with both checks", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + const { state } = await seedInterruptedRun(); + // Run beta via resume so its artifacts land on disk. + await captureStdout(() => handleResumeCommand("", stubCtx(cwd))); + void state; + + await captureStdout(() => handleExportCommand("", stubCtx(cwd))); + 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)"); + expect(bundle).toContain("# alpha findings"); + expect(bundle).toContain("# beta findings"); + }); + + it("/pygienium-export --check=beta produces a filtered bundle", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + await seedInterruptedRun(); + await captureStdout(() => handleResumeCommand("", stubCtx(cwd))); + + await captureStdout(() => + handleExportCommand("--check=beta", stubCtx(cwd)), + ); + const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); + expect(bundle).toContain("## beta (complete)"); + expect(bundle).not.toContain("## alpha"); + }); + + it("/pygienium-export --status=failed includes only failed checks", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + const { state } = await seedInterruptedRun(); + // Mark alpha failed (artifacts already on disk from the seed); leave beta pending. + markCheckStatus(state, "alpha", "failed", "fake failure"); + await saveRunState(state); + + await captureStdout(() => + handleExportCommand("--status=failed", stubCtx(cwd)), + ); + const bundle = await readFile(join(cwd, "pygienium", "export.md"), "utf8"); + expect(bundle).toContain("## alpha (failed)"); + expect(bundle).not.toContain("## beta"); + }); + + it("/pygienium-export --out=json writes JSON matching renderExportJson", async () => { + registerCheck(fakeCheck("alpha")); + registerCheck(fakeCheck("beta")); + await seedInterruptedRun(); + await captureStdout(() => handleResumeCommand("", stubCtx(cwd))); + + const out = await captureStdout(() => + handleExportCommand("--out=json", stubCtx(cwd)), + ); + expect(out.join("\n")).toContain("export.json"); + const raw = await readFile(join(cwd, "pygienium", "export.json"), "utf8"); + const parsed = JSON.parse(raw) as { + checks: Array<{ name: string; status: string; findings: string }>; + }; + const names = parsed.checks.map((c) => c.name).sort(); + expect(names).toEqual(["alpha", "beta"]); + expect(parsed.checks.find((c) => c.name === "alpha")?.findings).toContain( + "alpha findings", + ); + + // renderExportJson matches the on-disk content for the gathered set. + const state = await loadRunState(cwd); + const entries = await gatherExportEntries(cwd, state); + expect(renderExportJson(state, entries).trim()).toBe(raw.trim()); + }); + + it("parseExportFilters splits comma lists and trims values", () => { + const f = parseExportFilters( + "--check=alpha,beta --status=complete,failed --out=json", + ); + expect(f.check).toEqual(["alpha", "beta"]); + expect(f.status).toEqual(["complete", "failed"]); + 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"), { + recursive: true, + }); + await writeFile( + join(cwd, ".pygienium", "checks", "ghost", "findings.md"), + "# ghost findings\n", + "utf8", + ); + const entries = await gatherExportEntries(cwd, undefined); + const alpha = entries.find((e) => e.name === "alpha"); + expect(alpha).toBeDefined(); + expect(alpha?.findings).toContain("alpha findings"); + expect(alpha?.status).toBe("unknown"); + expect(alpha?.findingsPath).toBe( + join(cwd, "pygienium", "checks", "alpha", "findings.md"), + ); + expect(entries.find((e) => e.name === "ghost")).toBeUndefined(); + }); + + it("renderExportMarkdown includes a 'no artifacts' note for empty checks", async () => { + const entries = [{ name: "ghost", status: "unknown" }]; + const md = renderExportMarkdown(undefined, entries as never); + expect(md).toContain("## ghost (unknown)"); + expect(md).toContain("no findings.md or changes.md on disk"); + // And renderExportJson emits nulls for the empty check. + const json = renderExportJson(undefined, entries as never); + const parsed = JSON.parse(json) as { + checks: Array<{ findings: unknown; changes: unknown }>; + }; + expect(parsed.checks[0]!.findings).toBeNull(); + expect(parsed.checks[0]!.changes).toBeNull(); + }); + + 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")); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bcfed38 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"], + "typeRoots": [ + "/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/@types" + ] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}