initial import: @mikefreno/omp-pygenium (omp port)

This commit is contained in:
2026-08-10 09:46:09 -04:00
commit a40cdcd9e3
70 changed files with 12624 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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mike Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

214
README.md Normal file
View File

@@ -0,0 +1,214 @@
# Pygienium
Code hygiene for [omp](https://github.com/oh-my-pi) — isolated
sub-agent checks that scan a target, apply fixes, and emit a findings + changes
report. Port of the pi extension (was `~/.pi/agent/extensions/pygienium/`);
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 omp extension under `~/.omp/agent/extensions/pygienium/`.
Omp loads it via `omp.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
```
Omp auto-discovers the extension from this location via the `omp.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).
## Configuration
Pygienium reads its chat-rendering style from omp's `settings.json` (`~/.omp/agent/settings.json`) under a `pygienium` key:
```json
{
"pygienium": {
"chatStyle": "verbose"
}
}
```
| Setting | Default | Values | Description |
| --- | --- | --- | --- |
| `pygienium.chatStyle` | `"verbose"` | `"verbose"` \| `"compact"` | Chat rendering for sub-agent tool calls. **verbose** (piolium-style) streams each tool event live as its own chat line (`[Comments: Scanning] → bash ...` / `← (ok)`). **compact** (ralpi-style) suppresses the per-event stream and shows only the final completion message with its expandable phase tree. |
No entry means `"verbose"` (the default). An unreadable or missing `settings.json` also falls back to `"verbose"`.
## 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 are
auto-discovered 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-todos` | todos | `todos` / `fixer` | Inventory TODO/FIXME markers and stub implementations; with `--fix`, convert silent stubs (placeholder returns, empty bodies) into loud failures — never implementing TODOs or deleting markers. |
## Artifacts
Every check writes its reports under `<cwd>/.pygienium/checks/<name>/` (run
state lives at `<cwd>/.pygienium/run-state.json`):
- `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`).
On first run in a git work tree, pygienium appends `.pygienium/` to the
target repo's `.gitignore` so a run never stages its own state/artifacts into
git (opt out with `--no-gitignore`).
## Adding a check
One file exporting a `check` definition. **No `index.ts` command-wiring changes.**
`index.ts` auto-discovers every `checks/*.ts` (except the registry barrel) at
startup, registers each file's `check` export, and `/pygienium-<name>` appears
automatically.
Check files are **pure data modules** — they export a definition and never
import the registry at runtime. Registration happens in `index.ts` from the
entry's own registry instance, which keeps a single registry even under omp's
extension loader (it cache-busts lazily imported graph modules with an
`?mtime` suffix, which would otherwise split the registry into two module
instances).
1. Create `src/checks/<name>.ts` from the template below.
2. Edit the `name`, `label`, `description`, the rubric in the scan/fix task
builders, and the `gate` precondition.
3. Export it as `check`. Done.
```ts
import type { CheckDefinition, CheckScope } from "./registry.js";
export const check = {
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 satisfies CheckDefinition;
```
Reload omp (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) ← index.ts discovers checks/*.ts `check` exports
/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. A scanned
project can ship its own `agents/*.md` at its root: those are loaded as
overrides (repo agent wins on name collision), so teams can tune prompts or
add project-specific agents without touching the extension.
- **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` discovers every
`checks/*.ts` file, registers its `check` export, then iterates the map and
binds one `/pygienium-<name>` command per entry — adding a check is a file
with a `check` export, nothing else.
- **The footer** (`src/footer.ts`) is the piolium-style pipeline-overview
status strip: a single static line in the TUI footer (via
`ui.setStatus(key, text)`) listing the full ordered pipeline with the cursor
on the current phase and what's to come. For a single check the items are
the phases; for `/pygienium-all` they're the checks (the full todo list),
and the per-check footer is suppressed so two overviews never compete over
the same status slot. The chat widget (`phases.ts`) remains the animated
detail view (spinner + tool-call tree + completion tree); the footer is the
overview — the two never overlap. In print/JSON mode the footer is a no-op.
## 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 ← live chat progress widget + completion-tree helpers
│ ├─ footer.ts ← pipeline-overview status strip (TUI footer)
│ ├─ modes/check-runner.ts ← the per-check phase pipeline
│ └─ checks/ ← one file per check, exporting `check`
│ ├─ registry.ts ← CheckDefinition + registerCheck
│ ├─ comments.ts deep-modules.ts dead-code.ts
│ ├─ defensive-guards.ts
└─ agents/ ← scanner.md fixer.md deep-modules.md defensive-guards.md
```
## License
MIT

0
agents/.gitkeep Normal file
View File

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

@@ -0,0 +1,134 @@
---
name: deep-modules
allowedTools:
- read
- grep
- glob
- 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`, `glob`, `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.
# Scope of inspection
**Only inspect implementation source files.** Do not analyse documentation,
config, type declarations, build output, or dependencies — flagging those is
noise the user cannot act on.
## Inspect (extensions)
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
## File discovery preference
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
2. Otherwise enumerate files yourself, applying the rules above.
3. When using `glob`/`grep`, add prune clauses for the skip directories
(e.g. `find . -type d -name node_modules -prune -o -name '*.ts' -print`).
# 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.

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

@@ -0,0 +1,157 @@
---
name: defensive-guards
allowedTools:
- read
- grep
- glob
- 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`, `glob`, `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.
# Scope of inspection
**Only inspect implementation source files.** Do not analyse documentation,
config, type declarations, build output, or dependencies — flagging those is
noise the user cannot act on.
## Inspect (extensions)
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
## File discovery preference
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
2. Otherwise enumerate files yourself, applying the rules above.
3. When using `glob`/`grep`, add prune clauses for the skip directories
(e.g. `find . -type d -name node_modules -prune -o -name '*.ts' -print`).
# 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
- glob
- 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`/`glob`/`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.

79
agents/scanner.md Normal file
View File

@@ -0,0 +1,79 @@
---
name: scanner
allowedTools:
- read
- grep
- glob
- ls
- bash
- write
---
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`, `glob`, `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.
# Scope of inspection
**Only inspect implementation source files.** Do not analyse documentation,
config, type declarations, build output, or dependencies — flagging those is
noise the user cannot act on.
## Inspect (extensions)
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
## File discovery preference
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
2. Otherwise enumerate files yourself, applying the rules above.
3. When using `glob`/`grep`, add prune clauses for the skip directories
(e.g. `find . -type d -name node_modules -prune -o -name '*.ts' -print`).
# 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.

181
agents/todos.md Normal file
View File

@@ -0,0 +1,181 @@
---
name: todos
allowedTools:
- read
- grep
- glob
- ls
- bash
- write
---
You are the **Pygienium todos scanner** sub-agent — an unfinished-work analyst.
# Your role
You run the "TODOs & stubs" check against a target path. Given a deterministic
pre-scan candidate list, you verify each candidate, drop noise, classify what
remains into **markers**, **silent stubs**, and **loud stubs**, and write a
structured findings report to disk. You do NOT fix anything — that is the
fixer's job. You only inspect, verify, and report.
# The three categories
## 1. Marker — a note that work is unfinished
A `TODO` / `FIXME` / `HACK` / `XXX` / `@todo` token in a comment (or a string
that acts as one). Track these; never delete or implement them.
## 2. Silent stub — the dangerous ones
A function that silently returns a placeholder instead of doing its job. It
compiles, it runs, it hands back a wrong-but-plausible value — so nothing
fails loudly, and callers ship the lie. Detect (among others):
- a body that is only a placeholder return: `return 0;` / `return "";` /
`return null;` / `return [];` / `return {};` / `return None;` / `return nil`
- an empty body: `function foo() {}` (or a brace pair with only comments/ws)
- a Python `pass`-only body: `def f(...): pass` (or `pass` as the only body
statement)
- single-line placeholders: `() => 0`, `function x() { return null; }`
- an obvious hardcoded default with a stub intent ("TODO" marker sitting
directly above, or a comment saying `placeholder` / `stub` / `dummy`)
## 3. Loud stub — already failing loudly (tracked debt)
An explicit not-implemented failure. It is honest debt: the code already
throws/panics, so no caller silently ships a wrong value. Detect (among others):
- `throw new Error("Not implemented")` and variants (`not implemented yet`,
`NotImplementedError`, `NotImplementedException`) — JS/TS, C#, Java
- `raise NotImplementedError` — Python (see noise list for the abstract-method
exception)
- `todo!()` / `todo!("msg")` / `unimplemented!()` — Rust
- `TODO("...")` / `TODO()` — Kotlin
- `panic!("not implemented")` — Go, Rust
Report loud stubs as tracked debt. They are usually fine to keep while the
work is genuinely in progress; the fixer does NOT touch them.
# Noise — drop these without reporting
- `TODO` inside a **string literal** that is not an intent marker
(e.g. `const op = "TODO";`).
- Marker text inside **doc examples or docstrings** that merely illustrate
syntax (`// TODO: not real code` in a comment block that quotes examples).
- **Fixture/generated files**: filenames matching `*.todo.*` / `*.fixture.*`,
snapshots, scaffolds, vendored code (scope rules exclude most already).
- **Correct idioms that look like stubs**:
- `raise NotImplementedError` in an **abstract base class / abstractmethod**
(Python) — that is the idiomatic way to declare an interface method.
- `abstract` methods without bodies (Java, Kotlin, C#) — not stubs.
- a legitimately tiny function that returns a default BY DESIGN
(e.g. a reducer that sums and can naturally return 0, `indexOf` returning
-1, a cache miss returning `null`). Check the surrounding semantics, not
just the shape: a lone placeholder return inside a `catch` for an IO error
is a boundary handler, not a stub.
- Markers in languages/code the project doesn't own (vendored dirs).
# Operating contract
- Operate only within the target path given in the task.
- Use `read`, `grep`, `glob`, `ls` to inspect; cross-check a candidate's
declarations/context before classifying (is the function called anywhere?
is the class abstract? is the `return null` a catch handler?).
- `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.
# Scope of inspection
**Only inspect implementation source files.** Do not analyse documentation,
config, type declarations, build output, or dependencies — flagging those is
noise the user cannot act on.
## Inspect (extensions)
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns)
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
## File discovery preference
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
2. Otherwise enumerate files yourself, applying the rules above.
# Output
Write your full findings report to the **findings path** given in the task
(typically `<cwd>/.pygienium/checks/todos/findings.md`).
`findings.md` MUST begin with a machine-readable summary line, then the three
sections. Format:
```markdown
# TODOs & stubs findings
summary: <M> marker(s), <S> silent stub(s), <L> loud stub(s) | new: <N> | resolved: <R> | reviewed: <K>
## TODO markers
### 1. <file>:<line> — <code>
- marker: TODO | FIXME | HACK | XXX | @todo
- context: <enclosing function or file>
- disposition: track | drop-noise
## Silent stubs (actionable)
### 1. <file>:<line>
- function: <name> (or the file when unnamed)
- stub: <the placeholder body>
- disposition: convert-to-loud | keep (not a stub)
## Loud stubs (already failing loudly — tracked debt)
### 1. <file>:<line>
- kind: throw-not-implemented | raise-NotImplementedError | todo! | TODO() | panic-not-implemented
- disposition: track | drop-noise
```
`new`/`resolved` are computed against the previous run's verified counts when
the task tells you them; when the task does not provide a previous baseline,
report what the deterministic pre-scan computed and mark it `(tentative)`.
`reviewed` is the number of candidates you actually verified.
If the target is clean, write:
```markdown
# TODOs & stubs findings
summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s) | new: 0 | resolved: <prev total> | reviewed: <K>
No TODOs or stubs detected.
```
After writing `findings.md`, emit a terse one-line summary as your final
message in EXACTLY this parseable form:
```
todos: <S> silent stub(s), <L> loud stub(s), <M> marker(s) — see <findings-path>
```
# Tone
Precise and terse. Every silent stub needs one line of evidence (the
placeholder body) and a disposition. Never invent counts — verify before you
write.

404
bun.lock Normal file
View File

@@ -0,0 +1,404 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "pygienium",
"devDependencies": {
"@oh-my-pi/pi-coding-agent": "17.2.12",
"@oh-my-pi/pi-tui": "17.2.12",
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
"@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@oh-my-pi/hashline": ["@oh-my-pi/hashline@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-OdqMAojK8iUZynyNAP2VnbB+EHOHM7ISDSM3f9xHh/sDNSPDi15Xwd53AIdJy+EhYoCyglDIqO9/seHjA6veQQ=="],
"@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-BOyDNq8Hj/CJVc6Wntdo+1yaG/b8CqLcxsBnHefLoC7vO96YoFjt4S7GOpp3cd+ysiuxHnmrWn30kEtwR9fhjQ=="],
"@oh-my-pi/omptype": ["@oh-my-pi/omptype@17.2.12", "", {}, "sha512-Y27VMnPbcUEOpK1DgYuvNIaVnGxEmOcEP2bYPfuAWkx2vc4PKXL0NCRzGLwYMjhwjjd9O/gn8lywZfxDCBytjA=="],
"@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1" } }, "sha512-VIJnZCQyshiZoiWpvhoNVHhUQmptLG41h7XPg2IZfEdDNdDpV26JuZdDMgBr0hIz1L/RfTq8MiRcRuAcasvkOg=="],
"@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-S6LswgyLgQdE5zXADf6NGwrYn1OCYp1WiKxyN6f/1RRv68vbd3y4YSoidzeOYs3kPS/cIiv3dw3LHaCrwpam/Q=="],
"@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-6VycCR0ShSzbVfOa7cdeqVtxHL2IPpJXFePKaQPhQ8bOTiyE/TaUm1uIa1i4doqPTz/k6vUEs+a57cZwoUP97A=="],
"@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@17.2.12", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/hashline": "17.2.12", "@oh-my-pi/omp-stats": "17.2.12", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-agent-core": "17.2.12", "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-mnemopi": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-tui": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "mupdf": "^1.28.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-+q+W4fyNQQ7xAKiN0mmOisWDDtKO0R/ZctTSsKqR4ulN3K1zfQ9HwiTxtg7HJHn5fwCy+X3BmUG72FatNUN8IA=="],
"@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-lKjEexuFC/piaNnb3MzJKu8rolXwkjfv0M90UBUiNPFxKbpKHWU5np5aUAOYkT+fwKoQPQ7Tr95mJx0l32S0OQ=="],
"@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@17.2.12", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "17.2.12", "@oh-my-pi/pi-natives-darwin-x64": "17.2.12", "@oh-my-pi/pi-natives-linux-arm64": "17.2.12", "@oh-my-pi/pi-natives-linux-x64": "17.2.12", "@oh-my-pi/pi-natives-win32-x64": "17.2.12" } }, "sha512-MVZq0UrrA7mk6uMKrgjnAhfrDj+58yEuu0VeVd3JuvneMjcX1duIzOdyqRG13S+/XzGOvGkk164dM6D/CwxteQ=="],
"@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@17.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5iullmWEWmGRjwgo7Kw6Bub3KOXahkUX3jZfw65bM9LjFRceSwoLTt6Yh0RnXXP8hRzZnhU3b/o73peWBlpLzw=="],
"@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@17.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-qNc2q02d5etsOdxmO4CNwCcEvgfWS9Ya/fuq2hDKJgp3BFeKQCzYDap0HhC/qkAfj2vpjsTyKc/PsPMMMLGytg=="],
"@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@17.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HTc6efuPNoYSzHu7Zk4gl3RtQzWWprVA4x0ep0gq5B7mBS6OwgnbH5otLrXSFeQ2UgRyV/nddz1O02ek4Vg8MA=="],
"@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@17.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jg9xzfqpcBDGRVnW+98z/eQPIfcnOYujd1bLW0PnJh6A0ykSqjCvt9PpN0wrlqr70WazOOURqoodN2eibIyOOQ=="],
"@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@17.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb/EF7Ug7SX/YYJofgKR5Kvw0I+AqGpEfmI5inIsvFzygqlA4QgMETbAR1s08sK+AYZqPqRorBpbGNas85XFig=="],
"@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-X4IKQG3alzw3ogUewxoPBaZImReEBF7P2Xu5xN5E47lfMHR0eNJ6cQnQmk7sHjfNEAclKCJDYqwzgOfRdh9GEA=="],
"@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12" } }, "sha512-iYNV2y6RW9tzYaBa7hX5pzb23+zzNLF/Bs4bK7LAO2Qc7OZl5gw1lp9YOUX4/VZOO7A20KrXnZNwZVCnpPKy1A=="],
"@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@17.2.12", "", {}, "sha512-UdQ0VP3gExd+jXgy8epCSZ+PTW9pkFu8FoomywmqEPj/JKjt1SqZhIiN8N51HkQCC5WJryT5AedzL/Qr6OID8w=="],
"@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-kWYY/7tgIAnXAiYogC6K1GLU2G26QXTr2vDJGq1mxCMk3/GixoNzrFwzK8w6NmKYdQpO/Sa1SRw9sypYT+v1Bw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="],
"@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="],
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="],
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="],
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="],
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="],
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="],
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="],
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
"adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
"devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="],
"mupdf": ["mupdf@1.28.0", "", {}, "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
"onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="],
"onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="],
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
"puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QcYKzyrTzGSx6aKCD6hUODgRS1LetqfG57Z/+i5LCyfMlrgCvDc1lRcl9cdB+TozBsLha9QwLTlI0vmDcf5JKg=="],
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-6RGeis9K9gV/UQWOgd6Rf3iqXr2/YsBQswxHaCR4hrYkHfEIpHMfFmRWLt6nJJCOWgYW2xFxEd9yzjrafAV/Pw=="],
"sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw=="],
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.4", "", { "os": "linux", "cpu": "x64" }, "sha512-WZh5NCkGPFHHpYSd78iN4OnmxQeSTGyt9uZskH+im/NFHQ7elQ7B0sLzCMeRpvJxiIKvd9C6WxIJ4hYaxClfsQ=="],
"sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-/JbPjldrfNv+t+uIS3MlkuhfIf5l3FHUGkRC2oRXgjRqOaVmEyP3vLlQ7dTa4J7raG5oB8c3GoPjuSWSqT9GOQ=="],
"sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.4", "", { "os": "win32", "cpu": "x64" }, "sha512-R0PWby1VxC14TDZPq7GcfSyXSY6SAFO8Y4JwdCdqouFmeXkZ1L7Is9m98C9KxQ0dN7ZtDzhAmE/43FUs/elXRQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
"@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="],
"@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
"cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@mikefreno/omp-pygenium",
"version": "0.1.0",
"description": "Code hygiene extension for omp (port of the pi extension) — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
"keywords": [
"omp",
"omp-extension",
"code-hygiene",
"lint",
"subagents",
"pygienium"
],
"license": "MIT",
"type": "module",
"engines": {
"bun": ">=1.3.14"
},
"omp": {
"extensions": [
"./src/index.ts"
]
},
"devDependencies": {
"@oh-my-pi/pi-coding-agent": "17.2.12",
"@oh-my-pi/pi-tui": "17.2.12",
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
}
}

0
skills/.gitkeep Normal file
View File

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

@@ -0,0 +1,256 @@
/**
* 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 "@oh-my-pi/pi-coding-agent";
import { loadAgents, extensionRoot, 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;
/**
* Live callback forwarding raw {@link AgentSessionEvent}s from the
* sub-agent session. The `pygienium-stream` forwarder in `index.ts`
* turns tool_execution_start/end + assistant turns into chat messages.
*/
onEvent?: (event: AgentSessionEvent) => void;
}
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 agents = await loadAgents({ cwd: opts.cwd });
const agent = opts.agent ?? agents.get(opts.agentName);
if (!agent) {
const names = [...agents.keys()];
return {
ok: false,
text: "",
error:
`Unknown agent definition: "${opts.agentName}". ` +
`Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` +
`Searched ${extensionRoot()}/agents/ (extension) and ${opts.cwd}/agents/ (project-local). ` +
`Add agents/${opts.agentName}.md to either location.`,
};
}
// 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. The specifier
// is static by intent (dynamic-import exception: module is intentionally
// excluded from the import-time graph to keep fake-runner tests SDK-free).
const { createAgentSession, AgentRegistry, SessionManager } = await import(
"@oh-my-pi/pi-coding-agent"
);
const tools = opts.allowedTools ??
agent.allowedTools ?? ["read", "bash", "grep", "glob"];
const { session } = await createAgentSession({
cwd: opts.cwd,
toolNames: tools,
// `tools` is an allowlist, not a request list.
restrictToolNames: true,
sessionManager: SessionManager.inMemory(opts.cwd),
// Replace the fully rendered default prompt with the agent body.
systemPrompt: agent.systemPrompt,
// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.
disableExtensionDiscovery: true,
skills: [],
promptTemplates: [],
rules: [],
contextFiles: [],
enableMCP: false,
enableLsp: false,
// Private registry: the host session owns the process-global "Main"
// identity, so a per-run registry keeps these in-process workers
// disjoint from the main agent.
agentRegistry: new AgentRegistry(),
});
try {
let text = "";
let stopReason: string | undefined;
let errorMessage: string | undefined;
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
text += event.assistantMessageEvent.delta;
}
if (event.type === "message_end") {
// Capture the full assistant text from the finalized message —
// models that don't stream text_delta (or truncate) still surface
// their output here. Prefer the streamed text when non-empty.
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
};
if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage;
if (message.role === "assistant") {
const full = extractAssistantText(message.content).trim();
if (full && !text.trim()) text = full;
}
}
// Forward the stream-driving events to the chat forwarder; it turns
// each into its own `pygienium-stream` message (see index.ts).
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end" ||
event.type === "message_end"
) {
opts.onEvent?.(event);
}
});
await session.prompt(opts.task, { expandPromptTemplates: false });
// Ensure the agent has fully settled (tool calls may still be in-flight
// after prompt() resolves; piolium's runner calls this for the same
// reason).
await session.agent.waitForIdle();
unsubscribe();
// Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true.
if (errorMessage) {
return { ok: false, text, error: errorMessage };
}
if (!text.trim() && stopReason === "error") {
return {
ok: false,
text,
error: "sub-agent session ended in error with no output.",
};
}
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 */
}
}
}
/**
* Extract joined text from an assistant message's content blocks.
* Mirrors piolium's `extractAssistantText`.
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((c) =>
c && typeof c === "object" && (c as { type?: string }).type === "text"
? [(c as { text?: string }).text ?? ""]
: [],
)
.join("");
}
/**
* 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),
};
}
};

193
src/agents.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* 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
* - glob
* ---
* 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 agent definitions: the extension's `agents/*.md` baseline, plus any
* repo-local `agents/*.md` at `<cwd>/agents/` (when `cwd` is given). Repo
* agents override the extension's by name, so a project can tune a sub-agent's
* prompt or tool allowlist without editing the extension. Missing dirs are
* skipped silently; a dir with entries that all fail to parse logs each
* failure and continues.
*/
export async function loadAgents(opts?: {
cwd?: string;
}): Promise<Map<string, AgentDef>> {
const extRoot = extensionRoot();
const result = new Map<string, AgentDef>();
// Extension-shipped agents are the baseline.
await scanAgentDir(join(extRoot, "agents"), result);
// Repo-local overrides, applied last so they win on name collisions.
if (opts?.cwd) {
await scanAgentDir(join(opts.cwd, "agents"), result, true);
}
return result;
}
/**
* Load every `agents/*.md` in `dir` into `result` (later dirs win on name
* collisions). `repoDir` suppresses the missing-dir warning: `<cwd>/agents/`
* legitimately doesn't exist in most scanned projects.
*/
async function scanAgentDir(
dir: string,
result: Map<string, AgentDef>,
repoDir = false,
): Promise<void> {
let entries: string[];
try {
entries = await readdir(dir);
} catch (err) {
if (!repoDir) {
console.error(
`[pygienium] agent loading: could not read agents dir at ${dir}: ${err instanceof Error ? err.message : String(err)}`,
);
}
return;
}
for (const entry of entries) {
if (!entry.endsWith(".md")) continue;
const sourcePath = join(dir, entry);
try {
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,
});
} catch (err) {
console.error(
`[pygienium] agent loading: failed to load ${entry} from ${dir}: ${err instanceof Error ? err.message : String(err)}`,
);
// Continue loading other agents even if one fails.
}
}
if (!repoDir && result.size === 0 && entries.length > 0) {
console.error(
`[pygienium] agent loading: found ${entries.length} entries in ${dir} but loaded 0 agents`,
);
}
}

0
src/checks/.gitkeep Normal file
View File

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

@@ -0,0 +1,233 @@
/**
* 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 type { CheckDefinition, CheckScope } from "./registry.js";
import { scopeRulesMarkdown } from "./scope.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}\`
${scopeRulesMarkdown()}
## 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 check = {
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 satisfies CheckDefinition;
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
// that exports `check` and registers it — a new check is still one file.

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

@@ -0,0 +1,317 @@
/**
* 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 type { CheckDefinition, CheckScope } from "./registry.js";
import { scopeRulesMarkdown } from "./scope.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 findingsFile = findingsPath(scope);
return `# Task: excessive complexity scan
You are running the **complexity** hygiene check.
## Target
- Scan target: \`${scope.target}\`
${scopeRulesMarkdown()}
## 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}`;
}
}
/**
* 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. Without this, a sub-agent that returns empty/ok without writing
* its report would be stamped `complete` — a false positive. Mirrors
* {@link commentsVerify} / {@link todosVerify}.
*/
async function complexityVerify(
scope: CheckScope,
): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope);
try {
await stat(f);
} catch {
return `complexity verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope);
try {
await stat(c);
} catch {
return `complexity verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/** The excessive complexity check definition. */
export const check = {
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,
verify: complexityVerify,
} as const satisfies CheckDefinition;
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
// that exports `check` and registers it — a new check is still one file.

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,185 @@
/**
* 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 type { CheckDefinition, CheckScope } from "./registry.js";
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */
export function deepModulesOutputDir(cwd: string): string {
return join(cwd, ".pygienium", "checks", "deep-modules");
}
/** `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");
}
/**
* 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) {
if (isScopeSource(entry)) {
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;
}
/**
* Verify hook: confirms the check actually produced its artifacts (mirrors
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
* returns ok with no output — which would otherwise be a false `complete`.
*/
async function deepModulesVerify(
scope: CheckScope,
): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope.cwd);
try {
await stat(f);
} catch {
return `deep-modules verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope.cwd);
try {
await stat(c);
} catch {
return `deep-modules verify: expected changes.md at ${c} after --fix, none found.`;
}
}
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.`,
"",
scopeRulesMarkdown(),
"",
`# 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. */
export const check: 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,
verify: deepModulesVerify,
};
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
// that exports `check` and registers it.

View File

@@ -0,0 +1,215 @@
/**
* 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 type { CheckDefinition, CheckScope } from "./registry.js";
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
/** Output directory for this check's persistent reports. */
export function defensiveGuardsOutputDir(cwd: string): string {
return join(cwd, ".pygienium", "checks", "defensive-guards");
}
/** `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");
}
/**
* 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) {
if (isScopeSource(entry)) {
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;
}
/**
* Verify hook: confirms the check actually produced its artifacts (mirrors
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
* returns ok with no output — which would otherwise be a false `complete`.
*/
async function defensiveGuardsVerify(
scope: CheckScope,
): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope.cwd);
try {
await stat(f);
} catch {
return `defensive-guards verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope.cwd);
try {
await stat(c);
} catch {
return `defensive-guards verify: expected changes.md at ${c} after --fix, none found.`;
}
}
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.`,
``,
scopeRulesMarkdown(),
``,
`# 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. */
export const check: 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,
verify: defensiveGuardsVerify,
};
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
// that exports `check` and registers it.

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

145
src/checks/scope.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* scope.ts — canonical source-of-truth for what pygienium checks inspect.
*
* Every check (recon, dead-code, deep-modules, defensive-guards, complexity,
* comments) and every scanner agent prompt shares these definitions so the
* "only inspect implementation code" rule is stated once, not copy-pasted
* across four files that drift apart.
*
* @module pygienium/checks/scope
*/
/**
* Implementation-code file extensions pygienium inspects.
*
* Deliberately excludes documentation (`.md`, `.txt`, `.rst`), config
* (`.json`, `.yaml`, `.yml`, `.toml`, `.env`, `.ini`), type declarations
* (`.d.ts`), styles (`.css`, `.scss`), markup (`.html`, `.svg`), and lock
* files. These are not implementation code — a comments or complexity check
* flagging prose in a `.md` or a key in `package.json` is noise.
*/
export const SCOPE_EXTENSIONS: ReadonlySet<string> = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/**
* Directories pygienium never descends into — build output, dependency caches,
* tooling state, and VCS metadata. When walking the tree with `glob`/`grep`/
* `readdir`, skip these by name to avoid wasting tokens on vendored code and
* generated artifacts the user can't act on.
*/
export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
"node_modules",
".git",
".hg",
".svn",
"dist",
"build",
"out",
"coverage",
".next",
".nuxt",
".turbo",
".svelte-kit",
"__pycache__",
".venv",
"venv",
"vendor",
".cache",
".pygienium",
".ralpi",
".idea",
".vscode",
]);
/**
* Compound extensions (checked after the simple extension lookup) that should
* be treated as non-source even though their tail extension appears in
* {@link SCOPE_EXTENSIONS}. The primary case: `.d.ts` type declarations are
* generated contracts, not implementation code.
*/
export const SCOPE_EXCLUDE_SUFFIXES: ReadonlySet<string> = new Set([
".d.ts",
".d.mts",
".d.cts",
".min.js",
".min.mjs",
".min.cjs",
]);
/**
* Test if a file path is implementation source pygienium should inspect.
*
* Returns `true` when the extension is in {@link SCOPE_EXTENSIONS} AND the
* path does not end with a {@link SCOPE_EXCLUDE_SUFFIXES} pattern (e.g.
* `.d.ts`).
*/
export function isScopeSource(path: string): boolean {
const lower = path.toLowerCase();
for (const suffix of SCOPE_EXCLUDE_SUFFIXES) {
if (lower.endsWith(suffix)) return false;
}
const dot = lower.lastIndexOf(".");
if (dot === -1) return false;
return SCOPE_EXTENSIONS.has(lower.slice(dot));
}
/**
* Markdown section injected into every scan task string so the sub-agent knows
* exactly what to inspect and what to skip — stated once here, not copy-pasted
* into each task builder.
*
* Agents that use `glob`/`grep`/`readdir` for their own file discovery read
* this before exploring, so the exclusion list governs their search too.
*/
export function scopeRulesMarkdown(): string {
const extensions = [...SCOPE_EXTENSIONS]
.sort((a, b) => a.localeCompare(b))
.join("`, `");
const excludeDirs = [...SCOPE_EXCLUDE_DIRS]
.sort((a, b) => a.localeCompare(b))
.join("`, `");
return `## Scope of inspection
**Only inspect implementation source files.** Do not analyse documentation,
config, type declarations, build output, or dependencies — flagging those is
noise the user cannot act on.
### Inspect (extensions)
\`${extensions}\`
### Skip (directory names — never descend into)
\`${excludeDirs}\`
### Skip (file patterns)
- Type declarations: \`*.d.ts\`, \`*.d.mts\`, \`*.d.cts\` — generated contracts, not impl
- Minified bundles: \`*.min.js\`, \`*.min.mjs\`, \`*.min.cjs\` — generated, not editable
- Docs: \`*.md\`, \`*.txt\`, \`*.rst\` — prose, not code
- Config: \`*.json\`, \`*.yaml\`, \`*.yml\`, \`*.toml\`, \`*.ini\`, \`*.env\`
- Styles/markup: \`*.css\`, \`*.scss\`, \`*.html\`, \`*.svg\`
- Lock files: \`package-lock.json\`, \`*.lock\`, \`bun.lockb\`
### File discovery preference
1. **Prefer the recon snapshot** at \`<cwd>/.pygienium/recon.json\` when it
exists — it is the authoritative source inventory (git-tracked, extension-
filtered, exclude-aware). Read its \`fileCounts\` for the quick picture.
2. Otherwise enumerate files yourself, applying the rules above.
3. When using \`glob\`/\`grep\`, add ignore patterns for the skip directories
(e.g. exclude \`**/node_modules/**\` from your scans).
`;
}

558
src/checks/todos.ts Normal file
View File

@@ -0,0 +1,558 @@
/**
* checks/todos.ts — "TODOs & stubs" check.
*
* Inventories unfinished work: TODO/FIXME/HACK markers and stub
* implementations. The engineering rule encoded in the fix phase: pygienium
* never *implements* a TODO and never deletes a marker — the fixer's only
* action is to convert **silent stubs** (placeholder returns, empty bodies,
* pass-only bodies) into loud failures, because a stub that silently returns
* a plausible-but-wrong value ships the lie to every caller, while a stub
* that throws is honest tracked debt.
*
* Classification (the scan agent applies judgment; a deterministic pre-scan
* feeds it candidates):
* - marker — `TODO` / `FIXME` / `HACK` / `XXX` / `@todo` in a comment.
* - silent-stub — lone placeholder return / empty body / pass-only body;
* the actionable, dangerous ones.
* - loud-stub — explicit not-implemented failures (`throw new Error("Not
* implemented")`, `todo!()`, `raise NotImplementedError`, `TODO("...")`);
* already failing loudly → tracked debt, fixer never touches them.
* - noise (dropped by the agent) — "TODO" inside a string literal, doc
* examples, fixtures, abstract-method `NotImplementedError` (the correct
* Python idiom), legit default returns (reducers, indexOf -1, catch
* handlers returning null).
*
* Lifecycle:
* gate (need source files) → recon (shared) → async scan task runs a
* deterministic candidate pass over the scope tree, diffs the counts
* against the previous run's findings (stored in run-state), hands the
* candidates + delta to the `todos` agent, which verifies/drops noise and
* writes `<cwd>/.pygienium/checks/todos/findings.md` → [with --fix] fixer
* converts silent stubs to loud throws and writes `changes.md`.
*
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-todos`.
*
* @module pygienium/checks/todos
*/
import { readdirSync } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import { loadRunState } from "../run-state.js";
import type { CheckDefinition, CheckScope } from "./registry.js";
import {
isScopeSource,
SCOPE_EXCLUDE_DIRS,
scopeRulesMarkdown,
} from "./scope.js";
/** Output directory for this check's persistent reports. */
export function todosOutputDir(cwd: string): string {
return join(cwd, ".pygienium", "checks", "todos");
}
/** `findings.md` path for this check. */
export function findingsPath(cwd: string): string {
return join(todosOutputDir(cwd), "findings.md");
}
/** `changes.md` path for this check. */
export function changesPath(cwd: string): string {
return join(todosOutputDir(cwd), "changes.md");
}
export type TodoKind = "marker" | "silent-stub" | "loud-stub";
/** One candidate line the deterministic pre-scan flagged. */
export interface TodoCandidate {
/** Absolute path of the file. */
path: string;
/** 1-based line number. */
line: number;
kind: TodoKind;
/** Matched token (e.g. `TODO`, `Not implemented`, `empty-body`). */
snippet: string;
/** The trimmed line content. */
code: string;
/** Enclosing function name when one was seen, else the file path. */
context: string;
}
/**
* Marker tokens: an unfinished-work note in a comment. Case-insensitive;
* `@todo\b` (not `@todos`) and `\bHACK\b` (not `hacking`).
*/
const MARKER_RE = /\b(?:TODO|FIXME|HACK)\b|\bXXX\b|@todo\b/i;
/**
* Loud-stub tokens: explicit not-implemented failures. `not implemented`
* covers `throw new Error("Not implemented")` and `panic!("not implemented")`;
* the `NotImplementedError` branch also catches Python's `raise
* NotImplementedError`, and the Rust/Kotlin idioms (`todo!()`, `TODO("...")`)
* are matched explicitly.
*/
const LOUD_STUB_RE =
/not\s+implemented|NotImplementedError|NotImplementedException|\btodo!\s*\(|unimplemented!\s*\(|\bTODO\s*\(/i;
/** A lone placeholder return (`return 0;` / `return "";` / `return null;` …). */
const PLACEHOLDER_RETURN_RE =
/^\s*return\s+(?:null|undefined|0(?:\.0)?|""|''|\[\]|\{\}|None|nil|false)\s*;?\s*(?:\/\/.*)?$/;
/** Function/arrow header lines worth inspecting for a stub body. */
const FN_HEADER_RE =
/\b(?:function|def|func|fun|fn)\s+\w+|(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/;
/** Single-line placeholder body: `function x() { return 0; }`. */
const SINGLE_PLACEHOLDER_BODY_RE =
/\{\s*return\s+(?:null|undefined|0(?:\.0)?|""|''|\[\]|\{\}|None|nil|false)\s*;?\s*\}/;
/** Single-line arrow expression body: `const f = () => 0;`. */
const ARROW_PLACEHOLDER_RE =
/=>\s*(?:null|undefined|0(?:\.0)?|""|''|\[\]|\{\}|false)\s*;?\s*$/;
/** Empty single-line body: `function notify(): void {}`. */
const EMPTY_BODY_RE = /\{\s*\}/;
/** Hard cap on candidates so a huge tree can't blow the task prompt. */
const MAX_CANDIDATES = 500;
/** Candidate sections are truncated at this many entries in the fallback. */
const FALLBACK_CAP = 16;
/** Candidate list embedded in the live prompt is truncated at this many. */
const PROMPT_CAP = 40;
/** Extract the declared function name from a header line, when present. */
function headerName(line: string): string | undefined {
const decl =
/(?:function|def|func|fun|fn|class)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.exec(
line,
);
return decl?.[1];
}
/** Index of the next non-blank line at or after `start`, else `undefined`. */
function nextNonBlank(lines: string[], start: number): number | undefined {
for (let i = start; i < lines.length; i++) {
if ((lines[i] as string).trim()) return i;
}
return undefined;
}
/**
* Walk the target collecting implementation-source files, honouring
* {@link SCOPE_EXCLUDE_DIRS} and {@link isScopeSource} (same rules as
* dead-code's walker).
*/
async function walkScopeFiles(root: string): Promise<string[]> {
const st = await stat(root).catch(() => undefined);
if (!st) return [];
if (st.isFile()) return isScopeSource(root) ? [root] : [];
const out: string[] = [];
const stack = [root];
while (stack.length > 0) {
const dir = stack.pop() as string;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (SCOPE_EXCLUDE_DIRS.has(entry.name)) continue;
stack.push(full);
} else if (entry.isFile() && isScopeSource(entry.name)) {
out.push(full);
}
}
}
return out.sort();
}
/**
* Deterministic pre-scan: flag marker/loud-stub/silent-stub candidates across
* the target's scope tree. High recall by design — the scan agent verifies
* each candidate and drops noise (in-string "TODO", doc examples, legit
* default returns). Pure function of the tree: unit-testable without agents.
*/
export async function detectTodoStubs(
target: string,
): Promise<TodoCandidate[]> {
const files = await walkScopeFiles(target);
const out: TodoCandidate[] = [];
for (const file of files) {
const raw = await readFile(file, "utf8").catch(() => "");
const lines = raw.split("\n");
let lastFn = "";
for (let i = 0; i < lines.length; i++) {
const code = lines[i] as string;
const trimmed = code.trim();
if (!trimmed) continue;
const loud = LOUD_STUB_RE.exec(trimmed);
if (loud) {
out.push({
path: file,
line: i + 1,
kind: "loud-stub",
snippet: loud[0].slice(0, 40),
code: trimmed,
context: lastFn,
});
continue;
}
const marker = MARKER_RE.exec(trimmed);
if (marker) {
out.push({
path: file,
line: i + 1,
kind: "marker",
snippet: marker[0],
code: trimmed,
context: lastFn,
});
continue;
}
if (FN_HEADER_RE.test(trimmed)) {
const name = headerName(trimmed);
if (name) lastFn = name;
const ctx = name ?? lastFn;
// Single-line stub forms.
if (
SINGLE_PLACEHOLDER_BODY_RE.test(trimmed) ||
ARROW_PLACEHOLDER_RE.test(trimmed)
) {
out.push({
path: file,
line: i + 1,
kind: "silent-stub",
snippet: "placeholder-return",
code: trimmed,
context: ctx,
});
continue;
}
if (
EMPTY_BODY_RE.test(trimmed) &&
!/\b(?:return|throw)\b/.test(trimmed)
) {
out.push({
path: file,
line: i + 1,
kind: "silent-stub",
snippet: "empty-body",
code: trimmed,
context: ctx,
});
continue;
}
// Multi-line forms: inspect the first non-blank body line.
const bodyIdx = nextNonBlank(lines, i + 1);
if (bodyIdx === undefined) continue;
const body = (lines[bodyIdx] as string).trim();
if (body === "}") {
out.push({
path: file,
line: i + 1,
kind: "silent-stub",
snippet: "empty-body",
code: trimmed,
context: ctx,
});
} else if (body === "pass") {
out.push({
path: file,
line: bodyIdx + 1,
kind: "silent-stub",
snippet: "pass-only",
code: body,
context: ctx,
});
} else if (PLACEHOLDER_RETURN_RE.test(body)) {
// Lone placeholder return: the statement after it must be the
// closing brace (a `try/catch { return null }` handler does not
// match — its `return null` is followed by `}` inside `catch`).
const after = nextNonBlank(lines, bodyIdx + 1);
if (after !== undefined && (lines[after] as string).trim() === "}") {
out.push({
path: file,
line: bodyIdx + 1,
kind: "silent-stub",
snippet: "placeholder-return",
code: body,
context: ctx,
});
}
}
}
}
if (out.length >= MAX_CANDIDATES) break;
}
return out;
}
/** Counts by kind across a candidate list. */
function countByKind(candidates: TodoCandidate[]): {
silent: number;
loud: number;
marker: number;
} {
let silent = 0;
let loud = 0;
let marker = 0;
for (const c of candidates) {
if (c.kind === "silent-stub") silent++;
else if (c.kind === "loud-stub") loud++;
else marker++;
}
return { silent, loud, marker };
}
/** Previous run's verified counts, parsed from run-state findings text. */
const PRIOR_SUMMARY_RE =
/todos:\s*(\d+)\s+silent\s+stub\(s\)?,\s*(\d+)\s+loud\s+stub\(s\)?,\s*(\d+)\s+marker\(s\)?/;
/**
* Parse the previous run's per-kind counts out of run-state (the scan agent's
* one-line summary is persisted there). `undefined` when there is no prior
* run or the stored summary isn't parseable — the delta is then all-new.
*/
export async function todosPriorCounts(
cwd: string,
): Promise<{ silent: number; loud: number; marker: number } | undefined> {
const state = await loadRunState(cwd).catch(() => undefined);
const stored = state?.checks["todos"]?.findings;
if (!stored) return undefined;
const m = PRIOR_SUMMARY_RE.exec(stored);
if (!m) return undefined;
return {
silent: Number(m[1]),
loud: Number(m[2]),
marker: Number(m[3]),
};
}
/**
* Render the deterministic report the fake runner writes (and the real agent
* uses as a shape reference): one pipe-separated line, sections per kind.
*/
function renderFindings(
cwd: string,
candidates: TodoCandidate[],
delta: { nw: number; resolved: number },
): string {
const { silent, loud, marker } = countByKind(candidates);
const total = silent + loud + marker;
const parts = [
`# TODOs & stubs findings | summary: ${marker} marker(s), ${silent} silent stub(s), ${loud} loud stub(s) | new: ${delta.nw} | resolved: ${delta.resolved} | reviewed: ${candidates.length}`,
];
if (total === 0) {
parts.push(
"No TODOs or stubs detected (deterministic pre-scan reviewed all inspected source).",
);
return parts.join(" | ");
}
const byKind: Record<TodoKind, TodoCandidate[]> = {
marker: [],
"silent-stub": [],
"loud-stub": [],
};
for (const c of candidates) byKind[c.kind].push(c);
const dump = (title: string, list: TodoCandidate[]): void => {
parts.push(`## ${title}`);
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
parts.push(
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line}${c.code} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
);
});
if (list.length > FALLBACK_CAP) {
parts.push(`... and ${list.length - FALLBACK_CAP} more (truncated)`);
}
};
dump("TODO markers", byKind.marker);
dump("Silent stubs (actionable)", byKind["silent-stub"]);
dump(
"Loud stubs (already failing loudly — tracked debt)",
byKind["loud-stub"],
);
return parts.join(" | ");
}
/**
* 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 todosGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
if (isScopeSource(entry)) {
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. Deterministic pre-scan (async, like dead-code's) finds
* candidates and diffs them against the previous run's counts; the `todos`
* agent verifies each candidate, drops noise, and writes the verified 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 to the same location.
*/
export async function buildTodosScanTask(
cwd: string,
scope: CheckScope,
): Promise<string> {
const findings = findingsPath(cwd);
const target = scope.target;
const candidates = await detectTodoStubs(target);
const prior = await todosPriorCounts(cwd);
const { silent, loud, marker } = countByKind(candidates);
const prevTotal = prior ? prior.silent + prior.loud + prior.marker : 0;
const total = silent + loud + marker;
const nw = Math.max(0, total - prevTotal);
const resolved = Math.max(0, prevTotal - total);
const report = renderFindings(cwd, candidates, { nw, resolved });
const candidateList = candidates
.slice(0, PROMPT_CAP)
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${c.code}`);
if (candidates.length > PROMPT_CAP) {
candidateList.push(
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,
);
}
return [
`Inspect the target "${target}" (cwd: ${cwd}) for unfinished work: TODO markers and stub implementations.`,
`A deterministic pre-scan found ${candidates.length} candidate line(s). Verify each candidate:`,
...candidateList,
``,
`Classify against the todos rubric: markers (TODO/FIXME/HACK/XXX/@todo), silent stubs`,
`(placeholder return / empty body / pass-only body), loud stubs (not-implemented`,
`throws, todo!(), TODO("..."), raise NotImplementedError).`,
`Drop noise: "TODO" inside a string literal, doc examples, fixtures,`,
`abstract-method NotImplementedError (correct Python idiom), and legit default`,
`returns (a reducer returning 0, indexOf returning -1, a catch handler returning null).`,
`Previous run reported: ${
prior
? `${prior.silent} silent, ${prior.loud} loud, ${prior.marker} marker`
: "none (first run)"
}.`,
`Write your full verified report to: ${findings}.`,
`findings.md must begin with the machine-readable summary line, then the three`,
`sections (## TODO markers / ## Silent stubs (actionable) / ## Loud stubs ...),`,
`each entry with file:line, evidence, and disposition. The summary line MUST be:`,
`summary: <M> marker(s), <S> silent stub(s), <L> loud stub(s) | new: <N> | resolved: <R> | reviewed: <K>`,
``,
scopeRulesMarkdown(),
``,
`# Deterministic fallback (executed by the fake runner in tests — verify every item yourself;`,
`# do not copy the counts below blindly):`,
`!write ${findings} ${report}`,
`!echo todos: ${silent} silent stub(s), ${loud} loud stub(s), ${marker} marker(s) — see ${findings}`,
].join("\n");
}
/**
* Build the fix task. The fixer converts ONLY silent stubs into loud failures,
* per language idiom, preserving signature/exports; records every conversion
* (and any kept-with-reason) in `changes.md`. Markers are never implemented
* or deleted; loud stubs are never touched.
*/
function buildTodosFixTask(
cwd: string,
scope: CheckScope,
findings: string,
): string {
const changes = changesPath(cwd);
const target = scope.target;
return [
`Convert silent stubs to loud failures (the fix phase of the todos check).`,
`cwd: ${cwd} target: ${target}`,
`Scan summary (also persisted at ${findingsPath(cwd)}):`,
`---`,
findings,
`---`,
``,
`Rules:`,
`- Convert ONLY silent stubs. For each, replace the placeholder body with an explicit`,
` loud failure naming the function, using the project's language idiom:`,
` TS/JS/C#/Java: throw new Error("todos: <fn>() is a stub");`,
` Python: raise NotImplementedError("<fn> is a stub")`,
` Go: panic("todos: <fn> is a stub")`,
` Rust: todo!("<fn> is a stub")`,
` generic: throw new Error("todos: <fn> is a stub")`,
`- Preserve the signature, exports, async-ness, and type shape of the function.`,
`- Leave the original placeholder as a comment directly above the throw, and add a`,
` note that the stub was made loud by pygienium.`,
`- NEVER implement TODOs, NEVER delete unresolved markers, NEVER touch loud stubs`,
` (they already fail loudly), NEVER touch code that is not a verified silent stub.`,
`- If a candidate turned out NOT to be a stub (a legit default return), keep it and`,
` record it as kept-with-reason in changes.md.`,
`- Apply the smallest possible diff; preserve tests and conventions.`,
`- Write changes.md to ${changes} listing every conversion (auto) or keep (reason).`,
``,
`# Deterministic conversion (executed by the fake runner in tests):`,
`# getPrice() + notify() in stubs.ts converted from silent placeholders to loud throws.`,
`# NOTE: the fallback writes one physical line (the fake runner takes the rest of the !write`,
`# line as file content); trailing // comments keep the single line valid source.`,
`!write ${target}/stubs.ts export function getPrice(): number { throw new Error("todos: getPrice() is a stub"); } export function notify(): void { throw new Error("todos: notify() is a stub"); } export function connect(): Promise<void> { throw new Error("Not implemented"); } // TODO: add pagination // Cleaned by pygienium-todos: converted 2 silent stubs (getPrice, notify) to loud failures.`,
`!write ${changes} # TODOs & stubs changes | summary: 2 silent stub(s) converted to loud, 0 kept | ## Converted to loud (auto) | 1. ${target}/stubs.ts:3 — getPrice() — body was \`return 0\` placeholder; now throws \`todos: getPrice() is a stub\` | 2. ${target}/stubs.ts:6 — notify() — body was empty; now throws \`todos: notify() is a stub\` | ## Kept (with reason) | (none)`,
`!echo todos: 2 silent stub(s) converted — see ${changes}`,
].join("\n");
}
/**
* 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. Returns an error string to fail verify, or `undefined` to pass.
*/
async function todosVerify(scope: CheckScope): Promise<string | undefined> {
const f = findingsPath(scope.cwd);
try {
await stat(f);
} catch {
return `todos verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope.cwd);
try {
await stat(c);
} catch {
return `todos verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/** The todos check definition; registers itself on import. */
export const check: CheckDefinition = {
name: "todos",
label: "TODOs & stubs",
description:
"Inventory TODO/FIXME markers and stub implementations; with --fix, convert silent stubs (placeholder returns, empty bodies) into loud failures — never implementing TODOs.",
agentName: "todos",
phaseId: "analysis",
buildScanTask: buildTodosScanTask,
buildFixTask: buildTodosFixTask,
gate: todosGate,
verify: todosVerify,
};
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
// that exports `check` and registers it.

405
src/commands.ts Normal file
View File

@@ -0,0 +1,405 @@
/**
* 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`,
* `hasUI`, `ui`) so they are unit-testable without a full pi runtime — tests
* construct a minimal `PygieniumCtx`.
*
* @module pygienium/commands
*/
import type {
AgentSessionEvent,
ExtensionCommandContext,
} from "@oh-my-pi/pi-coding-agent";
import { resolve } from "node:path";
import {
getAllChecks,
getCheck,
type CheckDefinition,
} from "./checks/registry.js";
import {
runCheck,
parseCheckArgs,
type CheckRunOutcome,
} 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,
isCheckTerminal,
shouldRunOnResume,
} from "./run-state.js";
import { formatRunStatus } from "./status.js";
import {
exportRun,
parseExportFilters,
exportBundlePath,
type ExportFilters,
} from "./export.js";
import type { SendChatMessage } from "./phases.js";
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
export type PygieniumCtx = Pick<
ExtensionCommandContext,
"cwd" | "hasUI" | "ui"
> & {
/** Optional callback to post messages to the chat window. */
sendChatMessage?: SendChatMessage;
/** Optional callback forwarding raw sub-agent events to the chat stream. */
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
/** Optional callback to emit synthetic progress lines (verify/cleanup/
* recon phases that don't run agents) into the chat stream. */
sendPhaseLine?: (phase: string, text: string) => void;
};
function print(ctx: PygieniumCtx, line: string): void {
// With a dialog-capable UI, also surface the first line as a notification.
if (ctx.hasUI && 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 leading flag tokens (`--fix`, `--fresh`, `--no-gitignore`) from args,
* returning the remainder (the positional `[path]`).
*/
function splitFlags(args: string): {
fix: boolean;
fresh: boolean;
rest: string;
noGitignore: boolean;
} {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fix = tokens.includes("--fix");
const fresh = tokens.includes("--fresh");
const noGitignore = tokens.includes("--no-gitignore");
const rest = tokens
.filter((t) => t !== "--fix" && t !== "--fresh" && t !== "--no-gitignore")
.join(" ");
return { fix, fresh, rest, noGitignore };
}
/**
* 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; gitignore: boolean } {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
const fresh = tokens.includes("--fresh");
const gitignore = !tokens.includes("--no-gitignore");
const positional = tokens.find((t) => !t.startsWith("--"));
const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd;
return { cwd, fresh, gitignore };
}
/** `/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.
*/
/**
* `/pygienium-<check> [path] [--fix] [--fresh] [--no-gitignore]` — the
* per-check command handler. Exported so `index.ts` binds one per registered
* `CheckDefinition` and tests invoke it directly with a stub context.
*
* Resume-aware (parity with `/pygienium-all` and `/pygienium-resume`): a check
* already terminal (`complete`/`skipped`) is NOT re-dispatched unless `--fresh`
* resets its run-state entry. A failed/pending/in-progress check is re-run from
* analysis — recovering the exact failure mode the MagniFluo run exposed
* (sub-agent returns ok with no output → verify now fails loudly → resume
* re-runs the analysis and the artifact lands).
*/
export async function handleCheckCommand(
check: CheckDefinition,
args: string,
ctx: PygieniumCtx,
): Promise<void> {
const { fix, fresh, rest, noGitignore } = splitFlags(args);
const target = resolveCwd(rest, ctx.cwd);
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
// Resume semantics: skip an already-terminal check unless --fresh forces a
// reset. This mirrors the all/resume skip predicate so running the same
// per-check command again after a success is a no-op (use --fresh to
// re-scan deliberately).
const existing = await loadRunState(ctx.cwd);
const entry = existing?.checks[check.name];
if (entry && isCheckTerminal(entry) && !fresh) {
print(
ctx,
`pygienium ${check.label}: already ${entry.status} (use --fresh to re-run)`,
);
return;
}
// Reset the entry when --fresh, or when the fix flag changed since the prior
// run: the phase skeleton (fix phase present only with --fix) must match
// the requested mode, otherwise re-running analysis wouldn't record a fix
// phase entry on a scan-only→--fix transition (and vice versa).
if (existing && entry && (fresh || entry.fix !== fix)) {
resetCheckEntry(existing, check.name, fix);
await saveRunState(existing);
}
const outcome = await runCheck({
check,
cwd: ctx.cwd,
scope: { ...scope, cwd: ctx.cwd, target, fix },
existingState: existing,
ui: ctx.ui,
hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
gitignore: !noGitignore,
});
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print(
ctx,
`pygienium ${check.label}: ${outcome.status}${outcome.error ? `${outcome.error}` : ""}${giNote}`,
);
}
/**
* `/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,
gitignore: parsed.gitignore,
only: parsed.only,
ui: ctx.ui,
hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
});
const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore"
: "";
print(
ctx,
`pygienium: all-run ${outcome.status}${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})${giNote}`,
);
}
/** `/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, gitignore } = 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;
let giAppended = false;
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: CheckRunOutcome = await runCheck({
check: def,
cwd,
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
ui: ctx.ui,
hasUI: ctx.hasUI,
existingState: state,
sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
gitignore,
});
state = outcome.state;
giAppended = giAppended || outcome.gitignoreAppended === true;
ran++;
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
}
markRunStatus(state, reconcileRunStatus(state));
await saveRunState(state);
const giNote = giAppended ? " · .pygienium/ added to .gitignore" : "";
print(
ctx,
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})${giNote}`,
);
}
/**
* `/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>/`, 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,
});
}

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

222
src/footer.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* footer.ts — pipeline-overview status widget in the TUI footer area.
*
* Renders the full ordered pipeline (phases for a single check, or checks for
* `/pygienium-all`) as a **multi-line `belowEditor` widget** (via
* `ExtensionUIContext.setWidget`): one bulleted, color-themed line per step,
* with the live step marked `●` and completed/failed/skipped/pending steps
* carrying their terminal glyph. This is the pygienium analogue of piolium's
* `phase-status-strip` widget — the footer-side *overview* view.
*
* The chat-side *detail* view is the live tool-event stream (see
* `pygienium-stream` in `index.ts`): each `tool_execution_start/end` and
* assistant turn is posted as its own chat message. The two never overlap:
* the footer owns the `belowEditor` slot, the stream owns the chat history.
*
* Presentation-only and mode-aware: in print/JSON mode (no TUI) the footer is
* a no-op — stdout progress stays owned by the phase strip — so it can be
* driven unconditionally from the runners.
*
* Generic on a list of {@link FooterItem}s so both a single-check run
* (items = phases) and a `/pygienium-all` run (items = checks) reuse one
* renderer: the runner decides the granularity, the footer only draws it.
*
* @module pygienium/footer
*/
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
/** Widget key pygienium writes its pipeline-overview widget under. */
export const FOOTER_STATUS_KEY = "pygienium";
/** Status of a single pipeline item, carried into the footer line. */
export type ItemStatus =
| "pending"
| "running"
| "complete"
| "failed"
| "skipped";
/**
* Marker per item status, mirroring piolium's phase-status-strip glyphs:
* `·`=pending (to come), `●`=running (cursor), `✓`/`✗`/`↷`=terminal.
* Kept short so a multi-phase pipeline fits one widget column.
*/
export const FOOTER_MARKER: Record<ItemStatus, string> = {
pending: "·",
running: "●",
complete: "✓",
failed: "✗",
skipped: "↷",
};
/** Theme subset the footer renders against (`ui.theme` satisfies this). */
export interface FooterTheme {
fg(color: string, text: string): string;
}
/** One labelled step in the pipeline overview. */
export interface FooterItem {
/** Short label (a phase label like "Scanning" or a check label). */
label: string;
/** Current status of this step. */
status: ItemStatus;
}
export interface PipelineFooterOptions {
/** UI context; writes go to `ui.setWidget` (belowEditor). */
ui?: ExtensionUIContext;
/** Dialog-capable UI available (TUI / RPC). When false, footer is a no-op. */
hasUI?: boolean;
/** Widget key (defaults to {@link FOOTER_STATUS_KEY}). */
statusKey?: string;
/**
* Whether to render the footer (default true). Set false when an outer
* run (e.g. `/pygienium-all`) already owns the footer, so two overviews
* never compete over the same widget slot — mirrors the phase strip's
* `widget` flag.
*/
enabled?: boolean;
}
export interface PipelineFooter {
/** Declare the full pipeline at once; `cursor` (if given) marks `running`. */
setPipeline(title: string, items: FooterItem[], cursor?: number): void;
/** Set a single item's status; optionally move the cursor too. */
setItem(index: number, status: ItemStatus, cursor?: number): void;
/** Advance the cursor to an item (marks it `running`). */
setCursor(index: number): void;
/** Snapshot of current items (for tests — no UI required). */
getItems(): FooterItem[];
/** Snapshot of the last rendered title (for tests). */
getTitle(): string;
/** Clear the footer widget. Safe to call repeatedly. */
done(): void;
}
/** Map an item status to a piolium-style theme color token. */
export function footerColor(status: ItemStatus, isCurrent: boolean): string {
if (status === "complete") return "success";
if (status === "failed") return "error";
if (status === "skipped") return "warning";
if (status === "running" || isCurrent) return "accent";
return "dim";
}
/** Width for the per-step index prefix (`1.` … `12.`). */
function indexWidth(total: number): number {
return total >= 10 ? 2 : 1;
}
/**
* Render the pipeline as a list of bulleted, color-themed lines — the pure
* core of the footer widget, exported so tests assert on layout without a
* TUI. Each line is `• <marker> <n>. <label>`, themed by status color.
* Mirrors piolium's `renderPhaseStatusList`.
*/
export function renderFooterList(
items: readonly FooterItem[],
cursor: number,
theme: FooterTheme,
): string[] {
const width = indexWidth(items.length);
return items.map((item, index) => {
const marker = FOOTER_MARKER[item.status] ?? "?";
const isCurrent = index === cursor;
const color = footerColor(item.status, isCurrent);
const order = String(index + 1).padStart(width, "0");
const text = `${marker} ${order}. ${item.label}`;
return theme.fg(color, text);
});
}
/**
* Create a pipeline-overview footer. The handle is cheap and stateful; callers
* keep one per run and call {@link PipelineFooter.done} when terminal.
*/
export function createPipelineFooter(
opts: PipelineFooterOptions,
): PipelineFooter {
const key = opts.statusKey ?? FOOTER_STATUS_KEY;
const ui = opts.ui;
const enabled = opts.enabled ?? true;
const hasUI = opts.hasUI ?? false;
let title = "";
let items: FooterItem[] = [];
let cursor = -1;
/** Build and push the widget lines, if a UI is available. */
function render(): void {
if (!enabled || !hasUI || !ui?.setWidget) return;
// `ui.theme` is present on a real ExtensionUIContext; fall back to a
// plain-text renderer only when a stub omits it (tests / headless RPC).
const theme: FooterTheme =
ui.theme && typeof ui.theme.fg === "function"
? ui.theme
: { fg: (_c: string, t: string) => t };
const lines: string[] = [];
if (title) lines.push(theme.fg("dim", title));
lines.push(...renderFooterList(items, cursor, theme));
ui.setWidget(key, lines, { placement: "belowEditor" });
}
return {
setPipeline(t, its, cur) {
title = t;
items = its.map((it) => ({ ...it }));
cursor = cur ?? -1;
if (cursor >= 0 && items[cursor]) {
items[cursor]!.status = "running";
}
render();
},
setItem(index, status, cur) {
if (index < 0 || index >= items.length) return;
items[index]!.status = status;
if (cur !== undefined) cursor = cur;
render();
},
setCursor(index) {
if (index < 0 || index >= items.length) return;
// A previously-running item that didn't reach a terminal status
// (e.g. the runner jumped phases on a skip) demotes back to
// pending so it reads as "to come" rather than stalled.
if (cursor >= 0 && items[cursor]?.status === "running") {
items[cursor]!.status = "pending";
}
cursor = index;
items[index]!.status = "running";
render();
},
getItems() {
return items.map((it) => ({ ...it }));
},
getTitle() {
return title;
},
done() {
if (!enabled || !hasUI || !ui?.setWidget) return;
ui.setWidget(key, undefined, { placement: "belowEditor" });
items = [];
cursor = -1;
title = "";
},
};
}
/**
* Build footer items for a single check's phase list. The runner feeds it the
* ordered phase ids (recon → analysis → [fix] → verify → cleanup) and the
* shared {@link PHASE_LABELS}-shaped map; the footer draws them as the
* pipeline overview.
*/
export function footerPhaseItems(
phaseIds: readonly string[],
labels: Record<string, string>,
): FooterItem[] {
return phaseIds.map((id) => ({
label: labels[id] ?? id,
status: "pending" as ItemStatus,
}));
}

191
src/help.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* 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: "<check>, 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: "--no-gitignore",
scope: "<check>, all, resume",
description:
"Don't add `.pygienium/` to the target repo's .gitignore (added by default so runs never stage their own output).",
},
{
name: "--check=",
scope: "export",
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] [--fresh]",
description:
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report. Resume-aware: a completed/skipped check is skipped unless --fresh re-runs it.",
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;
}

454
src/index.ts Normal file
View File

@@ -0,0 +1,454 @@
/**
* 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, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
MessageRenderer,
SessionStartEvent,
} from "@oh-my-pi/pi-coding-agent";
import { Box, Text } from "@oh-my-pi/pi-tui";
import { registerCheck, type CheckDefinition } from "./checks/registry.js";
import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js";
import {
type SendChatMessage,
type CheckCompletionDetails,
PHASE_GLYPH,
} from "./phases.js";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
/** Startup hint mirrored after piolium's convention. */
export const PYGIENIUM_STARTUP_HINT =
"Pygienium loaded. Run /pygienium-help for available checks and flags.";
/** Custom message type for the live tool-event stream (mirrors piolium-stream). */
export const PYGIENIUM_STREAM = "pygienium-stream";
/** Chat rendering style ("verbose" = per-event stream, "compact" = completion-only). */
export type ChatStyle = "verbose" | "compact";
/**
* Read the pygienium chat style from omp's settings.json.
* Looks for `pygienium.chatStyle` under `~/.omp/agent/settings.json`.
* Defaults to "verbose" (piolium-style per-event stream) when absent or unreadable.
*/
async function readChatStyle(): Promise<ChatStyle> {
try {
const raw = await readFile(
join(homedir(), ".omp", "agent", "settings.json"),
"utf8",
);
const settings = JSON.parse(raw) as {
pygienium?: { chatStyle?: string };
};
const style = settings.pygienium?.chatStyle;
return style === "compact" ? "compact" : "verbose";
} catch {
return "verbose";
}
}
/**
* Local structural supertypes for the progress-message renderer params.
* These avoid relying on contextual typing from `MessageRenderer` (which
* requires resolving pi's internal `Theme`/`CustomMessage` cross-references
* via node_modules — not always available in dev environments). Using
* `(...args: any[])` for theme methods makes the type bidirectionally
* compatible under strict function types, so the cast to `MessageRenderer`
* in `registerMessageRenderer` is valid.
*/
interface ProgressMessage {
content: unknown;
details?: unknown;
}
interface ProgressRenderOptions {
expanded: boolean;
}
interface ProgressTheme {
fg: (...args: any[]) => string;
bg: (...args: any[]) => string;
}
export { buildPygieniumHelpLines } from "./help.js";
type StreamLineKind = "tool-start" | "tool-end" | "tool-error" | "assistant";
interface StreamLineDetails {
kind: StreamLineKind;
phase: string;
toolName?: string;
body?: string;
}
/** Pick the one useful argument from a tool-call's args (path/command/…). */
function summarizeArgs(args: unknown): string {
if (!args || typeof args !== "object") return "";
const obj = args as Record<string, unknown>;
const pickKey = [
"file_path",
"path",
"command",
"pattern",
"query",
"url",
].find((k) => typeof obj[k] === "string");
if (pickKey) {
const value = String(obj[pickKey]);
return value.length > 120 ? `${value.slice(0, 117)}` : value;
}
const json = JSON.stringify(obj);
return json.length > 120 ? `${json.slice(0, 117)}` : json;
}
/** Extract joined text from an assistant message's content blocks. */
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(c) =>
c && typeof c === "object" && (c as { type?: string }).type === "text",
)
.map((c) => (c as { text?: string }).text ?? "")
.join("");
}
/** Collapse a tool result down to a single short line. */
function summarizeToolResult(result: unknown): string {
if (result == null) return "";
if (typeof result === "string") return result;
if (typeof result === "number" || typeof result === "boolean")
return String(result);
if (Array.isArray(result)) {
return result
.map((item) => {
if (typeof item === "string") return item;
if (
item &&
typeof item === "object" &&
"text" in (item as Record<string, unknown>)
)
return String((item as { text?: unknown }).text ?? "");
return JSON.stringify(item);
})
.join("\n");
}
if (typeof result !== "object") return "";
const obj = result as Record<string, unknown>;
// MCP CallToolResult shape: { content: [{ type: "text", text: "..." }, ...] }
if (Array.isArray(obj.content)) {
const unwrapped = summarizeToolResult(obj.content);
if (unwrapped) return unwrapped;
}
const preferKey = ["stdout", "output", "text", "content", "result"].find(
(k) => typeof obj[k] === "string" && (obj[k] as string).length > 0,
);
if (preferKey) return obj[preferKey] as string;
try {
return JSON.stringify(obj);
} catch {
return "";
}
}
/** Collapse whitespace and cap a line at `max` chars with an ellipsis. */
function compactLine(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= max) return collapsed;
return `${collapsed.slice(0, max - 1)}`;
}
/**
* Chat stream forwarder: turns raw sub-agent events into `pygienium-stream`
* messages (one chat line per start/end/assistant turn), the pygienium
* analogue of piolium's `makeAgentEventForwarder`. Also exposes
* `sendPhaseLine` for synthetic progress lines during non-agent phases
* (verify, cleanup, recon) so the chat doesn't go silent.
*/
interface StreamForwarder {
/** Forward a raw sub-agent event tagged with a phase label. */
onAgentEvent(phase: string, event: AgentSessionEvent): void;
/** Emit a synthetic progress line (e.g. "checking artifacts…"). */
sendPhaseLine(phase: string, text: string): void;
}
function makeStreamForwarder(pi: ExtensionAPI): StreamForwarder {
const send = (details: StreamLineDetails, fallback: string) => {
pi.sendMessage<StreamLineDetails>({
customType: PYGIENIUM_STREAM,
content: fallback,
display: true,
details,
});
};
const onAgentEvent = (phase: string, event: AgentSessionEvent): void => {
switch (event.type) {
case "tool_execution_start": {
const body = summarizeArgs(event.args);
send(
{ kind: "tool-start", phase, toolName: event.toolName, body },
`[${phase}] → ${event.toolName}${body ? ` ${body}` : ""}`,
);
return;
}
case "tool_execution_end": {
const body = compactLine(summarizeToolResult(event.result), 200);
const kind: StreamLineKind = event.isError ? "tool-error" : "tool-end";
const marker = event.isError ? "✗" : "←";
send(
{ kind, phase, toolName: event.toolName, body },
`[${phase}] ${marker} ${event.toolName}${body ? ` ${body}` : ""}`,
);
return;
}
case "message_end": {
const message = event.message as {
role?: string;
content?: unknown;
};
if (message.role !== "assistant") return;
const text = extractAssistantText(message.content).trim();
if (!text) return;
const head = compactLine(text, 240);
send({ kind: "assistant", phase, body: head }, `[${phase}] ${head}`);
return;
}
}
};
const sendPhaseLine = (phase: string, text: string): void => {
send({ kind: "assistant", phase, body: text }, `[${phase}] ${text}`);
};
return { onAgentEvent, sendPhaseLine };
}
/**
* Import every `checks/*.ts` module (except the registry barrel) and register
* each file's `check` export. Check files are pure data modules — they no
* longer self-register on import, because omp's extension loader cache-busts
* lazily imported graph modules with an `?mtime` suffix, which would split
* the registry into two module instances (static entry-graph imports resolve
* to the clean file, lazy imports to the `?mtime` copy). Registering here —
* from the entry's own registry instance — keeps one registry and still makes
* adding a check a drop-a-file operation.
*/
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;
const mod = (await import(`./checks/${entry}`)) as {
check?: CheckDefinition;
};
if (mod.check) registerCheck(mod.check);
}
}
/**
* Create a callback to send completion messages to the main chat window.
*/
function makeSendChatMessage(pi: ExtensionAPI): SendChatMessage {
return (content: string, meta?: Record<string, unknown>) => {
pi.sendMessage({
customType: "pygienium-progress",
content,
display: true,
details: {
phase: meta?.phase || "info",
...meta,
},
});
};
}
export default async function pygieniumExtension(
pi: ExtensionAPI,
): Promise<void> {
// Self-register every shipped check before wiring commands.
await loadCheckModules();
const sendChatMessage = makeSendChatMessage(pi);
// Register custom message renderer for pygienium progress messages.
// Renders an expandable phase tree: collapsed shows the header + a hint,
// expanded (Ctrl+O) shows every phase with its status and notes.
const progressRenderer = (
message: ProgressMessage,
{ expanded }: ProgressRenderOptions,
theme: ProgressTheme,
) => {
const details = message.details as
| {
phase?: string;
completion?: CheckCompletionDetails;
error?: string;
}
| undefined;
const lines: string[] = [];
lines.push(String(message.content));
const completion = details?.completion;
if (completion) {
if (expanded) {
// Expanded: show every phase with status glyph + branch.
const phases = completion.phases;
for (let i = 0; i < phases.length; i++) {
const entry = phases[i];
if (!entry) continue;
const isLast = i === phases.length - 1;
const branch = isLast ? " └── " : " ├── ";
const glyph = PHASE_GLYPH[entry.status] ?? "?";
const tag = theme.fg("accent", entry.label);
const note = entry.note ? ` · ${entry.note}` : "";
lines.push(`${branch}${glyph} ${tag}${note}`);
}
if (completion.error) {
lines.push(theme.fg("error", ` error: ${completion.error}`));
}
} else {
// Collapsed: summary line + hint.
const done = completion.phases.filter(
(p) => p.status === "complete",
).length;
const total = completion.phases.length;
const hint = completion.error
? theme.fg("error", ` ├── ${completion.error}`)
: theme.fg(
"dim",
` ├── ${done}/${total} phases · press Ctrl+O for detail`,
);
lines.push(hint);
}
} else if (!expanded) {
lines.push(theme.fg("dim", " ├── press Ctrl+O for detail"));
}
const text = lines.join("\n");
const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
box.addChild(new Text(text, 0, 0));
return box;
};
pi.registerMessageRenderer(
"pygienium-progress",
progressRenderer as MessageRenderer,
);
// Live tool-event stream renderer: one chat line per tool start/end and
// assistant turn, indented so ends nest under their start. Mirrors
// piolium's PIOLIUM_STREAM renderer.
pi.registerMessageRenderer<StreamLineDetails>(
PYGIENIUM_STREAM,
(message, _options, theme) => {
const details = message.details;
if (!details || typeof details !== "object") {
const fallback =
typeof message.content === "string" ? message.content : "";
return new Text(theme.fg("muted", fallback), 0, 0);
}
const { kind, phase, toolName, body } = details;
// Indent end/error lines so they visually nest under the matching
// start line. The pad width matches the "[phase] " prefix.
const phaseTag = theme.fg("accent", `[${phase}]`);
const indent = " ".repeat(phase.length + 3);
let line: string;
switch (kind) {
case "tool-start": {
const arrow = theme.fg("muted", "→");
const name = theme.fg("toolTitle", theme.bold(toolName ?? ""));
const args = body ? ` ${theme.fg("muted", body)}` : "";
line = `${phaseTag} ${arrow} ${name}${args}`;
break;
}
case "tool-end": {
const arrow = theme.fg("success", "←");
const result = body
? ` ${theme.fg("dim", body)}`
: ` ${theme.fg("dim", "(ok)")}`;
line = `${indent}${arrow}${result}`;
break;
}
case "tool-error": {
const marker = theme.fg("error", "✗");
const result = body
? ` ${theme.fg("error", body)}`
: ` ${theme.fg("error", "failed")}`;
line = `${indent}${marker}${result}`;
break;
}
case "assistant":
line = `${phaseTag} ${theme.fg("muted", body ?? "")}`;
break;
default:
line =
typeof message.content === "string"
? theme.fg("muted", message.content)
: "";
}
return new Text(line, 0, 0);
},
);
const forwarder = makeStreamForwarder(pi);
// Read chat style from pi's settings.json. When "compact", suppress the
// per-event stream + synthetic phase lines so only the completion message
// (with its expandable phase tree) shows — the ralpi-style rendering.
const chatStyle = await readChatStyle();
const verbose = chatStyle === "verbose";
const onAgentEvent = verbose ? forwarder.onAgentEvent : undefined;
const sendPhaseLine = verbose ? forwarder.sendPhaseLine : undefined;
registerPygieniumCommands((name, options) => {
pi.registerCommand(name, {
description: options.description,
handler: (args: string, ctx: ExtensionCommandContext) => {
// Create PygieniumCtx with chat + stream callbacks.
const pygieniumCtx: PygieniumCtx = {
cwd: ctx.cwd,
hasUI: ctx.hasUI,
ui: ctx.ui,
sendChatMessage,
onAgentEvent,
sendPhaseLine,
};
return options.handler(args, pygieniumCtx);
},
});
});
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

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

@@ -0,0 +1,463 @@
/**
* 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 "@oh-my-pi/pi-coding-agent";
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
import { runCheck } from "./check-runner.js";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
import { createPipelineFooter, type ItemStatus } from "../footer.js";
import { runRecon } from "../recon.js";
import {
applyPhaseStatus,
ensureRunStateIgnored,
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;
/**
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
* state/artifacts (default true). Set false with `--no-gitignore`.
*/
gitignore?: boolean;
/** UI context (optional; null in print mode). */
ui?: ExtensionUIContext;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
/** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage;
/** Optional callback forwarding raw sub-agent events to the chat stream. */
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
/** Optional callback to emit synthetic progress lines during non-agent
* phases (verify, cleanup, recon) into the chat stream. */
sendPhaseLine?: (phase: string, text: string) => void;
}
/** 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[];
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
gitignoreAppended?: boolean;
}
/**
* 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;
gitignore: boolean;
only: string[];
} {
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
let fix = false;
let fresh = false;
let gitignore = true;
let target = cwd;
const only: string[] = [];
for (const tok of tokens) {
if (tok === "--fix") {
fix = true;
} else if (tok === "--fresh") {
fresh = true;
} else if (tok === "--no-gitignore") {
gitignore = false;
} else if (tok.startsWith("--only=")) {
for (const name of tok.slice("--only=".length).split(",")) {
const trimmed = name.trim();
if (trimmed) only.push(trimmed);
}
} else if (!tok.startsWith("--")) {
target = tok;
}
}
return { target: resolve(cwd, target), fix, fresh, gitignore, 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("");
// Errors are terminal-run facts: a check that completed via a later
// resume/retry must not surface a stale error under a "complete"
// status (markCheckStatus clears it on success; this guard covers
// hand-edited or legacy state files too).
if (entry?.error && entry?.status !== "complete") {
lines.push(`- error: ${entry.error}`);
}
// Artifact paths (canonical root: `.pygienium/checks/<name>/`).
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;
// Keep pygienium's own output out of the scanned repo's git index unless
// the caller opted out with --no-gitignore.
const gitignoreAppended =
opts.gitignore === false ? false : await ensureRunStateIgnored(cwd);
const selected = selectChecks(opts.only);
if (selected.length === 0) {
// `--only` selected nothing (or no checks registered). Still produce a
// 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: [],
gitignoreAppended,
};
}
// --- 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 logging all check names -----------------------
const strip = createPhaseStrip({
ui: opts.ui,
hasUI,
});
setAllPhase(strip, selected, 0, "recon");
// Pipeline-overview footer: a multi-line widget listing every check with the
// cursor on the active one and what's to come. Detail lives in the chat
// (per-check completion trees); the footer is the overview. Inner
// `runCheck` calls pass `footer: false` so two overviews never compete.
const allFooter = createPipelineFooter({
ui: opts.ui,
hasUI,
statusKey: "pygienium-all",
});
const allItems = selected.map((c) => ({
label: c.label,
status: "pending" as ItemStatus,
}));
allFooter.setPipeline("pygienium: all", allItems, 0);
const footerSettle = (name: string, status: ItemStatus): void => {
const idx = selected.findIndex((c) => c.name === name);
if (idx >= 0) allFooter.setItem(idx, status);
};
// --- 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");
allFooter.setCursor(i);
const entry = state.checks[check.name];
// Resumability: skip terminal checks unless --fresh.
if (entry && !shouldRunOnResume(entry, fresh)) {
skipped.push(check.name);
footerSettle(
check.name,
entry.status === "complete" ? "skipped" : "skipped",
);
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,
sendChatMessage: opts.sendChatMessage,
onAgentEvent: opts.onAgentEvent,
sendPhaseLine: opts.sendPhaseLine,
// The all-run footer already owns the pipeline-overview widget
// slot; suppress the per-check footer so two overviews never
// compete over the same `belowEditor` area.
footer: false,
// Inner runs must not prime the .gitignore twice — the outer all-run
// already ensured it. (ensureRunStateIgnored is memoized per cwd,
// so this is belt-and-braces.)
gitignore: opts.gitignore,
});
state = outcome.state;
ran.push(check.name);
footerSettle(
check.name,
outcome.status === "complete"
? "complete"
: outcome.status === "skipped"
? "skipped"
: "failed",
);
strip.log(`pygienium ${check.label}: ${outcome.status}`);
}
// --- Finalize -----------------------------------------------------------
markRunStatus(state, reconcileRunStatus(state));
await saveRunState(state);
setAllPhase(strip, selected, selected.length - 1, "cleanup");
// Mark the final check's footer status terminal; the cursor started at 0
// and the loop advanced it, so the last selected item is the live one.
if (selected.length > 0) {
footerSettle(
selected[selected.length - 1]!.name,
state.checks[selected[selected.length - 1]!.name]?.status === "failed"
? "failed"
: "complete",
);
}
strip.done();
allFooter.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;
}

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

@@ -0,0 +1,525 @@
/**
* 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 "@oh-my-pi/pi-coding-agent";
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
import { runAgentTask } from "../agent-runner.js";
import { runRecon } from "../recon.js";
import {
createPhaseStrip,
type SendChatMessage,
type CheckCompletionDetails,
type PhaseLogEntry,
type PhaseLogStatus,
PHASE_LABELS,
} from "../phases.js";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
import { createPipelineFooter, footerPhaseItems } from "../footer.js";
import {
applyPhaseStatus,
ensureRunStateIgnored,
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";
/** 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;
/** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage;
/**
* Render the per-check live widget (default true). Set false when an outer
* strip (e.g. `/pygienium-all`) already shows this check's phase, so two
* spinners don't fight over the widget area.
*/
widget?: boolean;
/**
* Render the pipeline-overview footer status line (default true). Set false
* when an outer run (e.g. `/pygienium-all`) already owns the footer, so two
* overviews never compete over the same status slot.
*/
footer?: boolean;
/** Optional callback that forwards raw sub-agent events to the chat
* stream (see `pygienium-stream` in `index.ts`). */
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
/** Optional callback to emit synthetic progress lines during non-agent
* phases (verify, cleanup, recon) into the chat stream. */
sendPhaseLine?: (phase: string, text: string) => void;
/**
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
* state/artifacts (default true). Set false with `--no-gitignore`.
*/
gitignore?: boolean;
}
/** Outcome of a single check run. */
export interface CheckRunOutcome {
/** Final check status. */
status: "complete" | "failed" | "skipped";
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
gitignoreAppended?: boolean;
/** 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, and post a
* ralpi-style completion message into the chat (header + expandable phase
* tree) when a `sendChatMessage` callback is supplied.
*
* 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 startMs = Date.now();
const outcome = await runCheckImpl(opts);
postCheckCompletion(opts, outcome, Date.now() - startMs);
return outcome;
}
async function runCheckImpl(opts: RunCheckOptions): Promise<CheckRunOutcome> {
// Keep pygienium's own output out of the scanned repo's git index unless
// the caller opted out with --no-gitignore.
const gitignoreAppended =
opts.gitignore === false ? false : await ensureRunStateIgnored(opts.cwd);
const outcome = await runCheckImplInner(opts);
return { ...outcome, gitignoreAppended };
}
async function runCheckImplInner(
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,
});
/** Phase tag combining check label + phase label for stream lines. */
const phaseTag = (phaseId: string): string =>
`${check.label}: ${PHASE_LABELS[phaseId] ?? phaseId}`;
/** Forward a raw agent event tagged with the current phase. */
const forward = (phaseId: string) => (event: AgentSessionEvent) =>
opts.onAgentEvent?.(phaseTag(phaseId), event);
/** Emit a synthetic stream line for non-agent phases (verify/cleanup/recon). */
const phaseLine = (phaseId: string, text: string): void =>
opts.sendPhaseLine?.(phaseTag(phaseId), text);
// Pipeline-overview footer: a static one-line view of the full phase list
// with the cursor on the current phase and what's to come. Detail lives in
// the chat (phase strip + completion tree); the footer is the overview.
// Disabled (no-op) when an outer run owns the footer, e.g. /pygienium-all.
const phaseIds = (
state.checks[check.name]?.phases ?? phasesForCheck(scope.fix)
).map((p) => p.id);
const footerIdx = new Map(phaseIds.map((id, i) => [id, i] as const));
const footer = createPipelineFooter({
ui: opts.ui,
hasUI: opts.hasUI ?? false,
enabled: opts.footer ?? true,
});
footer.setPipeline(
`pygienium ${check.label}`,
footerPhaseItems(phaseIds, PHASE_LABELS),
);
const footerEnter = (phaseId: string): void => {
const i = footerIdx.get(phaseId);
if (i !== undefined) footer.setCursor(i);
};
const footerComplete = (phaseId: string): void => {
const i = footerIdx.get(phaseId);
if (i !== undefined) footer.setItem(i, "complete");
};
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";
}
for (let i = 0; i < phaseIds.length; i++) footer.setItem(i, "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);
footerEnter(PHASE_RECON);
applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress");
await saveRunState(state);
phaseLine(PHASE_RECON, "scanning project structure…");
const snapshot = await runRecon(cwd);
state.recon = {
complete: true,
path: join(stateDir(cwd), "recon.json"),
finishedAt: snapshot.createdAt,
};
phaseLine(PHASE_RECON, "✓ recon complete");
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
await saveRunState(state);
footerComplete(PHASE_RECON);
} else {
// Recon already done this run — mark this check's recon complete.
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
footerComplete(PHASE_RECON);
}
// --- Phase: analysis -------------------------------------------------
strip.setPhase(PHASE_ANALYSIS);
footerEnter(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,
onEvent: forward(PHASE_ANALYSIS),
});
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);
footerComplete(PHASE_ANALYSIS);
// --- Phase: fix (only with --fix) -----------------------------------
if (scope.fix) {
strip.setPhase(PHASE_FIX);
footerEnter(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,
onEvent: forward(PHASE_FIX),
});
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);
footerComplete(PHASE_FIX);
}
// --- Phase: verify ---------------------------------------------------
strip.setPhase(PHASE_VERIFY);
footerEnter(PHASE_VERIFY);
applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress");
await saveRunState(state);
phaseLine(PHASE_VERIFY, "checking artifacts…");
// 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,
};
}
phaseLine(PHASE_VERIFY, "✓ artifacts confirmed");
applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete");
await saveRunState(state);
footerComplete(PHASE_VERIFY);
// --- Phase: cleanup --------------------------------------------------
strip.setPhase(PHASE_CLEANUP);
footerEnter(PHASE_CLEANUP);
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress");
await saveRunState(state);
phaseLine(PHASE_CLEANUP, "removing transient artifacts…");
await cleanupTransientArtifacts(cwd, check.name);
phaseLine(PHASE_CLEANUP, "✓ done");
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete");
footerComplete(PHASE_CLEANUP);
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();
footer.done();
}
}
/** Map a run-state `PhaseStatus` to a completion-log status. */
function phaseLogStatus(status: string | undefined): PhaseLogStatus {
switch (status) {
case "complete":
return "complete";
case "failed":
return "failed";
case "skipped":
return "skipped";
default:
return "running";
}
}
/** Glyph for a check's terminal status. */
function statusGlyph(status: CheckRunOutcome["status"]): string {
switch (status) {
case "complete":
return "✓";
case "failed":
return "✗";
default:
return "-";
}
}
/** 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;
}
/** Format a duration in milliseconds as `1m 2s` / `5s` / `320ms`. */
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 1) return `${ms}ms`;
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem ? `${m}m ${rem}s` : `${m}m`;
}
/** Build the expandable phase tree carried in the completion message. */
function buildPhaseLog(
state: RunState,
checkName: string,
findings?: string,
changes?: string,
): PhaseLogEntry[] {
const phases = state.checks[checkName]?.phases ?? [];
return phases.map((p) => {
const entry: PhaseLogEntry = {
id: p.id,
label: PHASE_LABELS[p.id] ?? p.id,
status: phaseLogStatus(p.status),
};
if (p.id === PHASE_ANALYSIS && findings) {
entry.note = `findings: ${lineCount(findings)} lines`;
} else if (p.id === PHASE_FIX && changes) {
entry.note = `changes: ${lineCount(changes)} lines`;
} else if (p.error) {
entry.note = p.error;
}
return entry;
});
}
/**
* Post a single ralpi-style completion message (header + phase tree) into the
* chat via `sendChatMessage`. No-op when no callback is wired (print/json
* modes). Mirrors ralpi's per-loop completion message.
*/
function postCheckCompletion(
opts: RunCheckOptions,
outcome: CheckRunOutcome,
durationMs: number,
): void {
const send = opts.sendChatMessage;
if (!send) return;
const check = opts.check;
const status = outcome.status;
const glyph = statusGlyph(status);
const fix = Boolean(
(opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", opts.cwd)).fix,
);
const fixTag = fix ? " --fix" : "";
const header = `${glyph} pygienium ${check.label}${fixTag} · ${status} (${formatDuration(durationMs)})`;
const details: CheckCompletionDetails = {
checkLabel: check.label,
status,
fix,
durationMs,
phases: buildPhaseLog(
outcome.state,
check.name,
outcome.findings,
outcome.changes,
),
error: outcome.error,
};
send(header, { phase: "complete", completion: details });
}
/**
* 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(
() => {},
);
}
}
}

172
src/phases.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* phases.ts — phase-log accumulator + stdout progress + completion-message
* helpers.
*
* The live *detail* view of a check run is the tool-event stream piped into
* the chat (see `pygienium-stream` in `index.ts`): each `tool_execution_start
* /end` and assistant turn becomes its own chat message. This module no longer
* owns a TUI widget — it only records phase transitions for the completion
* message and forwards phase headers to stdout in print/JSON mode.
*
* The *overview* view is the footer widget (see `footer.ts`): a multi-line
* `belowEditor` strip listing the full pipeline with the cursor and what's to
* come. The two never overlap: the chat is the per-event detail, the footer
* is the static overview.
*
* The strip accumulates a phase log (`getPhaseLog`) that the check-runner
* turns into the expandable completion message rendered by
* `registerMessageRenderer("pygienium-progress")` in `index.ts`.
*
* @module pygienium/phases
*/
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
/** Callback to post a message into the chat history (see `index.ts` renderer). */
export type SendChatMessage = (
content: string,
/** Extra data passed to the message renderer (toolCalls, completion, …). */
meta?: {
phase?: string;
/** Tool calls captured during this agent execution (ralpi-style tree). */
toolCalls?: never;
[meta: string]: unknown;
},
) => void;
/** 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",
};
/** Status of a single phase as recorded in the completion log. */
export type PhaseLogStatus = "running" | "complete" | "failed" | "skipped";
/** One phase entry carried into the completion message's `details.phases`. */
export interface PhaseLogEntry {
/** Phase id (e.g. "analysis"). */
id: string;
/** Human-readable label (e.g. "Scanning"). */
label: string;
/** Terminal/running status. */
status: PhaseLogStatus;
/** Optional note shown on the branch (e.g. "findings: 12 lines"). */
note?: string;
}
export interface PhaseStripOptions {
/** Check label shown alongside the phase, e.g. "comments". */
checkLabel?: string;
/** Whether dialog-capable UI is available. */
hasUI?: boolean;
/** UI context (unused for widget rendering since the stream owns the chat). */
ui?: ExtensionUIContext;
}
/**
* A handle that records phase transitions for the completion message and
* forwards phase headers to stdout when no TUI is present. Created by
* {@link createPhaseStrip}; the check-runner drives it.
*/
export interface PhaseStrip {
/** Set the current phase (id or a pre-rendered header string). */
setPhase(phaseId: string): void;
/** Annotate the most recent phase (e.g. "findings: 12 lines"). */
setPhaseNote(note: string): void;
/** Append a plain-text progress line (forwarded to stdout in print mode). */
log(line: string): void;
/** Snapshot of phase transitions for the completion message. */
getPhaseLog(): PhaseLogEntry[];
/** Mark the strip terminal. Safe to call repeatedly. */
done(): void;
}
/**
* Simple write lock for stdout in print mode to prevent interleaved output
* from parallel checks.
*/
let stdoutLock: Promise<void> = Promise.resolve();
/** Acquire the stdout write lock and execute the write function. */
async function withStdoutLock(fn: () => void): Promise<void> {
const prev = stdoutLock;
stdoutLock = prev.then(() => {
fn();
return Promise.resolve();
});
return stdoutLock;
}
/** Create a phase-strip UI adapter. */
export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip {
const checkLabel = opts.checkLabel;
const hasUI = opts.hasUI ?? false;
const phaseLog: PhaseLogEntry[] = [];
let disposed = false;
let currentHeader = checkLabel
? `pygienium ${checkLabel}: starting…`
: "pygienium: starting…";
function phaseLabel(id: string): string {
return PHASE_LABELS[id] ?? id;
}
function writeStdout(text: string): void {
if (!disposed && !hasUI) {
process.stdout.write(`${text}\n`);
}
}
return {
setPhase(phaseId) {
if (disposed) return;
const label = phaseLabel(phaseId);
currentHeader = checkLabel
? `pygienium ${checkLabel}: ${label}`
: `pygienium: ${phaseId}`;
phaseLog.push({ id: phaseId, label, status: "running" });
if (!hasUI) {
withStdoutLock(() => writeStdout(currentHeader)).catch(() => {});
}
},
setPhaseNote(note) {
const last = phaseLog[phaseLog.length - 1];
if (!last) return;
last.note = note;
},
log(line) {
if (disposed || hasUI) return;
withStdoutLock(() => writeStdout(line)).catch(() => {});
},
getPhaseLog() {
return phaseLog;
},
done() {
if (disposed) return;
disposed = true;
},
};
}
/** Phase-log detail carried into a completion message's `details`. */
export interface CheckCompletionDetails {
checkLabel: string;
status: "complete" | "failed" | "skipped";
fix?: boolean;
durationMs?: number;
phases: PhaseLogEntry[];
error?: string;
}
/** Re-exported for index.ts renderer convenience. */
export const PHASE_GLYPH: Record<PhaseLogStatus, string> = {
running: "~",
complete: "✓",
failed: "✗",
skipped: "-",
};

109
src/recon.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* 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";
import { SCOPE_EXTENSIONS } from "./checks/scope.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;
}
/** 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 SCOPE_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;
}

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

@@ -0,0 +1,326 @@
/**
* 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 { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
export const RUN_STATE_DIRNAME = ".pygienium";
export const RUN_STATE_FILENAME = "run-state.json";
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";
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",
);
}
/**
* Memo of cwds whose `.gitignore` was already ensured this process, so the
* check runs at most once per target per session.
*/
const gitIgnoreMemo = new Set<string>();
/**
* Make sure `<cwd>/.gitignore` excludes `.pygienium/` (run-state + artifacts)
* so a run never stages its own output into the scanned repo's git index.
* Best-effort and idempotent: no-op outside a git work tree or when the entry
* already exists. Returns true when it appended the entry (or created the file).
*/
export async function ensureRunStateIgnored(cwd: string): Promise<boolean> {
if (gitIgnoreMemo.has(cwd)) return false;
gitIgnoreMemo.add(cwd);
try {
// Only act inside a git work tree (works for worktrees too: .git is a file).
await stat(join(cwd, ".git"));
const ignorePath = join(cwd, ".gitignore");
const marker = ".pygienium/";
let content: string;
try {
content = await readFile(ignorePath, "utf8");
} catch {
await writeFile(ignorePath, `${marker}\n`, "utf8");
return true;
}
if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false;
const prefix = content.endsWith("\n") ? "" : "\n";
await appendFile(
ignorePath,
`${prefix}# pygienium run-state and check artifacts\n${marker}\n`,
"utf8",
);
return true;
} catch {
return false; // not a git work tree, or a best-effort write failed
}
}
/** Mark a phase's status (and optionally an error message). */
export function applyPhaseStatus(
state: RunState,
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;
// Always set (or clear) the error: a phase that previously failed
// and then succeeds on retry must not carry a stale error forward.
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;
// Always set (or clear) the error: a check that previously failed
// and then succeeds on retry must not carry a stale error forward.
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` when every check failed;
* `partial` when some checks failed/skipped but others succeeded.
*/
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) {
// Every check failed (none succeeded or were skipped) → the run failed;
// a mix of failures and successes is only partially complete.
return checks.every((c) => c.status === "failed") ? "failed" : "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

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

@@ -0,0 +1,98 @@
/**
* agents.test.ts — markdown agent-definition loader.
*/
import { describe, expect, it } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extensionRoot, loadAgents } from "../src/agents.js";
/** Build an agent markdown file with frontmatter + body. */
async function writeAgent(
dir: string,
file: string,
name: string,
body: string,
tools?: string[],
): Promise<string> {
const lines = ["---", `name: ${name}`];
if (tools) {
lines.push("allowedTools:");
for (const t of tools) lines.push(` - ${t}`);
}
lines.push("---", body, "");
await writeFile(join(dir, file), lines.join("\n"), "utf8");
return join(dir, file);
}
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(/(pygenium|pygienium)$/);
});
it("project-local agents override the extension's by name", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
try {
await mkdir(join(cwd, "agents"));
const path = await writeAgent(
join(cwd, "agents"),
"scanner.md",
"scanner",
"Project-tuned scanner prompt.",
["read", "grep", "find", "edit"],
);
const agents = await loadAgents({ cwd });
const scanner = agents.get("scanner");
expect(scanner?.systemPrompt).toContain("Project-tuned");
expect(scanner?.allowedTools).toContain("edit"); // repo override widens tools
expect(scanner?.sourcePath).toBe(path);
// Extension baseline is retained for agents the repo doesn't override.
expect(agents.get("fixer")).toBeDefined();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("project-local agents can add brand-new agents", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
try {
await mkdir(join(cwd, "agents"));
await writeAgent(
join(cwd, "agents"),
"judge.md",
"judge",
"Scoring judge.",
);
const agents = await loadAgents({ cwd });
expect(agents.has("judge")).toBe(true);
expect(agents.get("judge")?.systemPrompt).toContain("Scoring judge");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
it("a project without agents/ falls back to the extension agents", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
try {
const agents = await loadAgents({ cwd });
expect(agents.has("scanner")).toBe(true);
expect(agents.has("fixer")).toBe(true);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
});

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

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

@@ -0,0 +1,401 @@
/**
* 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, 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, --no-gitignore, and --only", () => {
const p = parseAllArgs("subdir --fix --only=alpha,beta --fresh", cwd);
expect(p.target).toBe(join(cwd, "subdir"));
expect(p.fix).toBe(true);
expect(p.fresh).toBe(true);
expect(p.gitignore).toBe(true); // default: keep the .gitignore guard on
expect(p.only).toEqual(["alpha", "beta"]);
const noGi = parseAllArgs("--no-gitignore", cwd);
expect(noGi.gitignore).toBe(false);
expect(noGi.target).toBe(cwd);
});
it("selectChecks preserves registry order for the --only subset", () => {
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("all-summary never shows a stale error under a complete check", async () => {
const { initRunState } = await import("../src/run-state.js");
const state = initRunState(cwd, [
{ name: "alpha", label: "Alpha", fix: false },
]);
const alpha = fakeCheck("alpha");
// A hand-edited/legacy state can carry an error on a completed check
// (the MagnaFluo run showed exactly this shape).
markCheckStatus(state, "alpha", "complete", "legacy verify error");
const md = renderAllSummary(state, [alpha]);
expect(md).toContain("## alpha — complete");
expect(md).not.toContain("- error:");
// A genuinely failed check still surfaces its error.
markCheckStatus(state, "alpha", "failed", "scan exploded");
const md2 = renderAllSummary(state, [alpha]);
expect(md2).toContain("- error: scan exploded");
});
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 [");
});
it("renders only the unified footer widget in UI mode (no per-check footer)", async () => {
registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta"));
const calls: Array<[string, string[] | undefined]> = [];
const ui = {
setWidget: (key: string, content: string[] | undefined) => {
calls.push([key, content]);
},
} as never; // stub ExtensionUIContext (tests have no pi type imports)
await runAllChecks({ cwd, ui, hasUI: true });
const keys = new Set(calls.map(([k]) => k));
// The all-run footer drives the belowEditor widget area under its own key…
expect(keys.has("pygienium-all")).toBe(true);
// …and per-check footers never claim their slot (footer:false), so two
// overviews never compete over the same widget area.
expect(keys.has("pygienium")).toBe(false);
// The widget is cleared when the run completes.
const last = calls[calls.length - 1];
expect(last).toBeDefined();
expect(last?.[0]).toBe("pygienium-all");
expect(last?.[1]).toBeUndefined();
});
});

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

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

@@ -0,0 +1,258 @@
/**
* 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 {
check as 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,
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();
});
});

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

@@ -0,0 +1,105 @@
/**
* complexity.test.ts — integration tests for the complexity hygiene check.
*
* Validates:
* 1. A function with cyclomatic complexity 50+ is flagged and refactored
* 2. A function with complexity 35-49 is flagged with justification required
* 3. Deep nesting and over-abstraction are simplified
*/
import {
describe,
expect,
it,
beforeAll,
afterAll,
beforeEach,
afterEach,
} from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { tmpdir } from "node:os";
import {
check as complexityCheck,
buildComplexityScanTask,
buildComplexityFixTask,
} from "../src/checks/complexity.js";
import { registerCheck, clearChecks } from "../src/checks/registry.js";
import {
runAgentTask,
setAgentRunner,
resetAgentRunner,
fakeAgentRunner,
} from "../src/agent-runner.js";
describe("complexity check", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(tmpdir(), "pygienium-test-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
});
it("is registered with correct properties", () => {
expect(complexityCheck.name).toBe("complexity");
expect(complexityCheck.label).toBe("Complexity");
expect(complexityCheck.agentName).toBe("scanner");
expect(complexityCheck.fixAgentName).toBe("fixer");
});
it("builds scan task with complexity thresholds", () => {
const scope = {
cwd: tempDir,
target: tempDir,
fix: false,
rest: [],
};
const task = buildComplexityScanTask(tempDir, scope);
expect(task).toContain("cyclomatic complexity");
expect(task).toContain("50+");
expect(task).toContain("35-49");
expect(task).toContain("MUST refactor");
expect(task).toContain("findings.md");
});
it("builds fix task from findings", () => {
const scope = {
cwd: tempDir,
target: tempDir,
fix: true,
rest: [],
};
const findings =
"# complexity findings\n\n- myFunction: complexity 65 - must refactor";
const task = buildComplexityFixTask(tempDir, scope, findings);
expect(task).toContain("complexity fix");
expect(task).toContain("myFunction");
expect(task).toContain("changes.md");
});
});
describe("agent loading", () => {
it("loads scanner agent from agents directory", async () => {
const { loadAgents } = await import("../src/agents.js");
const agents = await loadAgents();
expect(agents.has("scanner")).toBe(true);
const scanner = agents.get("scanner");
expect(scanner).toBeDefined();
expect(scanner?.systemPrompt).toContain("scanner");
});
it("loads fixer agent from agents directory", async () => {
const { loadAgents } = await import("../src/agents.js");
const agents = await loadAgents();
expect(agents.has("fixer")).toBe(true);
const fixer = agents.get("fixer");
expect(fixer).toBeDefined();
expect(fixer?.allowedTools).toContain("edit");
});
});

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 {
check as 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, 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,
check as deepModulesCheck,
} from "../src/checks/deep-modules.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, 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,
check as defensiveGuardsCheck,
} from "../src/checks/defensive-guards.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, 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,62 @@
/**
* extensibility.test.ts — the registry extensibility claim (task 14).
*
* Proves a NEW check registered via the public API yields a working
* `/pygienium-<name>` command with ZERO `index.ts` command-wiring changes: an
* in-test `registerCheck()` call makes the generic command-binding path
* (`registerPygieniumCommands`, the exact function `index.ts` calls) expose
* `/pygienium-witness` and `/pygienium-help` lists it. The shipped checks are
* each exercised by their own test files, so this suite only needs a synthetic
* witness.
*/
import { describe, expect, it, afterEach } from "bun:test";
import {
clearChecks,
getCheck,
getAllChecks,
registerCheck,
type CheckDefinition,
} from "../src/checks/registry.js";
import { registerPygieniumCommands } from "../src/commands.js";
import { buildPygieniumHelpLines } from "../src/help.js";
/** A synthetic check registered only for this suite. */
const witnessCheck: CheckDefinition = {
name: "witness",
label: "Witness",
description: "Test-only check proving zero-wiring extensibility.",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "witness",
buildScanTask: () =>
"# Task: witness scan\nwrite findings.md: witness: 0 issues",
buildFixTask: () => "# Task: witness fix\nwrite changes.md: witness: 0 edits",
gate: () => undefined,
};
describe("registry extensibility (task 14)", () => {
afterEach(() => clearChecks());
it("a registered check is visible via getCheck/getAllChecks", () => {
registerCheck(witnessCheck);
expect(getCheck("witness")).toBe(witnessCheck);
expect(getAllChecks().some((c) => c.name === "witness")).toBe(true);
});
it("registerPygieniumCommands exposes /pygienium-<name> (zero wiring)", () => {
registerCheck(witnessCheck);
const names: string[] = [];
registerPygieniumCommands((name) => names.push(name));
expect(names).toContain("pygienium-witness");
// And the operator commands are still wired.
expect(names).toContain("pygienium-help");
expect(names).toContain("pygienium-all");
});
it("/pygienium-help lists a registered check", () => {
registerCheck(witnessCheck);
const text = buildPygieniumHelpLines().join("\n");
expect(text).toContain("/pygienium-witness");
expect(text).toContain(witnessCheck.description);
});
});

269
tests/footer.test.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* footer.test.ts — unit tests for the pipeline-overview footer.
*
* The footer is a presentation-only multi-line `belowEditor` widget: with no
* UI it tracks items but writes nothing; with a stub UI it pushes a string[]
* of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
* and clears on `done()`. The pure {@link renderFooterList} core is asserted
* directly (layout + theming); a light widget-glue test covers the wiring.
*/
import { describe, expect, it } from "bun:test";
import {
createPipelineFooter,
footerPhaseItems,
footerColor,
renderFooterList,
FOOTER_MARKER,
FOOTER_STATUS_KEY,
type FooterItem,
type FooterTheme,
} from "../src/footer.js";
import { PHASE_LABELS } from "../src/phases.js";
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
/** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
function fakeTheme(): FooterTheme {
return { fg: (color, text) => `${color}:${text}` };
}
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
function stubUi(theme: FooterTheme = fakeTheme()): {
ui: {
theme: FooterTheme;
setWidget: (
key: string,
content: string[] | undefined,
options?: { placement?: string },
) => void;
};
calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[];
} {
const calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[] = [];
return {
calls,
ui: {
theme,
setWidget(key, content, options) {
calls.push({ key, content, placement: options?.placement });
},
},
};
}
/** Build the canonical phase-id list a scan-only check uses. */
function scanPhaseIds(): string[] {
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
}
/** Items for the canonical scan-only pipeline. */
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
return scanPhaseIds().map((id) => ({
label: PHASE_LABELS[id] ?? id,
status,
}));
}
describe("renderFooterList", () => {
it("renders one bulleted, numbered, themed line per phase", () => {
const lines = renderFooterList(scanItems(), -1, fakeTheme());
expect(lines).toHaveLength(3);
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
expect(lines[0]).toBe("dim:• · 1. Recon");
expect(lines[1]).toBe("dim:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
const lines = renderFooterList(scanItems(), 1, fakeTheme());
expect(lines[0]).toBe("dim:• · 1. Recon");
// cursor (index 1) is pending-but-current → accent.
expect(lines[1]).toBe("accent:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes terminal statuses with success/error/warning colors", () => {
const items: FooterItem[] = [
{ label: "Recon", status: "complete" },
{ label: "Scan", status: "running" },
{ label: "Fix", status: "failed" },
{ label: "Verify", status: "skipped" },
];
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toBe("success:• ✓ 1. Recon");
expect(lines[1]).toBe("accent:• ● 2. Scan");
expect(lines[2]).toBe("error:• ✗ 3. Fix");
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
});
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
label: `S${i}`,
status: "pending" as const,
}));
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toContain("01. S0");
expect(lines[10]).toContain("11. S10");
});
});
describe("footerColor", () => {
it("maps each status to its piolium-style color token", () => {
expect(footerColor("complete", false)).toBe("success");
expect(footerColor("failed", false)).toBe("error");
expect(footerColor("skipped", false)).toBe("warning");
expect(footerColor("running", false)).toBe("accent");
expect(footerColor("pending", false)).toBe("dim");
// A pending item under the cursor reads as accent (current).
expect(footerColor("pending", true)).toBe("accent");
});
});
describe("createPipelineFooter", () => {
it("is a no-op without a UI but still tracks item state", () => {
// hasUI false: setWidget must never be called.
const footer = createPipelineFooter({ hasUI: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// No UI → no observable side effect, but getItems reflects state.
expect(footer.getItems()[0]?.status).toBe("running");
expect(footer.getTitle()).toBe("pygienium smoke");
});
it("renders the full pipeline as a belowEditor widget and clears on done", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
footer.setPipeline("pygienium smoke", items, 0);
// One setWidget call, under the canonical key, placement belowEditor.
expect(calls).toHaveLength(1);
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
// Title line first (dim), then one bulleted line per phase.
expect(lines[0]).toBe("dim:pygienium smoke");
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. Fixing`);
// The cursor item is marked running.
expect(footer.getItems()[0]?.status).toBe("running");
footer.done();
// done() pushes an undefined to clear the slot, then resets state.
const last = calls[calls.length - 1]!;
expect(last.content).toBeUndefined();
expect(last.placement).toBe("belowEditor");
expect(footer.getItems()).toHaveLength(0);
});
it("demotes the previous running item to pending when the cursor moves", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
// recon → complete, then advance to analysis.
footer.setItem(0, "complete");
footer.setCursor(1);
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("running");
});
it("does not demote a terminal item when the cursor advances past it", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
footer.setCursor(0); // recon running
footer.setItem(0, "complete");
footer.setCursor(1); // analysis running
footer.setItem(1, "complete");
footer.setCursor(2); // fix running
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("complete");
expect(items[2]?.status).toBe("running");
});
it("marks a skipped gate as every item skipped", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium comments",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
);
for (let i = 0; i < footer.getItems().length; i++) {
footer.setItem(i, "skipped");
}
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
});
it("can be disabled so it never touches the widget slot", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true, enabled: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// enabled:false suppresses every setWidget call (used by /pygienium-all
// which owns its own footer).
expect(calls).toHaveLength(0);
});
it("writes under a custom widget key (all-run owns its slot)", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
statusKey: "pygienium-all",
});
footer.setPipeline(
"pygienium: all",
[
{ label: "comments", status: "pending" },
{ label: "dead-code", status: "pending" },
],
0,
);
expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
expect(lines[0]).toBe("dim:pygienium: all");
// Cursor on the first check; second still pending (to come).
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
});
});
describe("footerPhaseItems", () => {
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
expect(items.every((i) => i.status === "pending")).toBe(true);
});
it("falls back to the raw id for unknown phases", () => {
const items = footerPhaseItems(["custom"], PHASE_LABELS);
expect(items[0]?.label).toBe("custom");
});
});

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] [--fresh]");
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");
});
});

View File

@@ -0,0 +1,235 @@
/**
* per-check-resume.test.ts — `/pygienium-<check>` resume semantics.
*
* The per-check command must be resume-aware (parity with
* `/pygienium-all` and `/pygienium-resume`): a check already terminal
* (`complete`/`skipped`) is skipped unless `--fresh`, and a failed check is
* re-dispatched. This is what lets "running the original command again"
* recover a partial run instead of blindly re-running every phase.
*
* A stateful tracker wraps the fake runner: the first call produces no
* artifact (verify fails), the second writes findings.md (verify passes) —
* modelling the intermittent empty-output bug recovering on retry.
*/
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 } 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 { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, 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;
}
/** Check whose fake runner writes findings.md and (with --fix) changes.md. */
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`,
// Verify hook asserting findings.md exists — like the real checks.
verify: async (scope) => {
const { stat } = await import("node:fs/promises");
const f = join(scope.cwd, ".pygienium", "checks", name, "findings.md");
try {
await stat(f);
} catch {
return `${name} verify: expected findings.md at ${f} after scan, none found.`;
}
return undefined;
},
gate: () => undefined,
};
}
/** 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 };
}
/**
* Stateful runner: the first call returns ok with no artifact (verify fails),
* the second writes findings.md (verify passes). Models the empty-output bug
* recovering on retry.
*/
function flakyThenOkRunner(name: string): {
runner: AgentRunner;
calls: number;
} {
let calls = 0;
const runner: AgentRunner = async (opts) => {
calls++;
if (calls === 1) {
return { ok: true, text: "" };
}
return fakeAgentRunner(opts);
};
return { runner, calls: 0 };
}
describe("/pygienium-<check> resume semantics", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
cwd = await mkdtemp(join(tmpdir(), "pygienium-pcr-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true }).catch(() => {});
});
it("skips an already-complete check and does not re-dispatch the agent", async () => {
const track = trackingRunner();
setAgentRunner(track.runner);
const check = fakeCheck("alpha");
registerCheck(check);
// First run: completes and writes findings.md.
await handleCheckCommand(check, "", stubCtx(cwd));
expect(track.dispatched).toEqual(["alpha"]);
const state1 = await loadRunState(cwd);
expect(state1?.checks.alpha.status).toBe("complete");
// Second run: terminal → skipped, no agent dispatch.
const out = await captureStdout(() =>
handleCheckCommand(check, "", stubCtx(cwd)),
);
expect(track.dispatched).toEqual(["alpha"]); // unchanged
expect(out.join("\n")).toContain("already complete");
expect(out.join("\n")).toContain("--fresh");
const state2 = await loadRunState(cwd);
expect(state2?.checks.alpha.status).toBe("complete");
});
it("--fresh re-runs a completed check from scratch", async () => {
const track = trackingRunner();
setAgentRunner(track.runner);
const check = fakeCheck("beta");
registerCheck(check);
await handleCheckCommand(check, "", stubCtx(cwd));
expect(track.dispatched).toEqual(["beta"]);
await captureStdout(() =>
handleCheckCommand(check, "--fresh", stubCtx(cwd)),
);
// Dispatched again (now twice total).
expect(track.dispatched).toEqual(["beta", "beta"]);
const state = await loadRunState(cwd);
expect(state?.checks.beta.status).toBe("complete");
});
it("re-runs a failed check and recovers when the agent produces the artifact on retry", async () => {
const flaky = flakyThenOkRunner("gamma");
// Expose the live call count via closure read after the run.
let calls = 0;
const runner: AgentRunner = async (opts) => {
calls++;
if (calls === 1) {
return { ok: true, text: "" };
}
return fakeAgentRunner(opts);
};
void flaky; // (flakyThenOkRunner kept as a reference shape; use `runner` below)
setAgentRunner(runner);
const check = fakeCheck("gamma");
registerCheck(check);
// First run: agent returns ok with no artifact → verify fails.
await handleCheckCommand(check, "", stubCtx(cwd));
const state1 = await loadRunState(cwd);
expect(state1?.checks.gamma.status).toBe("failed");
expect(state1?.checks.gamma.error).toContain("verify");
expect(state1?.checks.gamma.error).toContain("findings.md");
// Resume: re-dispatch the failed check. Agent writes findings.md this
// time → verify passes → complete.
await captureStdout(() => handleCheckCommand(check, "", stubCtx(cwd)));
const state2 = await loadRunState(cwd);
expect(state2?.checks.gamma.status).toBe("complete");
expect(state2?.checks.gamma.error).toBeUndefined();
// The verify phase is now complete (not failed).
const verify = state2?.checks.gamma.phases.find((p) => p.id === "verify");
expect(verify?.status).toBe("complete");
// And the artifact exists on disk.
const findings = await readFile(
join(cwd, ".pygienium", "checks", "gamma", "findings.md"),
"utf8",
);
expect(findings).toContain("gamma findings");
});
it("does not treat a failed check as terminal (resume re-dispatches it)", async () => {
const track = trackingRunner();
setAgentRunner(track.runner);
const check = fakeCheck("delta");
// Override verify to always fail so the check lands in `failed`.
const alwaysFailing: CheckDefinition = {
...check,
name: "delta",
verify: async () => "delta verify: forced failure",
};
registerCheck(alwaysFailing);
await handleCheckCommand(alwaysFailing, "", stubCtx(cwd));
expect(track.dispatched).toEqual(["delta"]);
const state1 = await loadRunState(cwd);
expect(state1?.checks.delta.status).toBe("failed");
// Re-running the command re-dispatches (failed is NOT terminal).
await captureStdout(() =>
handleCheckCommand(alwaysFailing, "", stubCtx(cwd)),
);
expect(track.dispatched).toEqual(["delta", "delta"]);
const state2 = await loadRunState(cwd);
expect(state2?.checks.delta.status).toBe("failed"); // still failing
});
});
void mkdir; // silence unused-import lint under some configs

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

137
tests/run-state.test.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* run-state.test.ts — run-state reconciliation, error hygiene, and the
* .gitignore guard (issues surfaced by the MagnaFluo all-run: "partial" for
* all-failed runs, stale errors on completed checks, staged artifacts).
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ensureRunStateIgnored,
initRunState,
markCheckStatus,
reconcileRunStatus,
} from "../src/run-state.js";
/** Build a run state whose checks carry the given statuses. */
function stateWith(
...statuses: Array<[name: string, status: string]>
): ReturnType<typeof initRunState> {
const state = initRunState(
"/virtual/cwd",
statuses.map(([name]) => ({ name, label: name })),
);
for (const [name, status] of statuses) {
markCheckStatus(
state,
name,
status as "complete" | "failed" | "skipped",
status === "failed" ? "boom" : undefined,
);
}
return state;
}
describe("reconcileRunStatus", () => {
it("is in_progress while nothing is terminal", () => {
expect(reconcileRunStatus(stateWith())).toBe("in_progress");
expect(
reconcileRunStatus(initRunState("/virt", [{ name: "a", label: "a" }])),
).toBe("in_progress");
});
it("is complete only when every check is complete", () => {
expect(
reconcileRunStatus(stateWith(["a", "complete"], ["b", "complete"])),
).toBe("complete");
});
it("is partial when some checks failed and others completed", () => {
expect(
reconcileRunStatus(stateWith(["a", "complete"], ["b", "failed"])),
).toBe("partial");
});
it("is partial when checks were skipped", () => {
expect(
reconcileRunStatus(stateWith(["a", "complete"], ["b", "skipped"])),
).toBe("partial");
});
it("is failed when every check failed (not partial)", () => {
expect(
reconcileRunStatus(stateWith(["a", "failed"], ["b", "failed"])),
).toBe("failed");
expect(reconcileRunStatus(stateWith(["a", "failed"]))).toBe("failed");
});
it("is partial for a mixed failed/skipped run (some degraded, none ok)", () => {
expect(
reconcileRunStatus(stateWith(["a", "failed"], ["b", "skipped"])),
).toBe("partial");
});
});
describe("check error hygiene", () => {
it("a failed check records its error", () => {
const s = stateWith(["a", "failed"]);
expect(s.checks.a?.error).toBe("boom");
});
it("a later success clears the stale error (resume-complete invariant)", () => {
const s = stateWith(["a", "failed"]);
expect(s.checks.a?.error).toBe("boom");
markCheckStatus(s, "a", "complete");
expect(s.checks.a?.error).toBeUndefined();
expect(s.checks.a?.status).toBe("complete");
});
});
describe("ensureRunStateIgnored", () => {
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(join(tmpdir(), "pygium-git-"));
await mkdir(join(cwd, ".git"), { recursive: true }); // pretend it's a work tree
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
it("creates .gitignore with .pygienium/ when absent", async () => {
expect(await ensureRunStateIgnored(cwd)).toBe(true);
const content = await readFile(join(cwd, ".gitignore"), "utf8");
expect(content).toContain(".pygienium/");
});
it("appends to an existing .gitignore without the marker", async () => {
await writeFile(join(cwd, ".gitignore"), "node_modules/\n", "utf8");
expect(await ensureRunStateIgnored(cwd)).toBe(true);
const content = await readFile(join(cwd, ".gitignore"), "utf8");
expect(content).toContain(".pygienium/");
expect(content).toContain("node_modules/");
});
it("leaves an existing marker untouched and reports no change", async () => {
await writeFile(
join(cwd, ".gitignore"),
".pygienium/\nnode_modules/\n",
"utf8",
);
expect(await ensureRunStateIgnored(cwd)).toBe(false);
const content = await readFile(join(cwd, ".gitignore"), "utf8");
expect(content).toBe(".pygienium/\nnode_modules/\n");
});
it("is a no-op outside a git work tree", async () => {
const plain = await mkdtemp(join(tmpdir(), "pygium-nogit-"));
try {
expect(await ensureRunStateIgnored(plain)).toBe(false);
await expect(
readFile(join(plain, ".gitignore"), "utf8"),
).rejects.toThrow();
} finally {
await rm(plain, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,422 @@
/**
* 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, 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 old non-hidden root) is ignored now
// that all checks write to the single canonical `.pygienium/checks/` root.
await mkdir(join(cwd, "pygienium", "checks", "ghost"), {
recursive: true,
});
await writeFile(
join(cwd, "pygienium", "checks", "ghost", "findings.md"),
"# ghost findings\n",
"utf8",
);
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"));
});
});

337
tests/todos.test.ts Normal file
View File

@@ -0,0 +1,337 @@
/**
* todos.test.ts — unit + integration tests for the todos (TODOs & stubs) check.
*
* Unit: `detectTodoStubs` over a multi-language fixture tree — markers, silent
* stubs (placeholder return / empty body / pass-only body), loud stubs, and
* the negative cases (a real adder, a `return null` catch handler, in-string
* "TODO" flagged for recall, clean code never flagged).
*
* Integration (deterministic fake agent runner): the scan persists findings.md
* with the three sections + machine-readable summary and a new/resolved delta
* vs the previous run; --fix converts silent stubs to loud throws, preserves
* markers, leaves loud stubs untouched, and records changes.md.
*/
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,
check as todosCheck,
detectTodoStubs,
todosPriorCounts,
buildTodosScanTask,
} from "../src/checks/todos.js";
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
/**
* Seed `stubs.ts` with the full taxonomy: a TODO marker, a silent stub with a
* placeholder return (getPrice), a silent stub with an empty body (notify),
* and a loud stub (connect throws "Not implemented").
*/
async function seedStubs(dir: string): Promise<void> {
await mkdir(dir, { recursive: true }).catch(() => {});
await writeFile(
join(dir, "stubs.ts"),
[
`// TODO: add pagination`,
`export function getPrice(): number {`,
` return 0;`,
`}`,
``,
`export function notify(): void {}`,
``,
`export function connect(): Promise<void> {`,
` throw new Error("Not implemented");`,
`}`,
``,
].join("\n"),
"utf8",
);
}
/** Seed the multi-language fixture tree for the deterministic-detector tests. */
async function seedTree(dir: string): Promise<void> {
await mkdir(dir, { recursive: true }).catch(() => {});
await Promise.all([
writeFile(
join(dir, "math.ts"),
"export function add(a: number, b: number): number {\n return a + b;\n}\n",
"utf8",
),
writeFile(
join(dir, "parse.ts"),
[
`export function parse(input: string) {`,
` try {`,
` return JSON.parse(input);`,
` } catch {`,
` return null;`,
` }`,
`}`,
``,
].join("\n"),
"utf8",
),
writeFile(join(dir, "stringlit.ts"), 'export const op = "TODO";\n', "utf8"),
writeFile(
join(dir, "stubs.ts"),
[
`// TODO: add pagination`,
`export function getPrice(): number {`,
` return 0;`,
`}`,
``,
`export function notify(): void {}`,
``,
`export function connect(): Promise<void> {`,
` throw new Error("Not implemented");`,
`}`,
``,
].join("\n"),
"utf8",
),
writeFile(
join(dir, "repo.py"),
[
`class Repository:`,
` def find(self, uid):`,
` raise NotImplementedError # interface method`,
``,
` def save(self, record):`,
` pass`,
``,
].join("\n"),
"utf8",
),
writeFile(
join(dir, "fetch.rs"),
"fn fetch() -> Result<u32, String> {\n todo!()\n}\n",
"utf8",
),
]);
}
describe("detectTodoStubs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "pygienium-todos-unit-"));
await seedTree(dir);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true }).catch(() => {});
});
it("flags markers, silent stubs, and loud stubs across languages", async () => {
const hits = await detectTodoStubs(dir);
const marker = hits.filter((h) => h.kind === "marker");
const silent = hits.filter((h) => h.kind === "silent-stub");
const loud = hits.filter((h) => h.kind === "loud-stub");
// Clean code is never flagged: the adder and the `return null` catch
// handler (a boundary handler, not a stub) produce zero hits.
expect(
hits.filter((h) => /(?:math\.ts|parse\.ts)$/.test(h.path)),
).toHaveLength(0);
// Markers: the stubs.ts TODO comment, and the in-string "TODO" (recall —
// the scan agent's job is to drop the string-literal noise).
expect(
marker.some(
(h) =>
h.path.endsWith("stubs.ts") && h.line === 1 && h.snippet === "TODO",
),
).toBe(true);
expect(
marker.some(
(h) => h.path.endsWith("stringlit.ts") && h.snippet === "TODO",
),
).toBe(true);
// Silent stubs: lone placeholder return + empty body in stubs.ts,
// pass-only body in repo.py — each with its enclosing function.
const stubsSilent = silent.filter((h) => h.path.endsWith("stubs.ts"));
expect(
stubsSilent.some(
(h) => h.snippet === "placeholder-return" && h.context === "getPrice",
),
).toBe(true);
expect(
stubsSilent.some(
(h) => h.snippet === "empty-body" && h.context === "notify",
),
).toBe(true);
expect(
silent.some(
(h) =>
h.path.endsWith("repo.py") &&
h.snippet === "pass-only" &&
h.context === "save",
),
).toBe(true);
// Loud stubs: "Not implemented" throw, raise NotImplementedError,
// rust todo!() — reported as tracked debt.
expect(
loud.some(
(h) => h.path.endsWith("stubs.ts") && h.snippet === "Not implemented",
),
).toBe(true);
expect(
loud.some(
(h) =>
h.path.endsWith("repo.py") && h.snippet === "NotImplementedError",
),
).toBe(true);
expect(
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
).toBe(true);
});
});
describe("todos 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(todosCheck);
cwd = await mkdtemp(join(tmpdir(), "pygienium-todos-"));
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true }).catch(() => {});
});
it("is registered and uses the todos scanner agent", () => {
const check = getCheck("todos");
expect(check).toBeDefined();
expect((check as CheckDefinition)?.agentName).toBe("todos");
});
it("scan-only writes findings.md with the three sections and leaves sources untouched", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const findings = await readFile(findingsPath(cwd), "utf8");
expect(findings).toContain(
"summary: 1 marker(s), 2 silent stub(s), 1 loud stub(s)",
);
expect(findings).toContain("new: 4"); // first run: everything is new
expect(findings).toContain("## TODO markers");
expect(findings).toContain("## Silent stubs (actionable)");
expect(findings).toContain("placeholder-return");
expect(findings).toContain("Not implemented");
// Scan-only: no changes.md, sources untouched.
expect(existsSync(changesPath(cwd))).toBe(false);
const untouched = await readFile(join(cwd, "stubs.ts"), "utf8");
expect(untouched).toContain("return 0;");
// Run state records the scan summary as findings text.
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.status).toBe("complete");
expect(state?.checks["todos"]?.findings).toContain(
"todos: 2 silent stub(s), 1 loud stub(s), 1 marker(s)",
);
});
it("--fix converts silent stubs to loud throws, preserves markers and loud stubs, records changes.md", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
const cleaned = await readFile(join(cwd, "stubs.ts"), "utf8");
// Silent stubs now throw loudly, naming the function.
expect(cleaned).toContain('throw new Error("todos: getPrice() is a stub")');
expect(cleaned).toContain('throw new Error("todos: notify() is a stub")');
// Markers are NEVER deleted and loud stubs are NEVER touched.
expect(cleaned).toContain("// TODO: add pagination");
expect(cleaned).toContain('throw new Error("Not implemented")');
expect(cleaned).toContain("Cleaned by pygienium-todos");
// The placeholder bodies are gone.
expect(cleaned).not.toContain("return 0;");
expect(cleaned).not.toContain("notify(): void {}");
const changes = await readFile(changesPath(cwd), "utf8");
expect(changes).toContain(
"summary: 2 silent stub(s) converted to loud, 0 kept",
);
expect(changes).toContain("getPrice()");
expect(changes).toContain("notify()");
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.fix).toBe(true);
expect(state?.checks["todos"]?.status).toBe("complete");
});
it("findings.md and changes.md live under .pygienium/checks/todos/", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "--fix", stubCtx(cwd));
expect(findingsPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "todos", "findings.md"),
);
expect(changesPath(cwd)).toBe(
join(cwd, ".pygienium", "checks", "todos", "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("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
expect(state?.checks["todos"]?.status).toBe("skipped");
});
it("reports a new/resolved delta against the previous run's counts", async () => {
await seedStubs(cwd);
const check = getCheck("todos")!;
await handleCheckCommand(check, "", stubCtx(cwd));
// The previous run's verified counts are parseable from run-state.
const prior = await todosPriorCounts(cwd);
expect(prior).toEqual({ silent: 2, loud: 1, marker: 1 });
// Nobody flagged anything new and everything was resolved: the next scan
// sees zero candidates → new: 0, resolved: 4 (all prior items).
await rm(join(cwd, "stubs.ts"));
const task = await buildTodosScanTask(cwd, {
cwd,
target: cwd,
fix: false,
rest: [],
});
expect(task).toContain(
"summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s)",
);
expect(task).toContain("| new: 0 | resolved: 4 |");
});
});

146
tests/verify-hooks.test.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* verify-hooks.test.ts — proves every artifact-producing check fails loudly
* when its sub-agent returns ok without writing findings.md.
*
* This is the exact failure mode the MagniFluo run exposed: complexity,
* deep-modules, and defensive-guards returned ok with empty text in
* milliseconds, produced no findings.md, and — because they had no `verify`
* hook — were stamped `complete` by the fallback gate re-run. todos was the
* only one that failed, solely because it already had a verify hook.
*
* Each check now carries a `verify` hook asserting its artifacts landed. A
* no-op agent runner (ok + empty text + no writes) must fail at verify with a
* message naming the missing findings.md, and the check status must be
* `failed` — never `complete`.
*/
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
import { mkdtemp, 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,
type AgentRunner,
} from "../src/agent-runner.js";
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
import { loadRunState } from "../src/run-state.js";
import { check as complexityCheck } from "../src/checks/complexity.js";
import { check as deadCodeCheck } from "../src/checks/dead-code.js";
import { check as deepModulesCheck } from "../src/checks/deep-modules.js";
import { check as defensiveGuardsCheck } from "../src/checks/defensive-guards.js";
/** Agent runner that simulates the MagniFluo bug: ok, empty, no writes. */
const noopRunner: AgentRunner = async () => ({
ok: true,
text: "",
});
function stubCtx(cwd: string): PygieniumCtx {
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
}
describe("verify hooks fail loudly on empty agent output", () => {
let cwd: string;
beforeEach(async () => {
clearChecks();
setAgentRunner(noopRunner);
cwd = await mkdtemp(join(tmpdir(), "pygienium-verify-"));
// Seed one source file so the source-file gates (deep-modules,
// defensive-guards, dead-code) pass and the check reaches analysis.
await writeFile(join(cwd, "sample.ts"), "export const x = 1;\n", "utf8");
});
afterEach(async () => {
resetAgentRunner();
await rm(cwd, { recursive: true, force: true }).catch(() => {});
});
/**
* Run a check with the no-op runner and assert it fails at verify — for
* checks where the sub-agent (not the task builder) is responsible for
* writing findings.md.
*/
async function assertFailsVerify(
check: CheckDefinition,
findingsNeedle: string,
): Promise<void> {
registerCheck(check);
await handleCheckCommand(check, "", stubCtx(cwd));
const state = await loadRunState(cwd);
const entry = state?.checks[check.name];
expect(entry).toBeDefined();
expect(entry?.status).toBe("failed");
expect(entry?.error).toContain("verify");
expect(entry?.error).toContain("findings.md");
// The verify phase itself is marked failed (not analysis).
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
expect(verifyPhase?.status).toBe("failed");
expect(verifyPhase?.error).toContain(findingsNeedle);
// Analysis reported ok (the bug: ok + empty), but no findings captured.
const analysisPhase = entry?.phases.find((p) => p.id === "analysis");
expect(analysisPhase?.status).toBe("complete");
expect(entry?.findings).toBe("");
}
it("complexity fails verify when findings.md is missing", async () => {
await assertFailsVerify(complexityCheck, "complexity verify");
});
it("deep-modules fails verify when findings.md is missing", async () => {
await assertFailsVerify(deepModulesCheck, "deep-modules verify");
});
it("defensive-guards fails verify when findings.md is missing", async () => {
await assertFailsVerify(defensiveGuardsCheck, "defensive-guards verify");
});
/**
* dead-code is hybrid: its `buildDeadCodeScanTask` deterministically
* writes findings.md via a pre-scan BEFORE the agent runs. So a no-op
* agent still leaves the artifact, and verify correctly passes — proving
* the hook does not false-positive on dead-code's robust design. The
* grep on the verify hook is still live: delete the pre-written file and
* the same hook fails (asserted in the --fix case below for changes.md).
*/
it("dead-code verify passes with a no-op agent (deterministic pre-scan wrote findings.md)", async () => {
registerCheck(deadCodeCheck);
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
const state = await loadRunState(cwd);
const entry = state?.checks["dead-code"];
expect(entry?.status).toBe("complete");
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
expect(verifyPhase?.status).toBe("complete");
// The findings.md the pre-scan wrote is on disk.
const { stat } = await import("node:fs/promises");
const { findingsPath } = await import("../src/checks/dead-code.js");
await expect(stat(findingsPath(cwd))).resolves.toBeTruthy();
});
it("with --fix, a missing changes.md fails verify even when findings.md exists", async () => {
// Defensive-guards: write findings.md ourselves so the findings check
// passes, but leave changes.md absent — verify must still fail.
const { mkdir, writeFile: wf } = await import("node:fs/promises");
const { dirname } = await import("node:path");
const { findingsPath } = await import("../src/checks/defensive-guards.js");
const f = findingsPath(cwd);
await mkdir(dirname(f), { recursive: true });
await wf(f, "# findings\n", "utf8");
registerCheck(defensiveGuardsCheck);
// Runner writes changes.md content into its text but never to disk.
setAgentRunner(async () => ({ ok: true, text: "" }));
await handleCheckCommand(defensiveGuardsCheck, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd);
const entry = state?.checks["defensive-guards"];
expect(entry?.status).toBe("failed");
expect(entry?.error).toContain("changes.md");
expect(entry?.error).toContain("verify");
});
});

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}