commit a40cdcd9e3a5b3f0f07384b4becb0ed2b4f79501 Author: Michael Freno Date: Mon Aug 10 09:46:09 2026 -0400 initial import: @mikefreno/omp-pygenium (omp port) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7a26a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules/ +dist/ +.pi-lens/ +.ralpi/ +package-lock.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a9e4e8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cbe7d8 --- /dev/null +++ b/README.md @@ -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 `/.pygienium/run-state.json`. + +| Command | What it does | +| --- | --- | +| `/pygienium-help` | Print every command, shipped check, and flag. | +| `/pygienium- [path] [--fix]` | Run one isolated sub-agent that scans a target, applies fixes with `--fix`, and emits a findings + changes report. | +| `/pygienium-all [path] [--fix]` | Run every registered check in sequence under one resumable run-state. | +| `/pygienium-status [path]` | Show per-check progress, artifact line counts, and errors for the latest run. | +| `/pygienium-resume [path] [--fresh]` | Resume the latest in-progress/failed/partial run; complete/skipped checks skip unless `--fresh`. | +| `/pygienium-export [path] [--check=] [--status=] [--out=md\|json]` | Bundle every check's `findings.md` + `changes.md` into `pygienium/export.{md\|json}`. | + +## Flags + +| Flag | Scope | Description | +| --- | --- | --- | +| `[path]` | all check commands | Target file or directory to scan (default: current dir). | +| `--fix` | ``, `all`, `resume` | Apply fixes (default: scan-only; emits findings only). | +| `--fresh` | `resume` | Re-dispatch completed checks too — reset their run-state entries and re-run. | +| `--check=` | `export` | Comma-separated check names to include in the bundle. | +| `--status=` | `export` | Comma-separated statuses to include (e.g. `complete,failed,skipped`). | +| `--out=` | `export` | Bundle format: `md` (default) or `json`. | + +## Checks + +The five shipped checks live in [`src/checks/`](./src/checks/) and 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 `/.pygienium/checks//` (run +state lives at `/.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-` 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/.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/.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- ─► runCheck(def) (src/modes/check-runner.ts) + │ + ├─ Q0 recon (shared, run once per run) (src/recon.ts) + │ git state + source-file inventory → .pygienium/recon.json + ├─ analysis sub-agent (buildScanTask) ← scanner/ agent + │ writes .pygienium/checks//findings.md + ├─ fix sub-agent (buildFixTask) ← fixer, only with --fix + │ writes .pygienium/checks//changes.md + ├─ verify gate (re-runs check.gate) + └─ cleanup (drops transient scratch artifacts) +``` + +- **Sub-agents** are isolated in-memory `AgentSession`s scoped to the target + `cwd` (see `src/agent-runner.ts`), with the agent definition's system prompt + and tool allowlist applied. Agent definitions are plain editable markdown in + [`agents/`](./agents/) (frontmatter `name` + `allowedTools`, body = system + prompt) — tuning a sub-agent never needs TypeScript changes. 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 `/.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-` 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 diff --git a/agents/.gitkeep b/agents/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agents/deep-modules.md b/agents/deep-modules.md new file mode 100644 index 0000000..2759bc4 --- /dev/null +++ b/agents/deep-modules.md @@ -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 .*"` 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 `/.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 `/.pygienium/checks/deep-modules/findings.md`). + +`findings.md` format — a markdown document: + +```markdown +# Deep-modules findings + +summary: shallow module(s) flagged of reviewed + +## 1. +- kind: pass-through-wrapper | one-line-reexport | trivial-getter-class | adapter-layer +- evidence: +- importers: (risk: low if 0, high if >0) +- recommendation: inline-and-remove | consolidate-with- | review-manually +- risk: low | high +``` + +If the target is clean, write: + +```markdown +# Deep-modules findings + +summary: 0 shallow module(s) flagged of reviewed + +No shallow modules detected. +``` + +After writing `findings.md`, emit a terse one-line summary as your final message: + +``` +deep-modules: issue(s) — see +``` + +# Tone + +Precise and terse. Quote the shallow code only when it clarifies the finding. +Always state the importer count and risk so the fixer can apply safe +consolidations and defer risky ones. diff --git a/agents/defensive-guards.md b/agents/defensive-guards.md new file mode 100644 index 0000000..62c77d0 --- /dev/null +++ b/agents/defensive-guards.md @@ -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 `/.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 `/.pygienium/checks/defensive-guards/findings.md`). + +`findings.md` MUST separate redundant guards from boundary guards. Format: + +```markdown +# Defensive-guards findings + +summary: redundant guard(s) flagged, boundary guard(s) kept of reviewed + +## Redundant (remove) + +### 1. : +- kind: redundant-null-check | swallowing-try-catch | rethrow-only-try-catch | error-masking-fallback | defensive-guard-on-validated-input | compatibility-fallback +- evidence: +- reason: + +## Boundary (keep) + +### 1. : +- kind: untrusted-input-guard | io-guard | parsing-guard +- evidence: +- reason: +``` + +If the target is clean, write: + +```markdown +# Defensive-guards findings + +summary: 0 redundant guard(s) flagged, 0 boundary guard(s) kept of reviewed + +No redundant defensive guarding detected. +``` + +After writing `findings.md`, emit a terse one-line summary as your final +message: + +``` +defensive-guards: redundant, boundary kept — see +``` + +# Tone + +Precise and terse. Always state the declared type when calling a null check +redundant, and always state which boundary a kept guard protects. diff --git a/agents/fixer.md b/agents/fixer.md new file mode 100644 index 0000000..6850dd5 --- /dev/null +++ b/agents/fixer.md @@ -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. : (auto) +2. — skipped: (manual) +``` + +# Tone + +Terse. State the file, the line, and the fix. Do not narrate exploration. diff --git a/agents/scanner.md b/agents/scanner.md new file mode 100644 index 0000000..573679e --- /dev/null +++ b/agents/scanner.md @@ -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 `/.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 +: issue(s) +1. [severity: high|med|low] : +2. ... +``` + +If the target is clean, emit: + +```findings +: 0 issues +``` + +# Tone + +Be precise and terse. Quote the offending code only when it clarifies a finding. +Do not propose fixes unless the task explicitly asks — the fixer agent receives +your findings separately. diff --git a/agents/todos.md b/agents/todos.md new file mode 100644 index 0000000..6cf81e3 --- /dev/null +++ b/agents/todos.md @@ -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 `/.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 `/.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: marker(s), silent stub(s), loud stub(s) | new: | resolved: | reviewed: + +## TODO markers + +### 1. : +- marker: TODO | FIXME | HACK | XXX | @todo +- context: +- disposition: track | drop-noise + +## Silent stubs (actionable) + +### 1. : +- function: (or the file when unnamed) +- stub: +- disposition: convert-to-loud | keep (not a stub) + +## Loud stubs (already failing loudly — tracked debt) + +### 1. : +- 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: | reviewed: + +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: silent stub(s), loud stub(s), marker(s) — see +``` + +# 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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3b977dd --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..60aab3d --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/agent-runner.ts b/src/agent-runner.ts new file mode 100644 index 0000000..11ec09d --- /dev/null +++ b/src/agent-runner.ts @@ -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 ` — 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; + +/** Module-level runner (defaults to the SDK-backed runner; tests override it). */ +let currentRunner: AgentRunner = defaultAgentRunner; + +/** Entry point used by the check-runner. */ +export function runAgentTask(opts: AgentTaskOptions): Promise { + return currentRunner(opts); +} + +/** Override the active agent runner (primarily for tests). */ +export function setAgentRunner(runner: AgentRunner): void { + currentRunner = runner; +} + +/** Restore the default SDK-backed agent runner. */ +export function resetAgentRunner(): void { + currentRunner = defaultAgentRunner; +} + +/** + * Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`, + * overrides the system prompt with the agent definition's body, restricts tools + * to the agent's allowlist, and runs the task to completion. + */ +export async function defaultAgentRunner( + opts: AgentTaskOptions, +): Promise { + const 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 — write text to path (relative to cwd); recorded + * !echo — appended to findings + * + * The agent's emitted findings text is the collected `!echo`/`!write` lines. + */ +export const fakeAgentRunner: AgentRunner = async (opts) => { + const lines = opts.task.split(/\r?\n/); + const findings: string[] = []; + try { + for (const line of lines) { + const trimmed = line.trim(); + const write = /^!write\s+(\S+)\s*(.*)$/.exec(trimmed); + if (write) { + const rel = write[1] as string; + const content = (write[2] ?? "").replace(/^["']|["']$/g, ""); + const full = isAbsolute(rel) ? rel : join(opts.cwd, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content + "\n", "utf8"); + findings.push(`wrote ${rel}`); + continue; + } + const echo = /^!echo\s+(.*)$/.exec(trimmed); + if (echo) { + findings.push((echo[1] ?? "").replace(/^["']|["']$/g, "")); + } + } + return { ok: true, text: findings.join("\n") }; + } catch (err) { + return { + ok: false, + text: findings.join("\n"), + error: err instanceof Error ? err.message : String(err), + }; + } +}; diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..e7c4e45 --- /dev/null +++ b/src/agents.ts @@ -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; + body: string; +} { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw); + if (!match) return { frontmatter: {}, body: raw }; + const fmText = match[1] ?? ""; + const body = match[2] ?? ""; + const frontmatter: Record = {}; + const lines = fmText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + // YAML block-list: a key with an empty value followed by "- item" lines. + const blockList = /^([A-Za-z_][A-Za-z0-9_-]*):\s*$/.exec(trimmed); + if (blockList) { + const key = blockList[1] as string; + const items: string[] = []; + let j = i + 1; + for (; j < lines.length; j++) { + const item = /^\s+-\s+(.*)$/.exec(lines[j] ?? ""); + if (!item) break; + items.push((item[1] ?? "").trim().replace(/^["']|["']$/g, "")); + } + if (items.length > 0) { + frontmatter[key] = items; + i = j - 1; + } + continue; + } + + // Inline key/value (also handles inline lists like key: [a, b]). + const idx = trimmed.indexOf(":"); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1); + const valueList: string[] = []; + for (const part of inner.split(",")) { + const v = part.trim().replace(/^["']|["']$/g, ""); + if (v) valueList.push(v); + } + frontmatter[key] = valueList; + } else { + frontmatter[key] = value.replace(/^["']|["']$/g, ""); + } + } + return { frontmatter, body: body.trim() + "\n" }; +} + +function asStringList(value: unknown): string[] | undefined { + if (value == null) return undefined; + if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean); + if (typeof value === "string") { + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + } + return undefined; +} + +function asString(value: unknown): string | undefined { + if (value == null) return undefined; + if (typeof value === "string") return value; + return String(value); +} + +/** + * Load agent definitions: the extension's `agents/*.md` baseline, plus any + * repo-local `agents/*.md` at `/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> { + const extRoot = extensionRoot(); + const result = new Map(); + + // 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: `/agents/` + * legitimately doesn't exist in most scanned projects. + */ +async function scanAgentDir( + dir: string, + result: Map, + repoDir = false, +): Promise { + 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`, + ); + } +} diff --git a/src/checks/.gitkeep b/src/checks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/checks/comments.ts b/src/checks/comments.ts new file mode 100644 index 0000000..d776f2f --- /dev/null +++ b/src/checks/comments.ts @@ -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 `/.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: `/.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 + + comment smell(s) across file(s). + +## +- L: +- L: KEEP (why) — # listed for transparency +\`\`\` + +If no smells are found, write \`# comments — findings\n\n0 comment smell(s).\` +and return that text. Always create findings.md so the run has an artifact. + +Write the report under \`${outDir}\` (create directories as needed). +`; +} + +/** + * Build the fix sub-agent task from the scan findings. Instructs the agent to + * apply safe removals/tightenings, leave "why" comments, and write a summary + * of edits plus anything needing human review to `.pygienium/comments/changes.md`. + */ +export function buildCommentsFixTask( + _cwd: string, + scope: CheckScope, + findings: string, +): string { + const outDir = commentsArtifactDir(scope); + const changesFile = changesPath(scope); + return `# Task: comments hygiene fix + +You are running the **comments** hygiene fix phase. + +## Target +- Fix target: \`${scope.target}\` + +## Input: scan findings +${findings.trim().length > 0 ? findings : "(no findings text provided)"} + +## What to do +1. For each RESTATE finding: remove the comment entirely. +2. For each VERBOSE finding: replace the comment with a tightened one-sentence + version that keeps the key insight. +3. For every WHY comment: leave it untouched (tighten only if it is also + verbose, preserving the rationale). +4. Do not change any code logic, formatting, or ordering — only comments. +5. Write a summary to \`${changesFile}\` and return it as your final message. + +${RUBRIC} + +## changes.md format + +\`\`\`markdown +# comments — changes + + edit(s) applied; deferred for human review. + +## Applied +- : comment (auto) + +## Needs human review +- : (manual) +\`\`\` + +If nothing needed changing, write +\`# comments — changes\n\n0 edit(s) applied.\` and return that text. Always +create changes.md so the run has an artifact. Write it under \`${outDir}\`. +`; +} + +/** + * Precondition gate. Returns an error string when the comments check cannot + * proceed (target path missing or not a real file/directory), else + * `undefined`. Idempotent — passes identically before analysis and at verify. + */ +async function commentsGate(cwd: string): Promise { + const { stat } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + const target = resolve(cwd); + try { + const s = await stat(target); + if (s.isDirectory() || s.isFile()) return undefined; + return `target is not a file or directory: ${target}`; + } catch { + return `target path does not exist: ${target}`; + } +} + +/** + * Verify hook: confirms the check actually produced its artifacts. After the + * scan phase `findings.md` must exist; after the fix phase `changes.md` must + * exist too (the scan-phase-only run skips `changes.md` by design). Returns an + * error string to fail verify, or `undefined` to pass. Replaces the historical + * no-op verify (which only re-ran the existence gate) so the verify phase now + * genuinely asserts the run produced its report. + */ +async function commentsVerify(scope: CheckScope): Promise { + const { stat } = await import("node:fs/promises"); + const f = findingsPath(scope); + try { + await stat(f); + } catch { + return `comments verify: expected findings.md at ${f} after scan, none found.`; + } + if (scope.fix) { + const c = changesPath(scope); + try { + await stat(c); + } catch { + return `comments verify: expected changes.md at ${c} after --fix, none found.`; + } + } + return undefined; +} + +/** The comments hygiene check definition. */ +export const 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. diff --git a/src/checks/complexity.ts b/src/checks/complexity.ts new file mode 100644 index 0000000..09d8b5f --- /dev/null +++ b/src/checks/complexity.ts @@ -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. + * - 35–49 → heavy skepticism. Only keep if critical path + justified. + * - <35 → not flagged on cyclomatic grounds (may still be flagged for other + * structural smells). + * + * Structural smells detected: + * - deep nesting (>3 levels) + * - speculative abstractions + * - premature config indirection + * - non-idiomatic patterns + * - over-engineered generics + * - unnecessary wrappers + * + * @module pygienium/checks/complexity + */ + +import 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: `/.pygienium/checks/complexity/`. + */ +export function complexityArtifactDir(scope: CheckScope): string { + const base = scope.cwd.replace(/\/+$/, ""); + return `${base}/.pygienium/checks/complexity`; +} + +/** Absolute path to findings artifact. */ +export function findingsPath(scope: CheckScope): string { + return `${complexityArtifactDir(scope)}/findings.md`; +} + +/** Absolute path to changes artifact. */ +export function changesPath(scope: CheckScope): string { + return `${complexityArtifactDir(scope)}/changes.md`; +} + +/** + * Shared rubric for complexity analysis, injected into both scan and fix tasks. + */ +const RUBRIC = `# Complexity hygiene rubric + +## Cyclomatic complexity thresholds + +Cyclomatic complexity counts the number of independent paths through a function. +Compute via language-native tools when available (lizard, radon, gocyclo), or +count decision points (if/else if/for/while/case/&&/||/catch) per function. + +| Score | Action | +|-------|--------| +| 50+ | **MUST refactor.** No exceptions. Break the function into smaller pieces. | +| 35–49 | **Heavy skepticism.** Only keep if this is a massively critical point along the main path AND the complexity genuinely must be here. Document justification in findings.md; otherwise refactor. | +| <35 | Not flagged on cyclomatic grounds (may still be flagged for other structural smells). | + +## Structural complexity smells + +- **Deep nesting (>3 levels).** Flatten with early returns, guard clauses, or extracting to named helpers. +- **Speculative abstractions.** Remove abstractions created "just in case" — no concrete use case yet. +- **Premature config indirection.** Remove configuration layers that add no value yet. +- **Non-idiomatic patterns.** Replace with common conventions for the language. +- **Over-engineered generics.** Simplify to concrete types when only one type is used. +- **Unnecessary wrappers.** Inline trivial wrappers that add no logic. + +## Refactoring principles + +1. **Simplest implementation.** Choose the simplest implementation that fully meets current requirements. +2. **No backward-compat baggage.** Remove obsolete paths rather than adding compatibility layers. +3. **Grow in layers.** Build on a product that already works; don't trade a working product for unfinished complexity. +4. **Use existing libraries.** Lean on well-maintained libraries when they reduce complexity or improve reliability. +5. **Long-term decisions.** Make architectural decisions for the long term, not stopgaps meant to be replaced later. +`; + +/** + * Build the analysis sub-agent task. Instructs the agent to: + * 1. Compute cyclomatic complexity per function + * 2. Identify structural complexity smells + * 3. Write findings to .pygienium/checks/complexity/findings.md + */ +export function buildComplexityScanTask( + _cwd: string, + scope: CheckScope, +): string { + const 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) + - **35–49** = heavy skepticism (must justify or refactor) + - **<35** = not flagged on cyclomatic grounds + +### 2. Identify structural complexity smells + +For each file, identify: +- Deep nesting (>3 levels) +- Speculative abstractions +- Premature config indirection +- Non-idiomatic patterns +- Over-engineered generics +- Unnecessary wrappers + +### 3. Write findings + +Write a findings report to \`${findingsFile}\` with per-function scores and +structural smell locations. Include a proposed simpler form for every flagged +function. + +${RUBRIC} + +## findings.md format + +\`\`\`markdown +# complexity — findings + +## Cyclomatic complexity + +| File | Function | Score | Band | Action | +|------|----------|-------|------|--------| +| path/to/file:42 | myFunction | 65 | 50+ | MUST refactor | +| path/to/file:100 | otherFunction | 42 | 35-49 | Skepticism — justify or refactor | +| path/to/file:150 | simpleFunction | 8 | <35 | OK | + +## Structural smells + +- [severity] : +- + +## Justifications (35–49 band) + +For each function kept at 35–49 complexity: +- **Function:** at : +- **Score:** +- **Justification:** +\`\`\` + +If no issues found, write: +\`# complexity — findings\n\n0 complexity issues found.\` + +Always create findings.md so the run has an artifact. +`; +} + +/** + * Build the fix sub-agent task from the scan findings. Instructs the agent to: + * 1. Split 50+ complexity functions + * 2. Refactor or justify 35–49 functions + * 3. Apply safe refactors for structural smells + * 4. Write changes summary to .pygienium/checks/complexity/changes.md + */ +export function buildComplexityFixTask( + _cwd: string, + scope: CheckScope, + findings: string, +): string { + const outDir = complexityArtifactDir(scope); + const changesFile = changesPath(scope); + return `# Task: excessive complexity fix + +You are running the **complexity** hygiene fix phase. + +## Target +- Fix target: \`${scope.target}\` + +## Input: scan findings +${findings.trim().length > 0 ? findings : "(no findings text provided)"} + +## What to do + +### 1. Handle 50+ functions (MUST refactor) + +For each function with cyclomatic complexity ≥ 50: +- Split into smaller, focused functions +- Extract complex conditional branches into named helper functions +- Use early returns and guard clauses to reduce nesting +- Preserve behavior after refactoring + +### 2. Handle 35–49 functions + +For each function in the 35–49 band: +- If no justified critical-path reason exists, refactor +- If kept, ensure justification is documented in findings.md +- Prefer refactoring over keeping + +### 3. Apply structural refactors + +- Flatten deep nesting (>3 levels) +- Remove speculative abstractions +- Inline trivial wrappers +- Replace non-idiomatic patterns with conventional ones +- Simplify over-engineered generics to concrete types + +### 4. Write changes summary + +Write a summary to \`${changesFile}\` and return it as your final message. + +${RUBRIC} + +## changes.md format + +\`\`\`markdown +# complexity — changes + + refactoring(s) applied; deferred for human review. + +## Applied + +- : split (was , now ) +- : — nested conditionals flattened +- : — trivial wrapper inlined +- : — speculative abstraction removed + +## Deferred (needs human review) + +- : (manual) + +## Justified (kept at 35–49) + +- : () — +\`\`\` + +If nothing needed changing, write: +\`# complexity — changes\n\n0 refactoring(s) applied.\` + +Always create changes.md so the run has an artifact. Write it under \`${outDir}\`. +`; +} + +/** + * Precondition gate. Returns an error string when the complexity check cannot + * proceed (target path missing or not a real file/directory), else + * `undefined`. + */ +async function complexityGate(cwd: string): Promise { + const { stat } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + const target = resolve(cwd); + try { + const s = await stat(target); + if (s.isDirectory() || s.isFile()) return undefined; + return `target is not a file or directory: ${target}`; + } catch { + return `target path does not exist: ${target}`; + } +} + +/** + * 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 { + 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. diff --git a/src/checks/dead-code.ts b/src/checks/dead-code.ts new file mode 100644 index 0000000..57c9c82 --- /dev/null +++ b/src/checks/dead-code.ts @@ -0,0 +1,1144 @@ +/** + * checks/dead-code.ts — dead code and obsolete paths check. + * + * Finds unreferenced exports, files with zero importers, obsolete compatibility + * shims / migration helpers, and unused dependencies — then removes the + * clearly-dead items (engineering rule: no backward-compat layers) while + * preserving items that are only reachable through dynamic imports or runtime + * registration, which are listed for human review instead (pi-lens + * "suspected dead weight" semantics: zero/single-importer files get a review + * flag, not an unconditional delete). + * + * The check is hybrid by design: `buildScanTask`/`buildFixTask` run a + * deterministic pre-scan (import-graph + heuristics) and materialise + * `/.pygienium/checks/dead-code/`, + * then hand the sub-agent a prompt to verify/refine the pre-computed report. + * That keeps the E2E behaviour reproducible (and testable without a model) + * while the sub-agent's language-level judgment stays authoritative for the + * ambiguous cases. + * + * @module pygienium/checks/dead-code + */ + +import { + readFile, + readdir, + stat, + mkdir, + writeFile, + rm, +} from "node:fs/promises"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import type { CheckDefinition, CheckScope } from "./registry.js"; +import { + isScopeSource, + SCOPE_EXCLUDE_DIRS, + scopeRulesMarkdown, +} from "./scope.js"; + +/** Dead-code categories recognised by the rubric. */ +export type DeadCategory = "export" | "file" | "shim" | "dep"; + +/** Removal target of a dead-code item: the whole file or one symbol. */ +export type RemovalTarget = "file" | "symbol"; + +/** One dead-code finding. */ +export interface DeadCodeItem { + category: DeadCategory; + /** Absolute path of the offending file. */ + path: string; + /** Path relative to the scanned target (for reports). */ + rel: string; + /** Symbol name when the item is an export/shim symbol. */ + name?: string; + /** 1-based line of the declaration, when known. */ + line?: number; + /** Whether to remove the whole file or just the symbol. */ + target: RemovalTarget; + /** + * `true` when removal is ambiguous — dynamically imported, runtime + * registered, an entry point, or a still-referenced compat shim. Review + * items are preserved by `--fix` and surfaced for human judgement. + */ + review: boolean; + /** Human-readable justification. */ + reason: string; +} + +/** Full report of one dead-code scan. */ +export interface DeadCodeReport { + /** Absolute scanned target. */ + target: string; + /** ISO timestamp of the scan. */ + scannedAt: string; + /** All candidates, unsorted across categories. */ + items: DeadCodeItem[]; +} + +/** Categories in report display order. */ +export const CATEGORY_LABELS: Record = { + export: "Unused exports", + file: "Dead files (zero importers)", + shim: "Obsolete shims / migration helpers", + dep: "Unused dependencies", +}; + +/** Extensions tried when resolving a bare import specifier. */ +const RESOLVE_EXTENSIONS = [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", +]; + +/** Filename signals of a compat/deprecated/migration shim. */ +const SHIM_NAME_RE = + /(?:compat|legacy|deprecated|obsolete|obsoleted|migration|migrate|backcompat|back-compat|fallback|shim)/i; + +/** Inline signals of an actually-obsolete/deprecated declaration. Tagged + * markers only — bare prose words ("deprecated", "obsolete", "legacy", + * "compat") are deliberately excluded so generic comments cannot mislabel an + * ordinary or entry-like file as a compat shim and auto-delete it on `--fix`. */ +const SHIM_TAG_RE = + /@deprecated|@obsolete|@deprecat(?:ed|ion)?|migration helper|\bback(?:wards?-)?compat\b/i; + +/** Entry-ish basenames — zero-importer files that are likely app entries. */ +const ENTRY_NAME_RE = /^(?:index|main|cli|app|server|entry|bin|start)\./i; + +/** Strip a leading scope (`@scope/name`) so deps match `name` and `name/sub`. */ +function depStem(spec: string): string { + return spec.startsWith("@") + ? spec.split("/").slice(0, 2).join("/") + : (spec.split("/")[0] ?? spec); +} + +/** Recursively collect source files under `root` (or `[root]` when it is a file). */ +async function walkSourceFiles(root: string): Promise { + const st = await stat(root).catch(() => undefined); + if (!st) return []; + if (st.isFile()) return 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(); +} + +/** Resolve an import specifier to an absolute project file, or undefined. */ +async function resolveImport( + fromFile: string, + spec: string, +): Promise { + if (!spec.startsWith(".") && !spec.startsWith("/")) return undefined; + const base = resolve(dirname(fromFile), spec); + const candidates = [base]; + for (const e of RESOLVE_EXTENSIONS) { + candidates.push(base + e); + candidates.push(join(base, `index${e}`)); + } + for (const candidate of candidates) { + const st = await stat(candidate).catch(() => undefined); + if (st?.isFile()) return candidate; + } + return undefined; +} + +interface FileInfo { + path: string; + rel: string; + text: string; + /** Files that import this one via a static `import`/`require` statement. */ + staticImporters: Set; + /** Files that reference this one via `import(...)` / runtime registration. */ + dynamicImporters: Set; +} + +/** One exported symbol: the bound name and whether it comes from an `export {}` list. */ +interface DeclaredExport { + name: string; + line: number; + /** `true` when exported via `export { … }` (a list), not a direct declaration. */ + fromList: boolean; +} + +/** Extract declared export names with their 1-based declaration lines. */ +function declaredExports(text: string): DeclaredExport[] { + const out: DeclaredExport[] = []; + const re = + /export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g; + for (const m of text.matchAll(re)) { + if (m[1]) + out.push({ + name: m[1] as string, + line: lineOf(text, m.index ?? 0), + fromList: false, + }); + } + // `export { a, b as c }` named export lists (local re-exports). + const reExport = /export\s*\{([^}]*)\}/g; + for (const m of text.matchAll(reExport)) { + const inner = m[1] ?? ""; + for (const part of inner.split(",")) { + const name = part + .trim() + .split(/\s+as\s+/)[0] + ?.trim(); + if (name && /^[A-Za-z_$][\w$]*$/.test(name)) { + out.push({ + name, + line: lineOf(text, m.index ?? 0), + fromList: true, + }); + } + } + } + return out; +} + +function lineOf(text: string, index: number): number { + return text.slice(0, index).split("\n").length; +} + +/** Does `text` look like a barrel (aggregating re-exports)? */ +function isBarrel(text: string): boolean { + return /export\s*\{|export\s+\*\s+from|export\s*\{[^}]*\}\s*from/.test(text); +} + +/** Fetch the nearest package.json from `target` upward (within `cwd`). */ +async function findPackageJson( + target: string, + cwd: string, +): Promise { + const dir = stat(target) + .then((s) => (s.isDirectory() ? target : dirname(target))) + .catch(() => dirname(target)); + const start = await dir; + const stop = resolve(cwd); + let current = start; + // eslint-disable-next-line no-constant-condition + while (true) { + const candidate = join(current, "package.json"); + if (await stat(candidate).catch(() => undefined)) return candidate; + if (current === stop || dirname(current) === current) return undefined; + current = dirname(current); + } +} + +/** + * Deterministic dead-code scan: walks the target, builds a lightweight import + * graph, and classifies candidates into `export` / `file` / `shim` / `dep`. + * Ambiguity (dynamic imports, runtime registration, entry points, referenced + * compat shims) is captured via the `review` flag rather than guessed away. + */ +export async function detectDeadCode(target: string): Promise { + const files = await walkSourceFiles(target); + const infos = new Map(); + for (const path of files) { + const text = await readFile(path, "utf8").catch(() => ""); + infos.set(path, { + path, + rel: relative(target, path), + text, + staticImporters: new Set(), + dynamicImporters: new Set(), + }); + } + + // --- Pass 1: resolve every import/require/re-export edge ----------------- + const allSpecs = new Set(); + // Paths that are the SOURCE of a `export { … } from` / `export * from` + // re-export. Their whole export surface is reachable through the barrel even + // when no other file names the symbols, so their exports must never be + // auto-removed by a bare name search. + const reexportTargets = new Set(); + for (const info of infos.values()) { + const text = info.text; + // static: re `import x from '…'`, `import '…'`, `require('…')` + const staticRe = + /(?:^\s*import\s+(?:[^'"`]*?\s+from\s+)?|require\(\s*)(['"`])([^'"`]+)\1/gm; + for (const m of text.matchAll(staticRe)) { + const spec = m[2] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) infos.get(resolved)?.staticImporters.add(info.path); + } + // barrel re-exports aggregate into a barrel graph edge: `export * from '…'` + const barrelStarRe = /^\s*export\s+\*\s+from\s*(['"`])([^'"`]+)\1/gm; + for (const m of text.matchAll(barrelStarRe)) { + const spec = m[2] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) { + infos.get(resolved)?.staticImporters.add(info.path); + reexportTargets.add(resolved); + } + } + // `export { a, b } from '…'` + const barrelNamedRe = + /^\s*export\s*\{[^}]*\}\s*from\s*(['"`])([^'"`]+)\1/gm; + for (const m of text.matchAll(barrelNamedRe)) { + const spec = m[2] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) { + infos.get(resolved)?.staticImporters.add(info.path); + reexportTargets.add(resolved); + } + } + // dynamic: `import("…")` + const dynamicRe = /import\(\s*['"`]([^'"`]+)['"`]\s*\)/g; + for (const m of text.matchAll(dynamicRe)) { + const spec = m[1] as string; + allSpecs.add(spec); + const resolved = await resolveImport(info.path, spec); + if (resolved) infos.get(resolved)?.dynamicImporters.add(info.path); + } + } + + // --- Pass 2: classify candidates ---------------------------------------- + const items: DeadCodeItem[] = []; + // Paths whose whole file is a candidate (dead file, dynamic module, or + // compat shim) — their exports are handled at file level, never individually. + const wholeFileTarget = new Set(); + const packageJson = await findPackageJson(target, resolve(target)); + const packageJsonText = packageJson + ? await readFile(packageJson, "utf8").catch(() => undefined) + : undefined; + let packageJsonDeps: Record | undefined; + if (packageJsonText) { + try { + const parsed = JSON.parse(packageJsonText) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + scripts?: Record; + }; + packageJsonDeps = { + ...(parsed.dependencies ?? {}), + ...(parsed.devDependencies ?? {}), + ...(parsed.peerDependencies ?? {}), + ...(parsed.optionalDependencies ?? {}), + }; + const referencedNames = new Set(); + for (const spec of allSpecs) referencedNames.add(depStem(spec)); + for (const name of Object.keys(packageJsonDeps)) { + const stem = depStem(name); + const inScripts = Object.values(parsed.scripts ?? {}).some((s) => + s.includes(stem), + ); + if (!referencedNames.has(stem) && !inScripts) { + items.push({ + category: "dep", + path: packageJson as string, + rel: relative(target, packageJson as string), + name, + target: "symbol", + review: false, + reason: `"${name}" is declared in package.json but never imported or required by any source file.`, + }); + } + } + } catch { + /* unparseable package.json — skip dep analysis */ + } + } + + const packageJsonMain = + packageJsonText && packageJson + ? (() => { + try { + const p = JSON.parse(packageJsonText) as { + main?: string; + bin?: string | Record; + }; + return [p.main, ...Object.values(p.bin ?? {})] + .filter((v): v is string => typeof v === "string") + .map((v) => resolve(dirname(packageJson), v)); + } catch { + return [] as string[]; + } + })() + : ([] as string[]); + + for (const info of infos.values()) { + const staticImports = info.staticImporters.size; + const dynamicImports = info.dynamicImporters.size; + const filename = basename(info.path); + const shimNamed = SHIM_NAME_RE.test(filename); + const shimText = SHIM_TAG_RE.test(info.text); + const isShim = shimNamed || shimText; + const entryLike = + ENTRY_NAME_RE.test(filename) || + packageJsonMain.includes(info.path) || + /\.(?:config|test|spec|setup|env)\./i.test(filename) || + /^test(?:s)?[\\/]/i.test(info.rel); + + if (isShim) { + // Auto-remove ONLY when the signal is trustworthy (filename match or + // a tagged marker), the file has no importers, and it is NOT an entry + // point / test / config file. Entry-like files and any shim that is + // still imported are deferred to manual review — never auto-deleted, + // so prose or naming alone can never delete a live entry point. + const autoRemovable = + (shimNamed || shimText) && + staticImports === 0 && + dynamicImports === 0 && + !entryLike; + items.push({ + category: "shim", + path: info.path, + rel: info.rel, + target: "file", + review: !autoRemovable, + reason: shimNamed + ? `"${filename}" matches a compat/legacy/migration shim naming pattern.` + : `"${info.rel}" carries a deprecated/obsolete tag (manual review).`, + }); + // Whole-file handling subsumes any symbol-level export cleanup. + wholeFileTarget.add(info.path); + continue; + } + + if (staticImports === 0 && dynamicImports === 0 && !entryLike) { + // Zero importers, zero dynamic references, not an entry point. + items.push({ + category: "file", + path: info.path, + rel: info.rel, + target: "file", + review: false, + reason: "No file statically or dynamically imports this module.", + }); + wholeFileTarget.add(info.path); + continue; + } + + if (staticImports === 0 && dynamicImports > 0) { + // Only reachable through dynamic import / runtime registration. + items.push({ + category: "file", + path: info.path, + rel: info.rel, + target: "file", + review: true, + reason: + "Only referenced via dynamic import / runtime registration — removal is a judgement call.", + }); + // Preserve whole file; never pick at its exports (runtime-registered). + wholeFileTarget.add(info.path); + continue; + } + + // Unused exported symbols — only for files not slated for whole-file + // treatment (dead files, dynamic/runtime modules, compat shims). + if (wholeFileTarget.has(info.path)) continue; + + // Exports of a barrel SOURCE (re-exported by `export * from` / + // `export {…} from`) are reachable by name through the barrel even when + // no other file spells the symbol out — the review flag gates deletion. + const barrel = isBarrel(info.text); + const reexported = + reexportTargets.has(info.path) || + barrel || + /^\s*export\s+\*\s+from/.test(info.text); + const exports = declaredExports(info.text); + for (const exp of exports) { + const externalRefs = [...infos.values()].some( + (other) => + other.path !== info.path && + new RegExp(`\\b${escapeRegExp(exp.name)}\\b`).test(other.text), + ); + if (externalRefs) continue; + // Anything reachable through a barrel or exported via an `export {}` + // list stays behind a review flag: a bare name search cannot prove it + // is dead, so the deterministic fixer must not auto-remove it. + const needsReview = reexported || exp.fromList; + items.push({ + category: "export", + path: info.path, + rel: info.rel, + name: exp.name, + line: exp.line, + target: "symbol", + review: + needsReview || + externalRefs || + (staticImports > 0 && dynamicImports > 0), + reason: needsReview + ? exp.fromList + ? `"${exp.name}" is exported via an export list (${info.rel}) — removal is a judgement call.` + : `"${exp.name}" is reachable through a barrel/reexport (${info.rel}) and referenced nowhere by name — confirm before removing.` + : `Exported symbol "${exp.name}" is never imported or referenced by any other source file.`, + }); + } + } + + return { target, scannedAt: new Date().toISOString(), items }; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// --------------------------------------------------------------------------- +// Report renderers +// --------------------------------------------------------------------------- + +/** Render `findings.md` for the report, grouped by category with review flags. */ +export function renderFindingsMd(report: DeadCodeReport): string { + const lines: string[] = []; + lines.push("# Dead code findings"); + lines.push(""); + lines.push(`- Target: \`${report.target}\``); + lines.push(`- Scanned: ${report.scannedAt}`); + lines.push(`- Items: ${report.items.length}`); + lines.push(""); + for (const category of Object.keys(CATEGORY_LABELS) as DeadCategory[]) { + const items = report.items.filter((i) => i.category === category); + if (items.length === 0) continue; + lines.push(`## ${CATEGORY_LABELS[category]}`); + lines.push(""); + for (const item of items) { + const loc = item.name + ? `${item.rel}:${item.line ?? "?"} — ${item.name}` + : `${item.rel}`; + const flag = item.review ? "review" : "auto"; + lines.push(`- [${flag}] ${loc} — ${item.reason}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +/** Render `changes.md` for applied edits + preserved review items. */ +export function renderChangesMd( + applied: DeadCodeItem[], + preserved: DeadCodeItem[], +): string { + const lines: string[] = []; + lines.push("# Dead code changes"); + lines.push(""); + lines.push(`- Applied: ${applied.length}`); + lines.push(`- Preserved for review: ${preserved.length}`); + lines.push(""); + lines.push("## Removed (auto)"); + lines.push(""); + if (applied.length === 0) lines.push("- none"); + for (const item of applied) { + if (item.category === "dep") { + lines.push(`- dep: ${item.rel} — remove "${item.name}" (auto)`); + } else if (item.target === "file") { + lines.push(`- file: ${item.rel} — deleted (auto)`); + } else { + lines.push( + `- export: ${item.rel}:${item.line ?? "?"} — ${item.name} — removed (auto)`, + ); + } + } + lines.push(""); + lines.push("## Preserved for review (manual)"); + lines.push(""); + if (preserved.length === 0) lines.push("- none"); + for (const item of preserved) { + const kind = + item.category === "dep" + ? "dep" + : item.target === "file" + ? "file" + : "export"; + const loc = item.name + ? `${item.rel}:${item.line ?? "?"} — ${item.name}` + : item.rel; + lines.push(`- ${kind}: ${loc} — ${item.reason}`); + } + return lines.join("\n").trimEnd() + "\n"; +} + +// --------------------------------------------------------------------------- +// Fix application +// --------------------------------------------------------------------------- + +/** + * Compute the fix plan for a report: the clearly-dead items to remove and the + * review items to preserve. Review items are never touched by `--fix`. + */ +export function planFixes(report: DeadCodeReport): { + remove: DeadCodeItem[]; + preserve: DeadCodeItem[]; +} { + const remove: DeadCodeItem[] = []; + const preserve: DeadCodeItem[] = []; + for (const item of report.items) { + if (item.review) preserve.push(item); + else remove.push(item); + } + return { remove, preserve }; +} + +/** + * Remove one exported symbol declaration from JS/TS source, returning the + * new text. Handles direct declarations (`export function/class/const/let/var + * name …`), named export-list members (`export { name }`); optionally followed + * by `from "…"`), and single- or multi-statement bodies (arrow functions with + * block bodies, object literals, inline-closing braces). + * + * Uses a brace/token-aware scanner rather than a regex so a declaration is + * removed whole instead of being truncated at the first `;` or bailing on an + * unusual closing layout. A final delimiter-balance guard refuses to write a + * corrupt file: if the edit would unbalance the source, the original text is + * returned unchanged and the caller must not claim a removal. + */ +function removeExportSymbol(text: string, name: string): string { + const esc = escapeRegExp(name); + + // 1. Direct declaration: `export (async) (function|class|const|let|var) name`. + const direct = new RegExp( + `export\\s+(?:async\\s+)?(enum|interface|type|function|class|const|let|var)\\s+${esc}\\b`, + ); + const dm = direct.exec(text); + if (dm) { + const keyword = dm[1] as string; + const bodyStart = dm.index + dm[0].length; + const funcLike = keyword === "function" || keyword === "class"; + const end = scanDeclarationEnd(text, bodyStart, funcLike); + if (end === -1) return text; + const next = spliceStatement(text, dm.index, end); + return delimitersBalanced(next) ? next : text; + } + + // 2. Export-list member: `export { a, name as b, c }` (optionally `from …`). + return removeFromExportList(text, name); +} + +/** Index just past the end of a declaration body, or -1 when unbalanced. */ +function scanDeclarationEnd( + text: string, + start: number, + funcLike: boolean, +): number { + let i = start; + let mode: "line" | "block" | "regex" | '"' | "'" | "`" | null = null; + let paren = 0; + let bracket = 0; + let brace = 0; + while (i < text.length) { + const c = text[i]; + const n = text[i + 1]; + if (mode === "line") { + if (c === "\n") mode = null; + i++; + continue; + } + if (mode === "block") { + if (c === "*" && n === "/") { + mode = null; + i += 2; + } else { + i++; + } + continue; + } + if (mode === '"' || mode === "'" || mode === "`") { + if (c === "\\") { + i += 2; + } else { + if (c === mode) mode = null; + i++; + } + continue; + } + if (mode === "regex") { + if (c === "\\") { + i += 2; + } else { + if (c === "/") mode = null; + i++; + } + continue; + } + if (c === "/" && n === "/") { + mode = "line"; + i += 2; + continue; + } + if (c === "/" && n === "*") { + mode = "block"; + i += 2; + continue; + } + if (c === '"' || c === "'" || c === "`") { + mode = c; + i++; + continue; + } + if (c === "/" && looksLikeRegexStart(text, i)) { + mode = "regex"; + i++; + continue; + } + if (c === "(") paren++; + else if (c === ")") { + if (paren > 0) paren--; + } else if (c === "[") bracket++; + else if (c === "]") { + if (bracket > 0) bracket--; + } else if (c === "{") brace++; + else if (c === "}") { + if (brace > 0) brace--; + if (paren === 0 && bracket === 0 && brace === 0) { + if (funcLike) return i + 1; + // Statement end at a closing brace (object literal / arrow body): + // fold in an immediately-following `;` if present. + let j = i + 1; + while (j < text.length && /\s/.test(text[j]) && text[j] !== "\n") { + j++; + } + return text[j] === ";" ? j + 1 : i + 1; + } + } else if ( + !funcLike && + c === ";" && + paren === 0 && + bracket === 0 && + brace === 0 + ) { + return i + 1; + } + i++; + } + return -1; +} + +/** Recognize a regex literal start at `/` (division operators are not). */ +function looksLikeRegexStart(text: string, i: number): boolean { + let j = i - 1; + while (j >= 0 && /\s/.test(text[j])) j--; + if (j < 0) return true; + return !/[A-Za-z0-9_)]}\\`'"`]/.test(text[j]); +} + +/** Remove the first `[start, end)` slice, collapsing the vacated line. */ +function spliceStatement(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(end).replace(/^\s*\n+/, ""); +} + +/** Remove `name` from an `export { … }` / `export { … } from "…"` list. */ +function removeFromExportList(text: string, name: string): string { + const listRe = /export\s*\{/g; + let m: RegExpExecArray | null; + while ((m = listRe.exec(text))) { + const openIdx = (m.index ?? 0) + m[0].length - 1; + const closeIdx = findClosingBrace(text, openIdx); + if (closeIdx === -1) continue; + const inner = text.slice(openIdx + 1, closeIdx); + const parts = inner.split(",").map((p) => p.trim()); + const remaining = parts.filter((p) => localName(p) !== name); + if (remaining.length === parts.length) continue; // name not in this list + if (remaining.length === 0) { + // The whole statement becomes empty — drop it (including `from …`). + const end = statementTailEnd(text, closeIdx); + const next = spliceStatement(text, m.index ?? 0, end); + return delimitersBalanced(next) ? next : text; + } + const rebuilt = remaining.map((p) => ` ${p}`).join(","); + const next = + text.slice(0, openIdx) + "{" + rebuilt + " }" + text.slice(closeIdx + 1); + return delimitersBalanced(next) ? next : text; + } + return text; +} + +/** The local binding name left of `as` in an export-list member. */ +function localName(part: string): string { + return part.split(/\s+as\s+/)[0]?.trim() ?? part.trim(); +} + +/** Index one past the `}` that matches `{` at `openIdx`, or -1. */ +function findClosingBrace(text: string, openIdx: number): number { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + if (text[i] === "{") depth++; + else if (text[i] === "}") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** End index of an emptied export statement: past `}`, optional `from '…'`, `;`. */ +function statementTailEnd(text: string, closeIdx: number): number { + const i = closeIdx + 1; + const from = /^\s*from\s*(['"`])((?:[^'"`;])*)\1\s*;?/.exec(text.slice(i)); + if (from) return i + from[0].length; + if (text[i] === ";") return i + 1; + return i; +} + +/** Whether `(), [], {}` are balanced (strings/templates/comments skipped). */ +function delimitersBalanced(text: string): boolean { + let paren = 0; + let bracket = 0; + let brace = 0; + let mode: "line" | "block" | "regex" | '"' | "'" | "`" | null = null; + let i = 0; + while (i < text.length) { + const c = text[i]; + const n = text[i + 1]; + if (mode === "line") { + if (c === "\n") mode = null; + i++; + continue; + } + if (mode === "block") { + if (c === "*" && n === "/") { + mode = null; + i += 2; + } else { + i++; + } + continue; + } + if (mode === '"' || mode === "'" || mode === "`") { + if (c === "\\") { + i += 2; + } else { + if (c === mode) mode = null; + i++; + } + continue; + } + if (mode === "regex") { + if (c === "\\") { + i += 2; + } else { + if (c === "/") mode = null; + i++; + } + continue; + } + if (c === "/" && n === "/") { + mode = "line"; + i += 2; + continue; + } + if (c === "/" && n === "*") { + mode = "block"; + i += 2; + continue; + } + if (c === '"' || c === "'" || c === "`") { + mode = c; + i++; + continue; + } + if (c === "/" && looksLikeRegexStart(text, i)) { + mode = "regex"; + i++; + continue; + } + if (c === "(") paren++; + else if (c === ")") paren--; + else if (c === "[") bracket++; + else if (c === "]") bracket--; + else if (c === "{") brace++; + else if (c === "}") brace--; + i++; + } + return paren === 0 && bracket === 0 && brace === 0; +} + +/** + * Apply the deterministic fixes for a report. Returns the items actually + * removed and the preserved review items. `dryRun` returns the plan without + * touching the filesystem. + */ +export async function applyDeadCodeFixes( + report: DeadCodeReport, + dryRun = false, +): Promise<{ applied: DeadCodeItem[]; preserved: DeadCodeItem[] }> { + const { remove, preserve } = planFixes(report); + const applied: DeadCodeItem[] = []; + + for (const item of remove) { + // Review-flag semantics gate deletions at the mutation site too: an item + // marked for review is never deleted, even if it slipped into `remove` + // (e.g. a hand-built report or a classifier that flipped a flag). + if (item.review) { + preserve.push(item); + continue; + } + if (item.category === "dep") { + // Rewrite package.json minus the unused dependency. + try { + const raw = await readFile(item.path, "utf8"); + const parsed = JSON.parse(raw) as Record; + let touched = false; + for (const key of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) { + const deps = parsed[key] as Record | undefined; + if (deps && item.name && deps[item.name] !== undefined) { + delete deps[item.name]; + touched = true; + } + } + if (touched) { + if (!dryRun) + await writeFile( + item.path, + JSON.stringify(parsed, null, 2) + "\n", + "utf8", + ); + applied.push(item); + } + } catch { + /* leave unparseable manifests alone */ + } + continue; + } + if (item.target === "file") { + if (!dryRun) await rm(item.path, { force: true }); + applied.push(item); + continue; + } + // Symbol-level removal (unused export / inline shim). + try { + const text = await readFile(item.path, "utf8"); + const next = removeExportSymbol(text, item.name ?? ""); + if (next !== text) { + if (!dryRun) await writeFile(item.path, next, "utf8"); + applied.push(item); + } + } catch { + /* unreadable file — leave it alone */ + } + } + + return { applied, preserved: preserve }; +} + +// --------------------------------------------------------------------------- +// Artifact paths +// --------------------------------------------------------------------------- + +const CHECK_DIRNAME = ".pygienium/checks/dead-code"; + +/** `/.pygienium/checks/dead-code/findings.md` */ +export function findingsPath(target: string): string { + return join(target, CHECK_DIRNAME, "findings.md"); +} + +/** `/.pygienium/checks/dead-code/changes.md` */ +export function changesPath(target: string): string { + return join(target, CHECK_DIRNAME, "changes.md"); +} + +/** Write findings.md, returning its absolute path. */ +export async function writeFindingsFile( + target: string, + report: DeadCodeReport, +): Promise { + const path = findingsPath(target); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderFindingsMd(report), "utf8"); + return path; +} + +/** Write changes.md, returning its absolute path. */ +export async function writeChangesFile( + target: string, + applied: DeadCodeItem[], + preserved: DeadCodeItem[], +): Promise { + const path = changesPath(target); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderChangesMd(applied, preserved), "utf8"); + return path; +} + +// --------------------------------------------------------------------------- +// Task builders (sub-agent prompts) +// --------------------------------------------------------------------------- + +/** + * Scan task: pre-compute the candidate report, write `findings.md`, then ask + * the sub-agent to verify each candidate via grep/import-graph and emit the + * findings block. The pre-computed report keeps the agent's job to + * confirm/refute rather than re-derive the import graph from scratch. + */ +export async function buildDeadCodeScanTask( + cwd: string, + scope: CheckScope, +): Promise { + const report = await detectDeadCode(scope.target); + const path = await writeFindingsFile(scope.target, report); + return [ + `# Dead-code scan: ${scope.target}`, + ``, + `A deterministic import-graph pre-scan already ran. Its full, categorized report is`, + `written to \`${path}\`. Verify each candidate with \`grep\`/import-graph reasoning and`, + `correct any false positives before reporting.`, + ``, + `## Rubric — categorize findings by type`, + ``, + `1. **export** — exported functions/classes/consts referenced by no other module.`, + `2. **file** — files with zero importers (cross-check the pre-scan graph). Files only`, + ` reachable via \`import(...)\` or runtime registration are NOT dead: mark them`, + ` \`review\` and keep them in the findings under the review flag.`, + `3. **shim** — obsolete compatibility wrappers, deprecated aliases, migration`, + ` helpers (engineering rule: remove obsolete paths — do not keep compat layers).`, + `4. **dep** — dependencies declared in package.json but never imported/required.`, + ``, + `Items that are clearly dead (no importers, no dynamic reference, not an entry)`, + `are flagged \`auto\`. Items whose removal is ambiguous — dynamic imports, runtime`, + `registration (plugin manifests, lazy routes, decorators), entry points, or`, + `still-referenced compat shims — must be flagged \`review\` and never auto-removed.`, + ``, + `Write your verified findings back to \`${path}\` (same format), then end your`, + `response with a fenced \`findings\` block:`, + ``, + "```findings", + `dead-code: issue(s)`, + `1. [severity: high|med|low] : (auto|review)`, + "```", + ``, + `Target root: ${scope.target}. Work dir: ${cwd}.`, + ``, + scopeRulesMarkdown(), + ].join("\n"); +} + +/** + * Fix task: deterministically remove the clearly-dead items, write + * `changes.md`, then hand the sub-agent the remaining verification work. + * Review-flagged items (dynamic imports, runtime registration) are preserved + * and reported as `manual` — the sub-agent must NOT remove them. + */ +export async function buildDeadCodeFixTask( + cwd: string, + scope: CheckScope, + findings: string, +): Promise { + const report = await detectDeadCode(scope.target); + const { applied, preserved } = await applyDeadCodeFixes(report); + const path = await writeChangesFile(scope.target, applied, preserved); + return [ + `# Dead-code fix: ${scope.target}`, + ``, + `A deterministic fixer already ran against the pre-scan graph. By design it ONLY`, + `removed \`review:false\` (\`auto\`) items — unused exports, zero-importer files, obsolete`, + `shims, unused deps — and never touched \`review\` items. The change log is at`, + `\`${path}\`. Review it; then restore any auto-removal that looks like a false`, + `positive (the scan agent's classifier can be wrong) and re-list it as \`manual\`,`, + `and complete anything the pre-scan missed (use \`edit\` for symbol-level`, + `removals, \`rm\` via bash for dead files).`, + ``, + `## Hard rules`, + ``, + `- REMOVE clearly-dead items: unused exports, zero-importer files, obsolete`, + ` compat/migration shims, unused dependencies. Engineering rule: no compat layers.`, + `- NEVER remove \`review\`-flagged items: dynamically imported modules, runtime-`, + ` registered plugins/lazy routes, entry points, barrel re-export sources, or`, + ` export-list symbols. They stay — list them as manual.`, + `- If a listed auto-removal is wrong, revert it and record it as \`manual\` in`, + ` \`${path}\` rather than letting it stand.`, + `- Do not touch public API that is still imported; report it as \`manual\` instead.`, + ``, + `## Scanner findings`, + ``, + "```", + findings, + "```", + ``, + `Update \`${path}\` to reflect any additional removals you performed, then end your`, + `response with a fenced \`changes\` block:`, + ``, + "```changes", + `1. : (auto)`, + `2. — skipped: (manual)`, + "```", + ``, + `Target root: ${scope.target}. Work dir: ${cwd}.`, + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// Gate + registration +// --------------------------------------------------------------------------- + +/** Skip when the target holds no source files worth scanning. */ +export async function deadCodeGate(cwd: string): Promise { + const files = await walkSourceFiles(cwd); + if (files.length === 0) { + return "no source files found under target"; + } + return undefined; +} + +/** + * 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 deadCodeVerify(scope: CheckScope): Promise { + const f = findingsPath(scope.target); + try { + await stat(f); + } catch { + return `dead-code verify: expected findings.md at ${f} after scan, none found.`; + } + if (scope.fix) { + const c = changesPath(scope.target); + try { + await stat(c); + } catch { + return `dead-code verify: expected changes.md at ${c} after --fix, none found.`; + } + } + return undefined; +} + +/** The registered `CheckDefinition` (module self-registers on import). */ +export const check: CheckDefinition = { + name: "dead-code", + label: "Dead code", + description: + "Find unreferenced exports, dead files, obsolete compat shims, migration helpers, and unused dependencies; remove clearly-dead items and flag dynamic/runtime ones for review.", + agentName: "scanner", + fixAgentName: "fixer", + phaseId: "scan", + buildScanTask: buildDeadCodeScanTask, + buildFixTask: buildDeadCodeFixTask, + gate: deadCodeGate, + verify: deadCodeVerify, +}; + +// No self-registration here: `index.ts` auto-discovers every `checks/*.ts` +// that exports `check` and registers it. diff --git a/src/checks/deep-modules.ts b/src/checks/deep-modules.ts new file mode 100644 index 0000000..e8e3b10 --- /dev/null +++ b/src/checks/deep-modules.ts @@ -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 + * `/.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 { + 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 `) 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. diff --git a/src/checks/defensive-guards.ts b/src/checks/defensive-guards.ts new file mode 100644 index 0000000..9ea0bf5 --- /dev/null +++ b/src/checks/defensive-guards.ts @@ -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 + * `/.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 { + 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 + * `) 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. diff --git a/src/checks/registry.ts b/src/checks/registry.ts new file mode 100644 index 0000000..07f7854 --- /dev/null +++ b/src/checks/registry.ts @@ -0,0 +1,139 @@ +/** + * checks/registry.ts — pluggable check registry. + * + * A `CheckDefinition` describes one hygiene check (e.g. `comments`, `complexity`). + * The registry is a module-level `Map` so that adding a check only requires a + * new file in `src/checks/` plus one `registerCheck(def)` call — no changes to + * `index.ts` command wiring. At startup, `index.ts` iterates the registry and + * auto-registers a `/pygienium-` command per definition. + * + * Lifecycle of a single check run (orchestrated by `src/modes/check-runner.ts`): + * Q0 recon (shared) → analysis sub-agent (buildScanTask) → + * fix sub-agent (buildFixTask, only with --fix) → verify gate → cleanup. + * + * @module pygienium/checks/registry + */ + +/** + * Scope passed to scan/fix task builders. Resolved from the command args: + * a positional path (absolute or relative to `cwd`) plus parsed flags. + */ +export interface CheckScope { + /** Absolute working directory the check operates on. */ + cwd: string; + /** Target path (absolute) the check scans; defaults to `cwd` when none given. */ + target: string; + /** Whether fixes should be applied (the `--fix` flag). */ + fix: boolean; + /** Remaining raw tokens after flag parsing, for check-specific use. */ + rest: string[]; +} + +/** + * Verifies preconditions before a check runs its analysis phase. Returns an + * error string when the check cannot proceed (e.g. no source files match), + * or `undefined` when the gate passes. Implemented per-check so generic + * checks can bail early without spawning an agent. + */ +export type CheckGate = ( + cwd: string, +) => Promise | string | undefined; + +/** + * Optional post-analysis (+ optional fix) verify hook. Confirms the check + * actually produced its artifacts (e.g. `findings.md`/`changes.md`). Returns an + * error string to fail the verify phase, or `undefined` to pass. When omitted, + * the verify phase falls back to re-running {@link CheckDefinition.gate}, + * preserving the historical behaviour for checks that have nothing to verify. + */ +export type CheckVerify = ( + scope: CheckScope, +) => Promise | string | undefined; + +/** + * The structured task string handed to a sub-agent. `buildScanTask` produces + * the analysis prompt; `buildFixTask` consumes the findings text the scan + * agent emitted and produces a fix prompt. + * + * Task builders may be async: a check can pre-compute deterministic candidates + * (e.g. an import-graph scan) before assembling the prompt, so the sub-agent's + * job is to verify/refine rather than re-derive everything from scratch. + */ +export type BuildScanTask = ( + cwd: string, + scope: CheckScope, +) => string | Promise; +export type BuildFixTask = ( + cwd: string, + scope: CheckScope, + findings: string, +) => string | Promise; + +/** + * Definition of a single pluggable hygiene check. + */ +export interface CheckDefinition { + /** Lowercase kebab command suffix → `/pygienium-`. Must be unique. */ + name: string; + /** Human label shown in help and status strips. */ + label: string; + /** One-line description for `/pygienium-help`. */ + description: string; + /** + * Name of the agent definition (from `agents/*.md`) used for the analysis + * phase. The fix phase uses the `fixer` agent unless `fixAgentName` + * overrides it. + */ + agentName: string; + /** Optional override for the fix-phase agent (defaults to `fixer`). */ + fixAgentName?: string; + /** Identifier of the phase-strip phase this check belongs to (task 05). */ + phaseId: string; + /** Builds the analysis sub-agent task. */ + buildScanTask: BuildScanTask; + /** Builds the fix sub-agent task from scan findings. */ + buildFixTask: BuildFixTask; + /** + * Precondition gate. Returning a string skips the check (recorded as + * `skipped`); returning `undefined` proceeds normally. + */ + gate: CheckGate; + /** + * Optional verify hook confirming artifacts landed (see {@link CheckVerify}). + * Falls back to re-running `gate` when omitted. + */ + verify?: CheckVerify; +} + +const registry = new Map(); + +/** + * Register a check. Throws on duplicate names so wiring mistakes surface + * loudly at startup rather than silently shadowing a command. + */ +export function registerCheck(def: CheckDefinition): void { + if (!def.name || !/^[a-z0-9][a-z0-9-]*$/.test(def.name)) { + throw new Error( + `Invalid check name "${def.name}": must be lowercase kebab (e.g. "comments").`, + ); + } + if (registry.has(def.name)) { + throw new Error(`Duplicate pygienium check name: "${def.name}".`); + } + registry.set(def.name, def); +} + +/** Look up a registered check by name. */ +export function getCheck(name: string): CheckDefinition | undefined { + return registry.get(name); +} + +/** All registered checks in insertion order. */ +export function getAllChecks(): CheckDefinition[] { + return [...registry.values()]; +} + +/** Test-only: reset the registry between tests. */ +export function clearChecks(): void { + registry.clear(); +} diff --git a/src/checks/scope.ts b/src/checks/scope.ts new file mode 100644 index 0000000..c89d8c8 --- /dev/null +++ b/src/checks/scope.ts @@ -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 = 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 = 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 = 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 \`/.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). +`; +} diff --git a/src/checks/todos.ts b/src/checks/todos.ts new file mode 100644 index 0000000..7ffae08 --- /dev/null +++ b/src/checks/todos.ts @@ -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 `/.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 { + 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 { + 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 = { + 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 `) persist to the same location. + */ +export async function buildTodosScanTask( + cwd: string, + scope: CheckScope, +): Promise { + 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: marker(s), silent stub(s), loud stub(s) | new: | resolved: | reviewed: `, + ``, + 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: () is a stub");`, + ` Python: raise NotImplementedError(" is a stub")`, + ` Go: panic("todos: is a stub")`, + ` Rust: todo!(" is a stub")`, + ` generic: throw new Error("todos: 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 { 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 { + 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. diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..43f78fc --- /dev/null +++ b/src/commands.ts @@ -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 { + for (const line of buildPygieniumHelpLines()) { + process.stdout.write(`${line}\n`); + } +} + +/** + * `/pygienium- [path] [--fix]` — the per-check command handler. + * Exported so `index.ts` can bind one per registered `CheckDefinition` and so + * tests can invoke it directly with a stub context. + */ +/** + * `/pygienium- [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 { + 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 { + 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 { + const cwd = resolveCwd(args, ctx.cwd); + const state = await loadRunState(cwd); + if (!state) { + print(ctx, "pygienium: no run state found."); + return; + } + for (const line of formatRunStatus(state)) { + print(ctx, line); + } +} + +/** + * `/pygienium-resume [path] [--fresh]` — resume the most recent non-complete + * run by re-dispatching every check that isn't terminal (`complete`/`skipped`). + * Pass `--fresh` to re-dispatch even completed checks (their run-state entries + * are reset and the check re-runs analysis → fix → verify → cleanup fresh). + */ +export async function handleResumeCommand( + args: string, + ctx: PygieniumCtx, +): Promise { + const { cwd, fresh, 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=[,]] [--status=[,]] [--out=md|json]` + * — collect every check's `findings.md`/`changes.md` artifacts from + * `.pygienium/checks//`, apply filters, and write a single bundle to + * `.pygienium/export.{md|json}`. + */ +export async function handleExportCommand( + args: string, + ctx: PygieniumCtx, +): Promise { + const cwd = resolveCwd(args, ctx.cwd); + const filters: ExportFilters = parseExportFilters(args); + const state = await loadRunState(cwd); + const result = await exportRun(cwd, state, filters); + if (result.entries.length === 0) { + print( + ctx, + `pygienium: nothing to export (no findings.md/changes.md under ${cwd}/.pygienium/checks/).`, + ); + return; + } + const filterDesc = [ + filters.check ? `check=${filters.check.join(",")}` : null, + filters.status ? `status=${filters.status.join(",")}` : null, + ] + .filter(Boolean) + .join(" "); + const suffix = filterDesc ? ` [${filterDesc}]` : ""; + print( + ctx, + `pygienium export: ${result.entries.length} check(s) → ${exportBundlePath(cwd, result.format)}${suffix}`, + ); +} + +/** A minimal command-registration callback shape (matches `pi.registerCommand`). */ +export type RegisterCommandFn = ( + name: string, + options: { + description?: string; + handler: (args: string, ctx: PygieniumCtx) => Promise; + }, +) => void; + +/** + * Auto-register `/pygienium-help` plus one `/pygienium-` per registered + * `CheckDefinition`, plus the `all`/`resume`/`status`/`export` commands. + * Called from `index.ts` so that adding a check never requires editing command + * wiring. + */ +export function registerPygieniumCommands(register: RegisterCommandFn): void { + register("pygienium-help", { + description: "Show pygienium commands, checks, and usage.", + handler: handleHelpCommand, + }); + + for (const def of getAllChecks()) { + register(`pygienium-${def.name}`, { + description: def.description, + handler: (args, ctx) => handleCheckCommand(def, args, ctx), + }); + } + + register("pygienium-all", { + description: "Run every registered pygienium check in sequence.", + handler: handleAllCommand, + }); + register("pygienium-resume", { + description: "Resume the most recent in-progress or failed pygienium run.", + handler: handleResumeCommand, + }); + register("pygienium-status", { + description: "Show progress of the current or latest pygienium run.", + handler: handleStatusCommand, + }); + register("pygienium-export", { + description: "Export finalized findings and changes for a pygienium run.", + handler: handleExportCommand, + }); +} diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..eed4954 --- /dev/null +++ b/src/export.ts @@ -0,0 +1,269 @@ +/** + * export.ts — bundle find/changed artifacts for a pygienium run. + * + * `/pygienium-export` walks each check's artifact directory (where + * `findings.md` and `changes.md` live), applies `--check=` / `--status=` + * filters, and writes a single bundle to `.pygienium/export.{md|json}`. + * + * Artifact root: `/.pygienium/checks//` — the single canonical + * location every shipped check writes to. + * + * Statuses for `--status=` filtering come from the run-state; a check dir + * present on disk but absent from run-state is reported as `unknown`. + * + * @module pygienium/export + */ + +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { RunState } from "./run-state.js"; + +export type ExportFormat = "md" | "json"; + +/** Directory name (relative to cwd) that holds `checks/` and `export.md`. */ +export const PYGIENIUM_ARTIFACT_DIR = ".pygienium"; +/** Subdirectory holding per-check `findings.md`/`changes.md`. */ +export const CHECKS_SUBDIR = "checks"; +/** Base filename for the bundle (`export.md` / `export.json`). */ +export const EXPORT_FILENAME_BASE = "export"; + +/** Resolve `/.pygienium/` (the artifact root). */ +export function pygieniumArtifactDir(cwd: string): string { + return join(cwd, PYGIENIUM_ARTIFACT_DIR); +} + +/** Resolve `/.pygienium/checks/`. */ +export function canonicalChecksRoot(cwd: string): string { + return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR); +} + +/** Resolve `/.pygienium/export.`. */ +export function exportBundlePath(cwd: string, format: ExportFormat): string { + return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`); +} + +/** A single gathered check artifact entry (post-filter). */ +export interface ExportEntry { + /** Check name (the directory under `checks/`). */ + name: string; + /** Status from run-state, or `unknown` when not present there. */ + status: string; + /** `findings.md` contents, when present on disk. */ + findings?: string; + /** `changes.md` contents, when present on disk. */ + changes?: string; + /** Absolute path to `findings.md`, when read from disk. */ + findingsPath?: string; + /** Absolute path to `changes.md`, when read from disk. */ + changesPath?: string; +} + +/** Parsed `--check=` / `--status=` / `--out=` filters. */ +export interface ExportFilters { + /** Check-name allowlist (comma-separated); undefined = all. */ + check?: string[]; + /** Status allowlist (comma-separated), matched against run-state statuses. */ + status?: string[]; + /** Output format. Defaults to `md`. */ + out?: ExportFormat; +} + +/** Result of {@link exportRun}. */ +export interface ExportResult { + /** Format used. */ + format: ExportFormat; + /** Absolute path the bundle was written to. */ + path: string; + /** Entries included after filtering (in alphabetical order). */ + entries: ExportEntry[]; + /** Bundle size in bytes. */ + bytes: number; +} + +const FLAG_CHECK = "--check="; +const FLAG_STATUS = "--status="; +const FLAG_OUT = "--out="; + +/** Parse export flags from the raw arg string (flags + optional positional). */ +export function parseExportFilters(args: string): ExportFilters { + const filters: ExportFilters = { out: "md" }; + const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; + for (const tok of tokens) { + if (tok.startsWith(FLAG_CHECK)) { + filters.check = tok + .slice(FLAG_CHECK.length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (tok.startsWith(FLAG_STATUS)) { + filters.status = tok + .slice(FLAG_STATUS.length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (tok.startsWith(FLAG_OUT)) { + const v = tok.slice(FLAG_OUT.length).toLowerCase().trim(); + if (v === "json" || v === "md") { + filters.out = v; + } + } + } + return filters; +} + +async function readArtifact(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } +} + +async function gatherFromRoot( + root: string, + state: RunState | undefined, + merged: Map, +): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + const dir = join(root, name); + const fpath = join(dir, "findings.md"); + const cpath = join(dir, "changes.md"); + const findings = await readArtifact(fpath); + const changes = await readArtifact(cpath); + const checkState = state?.checks[name]; + merged.set(name, { + name, + status: checkState?.status ?? "unknown", + findings, + changes, + findingsPath: findings != null ? fpath : undefined, + changesPath: changes != null ? cpath : undefined, + }); + } +} + +/** + * Gather artifact entries from the canonical `/.pygienium/checks/` root, + * one entry per check directory. Entries are sorted alphabetically. Marks an + * entry `unknown` when its check is absent from `state`. + */ +export async function gatherExportEntries( + cwd: string, + state?: RunState, +): Promise { + const merged = new Map(); + await gatherFromRoot(canonicalChecksRoot(cwd), state, merged); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Apply `--check=` / `--status=` filters to gathered entries. */ +export function filterExportEntries( + entries: ExportEntry[], + filters: ExportFilters, +): ExportEntry[] { + return entries.filter((e) => { + if (filters.check && !filters.check.includes(e.name)) return false; + if (filters.status && !filters.status.includes(e.status)) return false; + return true; + }); +} + +/** Render the markdown bundle. */ +export function renderExportMarkdown( + state: RunState | undefined, + entries: ExportEntry[], +): string { + const lines: string[] = []; + lines.push("# Pygienium export"); + if (state) { + lines.push(""); + lines.push(`- status: ${state.status}`); + lines.push(`- started: ${new Date(state.startedAt).toISOString()}`); + lines.push(`- updated: ${new Date(state.updatedAt).toISOString()}`); + lines.push(`- cwd: ${state.cwd}`); + lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`); + } + lines.push(`- checks: ${entries.length}`); + lines.push(""); + for (const e of entries) { + lines.push(`## ${e.name} (${e.status})`); + if (e.findings != null) { + lines.push(""); + lines.push("### findings"); + lines.push(""); + lines.push(e.findings.replace(/\s+$/, "")); + } + if (e.changes != null) { + lines.push(""); + lines.push("### changes"); + lines.push(""); + lines.push(e.changes.replace(/\s+$/, "")); + } + if (e.findings == null && e.changes == null) { + lines.push(""); + lines.push("_(no findings.md or changes.md on disk)_"); + } + lines.push(""); + } + return lines.join("\n") + "\n"; +} + +/** Render the JSON bundle. */ +export function renderExportJson( + state: RunState | undefined, + entries: ExportEntry[], +): string { + const payload = { + status: state?.status ?? "unknown", + startedAt: state?.startedAt ?? null, + updatedAt: state?.updatedAt ?? null, + cwd: state?.cwd ?? null, + recon: state ? state.recon.complete : null, + checks: entries.map((e) => ({ + name: e.name, + status: e.status, + findings: e.findings ?? null, + findingsPath: e.findingsPath ?? null, + changes: e.changes ?? null, + changesPath: e.changesPath ?? null, + })), + }; + return JSON.stringify(payload, null, 2) + "\n"; +} + +/** + * Gather, filter, and write the export bundle. Returns the (would-be) path + * and the included entries. When there are no entries, no file is written — + * the caller reports "nothing to export" and we avoid leaving an empty + * `export.{md|json}` on disk. + */ +export async function exportRun( + cwd: string, + state: RunState | undefined, + filters: ExportFilters, +): Promise { + const all = await gatherExportEntries(cwd, state); + const entries = filterExportEntries(all, filters); + const format: ExportFormat = filters.out ?? "md"; + const path = exportBundlePath(cwd, format); + if (entries.length === 0) { + return { format, path, entries, bytes: 0 }; + } + const body = + format === "json" + ? renderExportJson(state, entries) + : renderExportMarkdown(state, entries); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, body, "utf8"); + return { format, path, entries, bytes: Buffer.byteLength(body) }; +} diff --git a/src/footer.ts b/src/footer.ts new file mode 100644 index 0000000..f2f8cf6 --- /dev/null +++ b/src/footer.ts @@ -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 = { + 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 `• .