Initial commit: pygenium as git submodule

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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.DS_Store
node_modules/
dist/
.pi-lens/
.ralpi/
package-lock.json

173
README.md Normal file
View File

@@ -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 `<cwd>/.pygienium/run-state.json`.
| Command | What it does |
| --- | --- |
| `/pygienium-help` | Print every command, shipped check, and flag. |
| `/pygienium-<check> [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` | `<check>`, `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 `<cwd>/pygienium/checks/<name>/`:
- `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-<name>` appears
automatically.
1. Clone `src/checks/noop.ts``src/checks/<name>.ts`.
2. Edit the `name`, `label`, `description`, the rubric in the scan/fix task
builders, and the `gate` precondition.
3. Keep the trailing `registerCheck(<name>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/<name>.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-<check> ─► 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/<check> agent
│ writes pygienium/checks/<name>/findings.md
├─ fix sub-agent (buildFixTask) ← fixer, only with --fix
│ writes pygienium/checks/<name>/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 `<cwd>/.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-<name>` 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

0
agents/.gitkeep Normal file
View File

101
agents/deep-modules.md Normal file
View File

@@ -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 .*<module>"` 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 `<cwd>/.pygienium/checks/deep-modules/findings.md`).
`findings.md` format — a markdown document:
```markdown
# Deep-modules findings
summary: <N> shallow module(s) flagged of <M> reviewed
## 1. <module-path>
- kind: pass-through-wrapper | one-line-reexport | trivial-getter-class | adapter-layer
- evidence: <one-line quote or description of the shallowness>
- importers: <count> (risk: low if 0, high if >0)
- recommendation: inline-and-remove | consolidate-with-<X> | review-manually
- risk: low | high
```
If the target is clean, write:
```markdown
# Deep-modules findings
summary: 0 shallow module(s) flagged of <M> reviewed
No shallow modules detected.
```
After writing `findings.md`, emit a terse one-line summary as your final message:
```
deep-modules: <N> issue(s) — see <findings-path>
```
# 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.

124
agents/defensive-guards.md Normal file
View File

@@ -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 `<cwd>/.pygienium/checks/defensive-guards/findings.md`).
`findings.md` MUST separate redundant guards from boundary guards. Format:
```markdown
# Defensive-guards findings
summary: <N> redundant guard(s) flagged, <M> boundary guard(s) kept of <K> reviewed
## Redundant (remove)
### 1. <file>:<line>
- kind: redundant-null-check | swallowing-try-catch | rethrow-only-try-catch | error-masking-fallback | defensive-guard-on-validated-input | compatibility-fallback
- evidence: <one-line quote or description>
- reason: <why the type system or upstream already guarantees the invariant>
## Boundary (keep)
### 1. <file>:<line>
- kind: untrusted-input-guard | io-guard | parsing-guard
- evidence: <one-line quote or description>
- reason: <which boundary it protects — IO, parsing, or untrusted input>
```
If the target is clean, write:
```markdown
# Defensive-guards findings
summary: 0 redundant guard(s) flagged, 0 boundary guard(s) kept of <K> reviewed
No redundant defensive guarding detected.
```
After writing `findings.md`, emit a terse one-line summary as your final
message:
```
defensive-guards: <N> redundant, <M> boundary kept — see <findings-path>
```
# Tone
Precise and terse. Always state the declared type when calling a null check
redundant, and always state which boundary a kept guard protects.

43
agents/fixer.md Normal file
View File

@@ -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. <file>:<line> — <what changed> (auto)
2. <file> — <finding> — skipped: <reason> (manual)
```
# Tone
Terse. State the file, the line, and the fix. Do not narrate exploration.

45
agents/scanner.md Normal file
View File

@@ -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
<check-name>: <count> issue(s)
1. [severity: high|med|low] <file>:<line> — <description>
2. ...
```
If the target is clean, emit:
```findings
<check-name>: 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.

55
package.json Normal file
View File

@@ -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"
}
}

0
skills/.gitkeep Normal file
View File

181
src/agent-runner.ts Normal file
View File

@@ -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 <path> <text>` — 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<AgentRunResult>;
/** 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<AgentRunResult> {
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<AgentRunResult> {
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 <path> <text...> — write text to path (relative to cwd); recorded
* !echo <text...> — 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),
};
}
};

150
src/agents.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown> = {};
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<Map<string, AgentDef>> {
const dir = join(extensionRoot(), "agents");
const result = new Map<string, AgentDef>();
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;
}

0
src/checks/.gitkeep Normal file
View File

231
src/checks/comments.ts Normal file
View File

@@ -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 `<cwd>/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: `<cwd>/pygienium/checks/comments/`. Based on `scope.cwd` (the
* project root, always a directory) so the path is valid whether the scan
* target is a single file or a directory. Matches the spec's
* `pygienium/checks/comments/findings.md` relative-path notation.
*/
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
<count> comment smell(s) across <files> file(s).
## <relative-file>
- L<line>: <smell: RESTATE|VERBOSE> — <quote or paraphrase>
- L<line>: KEEP (why) — <one-line reason> # 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
<applied> edit(s) applied; <deferred> deferred for human review.
## Applied
- <relative-file>:<line> — <removed|tightened> comment (auto)
## Needs human review
- <relative-file>:<line> — <reason> (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<string | undefined> {
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<string | undefined> {
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);

287
src/checks/complexity.ts Normal file
View File

@@ -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.
* - 3549 → 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: `<cwd>/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. |
| 3549 | **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)
- **3549** = 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] <file>:<line> — <smell type> — <description>
- <proposed simplification>
## Justifications (3549 band)
For each function kept at 3549 complexity:
- **Function:** <name> at <file>:<line>
- **Score:** <score>
- **Justification:** <why this is critical path and can't be simplified>
\`\`\`
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 3549 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 3549 functions
For each function in the 3549 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
<applied> refactoring(s) applied; <deferred> deferred for human review.
## Applied
- <file>:<line> — <function> split (was <score>, now <scores>)
- <file>:<line> — nested conditionals flattened
- <file>:<line> — trivial wrapper inlined
- <file>:<line> — speculative abstraction removed
## Deferred (needs human review)
- <file>:<line> — <function> — <reason> (manual)
## Justified (kept at 3549)
- <file>:<line> — <function> (<score>) — <justification>
\`\`\`
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<string | undefined> {
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);

1157
src/checks/dead-code.ts Normal file

File diff suppressed because it is too large Load Diff

181
src/checks/deep-modules.ts Normal file
View File

@@ -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
* `<cwd>/pygienium/checks/deep-modules/findings.md` → [with --fix] fix
* sub-agent writes `changes.md`, inlines safe pass-throughs, and lists
* risky consolidations (external importers / public API) for human review.
*
* 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 <path> <text>`) 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 };

View File

@@ -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
* `<cwd>/pygienium/checks/defensive-guards/findings.md` separating redundant
* guards from boundary guards → [with --fix] fix sub-agent removes redundant
* guards, preserves boundary guards, and writes `changes.md` distinguishing
* removed vs kept-with-reason.
*
* 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 <path>
* <text>`) 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 };

121
src/checks/noop.ts Normal file
View File

@@ -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 `<cwd>/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<string | undefined> {
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);

139
src/checks/registry.ts Normal file
View File

@@ -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-<name>` 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> | 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> | 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<string>;
export type BuildFixTask = (
cwd: string,
scope: CheckScope,
findings: string,
) => string | Promise<string>;
/**
* Definition of a single pluggable hygiene check.
*/
export interface CheckDefinition {
/** Lowercase kebab command suffix → `/pygienium-<name>`. 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<string, CheckDefinition>();
/**
* 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();
}

325
src/commands.ts Normal file
View File

@@ -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<void> {
for (const line of buildPygieniumHelpLines()) {
process.stdout.write(`${line}\n`);
}
}
/**
* `/pygienium-<check> [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<void> {
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<void> {
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<void> {
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<void> {
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=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
* — collect every check's `findings.md`/`changes.md` artifacts from
* `pygienium/checks/<name>/` (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<void> {
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>;
},
) => void;
/**
* Auto-register `/pygienium-help` plus one `/pygienium-<check>` 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 };

269
src/export.ts Normal file
View File

@@ -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: `<cwd>/pygienium/checks/<name>/` — the single canonical
* location every shipped check writes to.
*
* Statuses for `--status=` filtering come from the run-state; a check dir
* 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 `<cwd>/pygienium/` (the artifact root). */
export function pygieniumArtifactDir(cwd: string): string {
return join(cwd, PYGIENIUM_ARTIFACT_DIR);
}
/** Resolve `<cwd>/pygienium/checks/`. */
export function canonicalChecksRoot(cwd: string): string {
return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR);
}
/** Resolve `<cwd>/pygienium/export.<format>`. */
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<string | undefined> {
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<string, ExportEntry>,
): Promise<void> {
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 `<cwd>/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<ExportEntry[]> {
const merged = new Map<string, ExportEntry>();
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<ExportResult> {
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) };
}

185
src/help.ts Normal file
View File

@@ -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 0613. The per-check
* command family (`/pygienium-<check>`) 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-<check>` 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: "<check>, 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-<check>` 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-<check> [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;
}

472
src/hygiene-state.ts Normal file
View File

@@ -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.<name>`. 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.<checkName>` → that check's progress state. */
checks: Record<string, CheckState>;
}
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<string, unknown>;
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<HygieneStateFile> {
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-<timestamp>` 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<HygieneRunState> {
const startedAt = new Date().toISOString();
const checks: Record<string, CheckState> = {};
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<HygieneRunState | undefined> {
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<void> {
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<HygieneRunState | undefined> {
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;

76
src/index.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* pygienium — code hygiene extension for pi.
*
* Entry point. Registers `/pygienium-help`, auto-registers one
* `/pygienium-<check>` 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<void> {
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<void> {
// 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");
},
);
}

0
src/modes/.gitkeep Normal file
View File

375
src/modes/all.ts Normal file
View File

@@ -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 `<cwd>/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=<a>,<b>` 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<AllRunOutcome> {
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] <check-label>: <phase>` so every check name is
* surfaced in the strip over the course of the run.
*/
function setAllPhase(
strip: ReturnType<typeof createPhaseStrip>,
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<string, string> = {
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<string> {
const path = allSummaryPath(state.cwd);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, renderAllSummary(state, selected), "utf8");
return path;
}

310
src/modes/check-runner.ts Normal file
View File

@@ -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-<check>` 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<CheckRunOutcome> {
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 `<cwd>/.pygienium/<check>-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<void> {
const dir = stateDir(cwd);
// Best-effort: remove any `*-tmp-<check>` 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(
() => {},
);
}
}
}

83
src/phases.ts Normal file
View File

@@ -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<string, string> = {
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);
}
},
};
}

128
src/recon.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* recon.ts — shared Q0 reconnaissance phase.
*
* Runs once per hygiene run (before any check's analysis phase) and writes a
* project snapshot to `<cwd>/.pygienium/recon.json`. Each check can read this
* snapshot so the recon work isn't repeated per check. The snapshot is minimal
* and dependency-free (git state + source-file inventory) — real checks layer
* their own analysis on top via sub-agents.
*
* @module pygienium/recon
*/
import { exec } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { join } from "node:path";
import { stateDir, RECON_FILENAME } from "./run-state.js";
const execAsync = promisify(exec);
export interface ReconSnapshot {
cwd: string;
createdAt: number;
gitBranch?: string;
gitDirty?: boolean;
/** Count of source files by extension. */
fileCounts: Record<string, number>;
totalSourceFiles: number;
}
/** Source extensions worth inventorying for hygiene checks. */
const SOURCE_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/** Run `git ls-files` when possible to get a clean source inventory. */
async function listSourceFiles(cwd: string): Promise<string[]> {
try {
const { stdout } = await execAsync(
`git -C ${JSON.stringify(cwd)} ls-files --cached --others --exclude-standard`,
{ maxBuffer: 64 * 1024 * 1024 },
);
return stdout
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0)
.filter((l) => {
const dot = l.lastIndexOf(".");
if (dot === -1) return false;
return SOURCE_EXTENSIONS.has(l.slice(dot).toLowerCase());
});
} catch {
/* not a git repo or git unavailable — empty inventory */
return [];
}
}
/** Run the shared recon phase for `cwd`, writing the snapshot if missing. */
export async function runRecon(cwd: string): Promise<ReconSnapshot> {
const dir = stateDir(cwd);
const path = join(dir, RECON_FILENAME);
// Reuse a fresh-enough snapshot (< 5 min) when present.
try {
const raw = await readFile(path, "utf8");
const existing = JSON.parse(raw) as ReconSnapshot;
if (
existing.createdAt &&
Date.now() - existing.createdAt < 5 * 60 * 1000 &&
existing.cwd === cwd
) {
return existing;
}
} catch {
/* no existing snapshot */
}
const files = await listSourceFiles(cwd);
const fileCounts: Record<string, number> = {};
for (const f of files) {
const dot = f.lastIndexOf(".");
const ext = dot === -1 ? "" : f.slice(dot).toLowerCase();
fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
}
let gitBranch: string | undefined;
let gitDirty: boolean | undefined;
try {
const branchOut = await execAsync(
`git -C ${JSON.stringify(cwd)} rev-parse --abbrev-ref HEAD`,
);
gitBranch = branchOut.stdout.trim() || undefined;
const statusOut = await execAsync(
`git -C ${JSON.stringify(cwd)} status --porcelain`,
);
gitDirty = statusOut.stdout.trim().length > 0;
} catch {
/* not a git repo */
}
const snapshot: ReconSnapshot = {
cwd,
createdAt: Date.now(),
gitBranch,
gitDirty,
fileCounts,
totalSourceFiles: files.length,
};
await mkdir(dir, { recursive: true });
await writeFile(path, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
return snapshot;
}

286
src/run-state.ts Normal file
View File

@@ -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 `<cwd>/.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<string, CheckRun>;
}
/** 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<RunState | undefined> {
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<void> {
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";
}

108
src/status.ts Normal file
View File

@@ -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 — <status>
* started: <iso>
* updated: <iso>
* cwd: <cwd>
* recon: complete|pending
*
* checks (N):
* <name> — <status>
* phases: recon:✓ analysis:✓ [fix:✓] verify:✓ cleanup:✓
* findings: <N> line(s)
* changes: <N> line(s)
* error: <msg>
*/
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;
}

50
tasks/01-scaffolding.md Normal file
View File

@@ -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

46
tasks/02-recon.md Normal file
View File

@@ -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

47
tasks/03-agent-runner.md Normal file
View File

@@ -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/<runId>/{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`

45
tasks/04-hygiene-state.md Normal file
View File

@@ -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: { <check>: { 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`

View File

@@ -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=<name>`, `--scope=<path>`
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

View File

@@ -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-<name>` 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-<check>` 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.
- **3549 → 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 3549 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+ / 3549 / 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 3549 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 3549 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 3549 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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-<new>` 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-<name>` 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

42
tasks/README.md Normal file
View File

@@ -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, 3549 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-<check>` 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

25
tests/agents.test.ts Normal file
View File

@@ -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$/);
});
});

View File

@@ -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<void>): Promise<string[]> {
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");
});
});

354
tests/all.test.ts Normal file
View File

@@ -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<void>): Promise<string[]> {
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<typeof markCheckStatus>[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<typeof trackingRunner>;
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 [");
});
});

131
tests/check-runner.test.ts Normal file
View File

@@ -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");
});
});

61
tests/commands.test.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* commands.test.ts — auto-registration wiring.
*
* Asserts that `registerPygieniumCommands` exposes `/pygienium-help`, one
* `/pygienium-<check>` 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-<check> 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<string, string | undefined> = {};
registerPygieniumCommands((name, opts) => {
seen[name] = opts.description;
});
expect(seen["pygienium-smoke"]).toBe("smoke check");
expect(seen["pygienium-help"]).toBeDefined();
});
});

259
tests/comments.test.ts Normal file
View File

@@ -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();
});
});

165
tests/complexity.test.ts Normal file
View File

@@ -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 3549)\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");
});
});

623
tests/dead-code.test.ts Normal file
View File

@@ -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<void> {
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<string, string> };
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 });
}
});
});

171
tests/deep-modules.test.ts Normal file
View File

@@ -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(() => {});
}
});
});

View File

@@ -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<string> {
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<string> {
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");
});
});

View File

@@ -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-<name>` 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);
});
});

101
tests/help.test.ts Normal file
View File

@@ -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-<check> [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");
});
});

69
tests/registry.test.ts Normal file
View File

@@ -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",
]);
});
});

View File

@@ -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/<name>/`).
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<void>): Promise<string[]> {
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<typeof markCheckStatus>[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<typeof trackingRunner>;
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<typeof initRunState> }> {
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"));
});
});

12
tsconfig.json Normal file
View File

@@ -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"]
}