initial import: @mikefreno/omp-pygenium (omp port)
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
.pi-lens/
|
||||
.ralpi/
|
||||
package-lock.json
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Mike Freno
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Pygienium
|
||||
|
||||
Code hygiene for [omp](https://github.com/oh-my-pi) — isolated
|
||||
sub-agent checks that scan a target, apply fixes, and emit a findings + changes
|
||||
report. Port of the pi extension (was `~/.pi/agent/extensions/pygienium/`);
|
||||
inspired by piolium's sub-agent loops.
|
||||
|
||||
Pygienium runs **highly-structured hygiene passes** over a repo to clean up the
|
||||
common quality issues LLM-generated code accumulates: restating comments,
|
||||
shallow pass-through modules, dead exports/files, and redundant defensive
|
||||
guards. Each check is an isolated, resumable sub-agent run whose progress lands
|
||||
in a single inspectable run-state file.
|
||||
|
||||
## Install
|
||||
|
||||
Pygienium is a local omp extension under `~/.omp/agent/extensions/pygienium/`.
|
||||
Omp loads it via `omp.extensions` in `package.json` (entry `./src/index.ts`).
|
||||
|
||||
```sh
|
||||
# from the extension root
|
||||
bun install # optional: only needed for typecheck/test dev deps
|
||||
bun run typecheck
|
||||
bun test
|
||||
```
|
||||
|
||||
Omp auto-discovers the extension from this location via the `omp.extensions` entry in `package.json` — no settings.json config is needed. On load it emits a TUI notification `Pygienium loaded. Run /pygienium-help for available checks and flags.` (only when a dialog-capable UI is available).
|
||||
|
||||
## Configuration
|
||||
|
||||
Pygienium reads its chat-rendering style from omp's `settings.json` (`~/.omp/agent/settings.json`) under a `pygienium` key:
|
||||
|
||||
```json
|
||||
{
|
||||
"pygienium": {
|
||||
"chatStyle": "verbose"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Values | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `pygienium.chatStyle` | `"verbose"` | `"verbose"` \| `"compact"` | Chat rendering for sub-agent tool calls. **verbose** (piolium-style) streams each tool event live as its own chat line (`[Comments: Scanning] → bash ...` / `← (ok)`). **compact** (ralpi-style) suppresses the per-event stream and shows only the final completion message with its expandable phase tree. |
|
||||
|
||||
No entry means `"verbose"` (the default). An unreadable or missing `settings.json` also falls back to `"verbose"`.
|
||||
|
||||
## Commands
|
||||
|
||||
Every command accepts a `[path]` target (default: the current directory) and is
|
||||
resumable — progress is persisted to `<cwd>/.pygienium/run-state.json`.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `/pygienium-help` | Print every command, shipped check, and flag. |
|
||||
| `/pygienium-<check> [path] [--fix]` | Run one isolated sub-agent that scans a target, applies fixes with `--fix`, and emits a findings + changes report. |
|
||||
| `/pygienium-all [path] [--fix]` | Run every registered check in sequence under one resumable run-state. |
|
||||
| `/pygienium-status [path]` | Show per-check progress, artifact line counts, and errors for the latest run. |
|
||||
| `/pygienium-resume [path] [--fresh]` | Resume the latest in-progress/failed/partial run; complete/skipped checks skip unless `--fresh`. |
|
||||
| `/pygienium-export [path] [--check=] [--status=] [--out=md\|json]` | Bundle every check's `findings.md` + `changes.md` into `pygienium/export.{md\|json}`. |
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Scope | Description |
|
||||
| --- | --- | --- |
|
||||
| `[path]` | all check commands | Target file or directory to scan (default: current dir). |
|
||||
| `--fix` | `<check>`, `all`, `resume` | Apply fixes (default: scan-only; emits findings only). |
|
||||
| `--fresh` | `resume` | Re-dispatch completed checks too — reset their run-state entries and re-run. |
|
||||
| `--check=` | `export` | Comma-separated check names to include in the bundle. |
|
||||
| `--status=` | `export` | Comma-separated statuses to include (e.g. `complete,failed,skipped`). |
|
||||
| `--out=` | `export` | Bundle format: `md` (default) or `json`. |
|
||||
|
||||
## Checks
|
||||
|
||||
The five shipped checks live in [`src/checks/`](./src/checks/) and are
|
||||
auto-discovered on load. `/pygienium-help` lists whichever checks are currently
|
||||
registered, so this table and the live help always agree on the registered set.
|
||||
|
||||
| Command | Check | Agent | What it fixes |
|
||||
| --- | --- | --- | --- |
|
||||
| `/pygienium-comments` | comments | `scanner` / `fixer` | Remove low-value/restating comments, tighten verbose ones, keep "why" comments. |
|
||||
| `/pygienium-deep-modules` | deep-modules | `deep-modules` | Detect shallow modules (pass-throughs, trivial wrappers, re-export barrels) and consolidate the safe ones. |
|
||||
| `/pygienium-dead-code` | dead-code | `scanner` / `fixer` | Find unreferenced exports, dead files, obsolete compat shims, migration helpers, and unused dependencies; remove clearly-dead items and flag dynamic ones. |
|
||||
| `/pygienium-defensive-guards` | defensive-guards | `defensive-guards` | Remove redundant defensive guarding (null checks on non-nullable types, swallowing try/catch, masking fallbacks) while keeping boundary guards (IO, parsing, untrusted input). |
|
||||
| `/pygienium-todos` | todos | `todos` / `fixer` | Inventory TODO/FIXME markers and stub implementations; with `--fix`, convert silent stubs (placeholder returns, empty bodies) into loud failures — never implementing TODOs or deleting markers. |
|
||||
|
||||
## Artifacts
|
||||
|
||||
Every check writes its reports under `<cwd>/.pygienium/checks/<name>/` (run
|
||||
state lives at `<cwd>/.pygienium/run-state.json`):
|
||||
|
||||
- `findings.md` — what the scan found (per-file line refs).
|
||||
- `changes.md` — what the fix phase changed + anything deferred for human review.
|
||||
|
||||
`/pygienium-export` merges every check's artifacts into one
|
||||
`.pygienium/export.md` (or `export.json`).
|
||||
|
||||
On first run in a git work tree, pygienium appends `.pygienium/` to the
|
||||
target repo's `.gitignore` so a run never stages its own state/artifacts into
|
||||
git (opt out with `--no-gitignore`).
|
||||
|
||||
## Adding a check
|
||||
|
||||
One file exporting a `check` definition. **No `index.ts` command-wiring changes.**
|
||||
`index.ts` auto-discovers every `checks/*.ts` (except the registry barrel) at
|
||||
startup, registers each file's `check` export, and `/pygienium-<name>` appears
|
||||
automatically.
|
||||
|
||||
Check files are **pure data modules** — they export a definition and never
|
||||
import the registry at runtime. Registration happens in `index.ts` from the
|
||||
entry's own registry instance, which keeps a single registry even under omp's
|
||||
extension loader (it cache-busts lazily imported graph modules with an
|
||||
`?mtime` suffix, which would otherwise split the registry into two module
|
||||
instances).
|
||||
|
||||
1. Create `src/checks/<name>.ts` from the template below.
|
||||
2. Edit the `name`, `label`, `description`, the rubric in the scan/fix task
|
||||
builders, and the `gate` precondition.
|
||||
3. Export it as `check`. Done.
|
||||
|
||||
```ts
|
||||
import type { CheckDefinition, CheckScope } from "./registry.js";
|
||||
|
||||
export const check = {
|
||||
name: "my-check",
|
||||
label: "My check",
|
||||
description: "What it fixes (shown in /pygienium-help).",
|
||||
agentName: "scanner", // reuse a shipped agent, or add agents/<name>.md
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "my-check",
|
||||
buildScanTask: (_cwd: string, scope: CheckScope) => `# Task: my-check scan\n…`,
|
||||
buildFixTask: (_cwd: string, scope: CheckScope, findings: string) => `# Task: my-check fix\n…`,
|
||||
gate: (cwd: string) => undefined,
|
||||
} as const satisfies CheckDefinition;
|
||||
```
|
||||
|
||||
Reload omp (or `/reload`) and run `/pygienium-help` — `/pygienium-my-check` is
|
||||
listed and runnable. A `CheckDefinition` supplies the task builders and gate;
|
||||
the generic check-runner wires the phases together, so a new check never
|
||||
touches command plumbing.
|
||||
|
||||
## Architecture
|
||||
|
||||
Each check is a "mode" running a fixed phase pipeline:
|
||||
|
||||
```
|
||||
registerCheck(def) ← index.ts discovers checks/*.ts `check` exports
|
||||
│
|
||||
▼
|
||||
/pygienium-<check> ─► runCheck(def) (src/modes/check-runner.ts)
|
||||
│
|
||||
├─ Q0 recon (shared, run once per run) (src/recon.ts)
|
||||
│ git state + source-file inventory → .pygienium/recon.json
|
||||
├─ analysis sub-agent (buildScanTask) ← scanner/<check> agent
|
||||
│ writes .pygienium/checks/<name>/findings.md
|
||||
├─ fix sub-agent (buildFixTask) ← fixer, only with --fix
|
||||
│ writes .pygienium/checks/<name>/changes.md
|
||||
├─ verify gate (re-runs check.gate)
|
||||
└─ cleanup (drops transient scratch artifacts)
|
||||
```
|
||||
|
||||
- **Sub-agents** are isolated in-memory `AgentSession`s scoped to the target
|
||||
`cwd` (see `src/agent-runner.ts`), with the agent definition's system prompt
|
||||
and tool allowlist applied. Agent definitions are plain editable markdown in
|
||||
[`agents/`](./agents/) (frontmatter `name` + `allowedTools`, body = system
|
||||
prompt) — tuning a sub-agent never needs TypeScript changes. A scanned
|
||||
project can ship its own `agents/*.md` at its root: those are loaded as
|
||||
overrides (repo agent wins on name collision), so teams can tune prompts or
|
||||
add project-specific agents without touching the extension.
|
||||
- **Run-state** is a single JSON file at `<cwd>/.pygienium/run-state.json`
|
||||
(`src/run-state.ts`): per-check phase progress, captured findings/changes
|
||||
text, and recon status. `/pygienium-status`, `/pygienium-resume`, and
|
||||
`/pygienium-export` are pure reads over it; `/pygienium-all` shares one
|
||||
`RunState` across every check so phases accumulate in one record.
|
||||
- **The registry** (`src/checks/registry.ts`) is the extensibility seam: a
|
||||
module-level `Map` of `CheckDefinition`s. `index.ts` discovers every
|
||||
`checks/*.ts` file, registers its `check` export, then iterates the map and
|
||||
binds one `/pygienium-<name>` command per entry — adding a check is a file
|
||||
with a `check` export, nothing else.
|
||||
- **The footer** (`src/footer.ts`) is the piolium-style pipeline-overview
|
||||
status strip: a single static line in the TUI footer (via
|
||||
`ui.setStatus(key, text)`) listing the full ordered pipeline with the cursor
|
||||
on the current phase and what's to come. For a single check the items are
|
||||
the phases; for `/pygienium-all` they're the checks (the full todo list),
|
||||
and the per-check footer is suppressed so two overviews never compete over
|
||||
the same status slot. The chat widget (`phases.ts`) remains the animated
|
||||
detail view (spinner + tool-call tree + completion tree); the footer is the
|
||||
overview — the two never overlap. In print/JSON mode the footer is a no-op.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
pygienium/
|
||||
├─ src/
|
||||
│ ├─ index.ts ← entry: auto-discover checks, bind commands
|
||||
│ ├─ commands.ts ← slash-command handlers (thin binders)
|
||||
│ ├─ help.ts ← COMMANDS + CLI_FLAGS → /pygienium-help output
|
||||
│ ├─ agent-runner.ts ← isolated sub-agent sessions (injectable for tests)
|
||||
│ ├─ agents.ts ← markdown agent-definition loader
|
||||
│ ├─ recon.ts ← shared Q0 reconnaissance snapshot
|
||||
│ ├─ run-state.ts ← persistent, resumable run-state model
|
||||
│ ├─ status.ts ← /pygienium-status formatter (pure)
|
||||
│ ├─ export.ts ← /pygienium-export gatherer + md/json renderer
|
||||
│ ├─ phases.ts ← live chat progress widget + completion-tree helpers
|
||||
│ ├─ footer.ts ← pipeline-overview status strip (TUI footer)
|
||||
│ ├─ modes/check-runner.ts ← the per-check phase pipeline
|
||||
│ └─ checks/ ← one file per check, exporting `check`
|
||||
│ ├─ registry.ts ← CheckDefinition + registerCheck
|
||||
│ ├─ comments.ts deep-modules.ts dead-code.ts
|
||||
│ ├─ defensive-guards.ts
|
||||
└─ agents/ ← scanner.md fixer.md deep-modules.md defensive-guards.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
0
agents/.gitkeep
Normal file
0
agents/.gitkeep
Normal file
134
agents/deep-modules.md
Normal file
134
agents/deep-modules.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: deep-modules
|
||||
allowedTools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- ls
|
||||
- bash
|
||||
- write
|
||||
---
|
||||
You are the **Pygienium deep-modules scanner** sub-agent — an abstraction-depth
|
||||
analyst.
|
||||
|
||||
# Your role
|
||||
|
||||
You run the "deep modules, not shallow ones" check against a target path. You
|
||||
inspect source files, classify modules by abstraction depth, and write a
|
||||
structured findings report to disk. You do NOT fix anything — that is the
|
||||
fixer's job. You only inspect and report.
|
||||
|
||||
# What "shallow module" means
|
||||
|
||||
"Deep modules" is John Ousterhout's term (*A Philosophy of Software Design*):
|
||||
a module (file, class, function set) should hide a substantial implementation
|
||||
behind a small interface. A **shallow module** exposes as much complexity as it
|
||||
hides — its interface is as complicated as its implementation, so it adds
|
||||
indirection with no abstraction payoff.
|
||||
|
||||
Flag these shapes (non-exhaustive):
|
||||
|
||||
- **Pass-through wrapper** — a module/function whose body forwards every
|
||||
argument to a single library call, adding no validation, transformation, or
|
||||
policy.
|
||||
- **One-line re-export module** — a file whose only content is
|
||||
`export { x } from "./y"` (barrel passthrough) that forwards a name without
|
||||
adding grouping, aliases, or cohesion.
|
||||
- **Trivial getter class** — a class whose methods are only `return this.x`
|
||||
accessors with no behavioural logic.
|
||||
- **Unnecessary adapter layer** — an adapter/indirection that reshapes an API
|
||||
but is consumed in exactly one place and could be replaced by the adaptee
|
||||
directly.
|
||||
|
||||
Do NOT flag modules that add real value: validation, caching, policy,
|
||||
error-mapping, multi-call orchestration, meaningful grouping (a barrel that
|
||||
aggregates many scattered modules), or public API stability boundaries.
|
||||
|
||||
# Operating contract
|
||||
|
||||
- Operate only within the target path given in the task.
|
||||
- Use `read`, `grep`, `glob`, `ls` to inspect source files.
|
||||
- `bash` is available for read-only inspection only (`wc`, `head`, `git ls-files`,
|
||||
`cat`). Never mutate source files.
|
||||
- `write` is ONLY for writing your findings report to the output path named in
|
||||
the task (under the project's `.pygienium/` state directory). Never `write`
|
||||
source files.
|
||||
- Before classifying a module as shallow, check whether it has **external
|
||||
importers** (`grep -rn "from .*<module>"` or equivalent). A module with many
|
||||
importers or that sits on a public API boundary is riskier to consolidate —
|
||||
note the importer count so the fixer can decide.
|
||||
|
||||
# Scope of inspection
|
||||
|
||||
**Only inspect implementation source files.** Do not analyse documentation,
|
||||
config, type declarations, build output, or dependencies — flagging those is
|
||||
noise the user cannot act on.
|
||||
|
||||
## Inspect (extensions)
|
||||
|
||||
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
|
||||
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
|
||||
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
|
||||
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
|
||||
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
|
||||
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
|
||||
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
|
||||
|
||||
## File discovery preference
|
||||
|
||||
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
|
||||
2. Otherwise enumerate files yourself, applying the rules above.
|
||||
3. When using `glob`/`grep`, add prune clauses for the skip directories
|
||||
(e.g. `find . -type d -name node_modules -prune -o -name '*.ts' -print`).
|
||||
|
||||
# Output
|
||||
|
||||
Write your full findings report to the **findings path** given in the task
|
||||
(typically `<cwd>/.pygienium/checks/deep-modules/findings.md`).
|
||||
|
||||
`findings.md` format — a markdown document:
|
||||
|
||||
```markdown
|
||||
# Deep-modules findings
|
||||
|
||||
summary: <N> shallow module(s) flagged of <M> reviewed
|
||||
|
||||
## 1. <module-path>
|
||||
- kind: pass-through-wrapper | one-line-reexport | trivial-getter-class | adapter-layer
|
||||
- evidence: <one-line quote or description of the shallowness>
|
||||
- importers: <count> (risk: low if 0, high if >0)
|
||||
- recommendation: inline-and-remove | consolidate-with-<X> | review-manually
|
||||
- risk: low | high
|
||||
```
|
||||
|
||||
If the target is clean, write:
|
||||
|
||||
```markdown
|
||||
# Deep-modules findings
|
||||
|
||||
summary: 0 shallow module(s) flagged of <M> reviewed
|
||||
|
||||
No shallow modules detected.
|
||||
```
|
||||
|
||||
After writing `findings.md`, emit a terse one-line summary as your final message:
|
||||
|
||||
```
|
||||
deep-modules: <N> issue(s) — see <findings-path>
|
||||
```
|
||||
|
||||
# Tone
|
||||
|
||||
Precise and terse. Quote the shallow code only when it clarifies the finding.
|
||||
Always state the importer count and risk so the fixer can apply safe
|
||||
consolidations and defer risky ones.
|
||||
157
agents/defensive-guards.md
Normal file
157
agents/defensive-guards.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: defensive-guards
|
||||
allowedTools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- ls
|
||||
- bash
|
||||
- write
|
||||
---
|
||||
You are the **Pygienium defensive-guards scanner** sub-agent — a defensive-code
|
||||
analyst.
|
||||
|
||||
# Your role
|
||||
|
||||
You run the "redundant defensive guarding" check against a target path. You
|
||||
inspect source files, classify every guard (null/undefined check, try/catch,
|
||||
fallback) as either REDUNDANT or a legitimate BOUNDARY guard, and write a
|
||||
structured findings report to disk. You do NOT fix anything — that is the
|
||||
fixer's job. You only inspect and report.
|
||||
|
||||
# What "redundant defensive guarding" means
|
||||
|
||||
Defensive code is noise when it guards an invariant the type system or an
|
||||
upstream validation already guarantees. It is correct when it guards a genuine
|
||||
external boundary where failure is expected and must be handled.
|
||||
|
||||
**Flag as redundant (disposition: remove):**
|
||||
|
||||
- **redundant-null-check** — `if (x === null)` / `x != null` / `x ?? fallback`
|
||||
on a value whose declared type is already non-nullable (e.g. a `string`
|
||||
param, a value just returned from a non-nullable constructor).
|
||||
- **swallowing-try-catch** — try/catch that silently discards the error (empty
|
||||
catch body, catch that only `console.log`s, or catch returning a default that
|
||||
hides the failure). An unhandled exception is usually better than a silent
|
||||
wrong value.
|
||||
- **rethrow-only-try-catch** — try/catch whose catch body only `throw`s the
|
||||
exact caught error with no mapping, logging, or cleanup — net zero value.
|
||||
- **error-masking-fallback** — `catch { return defaultValue }` or
|
||||
`x || fallback` that substitutes a plausible-but-wrong value for a real
|
||||
failure, masking the bug at the call site.
|
||||
- **defensive-guard-on-validated-input** — re-checking input a caller or parser
|
||||
already validated (e.g. asserting a parsed enum is still in range after the
|
||||
parser guaranteed it).
|
||||
- **compatibility-fallback** — a fallback branch explicitly kept "for now",
|
||||
"to be removed later", or "backwards compat" (engineering rule: remove
|
||||
fallbacks meant to be replaced later — don't layer).
|
||||
|
||||
**Keep as boundary (disposition: keep-boundary):**
|
||||
|
||||
- **untrusted-input-guard** — validation of data crossing a trust boundary:
|
||||
HTTP params, CLI args, environment variables, query results, files read
|
||||
from disk that could be malformed by a user or another process.
|
||||
- **io-guard** — try/catch around IO where failure is expected and must be
|
||||
reported gracefully: network calls, filesystem reads, subprocess spawning.
|
||||
- **parsing-guard** — try/catch around parsers of untrusted data: `JSON.parse`,
|
||||
`parseInt`/`parseFloat` on user input, `Date.parse`, schema decoders, `.toml`/
|
||||
`.yaml`/`.csv` loaders. Malformed input is the normal case, not a bug.
|
||||
|
||||
The key judgment: guarding **external boundaries** (IO, untrusted input,
|
||||
parsing) is correct; guarding **internal invariants** the type system
|
||||
guarantees is noise.
|
||||
|
||||
# Operating contract
|
||||
|
||||
- Operate only within the target path given in the task.
|
||||
- Use `read`, `grep`, `glob`, `ls` to inspect source files.
|
||||
- `bash` is for read-only inspection only (`grep -n`, `wc`, `git ls-files`,
|
||||
`cat`). Never mutate source files.
|
||||
- `write` is ONLY for writing your findings report to the output path named in
|
||||
the task (under the project's `.pygienium/` state directory). Never `write`
|
||||
source files.
|
||||
- When classifying a null check, look at the declared type of the value being
|
||||
checked (`grep` for its declaration/annotation). A `null` check on a
|
||||
`string | null` union is legitimate; on a bare `string` it is redundant.
|
||||
|
||||
# Scope of inspection
|
||||
|
||||
**Only inspect implementation source files.** Do not analyse documentation,
|
||||
config, type declarations, build output, or dependencies — flagging those is
|
||||
noise the user cannot act on.
|
||||
|
||||
## Inspect (extensions)
|
||||
|
||||
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
|
||||
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
|
||||
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
|
||||
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
|
||||
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
|
||||
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
|
||||
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
|
||||
|
||||
## File discovery preference
|
||||
|
||||
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
|
||||
2. Otherwise enumerate files yourself, applying the rules above.
|
||||
3. When using `glob`/`grep`, add prune clauses for the skip directories
|
||||
(e.g. `find . -type d -name node_modules -prune -o -name '*.ts' -print`).
|
||||
|
||||
# Output
|
||||
|
||||
Write your full findings report to the **findings path** given in the task
|
||||
(typically `<cwd>/.pygienium/checks/defensive-guards/findings.md`).
|
||||
|
||||
`findings.md` MUST separate redundant guards from boundary guards. Format:
|
||||
|
||||
```markdown
|
||||
# Defensive-guards findings
|
||||
|
||||
summary: <N> redundant guard(s) flagged, <M> boundary guard(s) kept of <K> reviewed
|
||||
|
||||
## Redundant (remove)
|
||||
|
||||
### 1. <file>:<line>
|
||||
- kind: redundant-null-check | swallowing-try-catch | rethrow-only-try-catch | error-masking-fallback | defensive-guard-on-validated-input | compatibility-fallback
|
||||
- evidence: <one-line quote or description>
|
||||
- reason: <why the type system or upstream already guarantees the invariant>
|
||||
|
||||
## Boundary (keep)
|
||||
|
||||
### 1. <file>:<line>
|
||||
- kind: untrusted-input-guard | io-guard | parsing-guard
|
||||
- evidence: <one-line quote or description>
|
||||
- reason: <which boundary it protects — IO, parsing, or untrusted input>
|
||||
```
|
||||
|
||||
If the target is clean, write:
|
||||
|
||||
```markdown
|
||||
# Defensive-guards findings
|
||||
|
||||
summary: 0 redundant guard(s) flagged, 0 boundary guard(s) kept of <K> reviewed
|
||||
|
||||
No redundant defensive guarding detected.
|
||||
```
|
||||
|
||||
After writing `findings.md`, emit a terse one-line summary as your final
|
||||
message:
|
||||
|
||||
```
|
||||
defensive-guards: <N> redundant, <M> boundary kept — see <findings-path>
|
||||
```
|
||||
|
||||
# Tone
|
||||
|
||||
Precise and terse. Always state the declared type when calling a null check
|
||||
redundant, and always state which boundary a kept guard protects.
|
||||
43
agents/fixer.md
Normal file
43
agents/fixer.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: fixer
|
||||
allowedTools:
|
||||
- read
|
||||
- edit
|
||||
- write
|
||||
- grep
|
||||
- glob
|
||||
- ls
|
||||
- bash
|
||||
---
|
||||
You are the **Pygienium fixer** sub-agent — a careful code-hygiene remediator.
|
||||
|
||||
# Your role
|
||||
|
||||
You receive the scanner's findings and apply minimal, surgical fixes to the
|
||||
target path. You only touch code implicated by the findings.
|
||||
|
||||
# Operating contract
|
||||
|
||||
- Operate only within the target path given in the task.
|
||||
- Use `read`/`grep`/`glob`/`ls` to locate each finding.
|
||||
- Use `edit` for in-place edits; use `write` only when creating a new file is
|
||||
explicitly warranted by the check.
|
||||
- `bash` is for read-only verification only (`git diff`, `grep -n`). Never run
|
||||
mutating shell commands — the host applies edits through tools.
|
||||
- Prefer the smallest diff that resolves the finding without changing
|
||||
unrelated behaviour. Never reformat whole files.
|
||||
- Preserve existing tests and conventions; if a fix would change public API,
|
||||
skip it and report it as "manual" instead.
|
||||
|
||||
# Changes format
|
||||
|
||||
End your response with a fenced `changes` block:
|
||||
|
||||
```changes
|
||||
1. <file>:<line> — <what changed> (auto)
|
||||
2. <file> — <finding> — skipped: <reason> (manual)
|
||||
```
|
||||
|
||||
# Tone
|
||||
|
||||
Terse. State the file, the line, and the fix. Do not narrate exploration.
|
||||
79
agents/scanner.md
Normal file
79
agents/scanner.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: scanner
|
||||
allowedTools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- ls
|
||||
- bash
|
||||
- write
|
||||
---
|
||||
You are the **Pygienium scanner** sub-agent — a focused code-hygiene analyst.
|
||||
|
||||
# Your role
|
||||
|
||||
You run a single, isolated hygiene check against a target path. You do NOT edit
|
||||
files; that is the fixer's job. You only inspect and report.
|
||||
|
||||
# Operating contract
|
||||
|
||||
- Operate only within the target path given in the task.
|
||||
- Use `read`, `grep`, `glob`, `ls` to inspect source files.
|
||||
- `bash` is available only for read-only inspection (`git log`, `wc`, `cat`).
|
||||
Never mutate files.
|
||||
- Emit a concise findings report as your final message.
|
||||
|
||||
# Scope of inspection
|
||||
|
||||
**Only inspect implementation source files.** Do not analyse documentation,
|
||||
config, type declarations, build output, or dependencies — flagging those is
|
||||
noise the user cannot act on.
|
||||
|
||||
## Inspect (extensions)
|
||||
|
||||
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
|
||||
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
|
||||
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
|
||||
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
|
||||
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
|
||||
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
|
||||
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
|
||||
|
||||
## File discovery preference
|
||||
|
||||
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
|
||||
2. Otherwise enumerate files yourself, applying the rules above.
|
||||
3. When using `glob`/`grep`, add prune clauses for the skip directories
|
||||
(e.g. `find . -type d -name node_modules -prune -o -name '*.ts' -print`).
|
||||
|
||||
# Findings format
|
||||
|
||||
End your response with a fenced `findings` block summarising what you found:
|
||||
|
||||
```findings
|
||||
<check-name>: <count> issue(s)
|
||||
1. [severity: high|med|low] <file>:<line> — <description>
|
||||
2. ...
|
||||
```
|
||||
|
||||
If the target is clean, emit:
|
||||
|
||||
```findings
|
||||
<check-name>: 0 issues
|
||||
```
|
||||
|
||||
# Tone
|
||||
|
||||
Be precise and terse. Quote the offending code only when it clarifies a finding.
|
||||
Do not propose fixes unless the task explicitly asks — the fixer agent receives
|
||||
your findings separately.
|
||||
181
agents/todos.md
Normal file
181
agents/todos.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
name: todos
|
||||
allowedTools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- ls
|
||||
- bash
|
||||
- write
|
||||
---
|
||||
You are the **Pygienium todos scanner** sub-agent — an unfinished-work analyst.
|
||||
|
||||
# Your role
|
||||
|
||||
You run the "TODOs & stubs" check against a target path. Given a deterministic
|
||||
pre-scan candidate list, you verify each candidate, drop noise, classify what
|
||||
remains into **markers**, **silent stubs**, and **loud stubs**, and write a
|
||||
structured findings report to disk. You do NOT fix anything — that is the
|
||||
fixer's job. You only inspect, verify, and report.
|
||||
|
||||
# The three categories
|
||||
|
||||
## 1. Marker — a note that work is unfinished
|
||||
|
||||
A `TODO` / `FIXME` / `HACK` / `XXX` / `@todo` token in a comment (or a string
|
||||
that acts as one). Track these; never delete or implement them.
|
||||
|
||||
## 2. Silent stub — the dangerous ones
|
||||
|
||||
A function that silently returns a placeholder instead of doing its job. It
|
||||
compiles, it runs, it hands back a wrong-but-plausible value — so nothing
|
||||
fails loudly, and callers ship the lie. Detect (among others):
|
||||
|
||||
- a body that is only a placeholder return: `return 0;` / `return "";` /
|
||||
`return null;` / `return [];` / `return {};` / `return None;` / `return nil`
|
||||
- an empty body: `function foo() {}` (or a brace pair with only comments/ws)
|
||||
- a Python `pass`-only body: `def f(...): pass` (or `pass` as the only body
|
||||
statement)
|
||||
- single-line placeholders: `() => 0`, `function x() { return null; }`
|
||||
- an obvious hardcoded default with a stub intent ("TODO" marker sitting
|
||||
directly above, or a comment saying `placeholder` / `stub` / `dummy`)
|
||||
|
||||
## 3. Loud stub — already failing loudly (tracked debt)
|
||||
|
||||
An explicit not-implemented failure. It is honest debt: the code already
|
||||
throws/panics, so no caller silently ships a wrong value. Detect (among others):
|
||||
|
||||
- `throw new Error("Not implemented")` and variants (`not implemented yet`,
|
||||
`NotImplementedError`, `NotImplementedException`) — JS/TS, C#, Java
|
||||
- `raise NotImplementedError` — Python (see noise list for the abstract-method
|
||||
exception)
|
||||
- `todo!()` / `todo!("msg")` / `unimplemented!()` — Rust
|
||||
- `TODO("...")` / `TODO()` — Kotlin
|
||||
- `panic!("not implemented")` — Go, Rust
|
||||
|
||||
Report loud stubs as tracked debt. They are usually fine to keep while the
|
||||
work is genuinely in progress; the fixer does NOT touch them.
|
||||
|
||||
# Noise — drop these without reporting
|
||||
|
||||
- `TODO` inside a **string literal** that is not an intent marker
|
||||
(e.g. `const op = "TODO";`).
|
||||
- Marker text inside **doc examples or docstrings** that merely illustrate
|
||||
syntax (`// TODO: not real code` in a comment block that quotes examples).
|
||||
- **Fixture/generated files**: filenames matching `*.todo.*` / `*.fixture.*`,
|
||||
snapshots, scaffolds, vendored code (scope rules exclude most already).
|
||||
- **Correct idioms that look like stubs**:
|
||||
- `raise NotImplementedError` in an **abstract base class / abstractmethod**
|
||||
(Python) — that is the idiomatic way to declare an interface method.
|
||||
- `abstract` methods without bodies (Java, Kotlin, C#) — not stubs.
|
||||
- a legitimately tiny function that returns a default BY DESIGN
|
||||
(e.g. a reducer that sums and can naturally return 0, `indexOf` returning
|
||||
-1, a cache miss returning `null`). Check the surrounding semantics, not
|
||||
just the shape: a lone placeholder return inside a `catch` for an IO error
|
||||
is a boundary handler, not a stub.
|
||||
- Markers in languages/code the project doesn't own (vendored dirs).
|
||||
|
||||
# Operating contract
|
||||
|
||||
- Operate only within the target path given in the task.
|
||||
- Use `read`, `grep`, `glob`, `ls` to inspect; cross-check a candidate's
|
||||
declarations/context before classifying (is the function called anywhere?
|
||||
is the class abstract? is the `return null` a catch handler?).
|
||||
- `bash` is for read-only inspection only (`grep -n`, `wc`, `git ls-files`,
|
||||
`cat`). Never mutate source files.
|
||||
- `write` is ONLY for writing your findings report to the output path named in
|
||||
the task (under the project's `.pygienium/` state directory). Never `write`
|
||||
source files.
|
||||
|
||||
# Scope of inspection
|
||||
|
||||
**Only inspect implementation source files.** Do not analyse documentation,
|
||||
config, type declarations, build output, or dependencies — flagging those is
|
||||
noise the user cannot act on.
|
||||
|
||||
## Inspect (extensions)
|
||||
|
||||
`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`,
|
||||
`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx`
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl
|
||||
- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs`
|
||||
- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code
|
||||
- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env`
|
||||
- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg`
|
||||
- Lock files: `package-lock.json`, `*.lock`, `bun.lockb`
|
||||
|
||||
## File discovery preference
|
||||
|
||||
1. Prefer the recon snapshot at `<cwd>/.pygienium/recon.json` when it exists.
|
||||
2. Otherwise enumerate files yourself, applying the rules above.
|
||||
|
||||
# Output
|
||||
|
||||
Write your full findings report to the **findings path** given in the task
|
||||
(typically `<cwd>/.pygienium/checks/todos/findings.md`).
|
||||
|
||||
`findings.md` MUST begin with a machine-readable summary line, then the three
|
||||
sections. Format:
|
||||
|
||||
```markdown
|
||||
# TODOs & stubs findings
|
||||
|
||||
summary: <M> marker(s), <S> silent stub(s), <L> loud stub(s) | new: <N> | resolved: <R> | reviewed: <K>
|
||||
|
||||
## TODO markers
|
||||
|
||||
### 1. <file>:<line> — <code>
|
||||
- marker: TODO | FIXME | HACK | XXX | @todo
|
||||
- context: <enclosing function or file>
|
||||
- disposition: track | drop-noise
|
||||
|
||||
## Silent stubs (actionable)
|
||||
|
||||
### 1. <file>:<line>
|
||||
- function: <name> (or the file when unnamed)
|
||||
- stub: <the placeholder body>
|
||||
- disposition: convert-to-loud | keep (not a stub)
|
||||
|
||||
## Loud stubs (already failing loudly — tracked debt)
|
||||
|
||||
### 1. <file>:<line>
|
||||
- kind: throw-not-implemented | raise-NotImplementedError | todo! | TODO() | panic-not-implemented
|
||||
- disposition: track | drop-noise
|
||||
```
|
||||
|
||||
`new`/`resolved` are computed against the previous run's verified counts when
|
||||
the task tells you them; when the task does not provide a previous baseline,
|
||||
report what the deterministic pre-scan computed and mark it `(tentative)`.
|
||||
`reviewed` is the number of candidates you actually verified.
|
||||
|
||||
If the target is clean, write:
|
||||
|
||||
```markdown
|
||||
# TODOs & stubs findings
|
||||
|
||||
summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s) | new: 0 | resolved: <prev total> | reviewed: <K>
|
||||
|
||||
No TODOs or stubs detected.
|
||||
```
|
||||
|
||||
After writing `findings.md`, emit a terse one-line summary as your final
|
||||
message in EXACTLY this parseable form:
|
||||
|
||||
```
|
||||
todos: <S> silent stub(s), <L> loud stub(s), <M> marker(s) — see <findings-path>
|
||||
```
|
||||
|
||||
# Tone
|
||||
|
||||
Precise and terse. Every silent stub needs one line of evidence (the
|
||||
placeholder body) and a disposition. Never invent counts — verify before you
|
||||
write.
|
||||
404
bun.lock
Normal file
404
bun.lock
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "pygienium",
|
||||
"devDependencies": {
|
||||
"@oh-my-pi/pi-coding-agent": "17.2.12",
|
||||
"@oh-my-pi/pi-tui": "17.2.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||
|
||||
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
|
||||
|
||||
"@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@oh-my-pi/hashline": ["@oh-my-pi/hashline@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-OdqMAojK8iUZynyNAP2VnbB+EHOHM7ISDSM3f9xHh/sDNSPDi15Xwd53AIdJy+EhYoCyglDIqO9/seHjA6veQQ=="],
|
||||
|
||||
"@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-BOyDNq8Hj/CJVc6Wntdo+1yaG/b8CqLcxsBnHefLoC7vO96YoFjt4S7GOpp3cd+ysiuxHnmrWn30kEtwR9fhjQ=="],
|
||||
|
||||
"@oh-my-pi/omptype": ["@oh-my-pi/omptype@17.2.12", "", {}, "sha512-Y27VMnPbcUEOpK1DgYuvNIaVnGxEmOcEP2bYPfuAWkx2vc4PKXL0NCRzGLwYMjhwjjd9O/gn8lywZfxDCBytjA=="],
|
||||
|
||||
"@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1" } }, "sha512-VIJnZCQyshiZoiWpvhoNVHhUQmptLG41h7XPg2IZfEdDNdDpV26JuZdDMgBr0hIz1L/RfTq8MiRcRuAcasvkOg=="],
|
||||
|
||||
"@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-S6LswgyLgQdE5zXADf6NGwrYn1OCYp1WiKxyN6f/1RRv68vbd3y4YSoidzeOYs3kPS/cIiv3dw3LHaCrwpam/Q=="],
|
||||
|
||||
"@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-6VycCR0ShSzbVfOa7cdeqVtxHL2IPpJXFePKaQPhQ8bOTiyE/TaUm1uIa1i4doqPTz/k6vUEs+a57cZwoUP97A=="],
|
||||
|
||||
"@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@17.2.12", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/hashline": "17.2.12", "@oh-my-pi/omp-stats": "17.2.12", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-agent-core": "17.2.12", "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-mnemopi": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-tui": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "mupdf": "^1.28.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-+q+W4fyNQQ7xAKiN0mmOisWDDtKO0R/ZctTSsKqR4ulN3K1zfQ9HwiTxtg7HJHn5fwCy+X3BmUG72FatNUN8IA=="],
|
||||
|
||||
"@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-lKjEexuFC/piaNnb3MzJKu8rolXwkjfv0M90UBUiNPFxKbpKHWU5np5aUAOYkT+fwKoQPQ7Tr95mJx0l32S0OQ=="],
|
||||
|
||||
"@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@17.2.12", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "17.2.12", "@oh-my-pi/pi-natives-darwin-x64": "17.2.12", "@oh-my-pi/pi-natives-linux-arm64": "17.2.12", "@oh-my-pi/pi-natives-linux-x64": "17.2.12", "@oh-my-pi/pi-natives-win32-x64": "17.2.12" } }, "sha512-MVZq0UrrA7mk6uMKrgjnAhfrDj+58yEuu0VeVd3JuvneMjcX1duIzOdyqRG13S+/XzGOvGkk164dM6D/CwxteQ=="],
|
||||
|
||||
"@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@17.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5iullmWEWmGRjwgo7Kw6Bub3KOXahkUX3jZfw65bM9LjFRceSwoLTt6Yh0RnXXP8hRzZnhU3b/o73peWBlpLzw=="],
|
||||
|
||||
"@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@17.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-qNc2q02d5etsOdxmO4CNwCcEvgfWS9Ya/fuq2hDKJgp3BFeKQCzYDap0HhC/qkAfj2vpjsTyKc/PsPMMMLGytg=="],
|
||||
|
||||
"@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@17.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HTc6efuPNoYSzHu7Zk4gl3RtQzWWprVA4x0ep0gq5B7mBS6OwgnbH5otLrXSFeQ2UgRyV/nddz1O02ek4Vg8MA=="],
|
||||
|
||||
"@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@17.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jg9xzfqpcBDGRVnW+98z/eQPIfcnOYujd1bLW0PnJh6A0ykSqjCvt9PpN0wrlqr70WazOOURqoodN2eibIyOOQ=="],
|
||||
|
||||
"@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@17.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb/EF7Ug7SX/YYJofgKR5Kvw0I+AqGpEfmI5inIsvFzygqlA4QgMETbAR1s08sK+AYZqPqRorBpbGNas85XFig=="],
|
||||
|
||||
"@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-X4IKQG3alzw3ogUewxoPBaZImReEBF7P2Xu5xN5E47lfMHR0eNJ6cQnQmk7sHjfNEAclKCJDYqwzgOfRdh9GEA=="],
|
||||
|
||||
"@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12" } }, "sha512-iYNV2y6RW9tzYaBa7hX5pzb23+zzNLF/Bs4bK7LAO2Qc7OZl5gw1lp9YOUX4/VZOO7A20KrXnZNwZVCnpPKy1A=="],
|
||||
|
||||
"@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@17.2.12", "", {}, "sha512-UdQ0VP3gExd+jXgy8epCSZ+PTW9pkFu8FoomywmqEPj/JKjt1SqZhIiN8N51HkQCC5WJryT5AedzL/Qr6OID8w=="],
|
||||
|
||||
"@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-kWYY/7tgIAnXAiYogC6K1GLU2G26QXTr2vDJGq1mxCMk3/GixoNzrFwzK8w6NmKYdQpO/Sa1SRw9sypYT+v1Bw=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
|
||||
|
||||
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="],
|
||||
|
||||
"@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="],
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="],
|
||||
|
||||
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="],
|
||||
|
||||
"@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
|
||||
|
||||
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="],
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="],
|
||||
|
||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="],
|
||||
|
||||
"mupdf": ["mupdf@1.28.0", "", {}, "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
|
||||
|
||||
"onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="],
|
||||
|
||||
"onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="],
|
||||
|
||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QcYKzyrTzGSx6aKCD6hUODgRS1LetqfG57Z/+i5LCyfMlrgCvDc1lRcl9cdB+TozBsLha9QwLTlI0vmDcf5JKg=="],
|
||||
|
||||
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-6RGeis9K9gV/UQWOgd6Rf3iqXr2/YsBQswxHaCR4hrYkHfEIpHMfFmRWLt6nJJCOWgYW2xFxEd9yzjrafAV/Pw=="],
|
||||
|
||||
"sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw=="],
|
||||
|
||||
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.4", "", { "os": "linux", "cpu": "x64" }, "sha512-WZh5NCkGPFHHpYSd78iN4OnmxQeSTGyt9uZskH+im/NFHQ7elQ7B0sLzCMeRpvJxiIKvd9C6WxIJ4hYaxClfsQ=="],
|
||||
|
||||
"sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
|
||||
|
||||
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-/JbPjldrfNv+t+uIS3MlkuhfIf5l3FHUGkRC2oRXgjRqOaVmEyP3vLlQ7dTa4J7raG5oB8c3GoPjuSWSqT9GOQ=="],
|
||||
|
||||
"sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.4", "", { "os": "win32", "cpu": "x64" }, "sha512-R0PWby1VxC14TDZPq7GcfSyXSY6SAFO8Y4JwdCdqouFmeXkZ1L7Is9m98C9KxQ0dN7ZtDzhAmE/43FUs/elXRQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="],
|
||||
|
||||
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||
|
||||
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||
|
||||
"@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||
|
||||
"cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
}
|
||||
}
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@mikefreno/omp-pygenium",
|
||||
"version": "0.1.0",
|
||||
"description": "Code hygiene extension for omp (port of the pi extension) — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
|
||||
"keywords": [
|
||||
"omp",
|
||||
"omp-extension",
|
||||
"code-hygiene",
|
||||
"lint",
|
||||
"subagents",
|
||||
"pygienium"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"omp": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oh-my-pi/pi-coding-agent": "17.2.12",
|
||||
"@oh-my-pi/pi-tui": "17.2.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
256
src/agent-runner.ts
Normal file
256
src/agent-runner.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* agent-runner.ts — spawn isolated sub-agents for analysis and fix phases.
|
||||
*
|
||||
* The production runner uses pi's `createAgentSession` SDK to spin up a fresh
|
||||
* in-memory agent session scoped to the target `cwd`, with the agent
|
||||
* definition's system prompt and tool allowlist applied. Because that path
|
||||
* needs live model credentials (unsuitable for CI), the runner is backed by an
|
||||
* injectable factory: tests swap it for a deterministic fake that executes a
|
||||
* tiny instruction protocol embedded in the task string.
|
||||
*
|
||||
* Instruction protocol (used by the fake runner, harmless to the real one):
|
||||
* task lines may begin with `!write <path> <text>` — the fake writes the file
|
||||
* and reports it as a finding. Real sub-agents receive the whole task verbatim.
|
||||
*
|
||||
* @module pygienium/agent-runner
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
||||
|
||||
export interface AgentTaskOptions {
|
||||
/** Absolute working directory for the sub-agent. */
|
||||
cwd: string;
|
||||
/** Agent name to look up in `agents/*.md`. */
|
||||
agentName: string;
|
||||
/** The task prompt handed to the sub-agent. */
|
||||
task: string;
|
||||
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
|
||||
allowedTools?: string[];
|
||||
/** Optional explicit agent definition (skips `loadAgents`). */
|
||||
agent?: AgentDef;
|
||||
/**
|
||||
* Live callback forwarding raw {@link AgentSessionEvent}s from the
|
||||
* sub-agent session. The `pygienium-stream` forwarder in `index.ts`
|
||||
* turns tool_execution_start/end + assistant turns into chat messages.
|
||||
*/
|
||||
onEvent?: (event: AgentSessionEvent) => void;
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
/** Whether the sub-agent completed without throwing. */
|
||||
ok: boolean;
|
||||
/** The final assistant text emitted by the sub-agent. */
|
||||
text: string;
|
||||
/** Error message when `ok` is false. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
|
||||
|
||||
/** Module-level runner (defaults to the SDK-backed runner; tests override it). */
|
||||
let currentRunner: AgentRunner = defaultAgentRunner;
|
||||
|
||||
/** Entry point used by the check-runner. */
|
||||
export function runAgentTask(opts: AgentTaskOptions): Promise<AgentRunResult> {
|
||||
return currentRunner(opts);
|
||||
}
|
||||
|
||||
/** Override the active agent runner (primarily for tests). */
|
||||
export function setAgentRunner(runner: AgentRunner): void {
|
||||
currentRunner = runner;
|
||||
}
|
||||
|
||||
/** Restore the default SDK-backed agent runner. */
|
||||
export function resetAgentRunner(): void {
|
||||
currentRunner = defaultAgentRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`,
|
||||
* overrides the system prompt with the agent definition's body, restricts tools
|
||||
* to the agent's allowlist, and runs the task to completion.
|
||||
*/
|
||||
export async function defaultAgentRunner(
|
||||
opts: AgentTaskOptions,
|
||||
): Promise<AgentRunResult> {
|
||||
const agents = await loadAgents({ cwd: opts.cwd });
|
||||
const agent = opts.agent ?? agents.get(opts.agentName);
|
||||
if (!agent) {
|
||||
const names = [...agents.keys()];
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
error:
|
||||
`Unknown agent definition: "${opts.agentName}". ` +
|
||||
`Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` +
|
||||
`Searched ${extensionRoot()}/agents/ (extension) and ${opts.cwd}/agents/ (project-local). ` +
|
||||
`Add agents/${opts.agentName}.md to either location.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Lazily import the SDK so the rest of the module graph (and tests using the
|
||||
// fake runner) never resolve the heavy pi-coding-agent package. The specifier
|
||||
// is static by intent (dynamic-import exception: module is intentionally
|
||||
// excluded from the import-time graph to keep fake-runner tests SDK-free).
|
||||
const { createAgentSession, AgentRegistry, SessionManager } = await import(
|
||||
"@oh-my-pi/pi-coding-agent"
|
||||
);
|
||||
|
||||
const tools = opts.allowedTools ??
|
||||
agent.allowedTools ?? ["read", "bash", "grep", "glob"];
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: opts.cwd,
|
||||
toolNames: tools,
|
||||
// `tools` is an allowlist, not a request list.
|
||||
restrictToolNames: true,
|
||||
sessionManager: SessionManager.inMemory(opts.cwd),
|
||||
// Replace the fully rendered default prompt with the agent body.
|
||||
systemPrompt: agent.systemPrompt,
|
||||
// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
promptTemplates: [],
|
||||
rules: [],
|
||||
contextFiles: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
// Private registry: the host session owns the process-global "Main"
|
||||
// identity, so a per-run registry keeps these in-process workers
|
||||
// disjoint from the main agent.
|
||||
agentRegistry: new AgentRegistry(),
|
||||
});
|
||||
|
||||
try {
|
||||
let text = "";
|
||||
let stopReason: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === "message_update" &&
|
||||
event.assistantMessageEvent.type === "text_delta"
|
||||
) {
|
||||
text += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === "message_end") {
|
||||
// Capture the full assistant text from the finalized message —
|
||||
// models that don't stream text_delta (or truncate) still surface
|
||||
// their output here. Prefer the streamed text when non-empty.
|
||||
const message = event.message as {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
if (message.stopReason) stopReason = message.stopReason;
|
||||
if (message.errorMessage) errorMessage = message.errorMessage;
|
||||
if (message.role === "assistant") {
|
||||
const full = extractAssistantText(message.content).trim();
|
||||
if (full && !text.trim()) text = full;
|
||||
}
|
||||
}
|
||||
// Forward the stream-driving events to the chat forwarder; it turns
|
||||
// each into its own `pygienium-stream` message (see index.ts).
|
||||
if (
|
||||
event.type === "tool_execution_start" ||
|
||||
event.type === "tool_execution_end" ||
|
||||
event.type === "message_end"
|
||||
) {
|
||||
opts.onEvent?.(event);
|
||||
}
|
||||
});
|
||||
await session.prompt(opts.task, { expandPromptTemplates: false });
|
||||
// Ensure the agent has fully settled (tool calls may still be in-flight
|
||||
// after prompt() resolves; piolium's runner calls this for the same
|
||||
// reason).
|
||||
await session.agent.waitForIdle();
|
||||
unsubscribe();
|
||||
// Surface session errors that didn't throw but left no useful output.
|
||||
// A session ending with stopReason "error" and no text means the model
|
||||
// call failed silently — treat that as a failed run, not ok:true.
|
||||
if (errorMessage) {
|
||||
return { ok: false, text, error: errorMessage };
|
||||
}
|
||||
if (!text.trim() && stopReason === "error") {
|
||||
return {
|
||||
ok: false,
|
||||
text,
|
||||
error: "sub-agent session ended in error with no output.",
|
||||
};
|
||||
}
|
||||
return { ok: true, text };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
text: "",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
session.dispose();
|
||||
} catch {
|
||||
/* ignore dispose errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract joined text from an assistant message's content blocks.
|
||||
* Mirrors piolium's `extractAssistantText`.
|
||||
*/
|
||||
function extractAssistantText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.flatMap((c) =>
|
||||
c && typeof c === "object" && (c as { type?: string }).type === "text"
|
||||
? [(c as { text?: string }).text ?? ""]
|
||||
: [],
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake agent runner for tests: it understands a tiny instruction protocol
|
||||
* embedded in the task so a no-op check can produce deterministic findings
|
||||
* and write marker files without a model. Recognised instructions (one per
|
||||
* line, leading-whitespace tolerant):
|
||||
*
|
||||
* !write <path> <text...> — write text to path (relative to cwd); recorded
|
||||
* !echo <text...> — appended to findings
|
||||
*
|
||||
* The agent's emitted findings text is the collected `!echo`/`!write` lines.
|
||||
*/
|
||||
export const fakeAgentRunner: AgentRunner = async (opts) => {
|
||||
const lines = opts.task.split(/\r?\n/);
|
||||
const findings: string[] = [];
|
||||
try {
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const write = /^!write\s+(\S+)\s*(.*)$/.exec(trimmed);
|
||||
if (write) {
|
||||
const rel = write[1] as string;
|
||||
const content = (write[2] ?? "").replace(/^["']|["']$/g, "");
|
||||
const full = isAbsolute(rel) ? rel : join(opts.cwd, rel);
|
||||
await mkdir(dirname(full), { recursive: true });
|
||||
await writeFile(full, content + "\n", "utf8");
|
||||
findings.push(`wrote ${rel}`);
|
||||
continue;
|
||||
}
|
||||
const echo = /^!echo\s+(.*)$/.exec(trimmed);
|
||||
if (echo) {
|
||||
findings.push((echo[1] ?? "").replace(/^["']|["']$/g, ""));
|
||||
}
|
||||
}
|
||||
return { ok: true, text: findings.join("\n") };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
text: findings.join("\n"),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
};
|
||||
193
src/agents.ts
Normal file
193
src/agents.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* agents.ts — markdown agent-definition loader.
|
||||
*
|
||||
* Reads agent definitions from `agents/*.md` shipped with the extension so the
|
||||
* analysis and fix roles are plain editable markdown — no TypeScript changes
|
||||
* needed to tune a sub-agent's behaviour. Each `.md` file uses a YAML
|
||||
* frontmatter block to declare its `name` and `allowedTools`; the body becomes
|
||||
* the agent's system prompt.
|
||||
*
|
||||
* File shape:
|
||||
*
|
||||
* ---
|
||||
* name: scanner
|
||||
* allowedTools:
|
||||
* - read
|
||||
* - grep
|
||||
* - glob
|
||||
* ---
|
||||
* You are a code-hygiene scanner …
|
||||
*
|
||||
* @module pygienium/agents
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** A loaded agent definition. */
|
||||
export interface AgentDef {
|
||||
/** Unique agent name (matches `agentName` on a `CheckDefinition`). */
|
||||
name: string;
|
||||
/** The markdown body, used verbatim as the sub-agent system prompt. */
|
||||
systemPrompt: string;
|
||||
/** Tool names the sub-agent may use (`read`, `bash`, …), or undefined to inherit defaults. */
|
||||
allowedTools?: string[];
|
||||
/** Absolute path to the source `.md` file. */
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
/** Resolve the extension root (the directory holding `package.json` and `agents/`). */
|
||||
export function extensionRoot(): string {
|
||||
// src/agents.ts → ../ = extension root.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolve(here, "..");
|
||||
}
|
||||
|
||||
/** Parse a YAML-ish frontmatter block from markdown. Only the keys we use. */
|
||||
function parseFrontmatter(raw: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
|
||||
if (!match) return { frontmatter: {}, body: raw };
|
||||
const fmText = match[1] ?? "";
|
||||
const body = match[2] ?? "";
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
const lines = fmText.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
// YAML block-list: a key with an empty value followed by "- item" lines.
|
||||
const blockList = /^([A-Za-z_][A-Za-z0-9_-]*):\s*$/.exec(trimmed);
|
||||
if (blockList) {
|
||||
const key = blockList[1] as string;
|
||||
const items: string[] = [];
|
||||
let j = i + 1;
|
||||
for (; j < lines.length; j++) {
|
||||
const item = /^\s+-\s+(.*)$/.exec(lines[j] ?? "");
|
||||
if (!item) break;
|
||||
items.push((item[1] ?? "").trim().replace(/^["']|["']$/g, ""));
|
||||
}
|
||||
if (items.length > 0) {
|
||||
frontmatter[key] = items;
|
||||
i = j - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline key/value (also handles inline lists like key: [a, b]).
|
||||
const idx = trimmed.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
const inner = value.slice(1, -1);
|
||||
const valueList: string[] = [];
|
||||
for (const part of inner.split(",")) {
|
||||
const v = part.trim().replace(/^["']|["']$/g, "");
|
||||
if (v) valueList.push(v);
|
||||
}
|
||||
frontmatter[key] = valueList;
|
||||
} else {
|
||||
frontmatter[key] = value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
return { frontmatter, body: body.trim() + "\n" };
|
||||
}
|
||||
|
||||
function asStringList(value: unknown): string[] | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === "string") return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load agent definitions: the extension's `agents/*.md` baseline, plus any
|
||||
* repo-local `agents/*.md` at `<cwd>/agents/` (when `cwd` is given). Repo
|
||||
* agents override the extension's by name, so a project can tune a sub-agent's
|
||||
* prompt or tool allowlist without editing the extension. Missing dirs are
|
||||
* skipped silently; a dir with entries that all fail to parse logs each
|
||||
* failure and continues.
|
||||
*/
|
||||
export async function loadAgents(opts?: {
|
||||
cwd?: string;
|
||||
}): Promise<Map<string, AgentDef>> {
|
||||
const extRoot = extensionRoot();
|
||||
const result = new Map<string, AgentDef>();
|
||||
|
||||
// Extension-shipped agents are the baseline.
|
||||
await scanAgentDir(join(extRoot, "agents"), result);
|
||||
|
||||
// Repo-local overrides, applied last so they win on name collisions.
|
||||
if (opts?.cwd) {
|
||||
await scanAgentDir(join(opts.cwd, "agents"), result, true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every `agents/*.md` in `dir` into `result` (later dirs win on name
|
||||
* collisions). `repoDir` suppresses the missing-dir warning: `<cwd>/agents/`
|
||||
* legitimately doesn't exist in most scanned projects.
|
||||
*/
|
||||
async function scanAgentDir(
|
||||
dir: string,
|
||||
result: Map<string, AgentDef>,
|
||||
repoDir = false,
|
||||
): Promise<void> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch (err) {
|
||||
if (!repoDir) {
|
||||
console.error(
|
||||
`[pygienium] agent loading: could not read agents dir at ${dir}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".md")) continue;
|
||||
const sourcePath = join(dir, entry);
|
||||
try {
|
||||
const raw = await readFile(sourcePath, "utf8");
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length);
|
||||
const allowedTools = asStringList(frontmatter.allowedTools);
|
||||
result.set(name, {
|
||||
name,
|
||||
systemPrompt: body,
|
||||
allowedTools,
|
||||
sourcePath,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[pygienium] agent loading: failed to load ${entry} from ${dir}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Continue loading other agents even if one fails.
|
||||
}
|
||||
}
|
||||
|
||||
if (!repoDir && result.size === 0 && entries.length > 0) {
|
||||
console.error(
|
||||
`[pygienium] agent loading: found ${entries.length} entries in ${dir} but loaded 0 agents`,
|
||||
);
|
||||
}
|
||||
}
|
||||
0
src/checks/.gitkeep
Normal file
0
src/checks/.gitkeep
Normal file
233
src/checks/comments.ts
Normal file
233
src/checks/comments.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* checks/comments.ts — comments hygiene check (first end-to-end reference).
|
||||
*
|
||||
* This is the canonical `CheckDefinition` that future checks (08+) copy. It
|
||||
* reuses the generic `scanner`/`fixer` agents shipped in `agents/*.md`; the
|
||||
* check-specific rubric is embedded in the task text handed to the sub-agent
|
||||
* (see {@link buildCommentsScanTask} / {@link buildCommentsFixTask}) so no
|
||||
* per-check agent `.md` file is required.
|
||||
*
|
||||
* Rubric (the user's spec — short + high value):
|
||||
* - comments that restate the code they sit on ("what" comments) → REMOVE
|
||||
* - verbose narration / long-winded explanations → TIGHTEN (shorten)
|
||||
* - "why" comments that explain intent, rationale, or gotchas → KEEP
|
||||
* - code self-explanatory with no comment → no comment needed (don't add one)
|
||||
*
|
||||
* Artifacts (under `<cwd>/.pygienium/checks/comments/`):
|
||||
* - `findings.md` — per-file line refs for each smell
|
||||
* - `changes.md` — summary of edits + human-review items
|
||||
*
|
||||
* @module pygienium/checks/comments
|
||||
*/
|
||||
|
||||
import type { CheckDefinition, CheckScope } from "./registry.js";
|
||||
import { scopeRulesMarkdown } from "./scope.js";
|
||||
|
||||
/** Phase-strip phase this check belongs to. */
|
||||
export const COMMENTS_PHASE_ID = "C1";
|
||||
|
||||
/**
|
||||
* Directory where this check writes its `findings.md` and `changes.md`
|
||||
* artifacts: `<cwd>/.pygienium/checks/comments/`. Based on `scope.cwd` (the
|
||||
* project root, always a directory) so the path is valid whether the scan
|
||||
* target is a single file or a directory. Matches the spec's
|
||||
* `.pygienium/checks/comments/findings.md` relative-path notation.
|
||||
*/
|
||||
export function commentsArtifactDir(scope: CheckScope): string {
|
||||
const base = scope.cwd.replace(/\/+$/, "");
|
||||
return `${base}/.pygienium/checks/comments`;
|
||||
}
|
||||
|
||||
/** Absolute path to the findings artifact for this check. */
|
||||
export function findingsPath(scope: CheckScope): string {
|
||||
return `${commentsArtifactDir(scope)}/findings.md`;
|
||||
}
|
||||
|
||||
/** Absolute path to the changes artifact for this check. */
|
||||
export function changesPath(scope: CheckScope): string {
|
||||
return `${commentsArtifactDir(scope)}/changes.md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared rubric block, injected into both scan and fix task text so the analysis
|
||||
* and remediation sub-agents apply identical judgement.
|
||||
*/
|
||||
const RUBRIC = `# Comments hygiene rubric
|
||||
|
||||
Short + high value is the goal. Evaluate every comment in the target:
|
||||
|
||||
- **RESTATE → REMOVE.** A comment that paraphrases the line(s) it sits on adds
|
||||
no information. Examples: \`// increment i\` over \`i++\`, \`// return the
|
||||
result\` over \`return result\`. Delete it.
|
||||
- **VERBOSE → TIGHTEN.** A comment that is high-value but needlessly long.
|
||||
Rewrite it to one tight sentence preserving the key insight. Do not delete.
|
||||
- **"WHY" → KEEP.** A comment explaining intent, rationale, a non-obvious
|
||||
decision, a workaround, a gotcha, or a constraint the code cannot express.
|
||||
Leave it untouched (tighten only if it is also verbose).
|
||||
- **NO COMMENT NEEDED.** When the code is self-explanatory, do not add a comment.
|
||||
- Keep inline section headers/dividers that aid navigation only if they mark a
|
||||
real boundary; remove pure decoration.`;
|
||||
|
||||
/**
|
||||
* Build the analysis sub-agent task. Instructs the agent to read candidate
|
||||
* source files, identify comment smells per the rubric, and write per-file line
|
||||
* references to `.pygienium/comments/findings.md`.
|
||||
*/
|
||||
export function buildCommentsScanTask(_cwd: string, scope: CheckScope): string {
|
||||
const outDir = commentsArtifactDir(scope);
|
||||
const findingsFile = findingsPath(scope);
|
||||
return `# Task: comments hygiene scan
|
||||
|
||||
You are running the **comments** hygiene check.
|
||||
|
||||
## Target
|
||||
- Scan target: \`${scope.target}\`
|
||||
|
||||
${scopeRulesMarkdown()}
|
||||
## What to do
|
||||
1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it
|
||||
exists; otherwise enumerate source files directly under the target.
|
||||
2. For each source file, read it and locate every comment (inline \`//\`,
|
||||
block \`/* */\`, doc \`/** */\`, \`#\` for scripting languages, etc.).
|
||||
3. Apply the rubric below to each comment and classify it: RESTATE, VERBOSE,
|
||||
WHY, or OK.
|
||||
4. Write a findings report to \`${findingsFile}\` with per-file line refs.
|
||||
5. Return the findings report text as your final message (same content as the
|
||||
file). The host captures it as the analysis-phase findings.
|
||||
|
||||
${RUBRIC}
|
||||
|
||||
## findings.md format
|
||||
|
||||
\`\`\`markdown
|
||||
# comments — findings
|
||||
|
||||
<count> comment smell(s) across <files> file(s).
|
||||
|
||||
## <relative-file>
|
||||
- L<line>: <smell: RESTATE|VERBOSE> — <quote or paraphrase>
|
||||
- L<line>: KEEP (why) — <one-line reason> # listed for transparency
|
||||
\`\`\`
|
||||
|
||||
If no smells are found, write \`# comments — findings\n\n0 comment smell(s).\`
|
||||
and return that text. Always create findings.md so the run has an artifact.
|
||||
|
||||
Write the report under \`${outDir}\` (create directories as needed).
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fix sub-agent task from the scan findings. Instructs the agent to
|
||||
* apply safe removals/tightenings, leave "why" comments, and write a summary
|
||||
* of edits plus anything needing human review to `.pygienium/comments/changes.md`.
|
||||
*/
|
||||
export function buildCommentsFixTask(
|
||||
_cwd: string,
|
||||
scope: CheckScope,
|
||||
findings: string,
|
||||
): string {
|
||||
const outDir = commentsArtifactDir(scope);
|
||||
const changesFile = changesPath(scope);
|
||||
return `# Task: comments hygiene fix
|
||||
|
||||
You are running the **comments** hygiene fix phase.
|
||||
|
||||
## Target
|
||||
- Fix target: \`${scope.target}\`
|
||||
|
||||
## Input: scan findings
|
||||
${findings.trim().length > 0 ? findings : "(no findings text provided)"}
|
||||
|
||||
## What to do
|
||||
1. For each RESTATE finding: remove the comment entirely.
|
||||
2. For each VERBOSE finding: replace the comment with a tightened one-sentence
|
||||
version that keeps the key insight.
|
||||
3. For every WHY comment: leave it untouched (tighten only if it is also
|
||||
verbose, preserving the rationale).
|
||||
4. Do not change any code logic, formatting, or ordering — only comments.
|
||||
5. Write a summary to \`${changesFile}\` and return it as your final message.
|
||||
|
||||
${RUBRIC}
|
||||
|
||||
## changes.md format
|
||||
|
||||
\`\`\`markdown
|
||||
# comments — changes
|
||||
|
||||
<applied> edit(s) applied; <deferred> deferred for human review.
|
||||
|
||||
## Applied
|
||||
- <relative-file>:<line> — <removed|tightened> comment (auto)
|
||||
|
||||
## Needs human review
|
||||
- <relative-file>:<line> — <reason> (manual)
|
||||
\`\`\`
|
||||
|
||||
If nothing needed changing, write
|
||||
\`# comments — changes\n\n0 edit(s) applied.\` and return that text. Always
|
||||
create changes.md so the run has an artifact. Write it under \`${outDir}\`.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precondition gate. Returns an error string when the comments check cannot
|
||||
* proceed (target path missing or not a real file/directory), else
|
||||
* `undefined`. Idempotent — passes identically before analysis and at verify.
|
||||
*/
|
||||
async function commentsGate(cwd: string): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const { resolve } = await import("node:path");
|
||||
const target = resolve(cwd);
|
||||
try {
|
||||
const s = await stat(target);
|
||||
if (s.isDirectory() || s.isFile()) return undefined;
|
||||
return `target is not a file or directory: ${target}`;
|
||||
} catch {
|
||||
return `target path does not exist: ${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts. After the
|
||||
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
|
||||
* exist too (the scan-phase-only run skips `changes.md` by design). Returns an
|
||||
* error string to fail verify, or `undefined` to pass. Replaces the historical
|
||||
* no-op verify (which only re-ran the existence gate) so the verify phase now
|
||||
* genuinely asserts the run produced its report.
|
||||
*/
|
||||
async function commentsVerify(scope: CheckScope): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `comments verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `comments verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The comments hygiene check definition. */
|
||||
export const check = {
|
||||
name: "comments",
|
||||
label: "Comments",
|
||||
description:
|
||||
'Remove low-value/restating comments, tighten verbose ones, keep "why" comments.',
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: COMMENTS_PHASE_ID,
|
||||
buildScanTask: buildCommentsScanTask,
|
||||
buildFixTask: buildCommentsFixTask,
|
||||
gate: commentsGate,
|
||||
verify: commentsVerify,
|
||||
} as const satisfies CheckDefinition;
|
||||
|
||||
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
|
||||
// that exports `check` and registers it — a new check is still one file.
|
||||
317
src/checks/complexity.ts
Normal file
317
src/checks/complexity.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* checks/complexity.ts — excessive complexity check.
|
||||
*
|
||||
* Detects high cyclomatic complexity and structural complexity smells, then
|
||||
* refactors toward the simplest implementation that meets requirements.
|
||||
*
|
||||
* Cyclomatic complexity thresholds (MUST enforce, not advisory):
|
||||
* - 50+ → must refactor. No exceptions.
|
||||
* - 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: `<cwd>/.pygienium/checks/complexity/`.
|
||||
*/
|
||||
export function complexityArtifactDir(scope: CheckScope): string {
|
||||
const base = scope.cwd.replace(/\/+$/, "");
|
||||
return `${base}/.pygienium/checks/complexity`;
|
||||
}
|
||||
|
||||
/** Absolute path to findings artifact. */
|
||||
export function findingsPath(scope: CheckScope): string {
|
||||
return `${complexityArtifactDir(scope)}/findings.md`;
|
||||
}
|
||||
|
||||
/** Absolute path to changes artifact. */
|
||||
export function changesPath(scope: CheckScope): string {
|
||||
return `${complexityArtifactDir(scope)}/changes.md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared rubric for complexity analysis, injected into both scan and fix tasks.
|
||||
*/
|
||||
const RUBRIC = `# Complexity hygiene rubric
|
||||
|
||||
## Cyclomatic complexity thresholds
|
||||
|
||||
Cyclomatic complexity counts the number of independent paths through a function.
|
||||
Compute via language-native tools when available (lizard, radon, gocyclo), or
|
||||
count decision points (if/else if/for/while/case/&&/||/catch) per function.
|
||||
|
||||
| Score | Action |
|
||||
|-------|--------|
|
||||
| 50+ | **MUST refactor.** No exceptions. Break the function into smaller pieces. |
|
||||
| 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] <file>:<line> — <smell type> — <description>
|
||||
- <proposed simplification>
|
||||
|
||||
## Justifications (35–49 band)
|
||||
|
||||
For each function kept at 35–49 complexity:
|
||||
- **Function:** <name> at <file>:<line>
|
||||
- **Score:** <score>
|
||||
- **Justification:** <why this is critical path and can't be simplified>
|
||||
\`\`\`
|
||||
|
||||
If no issues found, write:
|
||||
\`# complexity — findings\n\n0 complexity issues found.\`
|
||||
|
||||
Always create findings.md so the run has an artifact.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fix sub-agent task from the scan findings. Instructs the agent to:
|
||||
* 1. Split 50+ complexity functions
|
||||
* 2. Refactor or justify 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
|
||||
|
||||
<applied> refactoring(s) applied; <deferred> deferred for human review.
|
||||
|
||||
## Applied
|
||||
|
||||
- <file>:<line> — <function> split (was <score>, now <scores>)
|
||||
- <file>:<line> — nested conditionals flattened
|
||||
- <file>:<line> — trivial wrapper inlined
|
||||
- <file>:<line> — speculative abstraction removed
|
||||
|
||||
## Deferred (needs human review)
|
||||
|
||||
- <file>:<line> — <function> — <reason> (manual)
|
||||
|
||||
## Justified (kept at 35–49)
|
||||
|
||||
- <file>:<line> — <function> (<score>) — <justification>
|
||||
\`\`\`
|
||||
|
||||
If nothing needed changing, write:
|
||||
\`# complexity — changes\n\n0 refactoring(s) applied.\`
|
||||
|
||||
Always create changes.md so the run has an artifact. Write it under \`${outDir}\`.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precondition gate. Returns an error string when the complexity check cannot
|
||||
* proceed (target path missing or not a real file/directory), else
|
||||
* `undefined`.
|
||||
*/
|
||||
async function complexityGate(cwd: string): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const { resolve } = await import("node:path");
|
||||
const target = resolve(cwd);
|
||||
try {
|
||||
const s = await stat(target);
|
||||
if (s.isDirectory() || s.isFile()) return undefined;
|
||||
return `target is not a file or directory: ${target}`;
|
||||
} catch {
|
||||
return `target path does not exist: ${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts. After the
|
||||
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
|
||||
* exist too. Without this, a sub-agent that returns empty/ok without writing
|
||||
* its report would be stamped `complete` — a false positive. Mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}.
|
||||
*/
|
||||
async function complexityVerify(
|
||||
scope: CheckScope,
|
||||
): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `complexity verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `complexity verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The excessive complexity check definition. */
|
||||
export const check = {
|
||||
name: "complexity",
|
||||
label: "Complexity",
|
||||
description:
|
||||
"Detect and refactor excessive complexity: high cyclomatic complexity (50+ must refactor, 35-49 needs justification), deep nesting, and speculative abstractions.",
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: COMPLEXITY_PHASE_ID,
|
||||
buildScanTask: buildComplexityScanTask,
|
||||
buildFixTask: buildComplexityFixTask,
|
||||
gate: complexityGate,
|
||||
verify: complexityVerify,
|
||||
} as const satisfies CheckDefinition;
|
||||
|
||||
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
|
||||
// that exports `check` and registers it — a new check is still one file.
|
||||
1144
src/checks/dead-code.ts
Normal file
1144
src/checks/dead-code.ts
Normal file
File diff suppressed because it is too large
Load Diff
185
src/checks/deep-modules.ts
Normal file
185
src/checks/deep-modules.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* checks/deep-modules.ts — "deep modules, not shallow ones" check.
|
||||
*
|
||||
* Detects modules with shallow abstractions (thin pass-throughs, one-line
|
||||
* re-export barrels, trivial getter classes, unnecessary adapter layers) and
|
||||
* recommends/applies consolidation. The rubric encodes John Ousterhout's
|
||||
* "deep modules" definition from *A Philosophy of Software Design*: a module
|
||||
* is valuable when it hides a substantial implementation behind a small
|
||||
* interface; a shallow one exposes as much complexity as it hides, so its
|
||||
* indirection adds cost without abstraction payoff.
|
||||
*
|
||||
* Lifecycle:
|
||||
* gate (need source files) → recon (shared) → scan sub-agent writes
|
||||
* `<cwd>/.pygienium/checks/deep-modules/findings.md` → [with --fix] fix
|
||||
* sub-agent writes `changes.md`, inlines safe pass-throughs, and lists
|
||||
* risky consolidations (external importers / public API) for human review.
|
||||
*
|
||||
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
|
||||
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-deep-modules`.
|
||||
*
|
||||
* @module pygienium/checks/deep-modules
|
||||
*/
|
||||
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { CheckDefinition, CheckScope } from "./registry.js";
|
||||
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
|
||||
|
||||
/** Output directory for this check's persistent reports. */
|
||||
export function deepModulesOutputDir(cwd: string): string {
|
||||
return join(cwd, ".pygienium", "checks", "deep-modules");
|
||||
}
|
||||
|
||||
/** `findings.md` path for this check. */
|
||||
export function findingsPath(cwd: string): string {
|
||||
return join(deepModulesOutputDir(cwd), "findings.md");
|
||||
}
|
||||
|
||||
/** `changes.md` path for this check. */
|
||||
export function changesPath(cwd: string): string {
|
||||
return join(deepModulesOutputDir(cwd), "changes.md");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate: skip when the cwd has no inspectable source files at all. A workspace
|
||||
* with zero source files gives the scanner nothing to classify.
|
||||
*/
|
||||
function deepModulesGate(cwd: string): string | undefined {
|
||||
let found = false;
|
||||
try {
|
||||
const entries = readdirSync(cwd);
|
||||
for (const entry of entries) {
|
||||
if (isScopeSource(entry)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// unreadable cwd → let the agent decide; don't block.
|
||||
return undefined;
|
||||
}
|
||||
if (!found) {
|
||||
return "no source files found to inspect";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
|
||||
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
|
||||
* returns ok with no output — which would otherwise be a false `complete`.
|
||||
*/
|
||||
async function deepModulesVerify(
|
||||
scope: CheckScope,
|
||||
): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope.cwd);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `deep-modules verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope.cwd);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `deep-modules verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scan task. The deep-modules scanner agent inspects the target,
|
||||
* classifies modules by abstraction depth against the rubric, and writes a
|
||||
* structured findings report to `findings.md`. The output path is passed into
|
||||
* the task so both the real agent (which uses its `write` tool) and the
|
||||
* deterministic fake runner (which understands `!write <path> <text>`) persist
|
||||
* the report to the same location.
|
||||
*
|
||||
* Note: the `!write`/`!echo` lines are the deterministic fallback the fake
|
||||
* runner executes for tests/smoke runs; a real model-driven agent receives the
|
||||
* whole prompt and writes a real analysis.
|
||||
*/
|
||||
function buildDeepScanTask(cwd: string, scope: CheckScope): string {
|
||||
const findings = findingsPath(cwd);
|
||||
const target = scope.target;
|
||||
// The expected findings document shape, shown to a real model-driven agent
|
||||
// as the format spec. The `!write`/`!echo` lines below are the deterministic
|
||||
// fallback the fake runner executes for tests/smoke runs.
|
||||
return [
|
||||
`Inspect the target "${target}" (cwd: ${cwd}) for shallow modules.`,
|
||||
`Classify every source module by abstraction depth (see your rubric).`,
|
||||
`Write your full findings report to: ${findings}.`,
|
||||
`findings.md must list each flagged module with: kind, evidence, importer`,
|
||||
`count, recommendation, and risk (low if no external importers, high else).`,
|
||||
`Then emit a one-line summary referencing the findings path.`,
|
||||
"",
|
||||
scopeRulesMarkdown(),
|
||||
"",
|
||||
`# Deterministic fallback (executed by the fake runner in tests):`,
|
||||
`!write ${findings} # Deep-modules findings | summary: 1 shallow module(s) flagged | ## 1. ${target}/wrapper.ts | kind: pass-through-wrapper | evidence: forwards every argument to a single library call with no added logic | importers: 0 | recommendation: inline-and-remove | risk: low`,
|
||||
`!echo deep-modules: 1 issue — see ${findings}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fix task. The fixer consumes the scan findings and applies ONLY safe
|
||||
* consolidations: inline-and-remove pass-through wrappers that have **zero**
|
||||
* external importers. Risky consolidations (any importer, or unclear
|
||||
* ownership) are listed in `changes.md` as `review-manual` and NOT applied.
|
||||
* Every action — applied or deferred — is recorded in `changes.md`.
|
||||
*/
|
||||
function buildDeepFixTask(
|
||||
cwd: string,
|
||||
scope: CheckScope,
|
||||
findings: string,
|
||||
): string {
|
||||
const changes = changesPath(cwd);
|
||||
const findingsFile = findingsPath(cwd);
|
||||
const target = scope.target;
|
||||
return [
|
||||
`Consolidate shallow modules found in the scan.`,
|
||||
`cwd: ${cwd} target: ${target}`,
|
||||
`Findings report (also persisted at ${findingsFile}):`,
|
||||
`---`,
|
||||
findings,
|
||||
`---`,
|
||||
``,
|
||||
`Rules:`,
|
||||
`- Apply ONLY safe consolidations: a pass-through wrapper with zero external`,
|
||||
` importers may be inlined at its single use site and the wrapper removed.`,
|
||||
`- NEVER auto-delete or rewrite a module with any external importer — list it`,
|
||||
` for human review instead.`,
|
||||
`- Preserve public API boundaries; when in doubt, defer to manual review.`,
|
||||
`- Write changes.md to ${changes} describing every action (auto | manual) with`,
|
||||
` the file, the finding, and the disposition.`,
|
||||
``,
|
||||
`# Deterministic consolidation (executed by the fake runner in tests):`,
|
||||
`# Safe: zero-importer pass-through rewritten/removed (auto).`,
|
||||
`# Risky: external-importer adapter left in place (manual).`,
|
||||
`!write ${target}/wrapper.ts // Consolidated by pygienium-deep-modules: pass-through wrapper removed; callers now use the underlying implementation directly.`,
|
||||
`!write ${changes} # Deep-modules changes | 1. ${target}/wrapper.ts — pass-through-wrapper — consolidated: inlined the underlying call at the use site and removed the wrapper module (auto) | 2. ${target}/risky-adapter.ts — adapter-layer — 2 external importer(s): left in place; listed for review (manual)`,
|
||||
`!echo deep-modules: 1 auto-applied, 1 deferred to review — see ${changes}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** The check definition; registers itself on import. */
|
||||
export const check: CheckDefinition = {
|
||||
name: "deep-modules",
|
||||
label: "Deep modules",
|
||||
description:
|
||||
"Detect shallow modules (pass-throughs, trivial wrappers, re-export barrels) and consolidate the safe ones.",
|
||||
agentName: "deep-modules",
|
||||
phaseId: "analysis",
|
||||
buildScanTask: buildDeepScanTask,
|
||||
buildFixTask: buildDeepFixTask,
|
||||
gate: deepModulesGate,
|
||||
verify: deepModulesVerify,
|
||||
};
|
||||
|
||||
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
|
||||
// that exports `check` and registers it.
|
||||
215
src/checks/defensive-guards.ts
Normal file
215
src/checks/defensive-guards.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* checks/defensive-guards.ts — "redundant defensive guarding" check.
|
||||
*
|
||||
* Detects defensive code that guards invariants the type system or an
|
||||
* upstream validation already guarantees, and removes the redundant guards
|
||||
* while preserving guards that protect genuine external boundaries (user
|
||||
* input, IO, parsing, untrusted data). The rubric encodes the engineering rule:
|
||||
* no compatibility layers or fallbacks meant to be "replaced later" — remove
|
||||
* them outright rather than layering over them.
|
||||
*
|
||||
* Flagged smells (non-exhaustive):
|
||||
* - redundant-null-check — null/undefined check on a value whose declared
|
||||
* type is already non-nullable.
|
||||
* - swallowing-try-catch — try/catch that silently discards the error
|
||||
* (empty catch, catch that only logs, or catch returning a fallback that
|
||||
* hides the failure).
|
||||
* - rethrow-only-try-catch — try/catch whose body only rethrows the exact
|
||||
* error, adding nothing.
|
||||
* - error-masking-fallback — `return defaultValue` / `|| fallback` in a
|
||||
* catch that masks a real failure with a plausible-but-wrong value.
|
||||
* - defensive-guard-on-validated-input — re-checking input that a caller or
|
||||
* parser already validated (e.g. asserting a parsed enum is in range).
|
||||
* - compatibility-fallback — a fallback branch kept "for now" / "to be
|
||||
* replaced later" (engineering rule: remove, don't layer).
|
||||
*
|
||||
* Kept (legitimate boundary guards):
|
||||
* - untrusted input (HTTP params, CLI args, env vars, files on disk).
|
||||
* - IO (network, filesystem, subprocess) where failures are expected.
|
||||
* - parsing (`JSON.parse`, `parseInt`, `Date.parse`, schema decoders).
|
||||
*
|
||||
* Lifecycle:
|
||||
* gate (need source files) → recon (shared) → scan sub-agent writes
|
||||
* `<cwd>/.pygienium/checks/defensive-guards/findings.md` separating redundant
|
||||
* guards from boundary guards → [with --fix] fix sub-agent removes redundant
|
||||
* guards, preserves boundary guards, and writes `changes.md` distinguishing
|
||||
* removed vs kept-with-reason.
|
||||
*
|
||||
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
|
||||
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-defensive-guards`.
|
||||
*
|
||||
* @module pygienium/checks/defensive-guards
|
||||
*/
|
||||
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { CheckDefinition, CheckScope } from "./registry.js";
|
||||
import { isScopeSource, scopeRulesMarkdown } from "./scope.js";
|
||||
|
||||
/** Output directory for this check's persistent reports. */
|
||||
export function defensiveGuardsOutputDir(cwd: string): string {
|
||||
return join(cwd, ".pygienium", "checks", "defensive-guards");
|
||||
}
|
||||
|
||||
/** `findings.md` path for this check. */
|
||||
export function findingsPath(cwd: string): string {
|
||||
return join(defensiveGuardsOutputDir(cwd), "findings.md");
|
||||
}
|
||||
|
||||
/** `changes.md` path for this check. */
|
||||
export function changesPath(cwd: string): string {
|
||||
return join(defensiveGuardsOutputDir(cwd), "changes.md");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate: skip when the cwd has no inspectable source files at all — a workspace
|
||||
* with zero source files gives the scanner nothing to analyse.
|
||||
*/
|
||||
function defensiveGuardsGate(cwd: string): string | undefined {
|
||||
let found = false;
|
||||
try {
|
||||
const entries = readdirSync(cwd);
|
||||
for (const entry of entries) {
|
||||
if (isScopeSource(entry)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// unreadable cwd → let the agent decide; don't block.
|
||||
return undefined;
|
||||
}
|
||||
if (!found) {
|
||||
return "no source files found to inspect";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
|
||||
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
|
||||
* returns ok with no output — which would otherwise be a false `complete`.
|
||||
*/
|
||||
async function defensiveGuardsVerify(
|
||||
scope: CheckScope,
|
||||
): Promise<string | undefined> {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = findingsPath(scope.cwd);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `defensive-guards verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope.cwd);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `defensive-guards verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scan task. The defensive-guards scanner agent inspects the target,
|
||||
* classifies each guard as redundant or a legitimate boundary guard against the
|
||||
* rubric, and writes a structured findings report to `findings.md`. The output
|
||||
* path is passed into the task so both the real agent (which uses its `write`
|
||||
* tool) and the deterministic fake runner (which understands `!write <path>
|
||||
* <text>`) persist the report to the same location.
|
||||
*
|
||||
* The `!write`/`!echo` lines are the deterministic fallback the fake runner
|
||||
* executes for tests/smoke runs; a real model-driven agent receives the whole
|
||||
* prompt and writes a real analysis.
|
||||
*/
|
||||
function buildDefensiveGuardsScanTask(cwd: string, scope: CheckScope): string {
|
||||
const findings = findingsPath(cwd);
|
||||
const target = scope.target;
|
||||
return [
|
||||
`Inspect the target "${target}" (cwd: ${cwd}) for redundant defensive guarding.`,
|
||||
`Classify every guard (null check, try/catch, fallback) against your rubric as`,
|
||||
`either REDUNDANT (remove) or BOUNDARY (keep). Boundary guards protect real`,
|
||||
`external boundaries: untrusted input, IO, and parsing. Redundant guards protect`,
|
||||
`invariants the type system or upstream validation already guarantees.`,
|
||||
`Write your full findings report to: ${findings}.`,
|
||||
`findings.md must separate redundant guards from legitimate boundary guards,`,
|
||||
`listing each with: kind, evidence, disposition (remove | keep-boundary), and`,
|
||||
`reason.`,
|
||||
`Then emit a one-line summary referencing the findings path.`,
|
||||
``,
|
||||
scopeRulesMarkdown(),
|
||||
``,
|
||||
`# Deterministic fallback (executed by the fake runner in tests):`,
|
||||
`!write ${findings} # Defensive-guards findings | summary: 2 redundant guard(s) flagged, 1 boundary guard kept | ## 1. ${target}/noise.ts:2 | kind: redundant-null-check | evidence: \`if (name === null)\` on \`name\` whose declared type is \`string\` (non-nullable) | disposition: remove | reason: type system already guarantees non-null | ## 2. ${target}/noise.ts:7 | kind: swallowing-try-catch | evidence: try/catch around doThing() discards the error silently (empty catch body) | disposition: remove | reason: masks bugs; no error mapping or recovery logic | ## 3. ${target}/boundary.ts:2 | kind: parsing-guard | evidence: try/catch around JSON.parse(input) | disposition: keep-boundary | reason: protects an external parsing boundary (JSON.parse of untrusted input)`,
|
||||
`!echo defensive-guards: 2 redundant, 1 boundary kept — see ${findings}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fix task. The fixer consumes the scan findings and removes ONLY
|
||||
* redundant guards — those whose protected invariant is already guaranteed by
|
||||
* the type system or upstream validation. Boundary guards (IO, parsing,
|
||||
* untrusted input) are preserved untouched. Every action — removed or kept —
|
||||
* is recorded in `changes.md`, distinguishing removed (auto) from kept with a
|
||||
* reason (boundary).
|
||||
*
|
||||
* The fixer rewrites the affected source files with the redundant guards
|
||||
* excised; compatibility fallbacks are removed outright (engineering rule:
|
||||
* remove, don't layer), never left behind as a transitional shim.
|
||||
*/
|
||||
function buildDefensiveGuardsFixTask(
|
||||
cwd: string,
|
||||
scope: CheckScope,
|
||||
findings: string,
|
||||
): string {
|
||||
const changes = changesPath(cwd);
|
||||
const findingsFile = findingsPath(cwd);
|
||||
const target = scope.target;
|
||||
return [
|
||||
`Remove redundant defensive guards found in the scan.`,
|
||||
`cwd: ${cwd} target: ${target}`,
|
||||
`Findings report (also persisted at ${findingsFile}):`,
|
||||
`---`,
|
||||
findings,
|
||||
`---`,
|
||||
``,
|
||||
`Rules:`,
|
||||
`- Remove ONLY redundant guards: null/undefined checks on non-nullable types,`,
|
||||
` try/catch that only rethrows or swallows, fallback values that hide errors,`,
|
||||
` defensive guards on already-validated input, and compatibility fallbacks.`,
|
||||
`- PRESERVE boundary guards: anything protecting untrusted input, IO, or parsing`,
|
||||
` (e.g. JSON.parse, network, filesystem, subprocess errors). Do not touch them.`,
|
||||
`- No compatibility layers: remove fallbacks outright — never leave a shim meant`,
|
||||
` to be "replaced later".`,
|
||||
`- Apply the smallest diff that removes the guard without changing behaviour for`,
|
||||
` the happy path. Preserve tests and existing conventions.`,
|
||||
`- Write changes.md to ${changes} distinguishing removed (auto) from kept`,
|
||||
` (boundary — with reason) for every finding.`,
|
||||
``,
|
||||
`# Deterministic removal (executed by the fake runner in tests):`,
|
||||
`# Redundant null check + swallowing try/catch removed from noise.ts (auto).`,
|
||||
`# JSON.parse boundary guard in boundary.ts preserved (boundary).`,
|
||||
`!write ${target}/noise.ts // Cleaned by pygienium-defensive-guards: removed redundant null check on non-nullable \`name\` and the swallowing try/catch around doThing(). export function greet(name: string) { return \`hello \${name}\`; } export function swallow() { doThing(); } function doThing() {}`,
|
||||
`!write ${changes} # Defensive-guards changes | summary: 2 removed, 1 kept (boundary) | ## Removed (auto) | 1. ${target}/noise.ts:2 — redundant-null-check — removed \`if (name === null) return ""\`; type system guarantees non-null | 2. ${target}/noise.ts:7 — swallowing-try-catch — removed the try/catch around doThing(); the error is no longer silently swallowed | ## Kept (boundary — with reason) | 1. ${target}/boundary.ts:2 — parsing-guard — kept: try/catch around JSON.parse protects an external parsing boundary (untrusted input)`,
|
||||
`!echo defensive-guards: 2 removed, 1 kept (boundary) — see ${changes}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** The check definition; registers itself on import. */
|
||||
export const check: CheckDefinition = {
|
||||
name: "defensive-guards",
|
||||
label: "Defensive guards",
|
||||
description:
|
||||
"Remove redundant defensive guarding (null checks on non-nullable types, swallowing try/catch, masking fallbacks) while keeping boundary guards (IO, parsing, untrusted input).",
|
||||
agentName: "defensive-guards",
|
||||
phaseId: "analysis",
|
||||
buildScanTask: buildDefensiveGuardsScanTask,
|
||||
buildFixTask: buildDefensiveGuardsFixTask,
|
||||
gate: defensiveGuardsGate,
|
||||
verify: defensiveGuardsVerify,
|
||||
};
|
||||
|
||||
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
|
||||
// that exports `check` and registers it.
|
||||
139
src/checks/registry.ts
Normal file
139
src/checks/registry.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* checks/registry.ts — pluggable check registry.
|
||||
*
|
||||
* A `CheckDefinition` describes one hygiene check (e.g. `comments`, `complexity`).
|
||||
* The registry is a module-level `Map` so that adding a check only requires a
|
||||
* new file in `src/checks/` plus one `registerCheck(def)` call — no changes to
|
||||
* `index.ts` command wiring. At startup, `index.ts` iterates the registry and
|
||||
* auto-registers a `/pygienium-<name>` command per definition.
|
||||
*
|
||||
* Lifecycle of a single check run (orchestrated by `src/modes/check-runner.ts`):
|
||||
* Q0 recon (shared) → analysis sub-agent (buildScanTask) →
|
||||
* fix sub-agent (buildFixTask, only with --fix) → verify gate → cleanup.
|
||||
*
|
||||
* @module pygienium/checks/registry
|
||||
*/
|
||||
|
||||
/**
|
||||
* Scope passed to scan/fix task builders. Resolved from the command args:
|
||||
* a positional path (absolute or relative to `cwd`) plus parsed flags.
|
||||
*/
|
||||
export interface CheckScope {
|
||||
/** Absolute working directory the check operates on. */
|
||||
cwd: string;
|
||||
/** Target path (absolute) the check scans; defaults to `cwd` when none given. */
|
||||
target: string;
|
||||
/** Whether fixes should be applied (the `--fix` flag). */
|
||||
fix: boolean;
|
||||
/** Remaining raw tokens after flag parsing, for check-specific use. */
|
||||
rest: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies preconditions before a check runs its analysis phase. Returns an
|
||||
* error string when the check cannot proceed (e.g. no source files match),
|
||||
* or `undefined` when the gate passes. Implemented per-check so generic
|
||||
* checks can bail early without spawning an agent.
|
||||
*/
|
||||
export type CheckGate = (
|
||||
cwd: string,
|
||||
) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
/**
|
||||
* Optional post-analysis (+ optional fix) verify hook. Confirms the check
|
||||
* actually produced its artifacts (e.g. `findings.md`/`changes.md`). Returns an
|
||||
* error string to fail the verify phase, or `undefined` to pass. When omitted,
|
||||
* the verify phase falls back to re-running {@link CheckDefinition.gate},
|
||||
* preserving the historical behaviour for checks that have nothing to verify.
|
||||
*/
|
||||
export type CheckVerify = (
|
||||
scope: CheckScope,
|
||||
) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
/**
|
||||
* The structured task string handed to a sub-agent. `buildScanTask` produces
|
||||
* the analysis prompt; `buildFixTask` consumes the findings text the scan
|
||||
* agent emitted and produces a fix prompt.
|
||||
*
|
||||
* Task builders may be async: a check can pre-compute deterministic candidates
|
||||
* (e.g. an import-graph scan) before assembling the prompt, so the sub-agent's
|
||||
* job is to verify/refine rather than re-derive everything from scratch.
|
||||
*/
|
||||
export type BuildScanTask = (
|
||||
cwd: string,
|
||||
scope: CheckScope,
|
||||
) => string | Promise<string>;
|
||||
export type BuildFixTask = (
|
||||
cwd: string,
|
||||
scope: CheckScope,
|
||||
findings: string,
|
||||
) => string | Promise<string>;
|
||||
|
||||
/**
|
||||
* Definition of a single pluggable hygiene check.
|
||||
*/
|
||||
export interface CheckDefinition {
|
||||
/** Lowercase kebab command suffix → `/pygienium-<name>`. Must be unique. */
|
||||
name: string;
|
||||
/** Human label shown in help and status strips. */
|
||||
label: string;
|
||||
/** One-line description for `/pygienium-help`. */
|
||||
description: string;
|
||||
/**
|
||||
* Name of the agent definition (from `agents/*.md`) used for the analysis
|
||||
* phase. The fix phase uses the `fixer` agent unless `fixAgentName`
|
||||
* overrides it.
|
||||
*/
|
||||
agentName: string;
|
||||
/** Optional override for the fix-phase agent (defaults to `fixer`). */
|
||||
fixAgentName?: string;
|
||||
/** Identifier of the phase-strip phase this check belongs to (task 05). */
|
||||
phaseId: string;
|
||||
/** Builds the analysis sub-agent task. */
|
||||
buildScanTask: BuildScanTask;
|
||||
/** Builds the fix sub-agent task from scan findings. */
|
||||
buildFixTask: BuildFixTask;
|
||||
/**
|
||||
* Precondition gate. Returning a string skips the check (recorded as
|
||||
* `skipped`); returning `undefined` proceeds normally.
|
||||
*/
|
||||
gate: CheckGate;
|
||||
/**
|
||||
* Optional verify hook confirming artifacts landed (see {@link CheckVerify}).
|
||||
* Falls back to re-running `gate` when omitted.
|
||||
*/
|
||||
verify?: CheckVerify;
|
||||
}
|
||||
|
||||
const registry = new Map<string, CheckDefinition>();
|
||||
|
||||
/**
|
||||
* Register a check. Throws on duplicate names so wiring mistakes surface
|
||||
* loudly at startup rather than silently shadowing a command.
|
||||
*/
|
||||
export function registerCheck(def: CheckDefinition): void {
|
||||
if (!def.name || !/^[a-z0-9][a-z0-9-]*$/.test(def.name)) {
|
||||
throw new Error(
|
||||
`Invalid check name "${def.name}": must be lowercase kebab (e.g. "comments").`,
|
||||
);
|
||||
}
|
||||
if (registry.has(def.name)) {
|
||||
throw new Error(`Duplicate pygienium check name: "${def.name}".`);
|
||||
}
|
||||
registry.set(def.name, def);
|
||||
}
|
||||
|
||||
/** Look up a registered check by name. */
|
||||
export function getCheck(name: string): CheckDefinition | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
/** All registered checks in insertion order. */
|
||||
export function getAllChecks(): CheckDefinition[] {
|
||||
return [...registry.values()];
|
||||
}
|
||||
|
||||
/** Test-only: reset the registry between tests. */
|
||||
export function clearChecks(): void {
|
||||
registry.clear();
|
||||
}
|
||||
145
src/checks/scope.ts
Normal file
145
src/checks/scope.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* scope.ts — canonical source-of-truth for what pygienium checks inspect.
|
||||
*
|
||||
* Every check (recon, dead-code, deep-modules, defensive-guards, complexity,
|
||||
* comments) and every scanner agent prompt shares these definitions so the
|
||||
* "only inspect implementation code" rule is stated once, not copy-pasted
|
||||
* across four files that drift apart.
|
||||
*
|
||||
* @module pygienium/checks/scope
|
||||
*/
|
||||
|
||||
/**
|
||||
* Implementation-code file extensions pygienium inspects.
|
||||
*
|
||||
* Deliberately excludes documentation (`.md`, `.txt`, `.rst`), config
|
||||
* (`.json`, `.yaml`, `.yml`, `.toml`, `.env`, `.ini`), type declarations
|
||||
* (`.d.ts`), styles (`.css`, `.scss`), markup (`.html`, `.svg`), and lock
|
||||
* files. These are not implementation code — a comments or complexity check
|
||||
* flagging prose in a `.md` or a key in `package.json` is noise.
|
||||
*/
|
||||
export const SCOPE_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".py",
|
||||
".rb",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".kt",
|
||||
".swift",
|
||||
".php",
|
||||
".cs",
|
||||
".lua",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Directories pygienium never descends into — build output, dependency caches,
|
||||
* tooling state, and VCS metadata. When walking the tree with `glob`/`grep`/
|
||||
* `readdir`, skip these by name to avoid wasting tokens on vendored code and
|
||||
* generated artifacts the user can't act on.
|
||||
*/
|
||||
export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"coverage",
|
||||
".next",
|
||||
".nuxt",
|
||||
".turbo",
|
||||
".svelte-kit",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"vendor",
|
||||
".cache",
|
||||
".pygienium",
|
||||
".ralpi",
|
||||
".idea",
|
||||
".vscode",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Compound extensions (checked after the simple extension lookup) that should
|
||||
* be treated as non-source even though their tail extension appears in
|
||||
* {@link SCOPE_EXTENSIONS}. The primary case: `.d.ts` type declarations are
|
||||
* generated contracts, not implementation code.
|
||||
*/
|
||||
export const SCOPE_EXCLUDE_SUFFIXES: ReadonlySet<string> = new Set([
|
||||
".d.ts",
|
||||
".d.mts",
|
||||
".d.cts",
|
||||
".min.js",
|
||||
".min.mjs",
|
||||
".min.cjs",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Test if a file path is implementation source pygienium should inspect.
|
||||
*
|
||||
* Returns `true` when the extension is in {@link SCOPE_EXTENSIONS} AND the
|
||||
* path does not end with a {@link SCOPE_EXCLUDE_SUFFIXES} pattern (e.g.
|
||||
* `.d.ts`).
|
||||
*/
|
||||
export function isScopeSource(path: string): boolean {
|
||||
const lower = path.toLowerCase();
|
||||
for (const suffix of SCOPE_EXCLUDE_SUFFIXES) {
|
||||
if (lower.endsWith(suffix)) return false;
|
||||
}
|
||||
const dot = lower.lastIndexOf(".");
|
||||
if (dot === -1) return false;
|
||||
return SCOPE_EXTENSIONS.has(lower.slice(dot));
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown section injected into every scan task string so the sub-agent knows
|
||||
* exactly what to inspect and what to skip — stated once here, not copy-pasted
|
||||
* into each task builder.
|
||||
*
|
||||
* Agents that use `glob`/`grep`/`readdir` for their own file discovery read
|
||||
* this before exploring, so the exclusion list governs their search too.
|
||||
*/
|
||||
export function scopeRulesMarkdown(): string {
|
||||
const extensions = [...SCOPE_EXTENSIONS]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("`, `");
|
||||
const excludeDirs = [...SCOPE_EXCLUDE_DIRS]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("`, `");
|
||||
return `## Scope of inspection
|
||||
|
||||
**Only inspect implementation source files.** Do not analyse documentation,
|
||||
config, type declarations, build output, or dependencies — flagging those is
|
||||
noise the user cannot act on.
|
||||
|
||||
### Inspect (extensions)
|
||||
\`${extensions}\`
|
||||
|
||||
### Skip (directory names — never descend into)
|
||||
\`${excludeDirs}\`
|
||||
|
||||
### Skip (file patterns)
|
||||
- Type declarations: \`*.d.ts\`, \`*.d.mts\`, \`*.d.cts\` — generated contracts, not impl
|
||||
- Minified bundles: \`*.min.js\`, \`*.min.mjs\`, \`*.min.cjs\` — generated, not editable
|
||||
- Docs: \`*.md\`, \`*.txt\`, \`*.rst\` — prose, not code
|
||||
- Config: \`*.json\`, \`*.yaml\`, \`*.yml\`, \`*.toml\`, \`*.ini\`, \`*.env\`
|
||||
- Styles/markup: \`*.css\`, \`*.scss\`, \`*.html\`, \`*.svg\`
|
||||
- Lock files: \`package-lock.json\`, \`*.lock\`, \`bun.lockb\`
|
||||
|
||||
### File discovery preference
|
||||
1. **Prefer the recon snapshot** at \`<cwd>/.pygienium/recon.json\` when it
|
||||
exists — it is the authoritative source inventory (git-tracked, extension-
|
||||
filtered, exclude-aware). Read its \`fileCounts\` for the quick picture.
|
||||
2. Otherwise enumerate files yourself, applying the rules above.
|
||||
3. When using \`glob\`/\`grep\`, add ignore patterns for the skip directories
|
||||
(e.g. exclude \`**/node_modules/**\` from your scans).
|
||||
`;
|
||||
}
|
||||
558
src/checks/todos.ts
Normal file
558
src/checks/todos.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* checks/todos.ts — "TODOs & stubs" check.
|
||||
*
|
||||
* Inventories unfinished work: TODO/FIXME/HACK markers and stub
|
||||
* implementations. The engineering rule encoded in the fix phase: pygienium
|
||||
* never *implements* a TODO and never deletes a marker — the fixer's only
|
||||
* action is to convert **silent stubs** (placeholder returns, empty bodies,
|
||||
* pass-only bodies) into loud failures, because a stub that silently returns
|
||||
* a plausible-but-wrong value ships the lie to every caller, while a stub
|
||||
* that throws is honest tracked debt.
|
||||
*
|
||||
* Classification (the scan agent applies judgment; a deterministic pre-scan
|
||||
* feeds it candidates):
|
||||
* - marker — `TODO` / `FIXME` / `HACK` / `XXX` / `@todo` in a comment.
|
||||
* - silent-stub — lone placeholder return / empty body / pass-only body;
|
||||
* the actionable, dangerous ones.
|
||||
* - loud-stub — explicit not-implemented failures (`throw new Error("Not
|
||||
* implemented")`, `todo!()`, `raise NotImplementedError`, `TODO("...")`);
|
||||
* already failing loudly → tracked debt, fixer never touches them.
|
||||
* - noise (dropped by the agent) — "TODO" inside a string literal, doc
|
||||
* examples, fixtures, abstract-method `NotImplementedError` (the correct
|
||||
* Python idiom), legit default returns (reducers, indexOf -1, catch
|
||||
* handlers returning null).
|
||||
*
|
||||
* Lifecycle:
|
||||
* gate (need source files) → recon (shared) → async scan task runs a
|
||||
* deterministic candidate pass over the scope tree, diffs the counts
|
||||
* against the previous run's findings (stored in run-state), hands the
|
||||
* candidates + delta to the `todos` agent, which verifies/drops noise and
|
||||
* writes `<cwd>/.pygienium/checks/todos/findings.md` → [with --fix] fixer
|
||||
* converts silent stubs to loud throws and writes `changes.md`.
|
||||
*
|
||||
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
|
||||
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-todos`.
|
||||
*
|
||||
* @module pygienium/checks/todos
|
||||
*/
|
||||
|
||||
import { readdirSync } from "node:fs";
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { loadRunState } from "../run-state.js";
|
||||
import type { CheckDefinition, CheckScope } from "./registry.js";
|
||||
import {
|
||||
isScopeSource,
|
||||
SCOPE_EXCLUDE_DIRS,
|
||||
scopeRulesMarkdown,
|
||||
} from "./scope.js";
|
||||
|
||||
/** Output directory for this check's persistent reports. */
|
||||
export function todosOutputDir(cwd: string): string {
|
||||
return join(cwd, ".pygienium", "checks", "todos");
|
||||
}
|
||||
|
||||
/** `findings.md` path for this check. */
|
||||
export function findingsPath(cwd: string): string {
|
||||
return join(todosOutputDir(cwd), "findings.md");
|
||||
}
|
||||
|
||||
/** `changes.md` path for this check. */
|
||||
export function changesPath(cwd: string): string {
|
||||
return join(todosOutputDir(cwd), "changes.md");
|
||||
}
|
||||
|
||||
export type TodoKind = "marker" | "silent-stub" | "loud-stub";
|
||||
|
||||
/** One candidate line the deterministic pre-scan flagged. */
|
||||
export interface TodoCandidate {
|
||||
/** Absolute path of the file. */
|
||||
path: string;
|
||||
/** 1-based line number. */
|
||||
line: number;
|
||||
kind: TodoKind;
|
||||
/** Matched token (e.g. `TODO`, `Not implemented`, `empty-body`). */
|
||||
snippet: string;
|
||||
/** The trimmed line content. */
|
||||
code: string;
|
||||
/** Enclosing function name when one was seen, else the file path. */
|
||||
context: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker tokens: an unfinished-work note in a comment. Case-insensitive;
|
||||
* `@todo\b` (not `@todos`) and `\bHACK\b` (not `hacking`).
|
||||
*/
|
||||
const MARKER_RE = /\b(?:TODO|FIXME|HACK)\b|\bXXX\b|@todo\b/i;
|
||||
|
||||
/**
|
||||
* Loud-stub tokens: explicit not-implemented failures. `not implemented`
|
||||
* covers `throw new Error("Not implemented")` and `panic!("not implemented")`;
|
||||
* the `NotImplementedError` branch also catches Python's `raise
|
||||
* NotImplementedError`, and the Rust/Kotlin idioms (`todo!()`, `TODO("...")`)
|
||||
* are matched explicitly.
|
||||
*/
|
||||
const LOUD_STUB_RE =
|
||||
/not\s+implemented|NotImplementedError|NotImplementedException|\btodo!\s*\(|unimplemented!\s*\(|\bTODO\s*\(/i;
|
||||
|
||||
/** A lone placeholder return (`return 0;` / `return "";` / `return null;` …). */
|
||||
const PLACEHOLDER_RETURN_RE =
|
||||
/^\s*return\s+(?:null|undefined|0(?:\.0)?|""|''|\[\]|\{\}|None|nil|false)\s*;?\s*(?:\/\/.*)?$/;
|
||||
|
||||
/** Function/arrow header lines worth inspecting for a stub body. */
|
||||
const FN_HEADER_RE =
|
||||
/\b(?:function|def|func|fun|fn)\s+\w+|(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/;
|
||||
|
||||
/** Single-line placeholder body: `function x() { return 0; }`. */
|
||||
const SINGLE_PLACEHOLDER_BODY_RE =
|
||||
/\{\s*return\s+(?:null|undefined|0(?:\.0)?|""|''|\[\]|\{\}|None|nil|false)\s*;?\s*\}/;
|
||||
|
||||
/** Single-line arrow expression body: `const f = () => 0;`. */
|
||||
const ARROW_PLACEHOLDER_RE =
|
||||
/=>\s*(?:null|undefined|0(?:\.0)?|""|''|\[\]|\{\}|false)\s*;?\s*$/;
|
||||
|
||||
/** Empty single-line body: `function notify(): void {}`. */
|
||||
const EMPTY_BODY_RE = /\{\s*\}/;
|
||||
|
||||
/** Hard cap on candidates so a huge tree can't blow the task prompt. */
|
||||
const MAX_CANDIDATES = 500;
|
||||
/** Candidate sections are truncated at this many entries in the fallback. */
|
||||
const FALLBACK_CAP = 16;
|
||||
/** Candidate list embedded in the live prompt is truncated at this many. */
|
||||
const PROMPT_CAP = 40;
|
||||
|
||||
/** Extract the declared function name from a header line, when present. */
|
||||
function headerName(line: string): string | undefined {
|
||||
const decl =
|
||||
/(?:function|def|func|fun|fn|class)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
|
||||
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.exec(
|
||||
line,
|
||||
);
|
||||
return decl?.[1];
|
||||
}
|
||||
|
||||
/** Index of the next non-blank line at or after `start`, else `undefined`. */
|
||||
function nextNonBlank(lines: string[], start: number): number | undefined {
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if ((lines[i] as string).trim()) return i;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the target collecting implementation-source files, honouring
|
||||
* {@link SCOPE_EXCLUDE_DIRS} and {@link isScopeSource} (same rules as
|
||||
* dead-code's walker).
|
||||
*/
|
||||
async function walkScopeFiles(root: string): Promise<string[]> {
|
||||
const st = await stat(root).catch(() => undefined);
|
||||
if (!st) return [];
|
||||
if (st.isFile()) return isScopeSource(root) ? [root] : [];
|
||||
const out: string[] = [];
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop() as string;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SCOPE_EXCLUDE_DIRS.has(entry.name)) continue;
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isScopeSource(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic pre-scan: flag marker/loud-stub/silent-stub candidates across
|
||||
* the target's scope tree. High recall by design — the scan agent verifies
|
||||
* each candidate and drops noise (in-string "TODO", doc examples, legit
|
||||
* default returns). Pure function of the tree: unit-testable without agents.
|
||||
*/
|
||||
export async function detectTodoStubs(
|
||||
target: string,
|
||||
): Promise<TodoCandidate[]> {
|
||||
const files = await walkScopeFiles(target);
|
||||
const out: TodoCandidate[] = [];
|
||||
for (const file of files) {
|
||||
const raw = await readFile(file, "utf8").catch(() => "");
|
||||
const lines = raw.split("\n");
|
||||
let lastFn = "";
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const code = lines[i] as string;
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const loud = LOUD_STUB_RE.exec(trimmed);
|
||||
if (loud) {
|
||||
out.push({
|
||||
path: file,
|
||||
line: i + 1,
|
||||
kind: "loud-stub",
|
||||
snippet: loud[0].slice(0, 40),
|
||||
code: trimmed,
|
||||
context: lastFn,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const marker = MARKER_RE.exec(trimmed);
|
||||
if (marker) {
|
||||
out.push({
|
||||
path: file,
|
||||
line: i + 1,
|
||||
kind: "marker",
|
||||
snippet: marker[0],
|
||||
code: trimmed,
|
||||
context: lastFn,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FN_HEADER_RE.test(trimmed)) {
|
||||
const name = headerName(trimmed);
|
||||
if (name) lastFn = name;
|
||||
const ctx = name ?? lastFn;
|
||||
// Single-line stub forms.
|
||||
if (
|
||||
SINGLE_PLACEHOLDER_BODY_RE.test(trimmed) ||
|
||||
ARROW_PLACEHOLDER_RE.test(trimmed)
|
||||
) {
|
||||
out.push({
|
||||
path: file,
|
||||
line: i + 1,
|
||||
kind: "silent-stub",
|
||||
snippet: "placeholder-return",
|
||||
code: trimmed,
|
||||
context: ctx,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
EMPTY_BODY_RE.test(trimmed) &&
|
||||
!/\b(?:return|throw)\b/.test(trimmed)
|
||||
) {
|
||||
out.push({
|
||||
path: file,
|
||||
line: i + 1,
|
||||
kind: "silent-stub",
|
||||
snippet: "empty-body",
|
||||
code: trimmed,
|
||||
context: ctx,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Multi-line forms: inspect the first non-blank body line.
|
||||
const bodyIdx = nextNonBlank(lines, i + 1);
|
||||
if (bodyIdx === undefined) continue;
|
||||
const body = (lines[bodyIdx] as string).trim();
|
||||
if (body === "}") {
|
||||
out.push({
|
||||
path: file,
|
||||
line: i + 1,
|
||||
kind: "silent-stub",
|
||||
snippet: "empty-body",
|
||||
code: trimmed,
|
||||
context: ctx,
|
||||
});
|
||||
} else if (body === "pass") {
|
||||
out.push({
|
||||
path: file,
|
||||
line: bodyIdx + 1,
|
||||
kind: "silent-stub",
|
||||
snippet: "pass-only",
|
||||
code: body,
|
||||
context: ctx,
|
||||
});
|
||||
} else if (PLACEHOLDER_RETURN_RE.test(body)) {
|
||||
// Lone placeholder return: the statement after it must be the
|
||||
// closing brace (a `try/catch { return null }` handler does not
|
||||
// match — its `return null` is followed by `}` inside `catch`).
|
||||
const after = nextNonBlank(lines, bodyIdx + 1);
|
||||
if (after !== undefined && (lines[after] as string).trim() === "}") {
|
||||
out.push({
|
||||
path: file,
|
||||
line: bodyIdx + 1,
|
||||
kind: "silent-stub",
|
||||
snippet: "placeholder-return",
|
||||
code: body,
|
||||
context: ctx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.length >= MAX_CANDIDATES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Counts by kind across a candidate list. */
|
||||
function countByKind(candidates: TodoCandidate[]): {
|
||||
silent: number;
|
||||
loud: number;
|
||||
marker: number;
|
||||
} {
|
||||
let silent = 0;
|
||||
let loud = 0;
|
||||
let marker = 0;
|
||||
for (const c of candidates) {
|
||||
if (c.kind === "silent-stub") silent++;
|
||||
else if (c.kind === "loud-stub") loud++;
|
||||
else marker++;
|
||||
}
|
||||
return { silent, loud, marker };
|
||||
}
|
||||
|
||||
/** Previous run's verified counts, parsed from run-state findings text. */
|
||||
const PRIOR_SUMMARY_RE =
|
||||
/todos:\s*(\d+)\s+silent\s+stub\(s\)?,\s*(\d+)\s+loud\s+stub\(s\)?,\s*(\d+)\s+marker\(s\)?/;
|
||||
|
||||
/**
|
||||
* Parse the previous run's per-kind counts out of run-state (the scan agent's
|
||||
* one-line summary is persisted there). `undefined` when there is no prior
|
||||
* run or the stored summary isn't parseable — the delta is then all-new.
|
||||
*/
|
||||
export async function todosPriorCounts(
|
||||
cwd: string,
|
||||
): Promise<{ silent: number; loud: number; marker: number } | undefined> {
|
||||
const state = await loadRunState(cwd).catch(() => undefined);
|
||||
const stored = state?.checks["todos"]?.findings;
|
||||
if (!stored) return undefined;
|
||||
const m = PRIOR_SUMMARY_RE.exec(stored);
|
||||
if (!m) return undefined;
|
||||
return {
|
||||
silent: Number(m[1]),
|
||||
loud: Number(m[2]),
|
||||
marker: Number(m[3]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the deterministic report the fake runner writes (and the real agent
|
||||
* uses as a shape reference): one pipe-separated line, sections per kind.
|
||||
*/
|
||||
function renderFindings(
|
||||
cwd: string,
|
||||
candidates: TodoCandidate[],
|
||||
delta: { nw: number; resolved: number },
|
||||
): string {
|
||||
const { silent, loud, marker } = countByKind(candidates);
|
||||
const total = silent + loud + marker;
|
||||
const parts = [
|
||||
`# TODOs & stubs findings | summary: ${marker} marker(s), ${silent} silent stub(s), ${loud} loud stub(s) | new: ${delta.nw} | resolved: ${delta.resolved} | reviewed: ${candidates.length}`,
|
||||
];
|
||||
if (total === 0) {
|
||||
parts.push(
|
||||
"No TODOs or stubs detected (deterministic pre-scan reviewed all inspected source).",
|
||||
);
|
||||
return parts.join(" | ");
|
||||
}
|
||||
const byKind: Record<TodoKind, TodoCandidate[]> = {
|
||||
marker: [],
|
||||
"silent-stub": [],
|
||||
"loud-stub": [],
|
||||
};
|
||||
for (const c of candidates) byKind[c.kind].push(c);
|
||||
const dump = (title: string, list: TodoCandidate[]): void => {
|
||||
parts.push(`## ${title}`);
|
||||
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
|
||||
parts.push(
|
||||
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line} — ${c.code} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
|
||||
);
|
||||
});
|
||||
if (list.length > FALLBACK_CAP) {
|
||||
parts.push(`... and ${list.length - FALLBACK_CAP} more (truncated)`);
|
||||
}
|
||||
};
|
||||
dump("TODO markers", byKind.marker);
|
||||
dump("Silent stubs (actionable)", byKind["silent-stub"]);
|
||||
dump(
|
||||
"Loud stubs (already failing loudly — tracked debt)",
|
||||
byKind["loud-stub"],
|
||||
);
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate: skip when the cwd has no inspectable source files at all — a workspace
|
||||
* with zero source files gives the scanner nothing to analyse.
|
||||
*/
|
||||
function todosGate(cwd: string): string | undefined {
|
||||
let found = false;
|
||||
try {
|
||||
const entries = readdirSync(cwd);
|
||||
for (const entry of entries) {
|
||||
if (isScopeSource(entry)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// unreadable cwd → let the agent decide; don't block.
|
||||
return undefined;
|
||||
}
|
||||
if (!found) {
|
||||
return "no source files found to inspect";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scan task. Deterministic pre-scan (async, like dead-code's) finds
|
||||
* candidates and diffs them against the previous run's counts; the `todos`
|
||||
* agent verifies each candidate, drops noise, and writes the verified report
|
||||
* to `findings.md`. The output path is passed into the task so both the real
|
||||
* agent (which uses its `write` tool) and the deterministic fake runner
|
||||
* (which understands `!write <path> <text>`) persist to the same location.
|
||||
*/
|
||||
export async function buildTodosScanTask(
|
||||
cwd: string,
|
||||
scope: CheckScope,
|
||||
): Promise<string> {
|
||||
const findings = findingsPath(cwd);
|
||||
const target = scope.target;
|
||||
const candidates = await detectTodoStubs(target);
|
||||
const prior = await todosPriorCounts(cwd);
|
||||
const { silent, loud, marker } = countByKind(candidates);
|
||||
const prevTotal = prior ? prior.silent + prior.loud + prior.marker : 0;
|
||||
const total = silent + loud + marker;
|
||||
const nw = Math.max(0, total - prevTotal);
|
||||
const resolved = Math.max(0, prevTotal - total);
|
||||
const report = renderFindings(cwd, candidates, { nw, resolved });
|
||||
|
||||
const candidateList = candidates
|
||||
.slice(0, PROMPT_CAP)
|
||||
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${c.code}`);
|
||||
if (candidates.length > PROMPT_CAP) {
|
||||
candidateList.push(
|
||||
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
`Inspect the target "${target}" (cwd: ${cwd}) for unfinished work: TODO markers and stub implementations.`,
|
||||
`A deterministic pre-scan found ${candidates.length} candidate line(s). Verify each candidate:`,
|
||||
...candidateList,
|
||||
``,
|
||||
`Classify against the todos rubric: markers (TODO/FIXME/HACK/XXX/@todo), silent stubs`,
|
||||
`(placeholder return / empty body / pass-only body), loud stubs (not-implemented`,
|
||||
`throws, todo!(), TODO("..."), raise NotImplementedError).`,
|
||||
`Drop noise: "TODO" inside a string literal, doc examples, fixtures,`,
|
||||
`abstract-method NotImplementedError (correct Python idiom), and legit default`,
|
||||
`returns (a reducer returning 0, indexOf returning -1, a catch handler returning null).`,
|
||||
`Previous run reported: ${
|
||||
prior
|
||||
? `${prior.silent} silent, ${prior.loud} loud, ${prior.marker} marker`
|
||||
: "none (first run)"
|
||||
}.`,
|
||||
`Write your full verified report to: ${findings}.`,
|
||||
`findings.md must begin with the machine-readable summary line, then the three`,
|
||||
`sections (## TODO markers / ## Silent stubs (actionable) / ## Loud stubs ...),`,
|
||||
`each entry with file:line, evidence, and disposition. The summary line MUST be:`,
|
||||
`summary: <M> marker(s), <S> silent stub(s), <L> loud stub(s) | new: <N> | resolved: <R> | reviewed: <K>`,
|
||||
``,
|
||||
scopeRulesMarkdown(),
|
||||
``,
|
||||
`# Deterministic fallback (executed by the fake runner in tests — verify every item yourself;`,
|
||||
`# do not copy the counts below blindly):`,
|
||||
`!write ${findings} ${report}`,
|
||||
`!echo todos: ${silent} silent stub(s), ${loud} loud stub(s), ${marker} marker(s) — see ${findings}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fix task. The fixer converts ONLY silent stubs into loud failures,
|
||||
* per language idiom, preserving signature/exports; records every conversion
|
||||
* (and any kept-with-reason) in `changes.md`. Markers are never implemented
|
||||
* or deleted; loud stubs are never touched.
|
||||
*/
|
||||
function buildTodosFixTask(
|
||||
cwd: string,
|
||||
scope: CheckScope,
|
||||
findings: string,
|
||||
): string {
|
||||
const changes = changesPath(cwd);
|
||||
const target = scope.target;
|
||||
return [
|
||||
`Convert silent stubs to loud failures (the fix phase of the todos check).`,
|
||||
`cwd: ${cwd} target: ${target}`,
|
||||
`Scan summary (also persisted at ${findingsPath(cwd)}):`,
|
||||
`---`,
|
||||
findings,
|
||||
`---`,
|
||||
``,
|
||||
`Rules:`,
|
||||
`- Convert ONLY silent stubs. For each, replace the placeholder body with an explicit`,
|
||||
` loud failure naming the function, using the project's language idiom:`,
|
||||
` TS/JS/C#/Java: throw new Error("todos: <fn>() is a stub");`,
|
||||
` Python: raise NotImplementedError("<fn> is a stub")`,
|
||||
` Go: panic("todos: <fn> is a stub")`,
|
||||
` Rust: todo!("<fn> is a stub")`,
|
||||
` generic: throw new Error("todos: <fn> is a stub")`,
|
||||
`- Preserve the signature, exports, async-ness, and type shape of the function.`,
|
||||
`- Leave the original placeholder as a comment directly above the throw, and add a`,
|
||||
` note that the stub was made loud by pygienium.`,
|
||||
`- NEVER implement TODOs, NEVER delete unresolved markers, NEVER touch loud stubs`,
|
||||
` (they already fail loudly), NEVER touch code that is not a verified silent stub.`,
|
||||
`- If a candidate turned out NOT to be a stub (a legit default return), keep it and`,
|
||||
` record it as kept-with-reason in changes.md.`,
|
||||
`- Apply the smallest possible diff; preserve tests and conventions.`,
|
||||
`- Write changes.md to ${changes} listing every conversion (auto) or keep (reason).`,
|
||||
``,
|
||||
`# Deterministic conversion (executed by the fake runner in tests):`,
|
||||
`# getPrice() + notify() in stubs.ts converted from silent placeholders to loud throws.`,
|
||||
`# NOTE: the fallback writes one physical line (the fake runner takes the rest of the !write`,
|
||||
`# line as file content); trailing // comments keep the single line valid source.`,
|
||||
`!write ${target}/stubs.ts export function getPrice(): number { throw new Error("todos: getPrice() is a stub"); } export function notify(): void { throw new Error("todos: notify() is a stub"); } export function connect(): Promise<void> { throw new Error("Not implemented"); } // TODO: add pagination // Cleaned by pygienium-todos: converted 2 silent stubs (getPrice, notify) to loud failures.`,
|
||||
`!write ${changes} # TODOs & stubs changes | summary: 2 silent stub(s) converted to loud, 0 kept | ## Converted to loud (auto) | 1. ${target}/stubs.ts:3 — getPrice() — body was \`return 0\` placeholder; now throws \`todos: getPrice() is a stub\` | 2. ${target}/stubs.ts:6 — notify() — body was empty; now throws \`todos: notify() is a stub\` | ## Kept (with reason) | (none)`,
|
||||
`!echo todos: 2 silent stub(s) converted — see ${changes}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hook: confirms the check actually produced its artifacts — after the
|
||||
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
|
||||
* exist too. Returns an error string to fail verify, or `undefined` to pass.
|
||||
*/
|
||||
async function todosVerify(scope: CheckScope): Promise<string | undefined> {
|
||||
const f = findingsPath(scope.cwd);
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `todos verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
if (scope.fix) {
|
||||
const c = changesPath(scope.cwd);
|
||||
try {
|
||||
await stat(c);
|
||||
} catch {
|
||||
return `todos verify: expected changes.md at ${c} after --fix, none found.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The todos check definition; registers itself on import. */
|
||||
export const check: CheckDefinition = {
|
||||
name: "todos",
|
||||
label: "TODOs & stubs",
|
||||
description:
|
||||
"Inventory TODO/FIXME markers and stub implementations; with --fix, convert silent stubs (placeholder returns, empty bodies) into loud failures — never implementing TODOs.",
|
||||
agentName: "todos",
|
||||
phaseId: "analysis",
|
||||
buildScanTask: buildTodosScanTask,
|
||||
buildFixTask: buildTodosFixTask,
|
||||
gate: todosGate,
|
||||
verify: todosVerify,
|
||||
};
|
||||
|
||||
// No self-registration here: `index.ts` auto-discovers every `checks/*.ts`
|
||||
// that exports `check` and registers it.
|
||||
405
src/commands.ts
Normal file
405
src/commands.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* commands.ts — pygienium slash-command handlers.
|
||||
*
|
||||
* Keeps `index.ts` thin: `index.ts` only binds these handlers to pi command
|
||||
* names. Each handler accepts the narrow context slice it needs (`cwd`,
|
||||
* `hasUI`, `ui`) so they are unit-testable without a full pi runtime — tests
|
||||
* construct a minimal `PygieniumCtx`.
|
||||
*
|
||||
* @module pygienium/commands
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentSessionEvent,
|
||||
ExtensionCommandContext,
|
||||
} from "@oh-my-pi/pi-coding-agent";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
getAllChecks,
|
||||
getCheck,
|
||||
type CheckDefinition,
|
||||
} from "./checks/registry.js";
|
||||
import {
|
||||
runCheck,
|
||||
parseCheckArgs,
|
||||
type CheckRunOutcome,
|
||||
} from "./modes/check-runner.js";
|
||||
import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js";
|
||||
import { buildPygieniumHelpLines } from "./help.js";
|
||||
import {
|
||||
loadRunState,
|
||||
runStatePath,
|
||||
saveRunState,
|
||||
markRunStatus,
|
||||
reconcileRunStatus,
|
||||
resetCheckEntry,
|
||||
isCheckTerminal,
|
||||
shouldRunOnResume,
|
||||
} from "./run-state.js";
|
||||
import { formatRunStatus } from "./status.js";
|
||||
import {
|
||||
exportRun,
|
||||
parseExportFilters,
|
||||
exportBundlePath,
|
||||
type ExportFilters,
|
||||
} from "./export.js";
|
||||
import type { SendChatMessage } from "./phases.js";
|
||||
|
||||
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
|
||||
export type PygieniumCtx = Pick<
|
||||
ExtensionCommandContext,
|
||||
"cwd" | "hasUI" | "ui"
|
||||
> & {
|
||||
/** Optional callback to post messages to the chat window. */
|
||||
sendChatMessage?: SendChatMessage;
|
||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
|
||||
/** Optional callback to emit synthetic progress lines (verify/cleanup/
|
||||
* recon phases that don't run agents) into the chat stream. */
|
||||
sendPhaseLine?: (phase: string, text: string) => void;
|
||||
};
|
||||
|
||||
function print(ctx: PygieniumCtx, line: string): void {
|
||||
// With a dialog-capable UI, also surface the first line as a notification.
|
||||
if (ctx.hasUI && ctx.ui?.notify) {
|
||||
ctx.ui.notify(line, "info");
|
||||
}
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
|
||||
/** Resolve an optional `[path]` argument to an absolute cwd. */
|
||||
function resolveCwd(args: string, ctxCwd: string): string {
|
||||
const tok = args.trim().split(/\s+/)[0];
|
||||
if (!tok || tok.startsWith("--")) return ctxCwd;
|
||||
return resolve(ctxCwd, tok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip leading flag tokens (`--fix`, `--fresh`, `--no-gitignore`) from args,
|
||||
* returning the remainder (the positional `[path]`).
|
||||
*/
|
||||
function splitFlags(args: string): {
|
||||
fix: boolean;
|
||||
fresh: boolean;
|
||||
rest: string;
|
||||
noGitignore: boolean;
|
||||
} {
|
||||
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
|
||||
const fix = tokens.includes("--fix");
|
||||
const fresh = tokens.includes("--fresh");
|
||||
const noGitignore = tokens.includes("--no-gitignore");
|
||||
const rest = tokens
|
||||
.filter((t) => t !== "--fix" && t !== "--fresh" && t !== "--no-gitignore")
|
||||
.join(" ");
|
||||
return { fix, fresh, rest, noGitignore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `/pygienium-resume` args: an optional `[path]` positional plus the
|
||||
* `--fresh` flag.` returns the resolved cwd and whether a fresh re-dispatch
|
||||
* is requested.
|
||||
*/
|
||||
function parseResumeArgs(
|
||||
args: string,
|
||||
ctxCwd: string,
|
||||
): { cwd: string; fresh: boolean; gitignore: boolean } {
|
||||
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
|
||||
const fresh = tokens.includes("--fresh");
|
||||
const gitignore = !tokens.includes("--no-gitignore");
|
||||
const positional = tokens.find((t) => !t.startsWith("--"));
|
||||
const cwd = positional ? resolve(ctxCwd, positional) : ctxCwd;
|
||||
return { cwd, fresh, gitignore };
|
||||
}
|
||||
|
||||
/** `/pygienium-help` */
|
||||
export async function handleHelpCommand(
|
||||
_args: string,
|
||||
_ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
for (const line of buildPygieniumHelpLines()) {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/pygienium-<check> [path] [--fix]` — the per-check command handler.
|
||||
* Exported so `index.ts` can bind one per registered `CheckDefinition` and so
|
||||
* tests can invoke it directly with a stub context.
|
||||
*/
|
||||
/**
|
||||
* `/pygienium-<check> [path] [--fix] [--fresh] [--no-gitignore]` — the
|
||||
* per-check command handler. Exported so `index.ts` binds one per registered
|
||||
* `CheckDefinition` and tests invoke it directly with a stub context.
|
||||
*
|
||||
* Resume-aware (parity with `/pygienium-all` and `/pygienium-resume`): a check
|
||||
* already terminal (`complete`/`skipped`) is NOT re-dispatched unless `--fresh`
|
||||
* resets its run-state entry. A failed/pending/in-progress check is re-run from
|
||||
* analysis — recovering the exact failure mode the MagniFluo run exposed
|
||||
* (sub-agent returns ok with no output → verify now fails loudly → resume
|
||||
* re-runs the analysis and the artifact lands).
|
||||
*/
|
||||
export async function handleCheckCommand(
|
||||
check: CheckDefinition,
|
||||
args: string,
|
||||
ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
const { fix, fresh, rest, noGitignore } = splitFlags(args);
|
||||
const target = resolveCwd(rest, ctx.cwd);
|
||||
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
|
||||
|
||||
// Resume semantics: skip an already-terminal check unless --fresh forces a
|
||||
// reset. This mirrors the all/resume skip predicate so running the same
|
||||
// per-check command again after a success is a no-op (use --fresh to
|
||||
// re-scan deliberately).
|
||||
const existing = await loadRunState(ctx.cwd);
|
||||
const entry = existing?.checks[check.name];
|
||||
if (entry && isCheckTerminal(entry) && !fresh) {
|
||||
print(
|
||||
ctx,
|
||||
`pygienium ${check.label}: already ${entry.status} (use --fresh to re-run)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reset the entry when --fresh, or when the fix flag changed since the prior
|
||||
// run: the phase skeleton (fix phase present only with --fix) must match
|
||||
// the requested mode, otherwise re-running analysis wouldn't record a fix
|
||||
// phase entry on a scan-only→--fix transition (and vice versa).
|
||||
if (existing && entry && (fresh || entry.fix !== fix)) {
|
||||
resetCheckEntry(existing, check.name, fix);
|
||||
await saveRunState(existing);
|
||||
}
|
||||
|
||||
const outcome = await runCheck({
|
||||
check,
|
||||
cwd: ctx.cwd,
|
||||
scope: { ...scope, cwd: ctx.cwd, target, fix },
|
||||
existingState: existing,
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
onAgentEvent: ctx.onAgentEvent,
|
||||
sendPhaseLine: ctx.sendPhaseLine,
|
||||
gitignore: !noGitignore,
|
||||
});
|
||||
|
||||
const giNote = outcome.gitignoreAppended
|
||||
? " · .pygienium/ added to .gitignore"
|
||||
: "";
|
||||
print(
|
||||
ctx,
|
||||
`pygienium ${check.label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}${giNote}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every
|
||||
* registered check in sequence under a unified status strip, writing
|
||||
* `.pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
|
||||
*/
|
||||
export async function handleAllCommand(
|
||||
args: string,
|
||||
ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
const checks = getAllChecks();
|
||||
if (checks.length === 0) {
|
||||
print(ctx, "pygienium: no checks registered.");
|
||||
return;
|
||||
}
|
||||
const parsed = parseAllArgs(args, ctx.cwd);
|
||||
const outcome = await runAllChecks({
|
||||
cwd: ctx.cwd,
|
||||
target: parsed.target,
|
||||
fix: parsed.fix,
|
||||
fresh: parsed.fresh,
|
||||
gitignore: parsed.gitignore,
|
||||
only: parsed.only,
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
onAgentEvent: ctx.onAgentEvent,
|
||||
sendPhaseLine: ctx.sendPhaseLine,
|
||||
});
|
||||
const giNote = outcome.gitignoreAppended
|
||||
? " · .pygienium/ added to .gitignore"
|
||||
: "";
|
||||
print(
|
||||
ctx,
|
||||
`pygienium: all-run ${outcome.status} — ${outcome.ran.length} ran, ${outcome.skipped.length} skipped; summary → ${allSummaryPath(ctx.cwd)} (${runStatePath(ctx.cwd)})${giNote}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** `/pygienium-status [path]` — print run-state progress as a line list. */
|
||||
export async function handleStatusCommand(
|
||||
args: string,
|
||||
ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
const cwd = resolveCwd(args, ctx.cwd);
|
||||
const state = await loadRunState(cwd);
|
||||
if (!state) {
|
||||
print(ctx, "pygienium: no run state found.");
|
||||
return;
|
||||
}
|
||||
for (const line of formatRunStatus(state)) {
|
||||
print(ctx, line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/pygienium-resume [path] [--fresh]` — resume the most recent non-complete
|
||||
* run by re-dispatching every check that isn't terminal (`complete`/`skipped`).
|
||||
* Pass `--fresh` to re-dispatch even completed checks (their run-state entries
|
||||
* are reset and the check re-runs analysis → fix → verify → cleanup fresh).
|
||||
*/
|
||||
export async function handleResumeCommand(
|
||||
args: string,
|
||||
ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
const { cwd, fresh, gitignore } = parseResumeArgs(args, ctx.cwd);
|
||||
let state = await loadRunState(cwd);
|
||||
if (!state) {
|
||||
print(ctx, "pygienium: no run state to resume.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the latest resumable run — with the single-file run-state model this
|
||||
// is the loaded run unless it's already fully complete AND --fresh wasn't set.
|
||||
const resumable = Object.values(state.checks).some((c) =>
|
||||
shouldRunOnResume(c, fresh),
|
||||
);
|
||||
if (!resumable) {
|
||||
print(
|
||||
ctx,
|
||||
`pygienium: run already ${state.status}; nothing to resume (use --fresh to re-run).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-dispatch this run's checks in stored order, skipping terminal ones
|
||||
// unless --fresh.
|
||||
let ran = 0;
|
||||
let skipped = 0;
|
||||
let giAppended = false;
|
||||
for (const entry of Object.values(state.checks)) {
|
||||
const def = getCheck(entry.name);
|
||||
if (!def) {
|
||||
print(
|
||||
ctx,
|
||||
`pygienium: check "${entry.name}" is no longer registered; skipping.`,
|
||||
);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!shouldRunOnResume(entry, fresh)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
resetCheckEntry(state, entry.name);
|
||||
}
|
||||
print(ctx, `pygienium: resuming ${def.label}…`);
|
||||
const outcome: CheckRunOutcome = await runCheck({
|
||||
check: def,
|
||||
cwd,
|
||||
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
existingState: state,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
onAgentEvent: ctx.onAgentEvent,
|
||||
sendPhaseLine: ctx.sendPhaseLine,
|
||||
gitignore,
|
||||
});
|
||||
state = outcome.state;
|
||||
giAppended = giAppended || outcome.gitignoreAppended === true;
|
||||
ran++;
|
||||
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
|
||||
}
|
||||
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
const giNote = giAppended ? " · .pygienium/ added to .gitignore" : "";
|
||||
print(
|
||||
ctx,
|
||||
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})${giNote}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/pygienium-export [path] [--check=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
|
||||
* — collect every check's `findings.md`/`changes.md` artifacts from
|
||||
* `.pygienium/checks/<name>/`, apply filters, and write a single bundle to
|
||||
* `.pygienium/export.{md|json}`.
|
||||
*/
|
||||
export async function handleExportCommand(
|
||||
args: string,
|
||||
ctx: PygieniumCtx,
|
||||
): Promise<void> {
|
||||
const cwd = resolveCwd(args, ctx.cwd);
|
||||
const filters: ExportFilters = parseExportFilters(args);
|
||||
const state = await loadRunState(cwd);
|
||||
const result = await exportRun(cwd, state, filters);
|
||||
if (result.entries.length === 0) {
|
||||
print(
|
||||
ctx,
|
||||
`pygienium: nothing to export (no findings.md/changes.md under ${cwd}/.pygienium/checks/).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const filterDesc = [
|
||||
filters.check ? `check=${filters.check.join(",")}` : null,
|
||||
filters.status ? `status=${filters.status.join(",")}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const suffix = filterDesc ? ` [${filterDesc}]` : "";
|
||||
print(
|
||||
ctx,
|
||||
`pygienium export: ${result.entries.length} check(s) → ${exportBundlePath(cwd, result.format)}${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A minimal command-registration callback shape (matches `pi.registerCommand`). */
|
||||
export type RegisterCommandFn = (
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
handler: (args: string, ctx: PygieniumCtx) => Promise<void>;
|
||||
},
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Auto-register `/pygienium-help` plus one `/pygienium-<check>` per registered
|
||||
* `CheckDefinition`, plus the `all`/`resume`/`status`/`export` commands.
|
||||
* Called from `index.ts` so that adding a check never requires editing command
|
||||
* wiring.
|
||||
*/
|
||||
export function registerPygieniumCommands(register: RegisterCommandFn): void {
|
||||
register("pygienium-help", {
|
||||
description: "Show pygienium commands, checks, and usage.",
|
||||
handler: handleHelpCommand,
|
||||
});
|
||||
|
||||
for (const def of getAllChecks()) {
|
||||
register(`pygienium-${def.name}`, {
|
||||
description: def.description,
|
||||
handler: (args, ctx) => handleCheckCommand(def, args, ctx),
|
||||
});
|
||||
}
|
||||
|
||||
register("pygienium-all", {
|
||||
description: "Run every registered pygienium check in sequence.",
|
||||
handler: handleAllCommand,
|
||||
});
|
||||
register("pygienium-resume", {
|
||||
description: "Resume the most recent in-progress or failed pygienium run.",
|
||||
handler: handleResumeCommand,
|
||||
});
|
||||
register("pygienium-status", {
|
||||
description: "Show progress of the current or latest pygienium run.",
|
||||
handler: handleStatusCommand,
|
||||
});
|
||||
register("pygienium-export", {
|
||||
description: "Export finalized findings and changes for a pygienium run.",
|
||||
handler: handleExportCommand,
|
||||
});
|
||||
}
|
||||
269
src/export.ts
Normal file
269
src/export.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* export.ts — bundle find/changed artifacts for a pygienium run.
|
||||
*
|
||||
* `/pygienium-export` walks each check's artifact directory (where
|
||||
* `findings.md` and `changes.md` live), applies `--check=` / `--status=`
|
||||
* filters, and writes a single bundle to `.pygienium/export.{md|json}`.
|
||||
*
|
||||
* Artifact root: `<cwd>/.pygienium/checks/<name>/` — the single canonical
|
||||
* location every shipped check writes to.
|
||||
*
|
||||
* Statuses for `--status=` filtering come from the run-state; a check dir
|
||||
* present on disk but absent from run-state is reported as `unknown`.
|
||||
*
|
||||
* @module pygienium/export
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { RunState } from "./run-state.js";
|
||||
|
||||
export type ExportFormat = "md" | "json";
|
||||
|
||||
/** Directory name (relative to cwd) that holds `checks/` and `export.md`. */
|
||||
export const PYGIENIUM_ARTIFACT_DIR = ".pygienium";
|
||||
/** Subdirectory holding per-check `findings.md`/`changes.md`. */
|
||||
export const CHECKS_SUBDIR = "checks";
|
||||
/** Base filename for the bundle (`export.md` / `export.json`). */
|
||||
export const EXPORT_FILENAME_BASE = "export";
|
||||
|
||||
/** Resolve `<cwd>/.pygienium/` (the artifact root). */
|
||||
export function pygieniumArtifactDir(cwd: string): string {
|
||||
return join(cwd, PYGIENIUM_ARTIFACT_DIR);
|
||||
}
|
||||
|
||||
/** Resolve `<cwd>/.pygienium/checks/`. */
|
||||
export function canonicalChecksRoot(cwd: string): string {
|
||||
return join(pygieniumArtifactDir(cwd), CHECKS_SUBDIR);
|
||||
}
|
||||
|
||||
/** Resolve `<cwd>/.pygienium/export.<format>`. */
|
||||
export function exportBundlePath(cwd: string, format: ExportFormat): string {
|
||||
return join(pygieniumArtifactDir(cwd), `${EXPORT_FILENAME_BASE}.${format}`);
|
||||
}
|
||||
|
||||
/** A single gathered check artifact entry (post-filter). */
|
||||
export interface ExportEntry {
|
||||
/** Check name (the directory under `checks/`). */
|
||||
name: string;
|
||||
/** Status from run-state, or `unknown` when not present there. */
|
||||
status: string;
|
||||
/** `findings.md` contents, when present on disk. */
|
||||
findings?: string;
|
||||
/** `changes.md` contents, when present on disk. */
|
||||
changes?: string;
|
||||
/** Absolute path to `findings.md`, when read from disk. */
|
||||
findingsPath?: string;
|
||||
/** Absolute path to `changes.md`, when read from disk. */
|
||||
changesPath?: string;
|
||||
}
|
||||
|
||||
/** Parsed `--check=` / `--status=` / `--out=` filters. */
|
||||
export interface ExportFilters {
|
||||
/** Check-name allowlist (comma-separated); undefined = all. */
|
||||
check?: string[];
|
||||
/** Status allowlist (comma-separated), matched against run-state statuses. */
|
||||
status?: string[];
|
||||
/** Output format. Defaults to `md`. */
|
||||
out?: ExportFormat;
|
||||
}
|
||||
|
||||
/** Result of {@link exportRun}. */
|
||||
export interface ExportResult {
|
||||
/** Format used. */
|
||||
format: ExportFormat;
|
||||
/** Absolute path the bundle was written to. */
|
||||
path: string;
|
||||
/** Entries included after filtering (in alphabetical order). */
|
||||
entries: ExportEntry[];
|
||||
/** Bundle size in bytes. */
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
const FLAG_CHECK = "--check=";
|
||||
const FLAG_STATUS = "--status=";
|
||||
const FLAG_OUT = "--out=";
|
||||
|
||||
/** Parse export flags from the raw arg string (flags + optional positional). */
|
||||
export function parseExportFilters(args: string): ExportFilters {
|
||||
const filters: ExportFilters = { out: "md" };
|
||||
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
|
||||
for (const tok of tokens) {
|
||||
if (tok.startsWith(FLAG_CHECK)) {
|
||||
filters.check = tok
|
||||
.slice(FLAG_CHECK.length)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else if (tok.startsWith(FLAG_STATUS)) {
|
||||
filters.status = tok
|
||||
.slice(FLAG_STATUS.length)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else if (tok.startsWith(FLAG_OUT)) {
|
||||
const v = tok.slice(FLAG_OUT.length).toLowerCase().trim();
|
||||
if (v === "json" || v === "md") {
|
||||
filters.out = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
async function readArtifact(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function gatherFromRoot(
|
||||
root: string,
|
||||
state: RunState | undefined,
|
||||
merged: Map<string, ExportEntry>,
|
||||
): Promise<void> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const name = entry.name;
|
||||
const dir = join(root, name);
|
||||
const fpath = join(dir, "findings.md");
|
||||
const cpath = join(dir, "changes.md");
|
||||
const findings = await readArtifact(fpath);
|
||||
const changes = await readArtifact(cpath);
|
||||
const checkState = state?.checks[name];
|
||||
merged.set(name, {
|
||||
name,
|
||||
status: checkState?.status ?? "unknown",
|
||||
findings,
|
||||
changes,
|
||||
findingsPath: findings != null ? fpath : undefined,
|
||||
changesPath: changes != null ? cpath : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather artifact entries from the canonical `<cwd>/.pygienium/checks/` root,
|
||||
* one entry per check directory. Entries are sorted alphabetically. Marks an
|
||||
* entry `unknown` when its check is absent from `state`.
|
||||
*/
|
||||
export async function gatherExportEntries(
|
||||
cwd: string,
|
||||
state?: RunState,
|
||||
): Promise<ExportEntry[]> {
|
||||
const merged = new Map<string, ExportEntry>();
|
||||
await gatherFromRoot(canonicalChecksRoot(cwd), state, merged);
|
||||
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Apply `--check=` / `--status=` filters to gathered entries. */
|
||||
export function filterExportEntries(
|
||||
entries: ExportEntry[],
|
||||
filters: ExportFilters,
|
||||
): ExportEntry[] {
|
||||
return entries.filter((e) => {
|
||||
if (filters.check && !filters.check.includes(e.name)) return false;
|
||||
if (filters.status && !filters.status.includes(e.status)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Render the markdown bundle. */
|
||||
export function renderExportMarkdown(
|
||||
state: RunState | undefined,
|
||||
entries: ExportEntry[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Pygienium export");
|
||||
if (state) {
|
||||
lines.push("");
|
||||
lines.push(`- status: ${state.status}`);
|
||||
lines.push(`- started: ${new Date(state.startedAt).toISOString()}`);
|
||||
lines.push(`- updated: ${new Date(state.updatedAt).toISOString()}`);
|
||||
lines.push(`- cwd: ${state.cwd}`);
|
||||
lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`);
|
||||
}
|
||||
lines.push(`- checks: ${entries.length}`);
|
||||
lines.push("");
|
||||
for (const e of entries) {
|
||||
lines.push(`## ${e.name} (${e.status})`);
|
||||
if (e.findings != null) {
|
||||
lines.push("");
|
||||
lines.push("### findings");
|
||||
lines.push("");
|
||||
lines.push(e.findings.replace(/\s+$/, ""));
|
||||
}
|
||||
if (e.changes != null) {
|
||||
lines.push("");
|
||||
lines.push("### changes");
|
||||
lines.push("");
|
||||
lines.push(e.changes.replace(/\s+$/, ""));
|
||||
}
|
||||
if (e.findings == null && e.changes == null) {
|
||||
lines.push("");
|
||||
lines.push("_(no findings.md or changes.md on disk)_");
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/** Render the JSON bundle. */
|
||||
export function renderExportJson(
|
||||
state: RunState | undefined,
|
||||
entries: ExportEntry[],
|
||||
): string {
|
||||
const payload = {
|
||||
status: state?.status ?? "unknown",
|
||||
startedAt: state?.startedAt ?? null,
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
cwd: state?.cwd ?? null,
|
||||
recon: state ? state.recon.complete : null,
|
||||
checks: entries.map((e) => ({
|
||||
name: e.name,
|
||||
status: e.status,
|
||||
findings: e.findings ?? null,
|
||||
findingsPath: e.findingsPath ?? null,
|
||||
changes: e.changes ?? null,
|
||||
changesPath: e.changesPath ?? null,
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(payload, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather, filter, and write the export bundle. Returns the (would-be) path
|
||||
* and the included entries. When there are no entries, no file is written —
|
||||
* the caller reports "nothing to export" and we avoid leaving an empty
|
||||
* `export.{md|json}` on disk.
|
||||
*/
|
||||
export async function exportRun(
|
||||
cwd: string,
|
||||
state: RunState | undefined,
|
||||
filters: ExportFilters,
|
||||
): Promise<ExportResult> {
|
||||
const all = await gatherExportEntries(cwd, state);
|
||||
const entries = filterExportEntries(all, filters);
|
||||
const format: ExportFormat = filters.out ?? "md";
|
||||
const path = exportBundlePath(cwd, format);
|
||||
if (entries.length === 0) {
|
||||
return { format, path, entries, bytes: 0 };
|
||||
}
|
||||
const body =
|
||||
format === "json"
|
||||
? renderExportJson(state, entries)
|
||||
: renderExportMarkdown(state, entries);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, body, "utf8");
|
||||
return { format, path, entries, bytes: Buffer.byteLength(body) };
|
||||
}
|
||||
222
src/footer.ts
Normal file
222
src/footer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* footer.ts — pipeline-overview status widget in the TUI footer area.
|
||||
*
|
||||
* Renders the full ordered pipeline (phases for a single check, or checks for
|
||||
* `/pygienium-all`) as a **multi-line `belowEditor` widget** (via
|
||||
* `ExtensionUIContext.setWidget`): one bulleted, color-themed line per step,
|
||||
* with the live step marked `●` and completed/failed/skipped/pending steps
|
||||
* carrying their terminal glyph. This is the pygienium analogue of piolium's
|
||||
* `phase-status-strip` widget — the footer-side *overview* view.
|
||||
*
|
||||
* The chat-side *detail* view is the live tool-event stream (see
|
||||
* `pygienium-stream` in `index.ts`): each `tool_execution_start/end` and
|
||||
* assistant turn is posted as its own chat message. The two never overlap:
|
||||
* the footer owns the `belowEditor` slot, the stream owns the chat history.
|
||||
*
|
||||
* Presentation-only and mode-aware: in print/JSON mode (no TUI) the footer is
|
||||
* a no-op — stdout progress stays owned by the phase strip — so it can be
|
||||
* driven unconditionally from the runners.
|
||||
*
|
||||
* Generic on a list of {@link FooterItem}s so both a single-check run
|
||||
* (items = phases) and a `/pygienium-all` run (items = checks) reuse one
|
||||
* renderer: the runner decides the granularity, the footer only draws it.
|
||||
*
|
||||
* @module pygienium/footer
|
||||
*/
|
||||
|
||||
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
/** Widget key pygienium writes its pipeline-overview widget under. */
|
||||
export const FOOTER_STATUS_KEY = "pygienium";
|
||||
|
||||
/** Status of a single pipeline item, carried into the footer line. */
|
||||
export type ItemStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "complete"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
|
||||
/**
|
||||
* Marker per item status, mirroring piolium's phase-status-strip glyphs:
|
||||
* `·`=pending (to come), `●`=running (cursor), `✓`/`✗`/`↷`=terminal.
|
||||
* Kept short so a multi-phase pipeline fits one widget column.
|
||||
*/
|
||||
export const FOOTER_MARKER: Record<ItemStatus, string> = {
|
||||
pending: "·",
|
||||
running: "●",
|
||||
complete: "✓",
|
||||
failed: "✗",
|
||||
skipped: "↷",
|
||||
};
|
||||
|
||||
/** Theme subset the footer renders against (`ui.theme` satisfies this). */
|
||||
export interface FooterTheme {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
/** One labelled step in the pipeline overview. */
|
||||
export interface FooterItem {
|
||||
/** Short label (a phase label like "Scanning" or a check label). */
|
||||
label: string;
|
||||
/** Current status of this step. */
|
||||
status: ItemStatus;
|
||||
}
|
||||
|
||||
export interface PipelineFooterOptions {
|
||||
/** UI context; writes go to `ui.setWidget` (belowEditor). */
|
||||
ui?: ExtensionUIContext;
|
||||
/** Dialog-capable UI available (TUI / RPC). When false, footer is a no-op. */
|
||||
hasUI?: boolean;
|
||||
/** Widget key (defaults to {@link FOOTER_STATUS_KEY}). */
|
||||
statusKey?: string;
|
||||
/**
|
||||
* Whether to render the footer (default true). Set false when an outer
|
||||
* run (e.g. `/pygienium-all`) already owns the footer, so two overviews
|
||||
* never compete over the same widget slot — mirrors the phase strip's
|
||||
* `widget` flag.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PipelineFooter {
|
||||
/** Declare the full pipeline at once; `cursor` (if given) marks `running`. */
|
||||
setPipeline(title: string, items: FooterItem[], cursor?: number): void;
|
||||
/** Set a single item's status; optionally move the cursor too. */
|
||||
setItem(index: number, status: ItemStatus, cursor?: number): void;
|
||||
/** Advance the cursor to an item (marks it `running`). */
|
||||
setCursor(index: number): void;
|
||||
/** Snapshot of current items (for tests — no UI required). */
|
||||
getItems(): FooterItem[];
|
||||
/** Snapshot of the last rendered title (for tests). */
|
||||
getTitle(): string;
|
||||
/** Clear the footer widget. Safe to call repeatedly. */
|
||||
done(): void;
|
||||
}
|
||||
|
||||
/** Map an item status to a piolium-style theme color token. */
|
||||
export function footerColor(status: ItemStatus, isCurrent: boolean): string {
|
||||
if (status === "complete") return "success";
|
||||
if (status === "failed") return "error";
|
||||
if (status === "skipped") return "warning";
|
||||
if (status === "running" || isCurrent) return "accent";
|
||||
return "dim";
|
||||
}
|
||||
|
||||
/** Width for the per-step index prefix (`1.` … `12.`). */
|
||||
function indexWidth(total: number): number {
|
||||
return total >= 10 ? 2 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the pipeline as a list of bulleted, color-themed lines — the pure
|
||||
* core of the footer widget, exported so tests assert on layout without a
|
||||
* TUI. Each line is `• <marker> <n>. <label>`, themed by status color.
|
||||
* Mirrors piolium's `renderPhaseStatusList`.
|
||||
*/
|
||||
export function renderFooterList(
|
||||
items: readonly FooterItem[],
|
||||
cursor: number,
|
||||
theme: FooterTheme,
|
||||
): string[] {
|
||||
const width = indexWidth(items.length);
|
||||
return items.map((item, index) => {
|
||||
const marker = FOOTER_MARKER[item.status] ?? "?";
|
||||
const isCurrent = index === cursor;
|
||||
const color = footerColor(item.status, isCurrent);
|
||||
const order = String(index + 1).padStart(width, "0");
|
||||
const text = `• ${marker} ${order}. ${item.label}`;
|
||||
return theme.fg(color, text);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pipeline-overview footer. The handle is cheap and stateful; callers
|
||||
* keep one per run and call {@link PipelineFooter.done} when terminal.
|
||||
*/
|
||||
export function createPipelineFooter(
|
||||
opts: PipelineFooterOptions,
|
||||
): PipelineFooter {
|
||||
const key = opts.statusKey ?? FOOTER_STATUS_KEY;
|
||||
const ui = opts.ui;
|
||||
const enabled = opts.enabled ?? true;
|
||||
const hasUI = opts.hasUI ?? false;
|
||||
|
||||
let title = "";
|
||||
let items: FooterItem[] = [];
|
||||
let cursor = -1;
|
||||
|
||||
/** Build and push the widget lines, if a UI is available. */
|
||||
function render(): void {
|
||||
if (!enabled || !hasUI || !ui?.setWidget) return;
|
||||
// `ui.theme` is present on a real ExtensionUIContext; fall back to a
|
||||
// plain-text renderer only when a stub omits it (tests / headless RPC).
|
||||
const theme: FooterTheme =
|
||||
ui.theme && typeof ui.theme.fg === "function"
|
||||
? ui.theme
|
||||
: { fg: (_c: string, t: string) => t };
|
||||
const lines: string[] = [];
|
||||
if (title) lines.push(theme.fg("dim", title));
|
||||
lines.push(...renderFooterList(items, cursor, theme));
|
||||
ui.setWidget(key, lines, { placement: "belowEditor" });
|
||||
}
|
||||
|
||||
return {
|
||||
setPipeline(t, its, cur) {
|
||||
title = t;
|
||||
items = its.map((it) => ({ ...it }));
|
||||
cursor = cur ?? -1;
|
||||
if (cursor >= 0 && items[cursor]) {
|
||||
items[cursor]!.status = "running";
|
||||
}
|
||||
render();
|
||||
},
|
||||
setItem(index, status, cur) {
|
||||
if (index < 0 || index >= items.length) return;
|
||||
items[index]!.status = status;
|
||||
if (cur !== undefined) cursor = cur;
|
||||
render();
|
||||
},
|
||||
setCursor(index) {
|
||||
if (index < 0 || index >= items.length) return;
|
||||
// A previously-running item that didn't reach a terminal status
|
||||
// (e.g. the runner jumped phases on a skip) demotes back to
|
||||
// pending so it reads as "to come" rather than stalled.
|
||||
if (cursor >= 0 && items[cursor]?.status === "running") {
|
||||
items[cursor]!.status = "pending";
|
||||
}
|
||||
cursor = index;
|
||||
items[index]!.status = "running";
|
||||
render();
|
||||
},
|
||||
getItems() {
|
||||
return items.map((it) => ({ ...it }));
|
||||
},
|
||||
getTitle() {
|
||||
return title;
|
||||
},
|
||||
done() {
|
||||
if (!enabled || !hasUI || !ui?.setWidget) return;
|
||||
ui.setWidget(key, undefined, { placement: "belowEditor" });
|
||||
items = [];
|
||||
cursor = -1;
|
||||
title = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build footer items for a single check's phase list. The runner feeds it the
|
||||
* ordered phase ids (recon → analysis → [fix] → verify → cleanup) and the
|
||||
* shared {@link PHASE_LABELS}-shaped map; the footer draws them as the
|
||||
* pipeline overview.
|
||||
*/
|
||||
export function footerPhaseItems(
|
||||
phaseIds: readonly string[],
|
||||
labels: Record<string, string>,
|
||||
): FooterItem[] {
|
||||
return phaseIds.map((id) => ({
|
||||
label: labels[id] ?? id,
|
||||
status: "pending" as ItemStatus,
|
||||
}));
|
||||
}
|
||||
191
src/help.ts
Normal file
191
src/help.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* help.ts — command + flag help builder (single source of truth for
|
||||
* `/pygienium-help`).
|
||||
*
|
||||
* `COMMANDS` and `CLI_FLAGS` are static arrays describing every operator
|
||||
* command and flag actually implemented across tasks 06–13. The per-check
|
||||
* command family (`/pygienium-<check>`) is a single generic entry because the
|
||||
* concrete check commands come from the live registry — `buildPygieniumHelpLines`
|
||||
* appends one row per registered `CheckDefinition`, so a newly registered check
|
||||
* appears in `/pygienium-help` with zero edits here. This is what backs the
|
||||
* "add a check = one file + registerCheck, no index.ts changes" guarantee.
|
||||
*
|
||||
* @module pygienium/help
|
||||
*/
|
||||
|
||||
import { getAllChecks } from "./checks/registry.js";
|
||||
|
||||
/** A flag row shown in the help output. */
|
||||
export interface HelpFlag {
|
||||
/** Flag token exactly as typed on the command line. */
|
||||
name: string;
|
||||
/** Which commands accept this flag. */
|
||||
scope: string;
|
||||
/** What the flag does. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** A command row shown in the help output. */
|
||||
export interface HelpCommand {
|
||||
/** Command invocation (without the leading `/`). */
|
||||
usage: string;
|
||||
/** One-line description of what it does. */
|
||||
description: string;
|
||||
/** Concrete example call. */
|
||||
example: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags supported by `/pygienium-<check>` and the operator commands.
|
||||
* Mirrors the arg parsing in `commands.ts` / `modes/all.ts` / `export.ts`
|
||||
* exactly.
|
||||
*/
|
||||
export const CLI_FLAGS: HelpFlag[] = [
|
||||
{
|
||||
name: "[path]",
|
||||
scope: "all check commands",
|
||||
description: "Target file or directory to scan (default: current dir).",
|
||||
},
|
||||
{
|
||||
name: "--fix",
|
||||
scope: "<check>, all, resume",
|
||||
description: "Apply fixes (default: scan-only; emits findings only).",
|
||||
},
|
||||
{
|
||||
name: "--fresh",
|
||||
scope: "<check>, all, resume",
|
||||
description:
|
||||
"Re-dispatch completed checks too — reset their run-state entries and re-run.",
|
||||
},
|
||||
{
|
||||
name: "--only=",
|
||||
scope: "all",
|
||||
description: "Comma-separated check names to run (subset of the registry).",
|
||||
},
|
||||
{
|
||||
name: "--no-gitignore",
|
||||
scope: "<check>, all, resume",
|
||||
description:
|
||||
"Don't add `.pygienium/` to the target repo's .gitignore (added by default so runs never stage their own output).",
|
||||
},
|
||||
{
|
||||
name: "--check=",
|
||||
scope: "export",
|
||||
description: "Comma-separated check names to include in the bundle.",
|
||||
},
|
||||
{
|
||||
name: "--status=",
|
||||
scope: "export",
|
||||
description:
|
||||
"Comma-separated statuses to include (e.g. complete,failed,skipped).",
|
||||
},
|
||||
{
|
||||
name: "--out=",
|
||||
scope: "export",
|
||||
description: "Bundle format: `md` (default) or `json`.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The operator commands (the per-check `/pygienium-<check>` family is rendered
|
||||
* dynamically from the registry below). Each entry carries a usage, a
|
||||
* one-line description, and an example so `/pygienium-help` is self-contained.
|
||||
*/
|
||||
export const COMMANDS: HelpCommand[] = [
|
||||
{
|
||||
usage: "pygienium-help",
|
||||
description: "Show every command, shipped check, and flag (this block).",
|
||||
example: "/pygienium-help",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-<check> [path] [--fix] [--fresh]",
|
||||
description:
|
||||
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report. Resume-aware: a completed/skipped check is skipped unless --fresh re-runs it.",
|
||||
example: "/pygienium-comments src --fix",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
||||
description:
|
||||
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes .pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
|
||||
example: "/pygienium-all --fix",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-status [path]",
|
||||
description:
|
||||
"Show per-check progress, captured findings/changes line counts, and errors for the latest run.",
|
||||
example: "/pygienium-status",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-resume [path] [--fresh]",
|
||||
description:
|
||||
"Resume the latest in-progress/failed/partial run, re-dispatching each non-terminal check (complete/skipped skip unless --fresh).",
|
||||
example: "/pygienium-resume --fresh",
|
||||
},
|
||||
{
|
||||
usage: "pygienium-export [path] [--check=] [--status=] [--out=md|json]",
|
||||
description:
|
||||
"Bundle every check's findings.md + changes.md into .pygienium/export.{md|json}.",
|
||||
example: "/pygienium-export --out=json",
|
||||
},
|
||||
];
|
||||
|
||||
/** Back-compat alias for the flag array. */
|
||||
export const PYGIENIUM_FLAGS = CLI_FLAGS;
|
||||
|
||||
/** Right-pad a string to `width` (no-op when already longer). */
|
||||
function pad(s: string, width: number): string {
|
||||
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full `/pygienium-help` text. Layout (one string per line):
|
||||
*
|
||||
* header
|
||||
* Commands: (one entry per COMMANDS row: usage, description, example)
|
||||
* Checks (N): (one row per registered check, registry-driven)
|
||||
* Flags: (one row per CLI_FLAGS entry)
|
||||
* Adding a check: (one-file + registerCheck note)
|
||||
*/
|
||||
export function buildPygieniumHelpLines(): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push("Pygienium — code hygiene for pi", "");
|
||||
lines.push("Commands:");
|
||||
|
||||
const usageWidth = Math.max(...COMMANDS.map((c) => c.usage.length)) + 2;
|
||||
for (const cmd of COMMANDS) {
|
||||
lines.push(` /${pad(cmd.usage, usageWidth)}${cmd.description}`);
|
||||
lines.push(` ${pad("", usageWidth)}e.g. ${cmd.example}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
const checks = getAllChecks();
|
||||
lines.push(`Checks (${checks.length}):`);
|
||||
if (checks.length === 0) {
|
||||
lines.push(
|
||||
" (none registered — drop a file in src/checks/ and add one registerCheck() entry)",
|
||||
);
|
||||
} else {
|
||||
const nameWidth = Math.max(...checks.map((c) => c.name.length)) + 2;
|
||||
for (const c of checks) {
|
||||
lines.push(` /pygienium-${pad(c.name, nameWidth)}${c.description}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("Flags:");
|
||||
const flagNameWidth = Math.max(...CLI_FLAGS.map((f) => f.name.length)) + 2;
|
||||
const flagScopeWidth =
|
||||
Math.max(...CLI_FLAGS.map((f) => `[${f.scope}]`.length)) + 2;
|
||||
for (const f of CLI_FLAGS) {
|
||||
lines.push(
|
||||
` ${pad(f.name, flagNameWidth)}${pad(`[${f.scope}]`, flagScopeWidth)}${f.description}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push(
|
||||
"Adding a check: drop a file in src/checks/ and add one registerCheck() entry.",
|
||||
);
|
||||
lines.push("No index.ts command-wiring changes are required.");
|
||||
return lines;
|
||||
}
|
||||
454
src/index.ts
Normal file
454
src/index.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* pygienium — code hygiene extension for pi.
|
||||
*
|
||||
* Entry point. Registers `/pygienium-help`, auto-registers one
|
||||
* `/pygienium-<check>` command per registered `CheckDefinition`, plus the
|
||||
* `all`/`resume`/`status`/`export` commands. Adding a check requires ONLY a new
|
||||
* file in `src/checks/` plus one `registerCheck(def)` call — no changes here.
|
||||
*
|
||||
* Check files in `src/checks/` are auto-discovered (every `.ts` except the
|
||||
* registry barrel), so they self-register at load time before commands bind.
|
||||
*
|
||||
* Pi loads this file via jiti at runtime (see `pi.extensions` in package.json).
|
||||
* The default export runs once per session; the factory is async so check
|
||||
* modules finish registering before command wiring.
|
||||
*
|
||||
* @module pygienium/index
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
MessageRenderer,
|
||||
SessionStartEvent,
|
||||
} from "@oh-my-pi/pi-coding-agent";
|
||||
import { Box, Text } from "@oh-my-pi/pi-tui";
|
||||
import { registerCheck, type CheckDefinition } from "./checks/registry.js";
|
||||
import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js";
|
||||
import {
|
||||
type SendChatMessage,
|
||||
type CheckCompletionDetails,
|
||||
PHASE_GLYPH,
|
||||
} from "./phases.js";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
/** Startup hint mirrored after piolium's convention. */
|
||||
export const PYGIENIUM_STARTUP_HINT =
|
||||
"Pygienium loaded. Run /pygienium-help for available checks and flags.";
|
||||
|
||||
/** Custom message type for the live tool-event stream (mirrors piolium-stream). */
|
||||
export const PYGIENIUM_STREAM = "pygienium-stream";
|
||||
|
||||
/** Chat rendering style ("verbose" = per-event stream, "compact" = completion-only). */
|
||||
export type ChatStyle = "verbose" | "compact";
|
||||
|
||||
/**
|
||||
* Read the pygienium chat style from omp's settings.json.
|
||||
* Looks for `pygienium.chatStyle` under `~/.omp/agent/settings.json`.
|
||||
* Defaults to "verbose" (piolium-style per-event stream) when absent or unreadable.
|
||||
*/
|
||||
async function readChatStyle(): Promise<ChatStyle> {
|
||||
try {
|
||||
const raw = await readFile(
|
||||
join(homedir(), ".omp", "agent", "settings.json"),
|
||||
"utf8",
|
||||
);
|
||||
const settings = JSON.parse(raw) as {
|
||||
pygienium?: { chatStyle?: string };
|
||||
};
|
||||
const style = settings.pygienium?.chatStyle;
|
||||
return style === "compact" ? "compact" : "verbose";
|
||||
} catch {
|
||||
return "verbose";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local structural supertypes for the progress-message renderer params.
|
||||
* These avoid relying on contextual typing from `MessageRenderer` (which
|
||||
* requires resolving pi's internal `Theme`/`CustomMessage` cross-references
|
||||
* via node_modules — not always available in dev environments). Using
|
||||
* `(...args: any[])` for theme methods makes the type bidirectionally
|
||||
* compatible under strict function types, so the cast to `MessageRenderer`
|
||||
* in `registerMessageRenderer` is valid.
|
||||
*/
|
||||
interface ProgressMessage {
|
||||
content: unknown;
|
||||
details?: unknown;
|
||||
}
|
||||
interface ProgressRenderOptions {
|
||||
expanded: boolean;
|
||||
}
|
||||
interface ProgressTheme {
|
||||
fg: (...args: any[]) => string;
|
||||
bg: (...args: any[]) => string;
|
||||
}
|
||||
|
||||
export { buildPygieniumHelpLines } from "./help.js";
|
||||
|
||||
type StreamLineKind = "tool-start" | "tool-end" | "tool-error" | "assistant";
|
||||
|
||||
interface StreamLineDetails {
|
||||
kind: StreamLineKind;
|
||||
phase: string;
|
||||
toolName?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** Pick the one useful argument from a tool-call's args (path/command/…). */
|
||||
function summarizeArgs(args: unknown): string {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
const obj = args as Record<string, unknown>;
|
||||
const pickKey = [
|
||||
"file_path",
|
||||
"path",
|
||||
"command",
|
||||
"pattern",
|
||||
"query",
|
||||
"url",
|
||||
].find((k) => typeof obj[k] === "string");
|
||||
if (pickKey) {
|
||||
const value = String(obj[pickKey]);
|
||||
return value.length > 120 ? `${value.slice(0, 117)}…` : value;
|
||||
}
|
||||
const json = JSON.stringify(obj);
|
||||
return json.length > 120 ? `${json.slice(0, 117)}…` : json;
|
||||
}
|
||||
|
||||
/** Extract joined text from an assistant message's content blocks. */
|
||||
function extractAssistantText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter(
|
||||
(c) =>
|
||||
c && typeof c === "object" && (c as { type?: string }).type === "text",
|
||||
)
|
||||
.map((c) => (c as { text?: string }).text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Collapse a tool result down to a single short line. */
|
||||
function summarizeToolResult(result: unknown): string {
|
||||
if (result == null) return "";
|
||||
if (typeof result === "string") return result;
|
||||
if (typeof result === "number" || typeof result === "boolean")
|
||||
return String(result);
|
||||
if (Array.isArray(result)) {
|
||||
return result
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"text" in (item as Record<string, unknown>)
|
||||
)
|
||||
return String((item as { text?: unknown }).text ?? "");
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
if (typeof result !== "object") return "";
|
||||
const obj = result as Record<string, unknown>;
|
||||
// MCP CallToolResult shape: { content: [{ type: "text", text: "..." }, ...] }
|
||||
if (Array.isArray(obj.content)) {
|
||||
const unwrapped = summarizeToolResult(obj.content);
|
||||
if (unwrapped) return unwrapped;
|
||||
}
|
||||
const preferKey = ["stdout", "output", "text", "content", "result"].find(
|
||||
(k) => typeof obj[k] === "string" && (obj[k] as string).length > 0,
|
||||
);
|
||||
if (preferKey) return obj[preferKey] as string;
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapse whitespace and cap a line at `max` chars with an ellipsis. */
|
||||
function compactLine(text: string, max: number): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length <= max) return collapsed;
|
||||
return `${collapsed.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat stream forwarder: turns raw sub-agent events into `pygienium-stream`
|
||||
* messages (one chat line per start/end/assistant turn), the pygienium
|
||||
* analogue of piolium's `makeAgentEventForwarder`. Also exposes
|
||||
* `sendPhaseLine` for synthetic progress lines during non-agent phases
|
||||
* (verify, cleanup, recon) so the chat doesn't go silent.
|
||||
*/
|
||||
interface StreamForwarder {
|
||||
/** Forward a raw sub-agent event tagged with a phase label. */
|
||||
onAgentEvent(phase: string, event: AgentSessionEvent): void;
|
||||
/** Emit a synthetic progress line (e.g. "checking artifacts…"). */
|
||||
sendPhaseLine(phase: string, text: string): void;
|
||||
}
|
||||
|
||||
function makeStreamForwarder(pi: ExtensionAPI): StreamForwarder {
|
||||
const send = (details: StreamLineDetails, fallback: string) => {
|
||||
pi.sendMessage<StreamLineDetails>({
|
||||
customType: PYGIENIUM_STREAM,
|
||||
content: fallback,
|
||||
display: true,
|
||||
details,
|
||||
});
|
||||
};
|
||||
|
||||
const onAgentEvent = (phase: string, event: AgentSessionEvent): void => {
|
||||
switch (event.type) {
|
||||
case "tool_execution_start": {
|
||||
const body = summarizeArgs(event.args);
|
||||
send(
|
||||
{ kind: "tool-start", phase, toolName: event.toolName, body },
|
||||
`[${phase}] → ${event.toolName}${body ? ` ${body}` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const body = compactLine(summarizeToolResult(event.result), 200);
|
||||
const kind: StreamLineKind = event.isError ? "tool-error" : "tool-end";
|
||||
const marker = event.isError ? "✗" : "←";
|
||||
send(
|
||||
{ kind, phase, toolName: event.toolName, body },
|
||||
`[${phase}] ${marker} ${event.toolName}${body ? ` ${body}` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "message_end": {
|
||||
const message = event.message as {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
if (message.role !== "assistant") return;
|
||||
const text = extractAssistantText(message.content).trim();
|
||||
if (!text) return;
|
||||
const head = compactLine(text, 240);
|
||||
send({ kind: "assistant", phase, body: head }, `[${phase}] ${head}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendPhaseLine = (phase: string, text: string): void => {
|
||||
send({ kind: "assistant", phase, body: text }, `[${phase}] ${text}`);
|
||||
};
|
||||
|
||||
return { onAgentEvent, sendPhaseLine };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import every `checks/*.ts` module (except the registry barrel) and register
|
||||
* each file's `check` export. Check files are pure data modules — they no
|
||||
* longer self-register on import, because omp's extension loader cache-busts
|
||||
* lazily imported graph modules with an `?mtime` suffix, which would split
|
||||
* the registry into two module instances (static entry-graph imports resolve
|
||||
* to the clean file, lazy imports to the `?mtime` copy). Registering here —
|
||||
* from the entry's own registry instance — keeps one registry and still makes
|
||||
* adding a check a drop-a-file operation.
|
||||
*/
|
||||
async function loadCheckModules(): Promise<void> {
|
||||
const dir = join(dirname(fileURLToPath(import.meta.url)), "checks");
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return; // no checks dir (e.g. minimal install)
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
if (entry === "registry.ts" || entry === "load.ts") continue;
|
||||
const mod = (await import(`./checks/${entry}`)) as {
|
||||
check?: CheckDefinition;
|
||||
};
|
||||
if (mod.check) registerCheck(mod.check);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback to send completion messages to the main chat window.
|
||||
*/
|
||||
function makeSendChatMessage(pi: ExtensionAPI): SendChatMessage {
|
||||
return (content: string, meta?: Record<string, unknown>) => {
|
||||
pi.sendMessage({
|
||||
customType: "pygienium-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: {
|
||||
phase: meta?.phase || "info",
|
||||
...meta,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
export default async function pygieniumExtension(
|
||||
pi: ExtensionAPI,
|
||||
): Promise<void> {
|
||||
// Self-register every shipped check before wiring commands.
|
||||
await loadCheckModules();
|
||||
|
||||
const sendChatMessage = makeSendChatMessage(pi);
|
||||
|
||||
// Register custom message renderer for pygienium progress messages.
|
||||
// Renders an expandable phase tree: collapsed shows the header + a hint,
|
||||
// expanded (Ctrl+O) shows every phase with its status and notes.
|
||||
const progressRenderer = (
|
||||
message: ProgressMessage,
|
||||
{ expanded }: ProgressRenderOptions,
|
||||
theme: ProgressTheme,
|
||||
) => {
|
||||
const details = message.details as
|
||||
| {
|
||||
phase?: string;
|
||||
completion?: CheckCompletionDetails;
|
||||
error?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(String(message.content));
|
||||
|
||||
const completion = details?.completion;
|
||||
if (completion) {
|
||||
if (expanded) {
|
||||
// Expanded: show every phase with status glyph + branch.
|
||||
const phases = completion.phases;
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
const entry = phases[i];
|
||||
if (!entry) continue;
|
||||
const isLast = i === phases.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const glyph = PHASE_GLYPH[entry.status] ?? "?";
|
||||
const tag = theme.fg("accent", entry.label);
|
||||
const note = entry.note ? ` · ${entry.note}` : "";
|
||||
lines.push(`${branch}${glyph} ${tag}${note}`);
|
||||
}
|
||||
if (completion.error) {
|
||||
lines.push(theme.fg("error", ` error: ${completion.error}`));
|
||||
}
|
||||
} else {
|
||||
// Collapsed: summary line + hint.
|
||||
const done = completion.phases.filter(
|
||||
(p) => p.status === "complete",
|
||||
).length;
|
||||
const total = completion.phases.length;
|
||||
const hint = completion.error
|
||||
? theme.fg("error", ` ├── ${completion.error}`)
|
||||
: theme.fg(
|
||||
"dim",
|
||||
` ├── ${done}/${total} phases · press Ctrl+O for detail`,
|
||||
);
|
||||
lines.push(hint);
|
||||
}
|
||||
} else if (!expanded) {
|
||||
lines.push(theme.fg("dim", " ├── press Ctrl+O for detail"));
|
||||
}
|
||||
|
||||
const text = lines.join("\n");
|
||||
const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
|
||||
box.addChild(new Text(text, 0, 0));
|
||||
return box;
|
||||
};
|
||||
pi.registerMessageRenderer(
|
||||
"pygienium-progress",
|
||||
progressRenderer as MessageRenderer,
|
||||
);
|
||||
|
||||
// Live tool-event stream renderer: one chat line per tool start/end and
|
||||
// assistant turn, indented so ends nest under their start. Mirrors
|
||||
// piolium's PIOLIUM_STREAM renderer.
|
||||
pi.registerMessageRenderer<StreamLineDetails>(
|
||||
PYGIENIUM_STREAM,
|
||||
(message, _options, theme) => {
|
||||
const details = message.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
const fallback =
|
||||
typeof message.content === "string" ? message.content : "";
|
||||
return new Text(theme.fg("muted", fallback), 0, 0);
|
||||
}
|
||||
const { kind, phase, toolName, body } = details;
|
||||
// Indent end/error lines so they visually nest under the matching
|
||||
// start line. The pad width matches the "[phase] " prefix.
|
||||
const phaseTag = theme.fg("accent", `[${phase}]`);
|
||||
const indent = " ".repeat(phase.length + 3);
|
||||
let line: string;
|
||||
switch (kind) {
|
||||
case "tool-start": {
|
||||
const arrow = theme.fg("muted", "→");
|
||||
const name = theme.fg("toolTitle", theme.bold(toolName ?? ""));
|
||||
const args = body ? ` ${theme.fg("muted", body)}` : "";
|
||||
line = `${phaseTag} ${arrow} ${name}${args}`;
|
||||
break;
|
||||
}
|
||||
case "tool-end": {
|
||||
const arrow = theme.fg("success", "←");
|
||||
const result = body
|
||||
? ` ${theme.fg("dim", body)}`
|
||||
: ` ${theme.fg("dim", "(ok)")}`;
|
||||
line = `${indent}${arrow}${result}`;
|
||||
break;
|
||||
}
|
||||
case "tool-error": {
|
||||
const marker = theme.fg("error", "✗");
|
||||
const result = body
|
||||
? ` ${theme.fg("error", body)}`
|
||||
: ` ${theme.fg("error", "failed")}`;
|
||||
line = `${indent}${marker}${result}`;
|
||||
break;
|
||||
}
|
||||
case "assistant":
|
||||
line = `${phaseTag} ${theme.fg("muted", body ?? "")}`;
|
||||
break;
|
||||
default:
|
||||
line =
|
||||
typeof message.content === "string"
|
||||
? theme.fg("muted", message.content)
|
||||
: "";
|
||||
}
|
||||
return new Text(line, 0, 0);
|
||||
},
|
||||
);
|
||||
|
||||
const forwarder = makeStreamForwarder(pi);
|
||||
|
||||
// Read chat style from pi's settings.json. When "compact", suppress the
|
||||
// per-event stream + synthetic phase lines so only the completion message
|
||||
// (with its expandable phase tree) shows — the ralpi-style rendering.
|
||||
const chatStyle = await readChatStyle();
|
||||
const verbose = chatStyle === "verbose";
|
||||
const onAgentEvent = verbose ? forwarder.onAgentEvent : undefined;
|
||||
const sendPhaseLine = verbose ? forwarder.sendPhaseLine : undefined;
|
||||
|
||||
registerPygieniumCommands((name, options) => {
|
||||
pi.registerCommand(name, {
|
||||
description: options.description,
|
||||
handler: (args: string, ctx: ExtensionCommandContext) => {
|
||||
// Create PygieniumCtx with chat + stream callbacks.
|
||||
const pygieniumCtx: PygieniumCtx = {
|
||||
cwd: ctx.cwd,
|
||||
hasUI: ctx.hasUI,
|
||||
ui: ctx.ui,
|
||||
sendChatMessage,
|
||||
onAgentEvent,
|
||||
sendPhaseLine,
|
||||
};
|
||||
return options.handler(args, pygieniumCtx);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pi.on(
|
||||
"session_start",
|
||||
async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.ui.notify(PYGIENIUM_STARTUP_HINT, "info");
|
||||
},
|
||||
);
|
||||
}
|
||||
0
src/modes/.gitkeep
Normal file
0
src/modes/.gitkeep
Normal file
463
src/modes/all.ts
Normal file
463
src/modes/all.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* modes/all.ts — `/pygienium-all` master orchestrator.
|
||||
*
|
||||
* Runs every registered check in sequence as ordered phases under a unified
|
||||
* status strip, with resumable state and a final summary report. This is the
|
||||
* piolium "balanced"/"deep" mode analogue, but simpler: one run, sequential
|
||||
* phases, shared recon (no scheduler — checks run one after another).
|
||||
*
|
||||
* Pipeline:
|
||||
* init single run (mode "all") → run shared recon once →
|
||||
* for each registered check (in registry order): call `runCheck` with the
|
||||
* SHARED run-state record (not a fresh one per check) → reconcile run
|
||||
* status → write `.pygienium/all-summary.md`.
|
||||
*
|
||||
* Resumability: terminal checks (`complete`/`skipped`) are skipped on resume;
|
||||
* `in_progress`/`failed`/`pending` checks re-run. `--fresh` resets every check
|
||||
* entry and re-runs the lot. `--only=comments,complexity` narrows the candidate
|
||||
* set to a named subset (registration order preserved).
|
||||
*
|
||||
* @module pygienium/modes/all
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
|
||||
import { runCheck } from "./check-runner.js";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
||||
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
||||
import { runRecon } from "../recon.js";
|
||||
import {
|
||||
applyPhaseStatus,
|
||||
ensureRunStateIgnored,
|
||||
initRunState,
|
||||
loadRunState,
|
||||
markRunStatus,
|
||||
PHASE_RECON,
|
||||
reconcileRunStatus,
|
||||
resetCheckEntry,
|
||||
saveRunState,
|
||||
shouldRunOnResume,
|
||||
stateDir,
|
||||
type RunState,
|
||||
} from "../run-state.js";
|
||||
|
||||
/** Artifact directory name (relative to cwd) that holds `all-summary.md`. */
|
||||
export const ALL_ARTIFACT_DIR = ".pygienium";
|
||||
/** Filename for the unified per-check summary report. */
|
||||
export const ALL_SUMMARY_FILENAME = "all-summary.md";
|
||||
|
||||
/** Resolve `<cwd>/.pygienium/all-summary.md`. */
|
||||
export function allSummaryPath(cwd: string): string {
|
||||
return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME);
|
||||
}
|
||||
|
||||
export interface AllRunOptions {
|
||||
/** Working directory (from `ctx.cwd`). */
|
||||
cwd: string;
|
||||
/** Target path to scan (absolute; defaults to `cwd`). */
|
||||
target?: string;
|
||||
/** Whether fixes should be applied (`--fix`). */
|
||||
fix?: boolean;
|
||||
/** Subset of check names to run (`--only=comments,complexity`). */
|
||||
only?: string[];
|
||||
/** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */
|
||||
fresh?: boolean;
|
||||
/**
|
||||
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
|
||||
* state/artifacts (default true). Set false with `--no-gitignore`.
|
||||
*/
|
||||
gitignore?: boolean;
|
||||
/** UI context (optional; null in print mode). */
|
||||
ui?: ExtensionUIContext;
|
||||
/** Whether dialog-capable UI is available. */
|
||||
hasUI?: boolean;
|
||||
/** Optional callback to post completion messages into the chat. */
|
||||
sendChatMessage?: SendChatMessage;
|
||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
|
||||
/** Optional callback to emit synthetic progress lines during non-agent
|
||||
* phases (verify, cleanup, recon) into the chat stream. */
|
||||
sendPhaseLine?: (phase: string, text: string) => void;
|
||||
}
|
||||
|
||||
/** Outcome of {@link runAllChecks}. */
|
||||
export interface AllRunOutcome {
|
||||
/** Final run status. */
|
||||
status: RunState["status"];
|
||||
/** The updated run state. */
|
||||
state: RunState;
|
||||
/** Absolute path the summary was written to. */
|
||||
summaryPath: string;
|
||||
/** Checks that were actually dispatched (ran `runCheck`). */
|
||||
ran: string[];
|
||||
/** Checks skipped because they were already terminal. */
|
||||
skipped: string[];
|
||||
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
|
||||
gitignoreAppended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the registry to the `only` subset, preserving insertion order.
|
||||
* Unknown names are silently dropped (a typo shouldn't abort an all-run).
|
||||
*/
|
||||
export function selectChecks(only?: string[]): CheckDefinition[] {
|
||||
const all = getAllChecks();
|
||||
if (!only || only.length === 0) return all;
|
||||
const set = new Set(only);
|
||||
return all.filter((c) => set.has(c.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `/pygienium-all` args: an optional `[path]` positional plus the
|
||||
* `--fix`, `--fresh`, and `--only=<a>,<b>` flags.
|
||||
*/
|
||||
export function parseAllArgs(
|
||||
args: string,
|
||||
cwd: string,
|
||||
): {
|
||||
target: string;
|
||||
fix: boolean;
|
||||
fresh: boolean;
|
||||
gitignore: boolean;
|
||||
only: string[];
|
||||
} {
|
||||
const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
|
||||
let fix = false;
|
||||
let fresh = false;
|
||||
let gitignore = true;
|
||||
let target = cwd;
|
||||
const only: string[] = [];
|
||||
for (const tok of tokens) {
|
||||
if (tok === "--fix") {
|
||||
fix = true;
|
||||
} else if (tok === "--fresh") {
|
||||
fresh = true;
|
||||
} else if (tok === "--no-gitignore") {
|
||||
gitignore = false;
|
||||
} else if (tok.startsWith("--only=")) {
|
||||
for (const name of tok.slice("--only=".length).split(",")) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) only.push(trimmed);
|
||||
}
|
||||
} else if (!tok.startsWith("--")) {
|
||||
target = tok;
|
||||
}
|
||||
}
|
||||
return { target: resolve(cwd, target), fix, fresh, gitignore, only };
|
||||
}
|
||||
|
||||
/** Count non-empty lines in captured findings/changes text. */
|
||||
function lineCount(text: string | undefined): number {
|
||||
if (!text) return 0;
|
||||
return text.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
|
||||
}
|
||||
|
||||
function toISO(ms: number | undefined): string {
|
||||
return ms == null ? "—" : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function short(status: string): string {
|
||||
return status[0]?.toUpperCase() ?? "?";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the unified summary markdown from run-state. Lists per-check
|
||||
* outcomes: status, artifact paths (when present on disk), findings/changes
|
||||
* line counts, phase breakdown, and errors.
|
||||
*/
|
||||
export function renderAllSummary(
|
||||
state: RunState,
|
||||
selected: CheckDefinition[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Pygienium all-run summary");
|
||||
lines.push("");
|
||||
lines.push(`- status: ${state.status}`);
|
||||
lines.push(`- started: ${toISO(state.startedAt)}`);
|
||||
lines.push(`- updated: ${toISO(state.updatedAt)}`);
|
||||
lines.push(`- cwd: ${state.cwd}`);
|
||||
lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`);
|
||||
lines.push(`- checks: ${selected.length}`);
|
||||
lines.push("");
|
||||
|
||||
for (const check of selected) {
|
||||
const entry = state.checks[check.name];
|
||||
const fixTag = entry?.fix ? " (--fix)" : "";
|
||||
lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`);
|
||||
lines.push("");
|
||||
|
||||
// Errors are terminal-run facts: a check that completed via a later
|
||||
// resume/retry must not surface a stale error under a "complete"
|
||||
// status (markCheckStatus clears it on success; this guard covers
|
||||
// hand-edited or legacy state files too).
|
||||
if (entry?.error && entry?.status !== "complete") {
|
||||
lines.push(`- error: ${entry.error}`);
|
||||
}
|
||||
|
||||
// Artifact paths (canonical root: `.pygienium/checks/<name>/`).
|
||||
const findingsPath = `${state.cwd}/.pygienium/checks/${check.name}/findings.md`;
|
||||
const changesPath = `${state.cwd}/.pygienium/checks/${check.name}/changes.md`;
|
||||
const fLines = lineCount(entry?.findings);
|
||||
const cLines = lineCount(entry?.changes);
|
||||
if (fLines > 0) {
|
||||
lines.push(`- findings: ${findingsPath} (${fLines} line(s))`);
|
||||
}
|
||||
if (cLines > 0) {
|
||||
lines.push(`- changes: ${changesPath} (${cLines} line(s))`);
|
||||
}
|
||||
|
||||
// Phase breakdown for transparency.
|
||||
if (entry?.phases?.length) {
|
||||
const phaseSummary = entry.phases
|
||||
.map(
|
||||
(p) =>
|
||||
`${p.id}:${
|
||||
p.status.startsWith("in_progress") ? "…" : short(p.status)
|
||||
}`,
|
||||
)
|
||||
.join(" ");
|
||||
lines.push(`- phases: ${phaseSummary}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every registered check in sequence under a unified status strip.
|
||||
*
|
||||
* Resumable: terminal checks skip on resume unless `fresh` resets them.
|
||||
*/
|
||||
export async function runAllChecks(
|
||||
opts: AllRunOptions,
|
||||
): Promise<AllRunOutcome> {
|
||||
const { cwd } = opts;
|
||||
const target = opts.target ?? cwd;
|
||||
const fix = opts.fix ?? false;
|
||||
const fresh = opts.fresh ?? false;
|
||||
const hasUI = opts.hasUI ?? false;
|
||||
|
||||
// Keep pygienium's own output out of the scanned repo's git index unless
|
||||
// the caller opted out with --no-gitignore.
|
||||
const gitignoreAppended =
|
||||
opts.gitignore === false ? false : await ensureRunStateIgnored(cwd);
|
||||
|
||||
const selected = selectChecks(opts.only);
|
||||
if (selected.length === 0) {
|
||||
// `--only` selected nothing (or no checks registered). Still produce a
|
||||
// summary so the caller has an artifact.
|
||||
const state = (await loadRunState(cwd)) ?? initRunState(cwd, []);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
const summaryPath = await writeAllSummary(state, []);
|
||||
return {
|
||||
status: state.status,
|
||||
state,
|
||||
summaryPath,
|
||||
ran: [],
|
||||
skipped: [],
|
||||
gitignoreAppended,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Init / resume the single shared run-state --------------------------
|
||||
let state =
|
||||
(await loadRunState(cwd)) ??
|
||||
initRunState(
|
||||
cwd,
|
||||
selected.map((c) => ({ name: c.name, label: c.label, fix })),
|
||||
);
|
||||
|
||||
// Ensure every selected check has an entry (adds any missing on resume).
|
||||
for (const check of selected) {
|
||||
if (!state.checks[check.name]) {
|
||||
state.checks[check.name] = {
|
||||
name: check.name,
|
||||
label: check.label,
|
||||
status: "pending",
|
||||
fix,
|
||||
phases: [
|
||||
{ id: "recon", status: "pending" },
|
||||
{ id: "analysis", status: "pending" },
|
||||
...(fix ? [{ id: "fix", status: "pending" as const }] : []),
|
||||
{ id: "verify", status: "pending" },
|
||||
{ id: "cleanup", status: "pending" },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
await saveRunState(state);
|
||||
|
||||
// --- Unified phase strip logging all check names -----------------------
|
||||
const strip = createPhaseStrip({
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
});
|
||||
setAllPhase(strip, selected, 0, "recon");
|
||||
|
||||
// Pipeline-overview footer: a multi-line widget listing every check with the
|
||||
// cursor on the active one and what's to come. Detail lives in the chat
|
||||
// (per-check completion trees); the footer is the overview. Inner
|
||||
// `runCheck` calls pass `footer: false` so two overviews never compete.
|
||||
const allFooter = createPipelineFooter({
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
statusKey: "pygienium-all",
|
||||
});
|
||||
const allItems = selected.map((c) => ({
|
||||
label: c.label,
|
||||
status: "pending" as ItemStatus,
|
||||
}));
|
||||
allFooter.setPipeline("pygienium: all", allItems, 0);
|
||||
const footerSettle = (name: string, status: ItemStatus): void => {
|
||||
const idx = selected.findIndex((c) => c.name === name);
|
||||
if (idx >= 0) allFooter.setItem(idx, status);
|
||||
};
|
||||
|
||||
// --- Shared recon (run once before any check) ---------------------------
|
||||
if (!state.recon.complete) {
|
||||
const snapshot = await runRecon(cwd);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: join(stateDir(cwd), "recon.json"),
|
||||
finishedAt: snapshot.createdAt,
|
||||
};
|
||||
// Mark recon complete for every selected check that hasn't run it yet.
|
||||
for (const check of selected) {
|
||||
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
|
||||
}
|
||||
await saveRunState(state);
|
||||
}
|
||||
|
||||
const ran: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
// --- Per-check loop (shared run-state, registry order) ------------------
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const check = selected[i]!;
|
||||
setAllPhase(strip, selected, i, "analysis");
|
||||
allFooter.setCursor(i);
|
||||
|
||||
const entry = state.checks[check.name];
|
||||
// Resumability: skip terminal checks unless --fresh.
|
||||
if (entry && !shouldRunOnResume(entry, fresh)) {
|
||||
skipped.push(check.name);
|
||||
footerSettle(
|
||||
check.name,
|
||||
entry.status === "complete" ? "skipped" : "skipped",
|
||||
);
|
||||
strip.log(
|
||||
`pygienium: ${check.label} — already ${entry.status}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fresh && entry) {
|
||||
resetCheckEntry(state, check.name, fix);
|
||||
await saveRunState(state);
|
||||
}
|
||||
|
||||
strip.log(`pygienium: running ${check.label}…`);
|
||||
const outcome = await runCheck({
|
||||
check,
|
||||
cwd,
|
||||
scope: { cwd, target, fix, rest: [] },
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
existingState: state,
|
||||
sendChatMessage: opts.sendChatMessage,
|
||||
onAgentEvent: opts.onAgentEvent,
|
||||
sendPhaseLine: opts.sendPhaseLine,
|
||||
// The all-run footer already owns the pipeline-overview widget
|
||||
// slot; suppress the per-check footer so two overviews never
|
||||
// compete over the same `belowEditor` area.
|
||||
footer: false,
|
||||
// Inner runs must not prime the .gitignore twice — the outer all-run
|
||||
// already ensured it. (ensureRunStateIgnored is memoized per cwd,
|
||||
// so this is belt-and-braces.)
|
||||
gitignore: opts.gitignore,
|
||||
});
|
||||
state = outcome.state;
|
||||
ran.push(check.name);
|
||||
footerSettle(
|
||||
check.name,
|
||||
outcome.status === "complete"
|
||||
? "complete"
|
||||
: outcome.status === "skipped"
|
||||
? "skipped"
|
||||
: "failed",
|
||||
);
|
||||
strip.log(`pygienium ${check.label}: ${outcome.status}`);
|
||||
}
|
||||
|
||||
// --- Finalize -----------------------------------------------------------
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
|
||||
setAllPhase(strip, selected, selected.length - 1, "cleanup");
|
||||
// Mark the final check's footer status terminal; the cursor started at 0
|
||||
// and the loop advanced it, so the last selected item is the live one.
|
||||
if (selected.length > 0) {
|
||||
footerSettle(
|
||||
selected[selected.length - 1]!.name,
|
||||
state.checks[selected[selected.length - 1]!.name]?.status === "failed"
|
||||
? "failed"
|
||||
: "complete",
|
||||
);
|
||||
}
|
||||
strip.done();
|
||||
allFooter.done();
|
||||
|
||||
const summaryPath = await writeAllSummary(state, selected);
|
||||
|
||||
return {
|
||||
status: state.status,
|
||||
state,
|
||||
summaryPath,
|
||||
ran,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the unified strip to reflect which check is active and its phase.
|
||||
* Renders `pygienium: all [i/N] <check-label>: <phase>` so every check name is
|
||||
* surfaced in the strip over the course of the run.
|
||||
*/
|
||||
function setAllPhase(
|
||||
strip: ReturnType<typeof createPhaseStrip>,
|
||||
selected: CheckDefinition[],
|
||||
index: number,
|
||||
phaseId: string,
|
||||
): void {
|
||||
const check = selected[index];
|
||||
const label = check?.label ?? "(none)";
|
||||
const total = selected.length;
|
||||
const pos = String(index + 1);
|
||||
const phaseLabel = PHASE_ALL_LABELS[phaseId] ?? phaseId;
|
||||
strip.setPhase(`all [${pos}/${total}] ${label}: ${phaseLabel}`);
|
||||
}
|
||||
|
||||
const PHASE_ALL_LABELS: Record<string, string> = {
|
||||
recon: "Recon",
|
||||
analysis: "Scanning",
|
||||
fix: "Fixing",
|
||||
verify: "Verifying",
|
||||
cleanup: "Done",
|
||||
};
|
||||
|
||||
/** Write the all-summary.md report, creating the directory as needed. */
|
||||
async function writeAllSummary(
|
||||
state: RunState,
|
||||
selected: CheckDefinition[],
|
||||
): Promise<string> {
|
||||
const path = allSummaryPath(state.cwd);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, renderAllSummary(state, selected), "utf8");
|
||||
return path;
|
||||
}
|
||||
525
src/modes/check-runner.ts
Normal file
525
src/modes/check-runner.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* modes/check-runner.ts — orchestrates a single check run.
|
||||
*
|
||||
* Pipeline:
|
||||
* init/resolve run-state → Q0 recon (shared, once) →
|
||||
* analysis sub-agent (buildScanTask) → fix sub-agent (buildFixTask, only
|
||||
* with --fix) → verify gate → cleanup transient artifacts.
|
||||
*
|
||||
* Every phase is recorded on the persisted run-state via `run-state.ts`, so
|
||||
* `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` reflect
|
||||
* real progress. The check-runner is check-agnostic: a `CheckDefinition`
|
||||
* supplies the task builders and gate; this module only wires the phases
|
||||
* together.
|
||||
*
|
||||
* @module pygienium/modes/check-runner
|
||||
*/
|
||||
|
||||
import { rm } from "node:fs/promises";
|
||||
import { resolve, join } from "node:path";
|
||||
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
|
||||
import { runAgentTask } from "../agent-runner.js";
|
||||
import { runRecon } from "../recon.js";
|
||||
import {
|
||||
createPhaseStrip,
|
||||
type SendChatMessage,
|
||||
type CheckCompletionDetails,
|
||||
type PhaseLogEntry,
|
||||
type PhaseLogStatus,
|
||||
PHASE_LABELS,
|
||||
} from "../phases.js";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
import { createPipelineFooter, footerPhaseItems } from "../footer.js";
|
||||
import {
|
||||
applyPhaseStatus,
|
||||
ensureRunStateIgnored,
|
||||
initRunState,
|
||||
loadRunState,
|
||||
markCheckStatus,
|
||||
markRunStatus,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_CLEANUP,
|
||||
PHASE_FIX,
|
||||
PHASE_RECON,
|
||||
PHASE_VERIFY,
|
||||
phasesForCheck,
|
||||
recordCheckOutput,
|
||||
reconcileRunStatus,
|
||||
saveRunState,
|
||||
stateDir,
|
||||
type RunState,
|
||||
} from "../run-state.js";
|
||||
|
||||
/** Resolve a raw arg string into a check scope (target path + flags). */
|
||||
export function parseCheckArgs(raw: string, cwd: string): CheckScope {
|
||||
const tokens = raw.trim().length > 0 ? raw.trim().split(/\s+/) : [];
|
||||
let fix = false;
|
||||
let target = cwd;
|
||||
const rest: string[] = [];
|
||||
for (const tok of tokens) {
|
||||
if (tok === "--fix") {
|
||||
fix = true;
|
||||
} else if (tok.startsWith("--")) {
|
||||
rest.push(tok);
|
||||
} else {
|
||||
target = tok;
|
||||
}
|
||||
}
|
||||
// Absolute-ize target against cwd.
|
||||
target = resolve(cwd, target);
|
||||
return { cwd, target, fix, rest };
|
||||
}
|
||||
|
||||
export interface RunCheckOptions {
|
||||
/** The check definition to run. */
|
||||
check: CheckDefinition;
|
||||
/** Working directory (from `ctx.cwd`). */
|
||||
cwd: string;
|
||||
/** Parsed scope (target + flags). When omitted, derived from `rawArgs`. */
|
||||
scope?: CheckScope;
|
||||
/** Raw command args, used when `scope` is omitted. */
|
||||
rawArgs?: string;
|
||||
/** UI context (optional; null in print mode). */
|
||||
ui?: ExtensionUIContext;
|
||||
/** Whether dialog-capable UI is available. */
|
||||
hasUI?: boolean;
|
||||
/** Pre-existing run state to update (for `/pygienium-all` and resume). */
|
||||
existingState?: RunState;
|
||||
/** Optional callback to post completion messages into the chat. */
|
||||
sendChatMessage?: SendChatMessage;
|
||||
/**
|
||||
* Render the per-check live widget (default true). Set false when an outer
|
||||
* strip (e.g. `/pygienium-all`) already shows this check's phase, so two
|
||||
* spinners don't fight over the widget area.
|
||||
*/
|
||||
widget?: boolean;
|
||||
/**
|
||||
* Render the pipeline-overview footer status line (default true). Set false
|
||||
* when an outer run (e.g. `/pygienium-all`) already owns the footer, so two
|
||||
* overviews never compete over the same status slot.
|
||||
*/
|
||||
footer?: boolean;
|
||||
/** Optional callback that forwards raw sub-agent events to the chat
|
||||
* stream (see `pygienium-stream` in `index.ts`). */
|
||||
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
|
||||
/** Optional callback to emit synthetic progress lines during non-agent
|
||||
* phases (verify, cleanup, recon) into the chat stream. */
|
||||
sendPhaseLine?: (phase: string, text: string) => void;
|
||||
/**
|
||||
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
|
||||
* state/artifacts (default true). Set false with `--no-gitignore`.
|
||||
*/
|
||||
gitignore?: boolean;
|
||||
}
|
||||
|
||||
/** Outcome of a single check run. */
|
||||
export interface CheckRunOutcome {
|
||||
/** Final check status. */
|
||||
status: "complete" | "failed" | "skipped";
|
||||
/** True when this run appended `.pygienium/` to the repo's .gitignore. */
|
||||
gitignoreAppended?: boolean;
|
||||
/** Findings text from the analysis phase. */
|
||||
findings?: string;
|
||||
/** Changes text from the fix phase (when run with --fix). */
|
||||
changes?: string;
|
||||
/** Error message on failure. */
|
||||
error?: string;
|
||||
/** The updated run state. */
|
||||
state: RunState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single check end-to-end, persisting progress to run-state, and post a
|
||||
* ralpi-style completion message into the chat (header + expandable phase
|
||||
* tree) when a `sendChatMessage` callback is supplied.
|
||||
*
|
||||
* Resumable: if `existingState` already has terminal-ish progress for this
|
||||
* check, the runner resumes the last in-progress phase rather than restarting.
|
||||
*/
|
||||
export async function runCheck(
|
||||
opts: RunCheckOptions,
|
||||
): Promise<CheckRunOutcome> {
|
||||
const startMs = Date.now();
|
||||
const outcome = await runCheckImpl(opts);
|
||||
postCheckCompletion(opts, outcome, Date.now() - startMs);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function runCheckImpl(opts: RunCheckOptions): Promise<CheckRunOutcome> {
|
||||
// Keep pygienium's own output out of the scanned repo's git index unless
|
||||
// the caller opted out with --no-gitignore.
|
||||
const gitignoreAppended =
|
||||
opts.gitignore === false ? false : await ensureRunStateIgnored(opts.cwd);
|
||||
const outcome = await runCheckImplInner(opts);
|
||||
return { ...outcome, gitignoreAppended };
|
||||
}
|
||||
|
||||
async function runCheckImplInner(
|
||||
opts: RunCheckOptions,
|
||||
): Promise<CheckRunOutcome> {
|
||||
const { check, cwd } = opts;
|
||||
const scope = opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", cwd);
|
||||
|
||||
// Resolve or init the run state, recording this check on first sight.
|
||||
const state: RunState =
|
||||
opts.existingState ?? (await loadRunState(cwd)) ?? initRunState(cwd, []);
|
||||
if (!state.checks[check.name]) {
|
||||
state.checks[check.name] = {
|
||||
name: check.name,
|
||||
label: check.label,
|
||||
status: "pending",
|
||||
fix: scope.fix,
|
||||
phases: phasesForCheck(scope.fix),
|
||||
};
|
||||
}
|
||||
await saveRunState(state);
|
||||
|
||||
const strip = createPhaseStrip({
|
||||
ui: opts.ui,
|
||||
hasUI: opts.hasUI ?? false,
|
||||
checkLabel: check.label,
|
||||
});
|
||||
|
||||
/** Phase tag combining check label + phase label for stream lines. */
|
||||
const phaseTag = (phaseId: string): string =>
|
||||
`${check.label}: ${PHASE_LABELS[phaseId] ?? phaseId}`;
|
||||
|
||||
/** Forward a raw agent event tagged with the current phase. */
|
||||
const forward = (phaseId: string) => (event: AgentSessionEvent) =>
|
||||
opts.onAgentEvent?.(phaseTag(phaseId), event);
|
||||
|
||||
/** Emit a synthetic stream line for non-agent phases (verify/cleanup/recon). */
|
||||
const phaseLine = (phaseId: string, text: string): void =>
|
||||
opts.sendPhaseLine?.(phaseTag(phaseId), text);
|
||||
|
||||
// Pipeline-overview footer: a static one-line view of the full phase list
|
||||
// with the cursor on the current phase and what's to come. Detail lives in
|
||||
// the chat (phase strip + completion tree); the footer is the overview.
|
||||
// Disabled (no-op) when an outer run owns the footer, e.g. /pygienium-all.
|
||||
const phaseIds = (
|
||||
state.checks[check.name]?.phases ?? phasesForCheck(scope.fix)
|
||||
).map((p) => p.id);
|
||||
const footerIdx = new Map(phaseIds.map((id, i) => [id, i] as const));
|
||||
const footer = createPipelineFooter({
|
||||
ui: opts.ui,
|
||||
hasUI: opts.hasUI ?? false,
|
||||
enabled: opts.footer ?? true,
|
||||
});
|
||||
footer.setPipeline(
|
||||
`pygienium ${check.label}`,
|
||||
footerPhaseItems(phaseIds, PHASE_LABELS),
|
||||
);
|
||||
const footerEnter = (phaseId: string): void => {
|
||||
const i = footerIdx.get(phaseId);
|
||||
if (i !== undefined) footer.setCursor(i);
|
||||
};
|
||||
const footerComplete = (phaseId: string): void => {
|
||||
const i = footerIdx.get(phaseId);
|
||||
if (i !== undefined) footer.setItem(i, "complete");
|
||||
};
|
||||
|
||||
let findings = "";
|
||||
let changes = "";
|
||||
let error: string | undefined;
|
||||
|
||||
try {
|
||||
// --- Phase: gate -----------------------------------------------------
|
||||
const gateResult = await Promise.resolve(check.gate(cwd));
|
||||
if (gateResult) {
|
||||
// Skip this check entirely (no agent work).
|
||||
for (const phase of state.checks[check.name]?.phases ?? []) {
|
||||
if (phase.status === "pending") phase.status = "skipped";
|
||||
}
|
||||
for (let i = 0; i < phaseIds.length; i++) footer.setItem(i, "skipped");
|
||||
markCheckStatus(state, check.name, "skipped", gateResult);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
strip.setPhase(PHASE_CLEANUP);
|
||||
strip.done();
|
||||
return { status: "skipped", error: gateResult, state };
|
||||
}
|
||||
|
||||
// --- Phase: recon (shared, run once per run) -------------------------
|
||||
if (!state.recon.complete) {
|
||||
strip.setPhase(PHASE_RECON);
|
||||
footerEnter(PHASE_RECON);
|
||||
applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress");
|
||||
await saveRunState(state);
|
||||
phaseLine(PHASE_RECON, "scanning project structure…");
|
||||
const snapshot = await runRecon(cwd);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: join(stateDir(cwd), "recon.json"),
|
||||
finishedAt: snapshot.createdAt,
|
||||
};
|
||||
phaseLine(PHASE_RECON, "✓ recon complete");
|
||||
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
|
||||
await saveRunState(state);
|
||||
footerComplete(PHASE_RECON);
|
||||
} else {
|
||||
// Recon already done this run — mark this check's recon complete.
|
||||
applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
|
||||
footerComplete(PHASE_RECON);
|
||||
}
|
||||
|
||||
// --- Phase: analysis -------------------------------------------------
|
||||
strip.setPhase(PHASE_ANALYSIS);
|
||||
footerEnter(PHASE_ANALYSIS);
|
||||
applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "in_progress");
|
||||
await saveRunState(state);
|
||||
const scanTask = await check.buildScanTask(cwd, scope);
|
||||
const scanResult = await runAgentTask({
|
||||
cwd: scope.target,
|
||||
agentName: check.agentName,
|
||||
task: scanTask,
|
||||
onEvent: forward(PHASE_ANALYSIS),
|
||||
});
|
||||
findings = scanResult.text;
|
||||
recordCheckOutput(state, check.name, { findings });
|
||||
if (!scanResult.ok) {
|
||||
applyPhaseStatus(
|
||||
state,
|
||||
check.name,
|
||||
PHASE_ANALYSIS,
|
||||
"failed",
|
||||
scanResult.error,
|
||||
);
|
||||
markCheckStatus(state, check.name, "failed", scanResult.error);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
return { status: "failed", error: scanResult.error, findings, state };
|
||||
}
|
||||
applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "complete");
|
||||
await saveRunState(state);
|
||||
footerComplete(PHASE_ANALYSIS);
|
||||
|
||||
// --- Phase: fix (only with --fix) -----------------------------------
|
||||
if (scope.fix) {
|
||||
strip.setPhase(PHASE_FIX);
|
||||
footerEnter(PHASE_FIX);
|
||||
applyPhaseStatus(state, check.name, PHASE_FIX, "in_progress");
|
||||
await saveRunState(state);
|
||||
const fixTask = await check.buildFixTask(cwd, scope, findings);
|
||||
const fixResult = await runAgentTask({
|
||||
cwd: scope.target,
|
||||
agentName: check.fixAgentName ?? "fixer",
|
||||
task: fixTask,
|
||||
onEvent: forward(PHASE_FIX),
|
||||
});
|
||||
changes = fixResult.text;
|
||||
recordCheckOutput(state, check.name, { changes });
|
||||
if (!fixResult.ok) {
|
||||
applyPhaseStatus(
|
||||
state,
|
||||
check.name,
|
||||
PHASE_FIX,
|
||||
"failed",
|
||||
fixResult.error,
|
||||
);
|
||||
markCheckStatus(state, check.name, "failed", fixResult.error);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
return {
|
||||
status: "failed",
|
||||
error: fixResult.error,
|
||||
findings,
|
||||
changes,
|
||||
state,
|
||||
};
|
||||
}
|
||||
applyPhaseStatus(state, check.name, PHASE_FIX, "complete");
|
||||
await saveRunState(state);
|
||||
footerComplete(PHASE_FIX);
|
||||
}
|
||||
|
||||
// --- Phase: verify ---------------------------------------------------
|
||||
strip.setPhase(PHASE_VERIFY);
|
||||
footerEnter(PHASE_VERIFY);
|
||||
applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress");
|
||||
await saveRunState(state);
|
||||
phaseLine(PHASE_VERIFY, "checking artifacts…");
|
||||
// Verify is a lightweight self-check. A check may supply a dedicated
|
||||
// `verify` hook to confirm its artifacts were produced (e.g.
|
||||
// findings.md / changes.md exist). When absent, fall back to re-running
|
||||
// the gate — unchanged from the historical behaviour.
|
||||
const verifyResult = await Promise.resolve(
|
||||
check.verify ? check.verify(scope) : check.gate(cwd),
|
||||
);
|
||||
if (verifyResult) {
|
||||
applyPhaseStatus(state, check.name, PHASE_VERIFY, "failed", verifyResult);
|
||||
markCheckStatus(state, check.name, "failed", verifyResult);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
return {
|
||||
status: "failed",
|
||||
error: verifyResult,
|
||||
findings,
|
||||
changes,
|
||||
state,
|
||||
};
|
||||
}
|
||||
phaseLine(PHASE_VERIFY, "✓ artifacts confirmed");
|
||||
applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete");
|
||||
await saveRunState(state);
|
||||
footerComplete(PHASE_VERIFY);
|
||||
|
||||
// --- Phase: cleanup --------------------------------------------------
|
||||
strip.setPhase(PHASE_CLEANUP);
|
||||
footerEnter(PHASE_CLEANUP);
|
||||
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress");
|
||||
await saveRunState(state);
|
||||
phaseLine(PHASE_CLEANUP, "removing transient artifacts…");
|
||||
await cleanupTransientArtifacts(cwd, check.name);
|
||||
phaseLine(PHASE_CLEANUP, "✓ done");
|
||||
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete");
|
||||
footerComplete(PHASE_CLEANUP);
|
||||
markCheckStatus(state, check.name, "complete");
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
|
||||
return { status: "complete", findings, changes, state };
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
markCheckStatus(state, check.name, "failed", error);
|
||||
markRunStatus(state, reconcileRunStatus(state));
|
||||
await saveRunState(state);
|
||||
return { status: "failed", error, findings, changes, state };
|
||||
} finally {
|
||||
strip.done();
|
||||
footer.done();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a run-state `PhaseStatus` to a completion-log status. */
|
||||
function phaseLogStatus(status: string | undefined): PhaseLogStatus {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return "complete";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "skipped":
|
||||
return "skipped";
|
||||
default:
|
||||
return "running";
|
||||
}
|
||||
}
|
||||
|
||||
/** Glyph for a check's terminal status. */
|
||||
function statusGlyph(status: CheckRunOutcome["status"]): string {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return "✓";
|
||||
case "failed":
|
||||
return "✗";
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
/** Count non-empty lines in captured findings/changes text. */
|
||||
function lineCount(text: string | undefined): number {
|
||||
if (!text) return 0;
|
||||
return text.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
|
||||
}
|
||||
|
||||
/** Format a duration in milliseconds as `1m 2s` / `5s` / `320ms`. */
|
||||
function formatDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 1) return `${ms}ms`;
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return rem ? `${m}m ${rem}s` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Build the expandable phase tree carried in the completion message. */
|
||||
function buildPhaseLog(
|
||||
state: RunState,
|
||||
checkName: string,
|
||||
findings?: string,
|
||||
changes?: string,
|
||||
): PhaseLogEntry[] {
|
||||
const phases = state.checks[checkName]?.phases ?? [];
|
||||
return phases.map((p) => {
|
||||
const entry: PhaseLogEntry = {
|
||||
id: p.id,
|
||||
label: PHASE_LABELS[p.id] ?? p.id,
|
||||
status: phaseLogStatus(p.status),
|
||||
};
|
||||
if (p.id === PHASE_ANALYSIS && findings) {
|
||||
entry.note = `findings: ${lineCount(findings)} lines`;
|
||||
} else if (p.id === PHASE_FIX && changes) {
|
||||
entry.note = `changes: ${lineCount(changes)} lines`;
|
||||
} else if (p.error) {
|
||||
entry.note = p.error;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a single ralpi-style completion message (header + phase tree) into the
|
||||
* chat via `sendChatMessage`. No-op when no callback is wired (print/json
|
||||
* modes). Mirrors ralpi's per-loop completion message.
|
||||
*/
|
||||
function postCheckCompletion(
|
||||
opts: RunCheckOptions,
|
||||
outcome: CheckRunOutcome,
|
||||
durationMs: number,
|
||||
): void {
|
||||
const send = opts.sendChatMessage;
|
||||
if (!send) return;
|
||||
const check = opts.check;
|
||||
const status = outcome.status;
|
||||
const glyph = statusGlyph(status);
|
||||
const fix = Boolean(
|
||||
(opts.scope ?? parseCheckArgs(opts.rawArgs ?? "", opts.cwd)).fix,
|
||||
);
|
||||
const fixTag = fix ? " --fix" : "";
|
||||
const header = `${glyph} pygienium ${check.label}${fixTag} · ${status} (${formatDuration(durationMs)})`;
|
||||
|
||||
const details: CheckCompletionDetails = {
|
||||
checkLabel: check.label,
|
||||
status,
|
||||
fix,
|
||||
durationMs,
|
||||
phases: buildPhaseLog(
|
||||
outcome.state,
|
||||
check.name,
|
||||
outcome.findings,
|
||||
outcome.changes,
|
||||
),
|
||||
error: outcome.error,
|
||||
};
|
||||
|
||||
send(header, { phase: "complete", completion: details });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove transient per-check scratch artifacts (e.g. agent-extracted
|
||||
* manifests) written under `<cwd>/.pygienium/<check>-tmp-*`. Findings and
|
||||
* changes are kept in run-state, not these scratch files, so removing them is
|
||||
* safe.
|
||||
*/
|
||||
async function cleanupTransientArtifacts(
|
||||
cwd: string,
|
||||
checkName: string,
|
||||
): Promise<void> {
|
||||
const dir = stateDir(cwd);
|
||||
// Best-effort: remove any `*-tmp-<check>` entries created by agents.
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.includes(`-tmp-${checkName}`)) {
|
||||
await rm(join(dir, entry), { recursive: true, force: true }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/phases.ts
Normal file
172
src/phases.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* phases.ts — phase-log accumulator + stdout progress + completion-message
|
||||
* helpers.
|
||||
*
|
||||
* The live *detail* view of a check run is the tool-event stream piped into
|
||||
* the chat (see `pygienium-stream` in `index.ts`): each `tool_execution_start
|
||||
* /end` and assistant turn becomes its own chat message. This module no longer
|
||||
* owns a TUI widget — it only records phase transitions for the completion
|
||||
* message and forwards phase headers to stdout in print/JSON mode.
|
||||
*
|
||||
* The *overview* view is the footer widget (see `footer.ts`): a multi-line
|
||||
* `belowEditor` strip listing the full pipeline with the cursor and what's to
|
||||
* come. The two never overlap: the chat is the per-event detail, the footer
|
||||
* is the static overview.
|
||||
*
|
||||
* The strip accumulates a phase log (`getPhaseLog`) that the check-runner
|
||||
* turns into the expandable completion message rendered by
|
||||
* `registerMessageRenderer("pygienium-progress")` in `index.ts`.
|
||||
*
|
||||
* @module pygienium/phases
|
||||
*/
|
||||
|
||||
import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
/** Callback to post a message into the chat history (see `index.ts` renderer). */
|
||||
export type SendChatMessage = (
|
||||
content: string,
|
||||
/** Extra data passed to the message renderer (toolCalls, completion, …). */
|
||||
meta?: {
|
||||
phase?: string;
|
||||
/** Tool calls captured during this agent execution (ralpi-style tree). */
|
||||
toolCalls?: never;
|
||||
[meta: string]: unknown;
|
||||
},
|
||||
) => void;
|
||||
|
||||
/** Phase display metadata for a check run's phases. */
|
||||
export const PHASE_LABELS: Record<string, string> = {
|
||||
recon: "Recon",
|
||||
analysis: "Scanning",
|
||||
fix: "Fixing",
|
||||
verify: "Verifying",
|
||||
cleanup: "Cleaning up",
|
||||
};
|
||||
|
||||
/** Status of a single phase as recorded in the completion log. */
|
||||
export type PhaseLogStatus = "running" | "complete" | "failed" | "skipped";
|
||||
|
||||
/** One phase entry carried into the completion message's `details.phases`. */
|
||||
export interface PhaseLogEntry {
|
||||
/** Phase id (e.g. "analysis"). */
|
||||
id: string;
|
||||
/** Human-readable label (e.g. "Scanning"). */
|
||||
label: string;
|
||||
/** Terminal/running status. */
|
||||
status: PhaseLogStatus;
|
||||
/** Optional note shown on the branch (e.g. "findings: 12 lines"). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PhaseStripOptions {
|
||||
/** Check label shown alongside the phase, e.g. "comments". */
|
||||
checkLabel?: string;
|
||||
/** Whether dialog-capable UI is available. */
|
||||
hasUI?: boolean;
|
||||
/** UI context (unused for widget rendering since the stream owns the chat). */
|
||||
ui?: ExtensionUIContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle that records phase transitions for the completion message and
|
||||
* forwards phase headers to stdout when no TUI is present. Created by
|
||||
* {@link createPhaseStrip}; the check-runner drives it.
|
||||
*/
|
||||
export interface PhaseStrip {
|
||||
/** Set the current phase (id or a pre-rendered header string). */
|
||||
setPhase(phaseId: string): void;
|
||||
/** Annotate the most recent phase (e.g. "findings: 12 lines"). */
|
||||
setPhaseNote(note: string): void;
|
||||
/** Append a plain-text progress line (forwarded to stdout in print mode). */
|
||||
log(line: string): void;
|
||||
/** Snapshot of phase transitions for the completion message. */
|
||||
getPhaseLog(): PhaseLogEntry[];
|
||||
/** Mark the strip terminal. Safe to call repeatedly. */
|
||||
done(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple write lock for stdout in print mode to prevent interleaved output
|
||||
* from parallel checks.
|
||||
*/
|
||||
let stdoutLock: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Acquire the stdout write lock and execute the write function. */
|
||||
async function withStdoutLock(fn: () => void): Promise<void> {
|
||||
const prev = stdoutLock;
|
||||
stdoutLock = prev.then(() => {
|
||||
fn();
|
||||
return Promise.resolve();
|
||||
});
|
||||
return stdoutLock;
|
||||
}
|
||||
|
||||
/** Create a phase-strip UI adapter. */
|
||||
export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip {
|
||||
const checkLabel = opts.checkLabel;
|
||||
const hasUI = opts.hasUI ?? false;
|
||||
const phaseLog: PhaseLogEntry[] = [];
|
||||
let disposed = false;
|
||||
|
||||
let currentHeader = checkLabel
|
||||
? `pygienium ${checkLabel}: starting…`
|
||||
: "pygienium: starting…";
|
||||
|
||||
function phaseLabel(id: string): string {
|
||||
return PHASE_LABELS[id] ?? id;
|
||||
}
|
||||
|
||||
function writeStdout(text: string): void {
|
||||
if (!disposed && !hasUI) {
|
||||
process.stdout.write(`${text}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setPhase(phaseId) {
|
||||
if (disposed) return;
|
||||
const label = phaseLabel(phaseId);
|
||||
currentHeader = checkLabel
|
||||
? `pygienium ${checkLabel}: ${label}`
|
||||
: `pygienium: ${phaseId}`;
|
||||
phaseLog.push({ id: phaseId, label, status: "running" });
|
||||
if (!hasUI) {
|
||||
withStdoutLock(() => writeStdout(currentHeader)).catch(() => {});
|
||||
}
|
||||
},
|
||||
setPhaseNote(note) {
|
||||
const last = phaseLog[phaseLog.length - 1];
|
||||
if (!last) return;
|
||||
last.note = note;
|
||||
},
|
||||
log(line) {
|
||||
if (disposed || hasUI) return;
|
||||
withStdoutLock(() => writeStdout(line)).catch(() => {});
|
||||
},
|
||||
getPhaseLog() {
|
||||
return phaseLog;
|
||||
},
|
||||
done() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Phase-log detail carried into a completion message's `details`. */
|
||||
export interface CheckCompletionDetails {
|
||||
checkLabel: string;
|
||||
status: "complete" | "failed" | "skipped";
|
||||
fix?: boolean;
|
||||
durationMs?: number;
|
||||
phases: PhaseLogEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Re-exported for index.ts renderer convenience. */
|
||||
export const PHASE_GLYPH: Record<PhaseLogStatus, string> = {
|
||||
running: "~",
|
||||
complete: "✓",
|
||||
failed: "✗",
|
||||
skipped: "-",
|
||||
};
|
||||
109
src/recon.ts
Normal file
109
src/recon.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* recon.ts — shared Q0 reconnaissance phase.
|
||||
*
|
||||
* Runs once per hygiene run (before any check's analysis phase) and writes a
|
||||
* project snapshot to `<cwd>/.pygienium/recon.json`. Each check can read this
|
||||
* snapshot so the recon work isn't repeated per check. The snapshot is minimal
|
||||
* and dependency-free (git state + source-file inventory) — real checks layer
|
||||
* their own analysis on top via sub-agents.
|
||||
*
|
||||
* @module pygienium/recon
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { join } from "node:path";
|
||||
import { stateDir, RECON_FILENAME } from "./run-state.js";
|
||||
import { SCOPE_EXTENSIONS } from "./checks/scope.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ReconSnapshot {
|
||||
cwd: string;
|
||||
createdAt: number;
|
||||
gitBranch?: string;
|
||||
gitDirty?: boolean;
|
||||
/** Count of source files by extension. */
|
||||
fileCounts: Record<string, number>;
|
||||
totalSourceFiles: number;
|
||||
}
|
||||
|
||||
/** Run `git ls-files` when possible to get a clean source inventory. */
|
||||
async function listSourceFiles(cwd: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git -C ${JSON.stringify(cwd)} ls-files --cached --others --exclude-standard`,
|
||||
{ maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.filter((l) => {
|
||||
const dot = l.lastIndexOf(".");
|
||||
if (dot === -1) return false;
|
||||
return SCOPE_EXTENSIONS.has(l.slice(dot).toLowerCase());
|
||||
});
|
||||
} catch {
|
||||
/* not a git repo or git unavailable — empty inventory */
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the shared recon phase for `cwd`, writing the snapshot if missing. */
|
||||
export async function runRecon(cwd: string): Promise<ReconSnapshot> {
|
||||
const dir = stateDir(cwd);
|
||||
const path = join(dir, RECON_FILENAME);
|
||||
|
||||
// Reuse a fresh-enough snapshot (< 5 min) when present.
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
const existing = JSON.parse(raw) as ReconSnapshot;
|
||||
if (
|
||||
existing.createdAt &&
|
||||
Date.now() - existing.createdAt < 5 * 60 * 1000 &&
|
||||
existing.cwd === cwd
|
||||
) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
/* no existing snapshot */
|
||||
}
|
||||
|
||||
const files = await listSourceFiles(cwd);
|
||||
const fileCounts: Record<string, number> = {};
|
||||
for (const f of files) {
|
||||
const dot = f.lastIndexOf(".");
|
||||
const ext = dot === -1 ? "" : f.slice(dot).toLowerCase();
|
||||
fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
|
||||
}
|
||||
|
||||
let gitBranch: string | undefined;
|
||||
let gitDirty: boolean | undefined;
|
||||
try {
|
||||
const branchOut = await execAsync(
|
||||
`git -C ${JSON.stringify(cwd)} rev-parse --abbrev-ref HEAD`,
|
||||
);
|
||||
gitBranch = branchOut.stdout.trim() || undefined;
|
||||
const statusOut = await execAsync(
|
||||
`git -C ${JSON.stringify(cwd)} status --porcelain`,
|
||||
);
|
||||
gitDirty = statusOut.stdout.trim().length > 0;
|
||||
} catch {
|
||||
/* not a git repo */
|
||||
}
|
||||
|
||||
const snapshot: ReconSnapshot = {
|
||||
cwd,
|
||||
createdAt: Date.now(),
|
||||
gitBranch,
|
||||
gitDirty,
|
||||
fileCounts,
|
||||
totalSourceFiles: files.length,
|
||||
};
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
||||
return snapshot;
|
||||
}
|
||||
326
src/run-state.ts
Normal file
326
src/run-state.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* run-state.ts — persistent hygiene-run state.
|
||||
*
|
||||
* Tracks per-check phase progress so `/pygienium-status`, `/pygienium-resume`,
|
||||
* and `/pygienium-export` work, and so `/pygienium-all` is resumable. State is
|
||||
* a single JSON file at `<cwd>/.pygienium/run-state.json` so it is trivial to
|
||||
* inspect and is naturally session-scoped to the target directory.
|
||||
*
|
||||
* @module pygienium/run-state
|
||||
*/
|
||||
|
||||
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const RUN_STATE_DIRNAME = ".pygienium";
|
||||
export const RUN_STATE_FILENAME = "run-state.json";
|
||||
export const RECON_FILENAME = "recon.json";
|
||||
|
||||
/** Resolve the pygienium state directory for a given cwd. */
|
||||
export function stateDir(cwd: string): string {
|
||||
return join(cwd, RUN_STATE_DIRNAME);
|
||||
}
|
||||
|
||||
/** Resolve the run-state file path for a given cwd. */
|
||||
export function runStatePath(cwd: string): string {
|
||||
return join(stateDir(cwd), RUN_STATE_FILENAME);
|
||||
}
|
||||
|
||||
export type PhaseStatus =
|
||||
| "pending"
|
||||
| "in_progress"
|
||||
| "complete"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
|
||||
export type CheckStatus = PhaseStatus;
|
||||
export type RunStatus = "in_progress" | "complete" | "failed" | "partial";
|
||||
|
||||
export interface PhaseEntry {
|
||||
id: string;
|
||||
status: PhaseStatus;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CheckRun {
|
||||
name: string;
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
fix: boolean;
|
||||
phases: PhaseEntry[];
|
||||
findings?: string;
|
||||
changes?: string;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReconState {
|
||||
complete: boolean;
|
||||
path: string;
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
export interface RunState {
|
||||
version: 1;
|
||||
cwd: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
status: RunStatus;
|
||||
recon: ReconState;
|
||||
checks: Record<string, CheckRun>;
|
||||
}
|
||||
|
||||
/** Phase ids shared by every check run, in execution order. */
|
||||
export const PHASE_RECON = "recon";
|
||||
export const PHASE_ANALYSIS = "analysis";
|
||||
export const PHASE_FIX = "fix";
|
||||
export const PHASE_VERIFY = "verify";
|
||||
export const PHASE_CLEANUP = "cleanup";
|
||||
|
||||
function freshPhase(id: string): PhaseEntry {
|
||||
return { id, status: "pending" };
|
||||
}
|
||||
|
||||
/** Create the phase skeleton for a single check (analysis always runs; fix only when requested). */
|
||||
export function phasesForCheck(fix: boolean): PhaseEntry[] {
|
||||
const phases = [freshPhase(PHASE_RECON), freshPhase(PHASE_ANALYSIS)];
|
||||
if (fix) phases.push(freshPhase(PHASE_FIX));
|
||||
phases.push(freshPhase(PHASE_VERIFY), freshPhase(PHASE_CLEANUP));
|
||||
return phases;
|
||||
}
|
||||
|
||||
/** Initialize a fresh run state for `checkNames` (labels default to the name). */
|
||||
export function initRunState(
|
||||
cwd: string,
|
||||
checks: Array<{ name: string; label: string; fix?: boolean }>,
|
||||
): RunState {
|
||||
const now = Date.now();
|
||||
const state: RunState = {
|
||||
version: 1,
|
||||
cwd,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
status: "in_progress",
|
||||
recon: { complete: false, path: join(stateDir(cwd), RECON_FILENAME) },
|
||||
checks: {},
|
||||
};
|
||||
for (const c of checks) {
|
||||
state.checks[c.name] = {
|
||||
name: c.name,
|
||||
label: c.label,
|
||||
status: "pending",
|
||||
fix: c.fix ?? false,
|
||||
phases: phasesForCheck(c.fix ?? false),
|
||||
startedAt: undefined,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Load run state for `cwd`. Returns `undefined` when none exists. */
|
||||
export async function loadRunState(cwd: string): Promise<RunState | undefined> {
|
||||
const path = runStatePath(cwd);
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as RunState;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist run state, creating the state directory as needed. */
|
||||
export async function saveRunState(state: RunState): Promise<void> {
|
||||
const dir = stateDir(state.cwd);
|
||||
await mkdir(dir, { recursive: true });
|
||||
state.updatedAt = Date.now();
|
||||
await writeFile(
|
||||
runStatePath(state.cwd),
|
||||
JSON.stringify(state, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memo of cwds whose `.gitignore` was already ensured this process, so the
|
||||
* check runs at most once per target per session.
|
||||
*/
|
||||
const gitIgnoreMemo = new Set<string>();
|
||||
|
||||
/**
|
||||
* Make sure `<cwd>/.gitignore` excludes `.pygienium/` (run-state + artifacts)
|
||||
* so a run never stages its own output into the scanned repo's git index.
|
||||
* Best-effort and idempotent: no-op outside a git work tree or when the entry
|
||||
* already exists. Returns true when it appended the entry (or created the file).
|
||||
*/
|
||||
export async function ensureRunStateIgnored(cwd: string): Promise<boolean> {
|
||||
if (gitIgnoreMemo.has(cwd)) return false;
|
||||
gitIgnoreMemo.add(cwd);
|
||||
try {
|
||||
// Only act inside a git work tree (works for worktrees too: .git is a file).
|
||||
await stat(join(cwd, ".git"));
|
||||
const ignorePath = join(cwd, ".gitignore");
|
||||
const marker = ".pygienium/";
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(ignorePath, "utf8");
|
||||
} catch {
|
||||
await writeFile(ignorePath, `${marker}\n`, "utf8");
|
||||
return true;
|
||||
}
|
||||
if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false;
|
||||
const prefix = content.endsWith("\n") ? "" : "\n";
|
||||
await appendFile(
|
||||
ignorePath,
|
||||
`${prefix}# pygienium run-state and check artifacts\n${marker}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // not a git work tree, or a best-effort write failed
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a phase's status (and optionally an error message). */
|
||||
export function applyPhaseStatus(
|
||||
state: RunState,
|
||||
checkName: string,
|
||||
phaseId: string,
|
||||
status: PhaseStatus,
|
||||
error?: string,
|
||||
): void {
|
||||
const check = state.checks[checkName];
|
||||
if (!check) return;
|
||||
const phase = check.phases.find((p) => p.id === phaseId);
|
||||
if (!phase) return;
|
||||
phase.status = status;
|
||||
const now = Date.now();
|
||||
if (status === "in_progress") {
|
||||
phase.startedAt = now;
|
||||
if (check.startedAt == null) check.startedAt = now;
|
||||
check.status = "in_progress";
|
||||
} else if (
|
||||
status === "complete" ||
|
||||
status === "failed" ||
|
||||
status === "skipped"
|
||||
) {
|
||||
phase.finishedAt = now;
|
||||
// Always set (or clear) the error: a phase that previously failed
|
||||
// and then succeeds on retry must not carry a stale error forward.
|
||||
phase.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a check's overall status from its phases and, when terminal,
|
||||
* stamp `finishedAt`. Used after the cleanup phase resolves.
|
||||
*/
|
||||
export function markCheckStatus(
|
||||
state: RunState,
|
||||
checkName: string,
|
||||
status: CheckStatus,
|
||||
error?: string,
|
||||
): void {
|
||||
const check = state.checks[checkName];
|
||||
if (!check) return;
|
||||
check.status = status;
|
||||
// Always set (or clear) the error: a check that previously failed
|
||||
// and then succeeds on retry must not carry a stale error forward.
|
||||
check.error = error;
|
||||
if (status === "complete" || status === "failed" || status === "skipped") {
|
||||
check.finishedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/** Record findings/changes text on a check. */
|
||||
export function recordCheckOutput(
|
||||
state: RunState,
|
||||
checkName: string,
|
||||
out: { findings?: string; changes?: string },
|
||||
): void {
|
||||
const check = state.checks[checkName];
|
||||
if (!check) return;
|
||||
if (out.findings !== undefined) check.findings = out.findings;
|
||||
if (out.changes !== undefined) check.changes = out.changes;
|
||||
}
|
||||
|
||||
/** Mark the overall run status. */
|
||||
export function markRunStatus(state: RunState, status: RunStatus): void {
|
||||
state.status = status;
|
||||
state.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a check is terminal (shouldn't be re-dispatched unless a
|
||||
* fresh run is forced). `pending`/`in_progress`/`failed` are resumable.
|
||||
*/
|
||||
export function isCheckTerminal(check: CheckRun): boolean {
|
||||
return check.status === "complete" || check.status === "skipped";
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-dispatch predicate for `/pygienium-resume`: a check runs on resume when it
|
||||
* is not terminal, OR when `--fresh` forced a re-dispatch of everything.
|
||||
*/
|
||||
export function shouldRunOnResume(check: CheckRun, fresh: boolean): boolean {
|
||||
return fresh || !isCheckTerminal(check);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a single check entry back to `pending` with fresh phases. Used by
|
||||
* `/pygienium-resume --fresh` so that previously-complete checks are
|
||||
* re-dispatched from scratch. Preserves `label`/`fix` from the existing entry
|
||||
* unless overridden.
|
||||
*/
|
||||
export function resetCheckEntry(
|
||||
state: RunState,
|
||||
name: string,
|
||||
fixOverride?: boolean,
|
||||
): void {
|
||||
const existing = state.checks[name];
|
||||
const fix = fixOverride ?? existing?.fix ?? false;
|
||||
state.checks[name] = {
|
||||
name,
|
||||
label: existing?.label ?? name,
|
||||
status: "pending",
|
||||
fix,
|
||||
phases: phasesForCheck(fix),
|
||||
startedAt: undefined,
|
||||
finishedAt: undefined,
|
||||
findings: undefined,
|
||||
changes: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next check to run when resuming: the first `in_progress` check,
|
||||
* else the first pending/failed check. Returns `undefined` when nothing remains.
|
||||
*/
|
||||
/**
|
||||
* Recompute the run-level status from check statuses. A run is `complete` only
|
||||
* when every check is terminal-complete; `failed` when every check failed;
|
||||
* `partial` when some checks failed/skipped but others succeeded.
|
||||
*/
|
||||
export function reconcileRunStatus(state: RunState): RunStatus {
|
||||
const checks = Object.values(state.checks);
|
||||
if (checks.length === 0) return "in_progress";
|
||||
let anyFailed = false;
|
||||
let anySkipped = false;
|
||||
for (const c of checks) {
|
||||
if (c.status === "pending" || c.status === "in_progress")
|
||||
return "in_progress";
|
||||
if (c.status === "failed") anyFailed = true;
|
||||
if (c.status === "skipped") anySkipped = true;
|
||||
}
|
||||
if (anyFailed) {
|
||||
// Every check failed (none succeeded or were skipped) → the run failed;
|
||||
// a mix of failures and successes is only partially complete.
|
||||
return checks.every((c) => c.status === "failed") ? "failed" : "partial";
|
||||
}
|
||||
if (anySkipped) return "partial";
|
||||
return "complete";
|
||||
}
|
||||
108
src/status.ts
Normal file
108
src/status.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* status.ts — readable run-state formatter.
|
||||
*
|
||||
* {@link formatRunStatus} turns a `RunState` into a plain line list covering
|
||||
* the run-level summary and one block per registered check: overall status, the
|
||||
* per-phase breakdown, captured findings/changes artifacts, and any errors.
|
||||
* It is a *pure* function of state — no disk I/O — so it is trivially
|
||||
* unit-testable and deterministic; the in-memory run-state is the single source
|
||||
* of truth for progress (the check-runner records findings/changes text on it).
|
||||
*
|
||||
* `formatRunStatus` is the single helper the `/pygienium-status` command uses;
|
||||
* keeping it here (out of `commands.ts`) lets `commands.ts` stay a thin binder.
|
||||
*
|
||||
* @module pygienium/status
|
||||
*/
|
||||
|
||||
import type { CheckRun, RunState } from "./run-state.js";
|
||||
|
||||
const PHASE_ORDER = ["recon", "analysis", "fix", "verify", "cleanup"] as const;
|
||||
|
||||
function toISO(ms: number | undefined): string {
|
||||
return ms == null ? "—" : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function short(status: string): string {
|
||||
return status[0]?.toUpperCase() ?? "?";
|
||||
}
|
||||
|
||||
/** Count non-empty lines in captured findings/changes text. */
|
||||
function lineCount(text: string | undefined): number {
|
||||
if (!text) return 0;
|
||||
const count = text.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single check block (without a trailing separator) — exposed so tests
|
||||
* and the status command share one rendering path.
|
||||
*/
|
||||
export function formatCheckBlock(check: CheckRun, indent = " "): string[] {
|
||||
const lines: string[] = [];
|
||||
const flag = check.fix ? " (--fix)" : "";
|
||||
lines.push(`${indent}${check.name} — ${check.status}${flag}`);
|
||||
|
||||
const phaseSummary = check.phases
|
||||
.map((p) => `${p.id}:${p.status.startsWith("in_progress") ? "…" : short(p.status)}`)
|
||||
.join(" ");
|
||||
if (phaseSummary) lines.push(`${indent} phases: ${phaseSummary}`);
|
||||
|
||||
const findingsLines = lineCount(check.findings);
|
||||
if (findingsLines > 0) {
|
||||
lines.push(`${indent} findings: ${findingsLines} line(s)`);
|
||||
}
|
||||
const changesLines = lineCount(check.changes);
|
||||
if (changesLines > 0) {
|
||||
lines.push(`${indent} changes: ${changesLines} line(s)`);
|
||||
}
|
||||
|
||||
if (check.error) {
|
||||
lines.push(`${indent} error: ${check.error}`);
|
||||
}
|
||||
for (const phase of check.phases) {
|
||||
if (phase.status === "failed" && phase.error && phase.error !== check.error) {
|
||||
lines.push(`${indent} ${phase.id}: ${phase.error}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `/pygienium-status` line list for a run state. Pure: no disk reads.
|
||||
* Layout:
|
||||
*
|
||||
* pygienium run — <status>
|
||||
* started: <iso>
|
||||
* updated: <iso>
|
||||
* cwd: <cwd>
|
||||
* recon: complete|pending
|
||||
*
|
||||
* checks (N):
|
||||
* <name> — <status>
|
||||
* phases: recon:✓ analysis:✓ [fix:✓] verify:✓ cleanup:✓
|
||||
* findings: <N> line(s)
|
||||
* changes: <N> line(s)
|
||||
* error: <msg>
|
||||
*/
|
||||
export function formatRunStatus(state: RunState): string[] {
|
||||
const checks = Object.values(state.checks);
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`pygienium run — ${state.status}`);
|
||||
lines.push(` started: ${toISO(state.startedAt)}`);
|
||||
lines.push(` updated: ${toISO(state.updatedAt)}`);
|
||||
lines.push(` cwd: ${state.cwd}`);
|
||||
const reconLabel = state.recon.complete ? "complete" : "pending";
|
||||
const reconTime = state.recon.finishedAt ? ` (${toISO(state.recon.finishedAt)})` : "";
|
||||
lines.push(` recon: ${reconLabel}${reconTime}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push(` checks (${checks.length}):`);
|
||||
if (checks.length === 0) {
|
||||
lines.push(" (none registered in this run state)");
|
||||
}
|
||||
for (const check of checks) {
|
||||
lines.push(...formatCheckBlock(check));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
50
tasks/01-scaffolding.md
Normal file
50
tasks/01-scaffolding.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 01. Extension scaffolding and project structure
|
||||
|
||||
meta:
|
||||
id: pygienium-01
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [infrastructure, setup]
|
||||
|
||||
objective:
|
||||
|
||||
- Establish the pygienium extension directory, package.json, tsconfig, and a minimal index.ts that loads without error and surfaces a startup notification.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `pygienium/package.json` with pi package manifest (`pi.extensions`, keywords, peer deps echoing piolium)
|
||||
- `pygienium/tsconfig.json` (extends the repo tsconfig)
|
||||
- `pygienium/src/index.ts` default-export factory registering a `session_start` notify + a stub `/pygienium-help`
|
||||
- `pygienium/` subdirectories: `src/`, `src/modes/`, `src/checks/`, `agents/`, `skills/` (empty, with `.gitkeep`)
|
||||
- Verified load: `pi -e ./pygienium/src/index.ts -p "/pygienium-help"` runs without throwing
|
||||
|
||||
steps:
|
||||
|
||||
- Create the `pygienium/` tree and empty subdirectories
|
||||
- Author `package.json`: name `pygienium`, type module, `pi.extensions: ["./src/index.ts"]`, peerDeps mirroring piolium (`@earendil-works/pi-coding-agent`, `typebox`, etc.), engines bun >=1.1.0
|
||||
- Author `tsconfig.json` referencing the parent `tsconfig.json`
|
||||
- Author `src/index.ts`: import `ExtensionAPI`, default-export factory that registers a `session_start` notify ("Pygienium loaded. Run /pygienium-help.") and a `/pygienium-help` command printing a placeholder line
|
||||
- Confirm the extension auto-discovers from `~/.pi/agent/extensions/pygienium/src/index.ts` (per docs, subdir `index.ts`); if discovery prefers `pygienium/index.ts`, adjust entry path in package.json
|
||||
|
||||
tests:
|
||||
|
||||
- Manual: run `pi -e ./pygienium/src/index.ts -p "/pygienium-help"`; confirm the placeholder help renders
|
||||
- Manual: start pi in `~/.pi/agent/extensions` and confirm the startup notify appears without `session_start` errors
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- The directory tree and package.json exist with valid JSON
|
||||
- `pi` loads the extension via auto-discovery with no load errors
|
||||
- `/pygienium-help` responds with a non-empty line
|
||||
|
||||
validation:
|
||||
|
||||
- `node -e "JSON.parse(require('fs').readFileSync('pygienium/package.json','utf8'))"` exits 0
|
||||
- `pi -e ./pygienium/src/index.ts -p "/pygienium-help"` prints the placeholder
|
||||
|
||||
notes:
|
||||
|
||||
- Place the extension in `~/.pi/agent/extensions/pygienium/` so it auto-discovers and hot-reloads with `/reload`
|
||||
- Do NOT add backward-compat shims or migration paths (engineering rule)
|
||||
- Keep index.ts minimal here; full command wiring lands in task 06
|
||||
46
tasks/02-recon.md
Normal file
46
tasks/02-recon.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 02. Deterministic code reconnaissance module
|
||||
|
||||
meta:
|
||||
id: pygienium-02
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: [pygienium-01]
|
||||
tags: [infrastructure, no-model]
|
||||
|
||||
objective:
|
||||
|
||||
- Build `src/recon.ts`: a deterministic, no-model pass that walks the target repo and writes a compact markdown report (languages, manifests, file counts, dead-file candidates) so every check has stable ground truth.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/recon.ts` exporting `runRecon(cwd)`, `runReconAsync(cwd, opts)`, `reconReportPath(cwd)`, `ReconResult`
|
||||
- Writes `pygienium/recon/report.md` under cwd
|
||||
- Detects: languages by extension, build manifests, total files/bytes, git head/branch, and a "candidate files" list (source files not under skip dirs) written to `pygienium/recon/candidates.jsonl` + summary
|
||||
- Soft caps (MAX_FILES, MAX_BYTES) and SIGINT-safe async walk mirroring piolium's recon
|
||||
|
||||
steps:
|
||||
|
||||
- Port piolium's `recon.ts` structure: MANIFEST_FILES, LANGUAGE_BY_EXT, SKIP_DIRS, safe git exec, walkAndTally + async variant with yieldToEventLoop
|
||||
- Adapt skip dirs to include `pygienium` (own output dir)
|
||||
- Add a `candidates.jsonl` emitter listing source files (by language) for the check runners to consume; cap entries to keep it bounded
|
||||
- Export `buildReconReport` for unit testing
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: `buildReconReport` on a fake ReconResult produces expected markdown sections (Arrange a result, Act build, Assert headers present)
|
||||
- Integration: run recon against this repo; assert report.md + candidates.jsonl exist and counts > 0
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `runRecon(cwd)` returns a ReconResult and writes report.md + candidates.jsonl
|
||||
- A repo without `.git` does not throw (graceful degradation)
|
||||
- Very large trees are capped without wedging
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/recon/report.md` and `pygienium/recon/candidates.jsonl` after a run
|
||||
|
||||
notes:
|
||||
|
||||
- Recon is Q0-equivalent: deterministic, runs in-process, no model calls
|
||||
- This is the foundation every check consumes for targeting
|
||||
47
tasks/03-agent-runner.md
Normal file
47
tasks/03-agent-runner.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 03. Sub-agent runner with createAgentSession
|
||||
|
||||
meta:
|
||||
id: pygienium-03
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: [pygienium-01]
|
||||
tags: [infrastructure, core]
|
||||
|
||||
objective:
|
||||
|
||||
- Build `src/agent-runner.ts`: spawn isolated child pi sessions via `createAgentSession`, capture transcripts/results, and return a typed result — the engine that powers every check's sub-agent phases.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/agent-runner.ts` exporting `runAgent(options)`, `AgentRuntimeModel`, `RunAgentResult`, `AgentRunError`, `buildRuntimeHeader`, `RuntimeContext`
|
||||
- Each run writes `pygienium/runs/<runId>/{prompt.md, transcript.jsonl, result.md, error.txt}`
|
||||
- Inherits parent model + modelRegistry + thinkingLevel (so child phases reason at the same depth)
|
||||
- AbortSignal support; `onEvent` forwarding hook for UI streaming
|
||||
- Child tools: the built-in edit/write/read/bash surface; noExtensions: true (avoid recursion)
|
||||
|
||||
steps:
|
||||
|
||||
- Port piolium's `agent-runner.ts` shape: composed system prompt = runtime header + agent systemPrompt; `DefaultResourceLoader` with noExtensions/noThemes/noContextFiles; in-memory SessionManager
|
||||
- Define `AgentDefinition` interface (name, description, systemPrompt, allowedTools, sourcePath) consumed here
|
||||
- Wire `session.subscribe` to capture final assistant text + stopReason + errorMessage
|
||||
- Handle abort: add/remove abort listener, call `session.agent.abort()`
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: `buildRuntimeHeader` includes cwd, mode, phase, assigned output paths
|
||||
- Integration: run a smoke agent (no-tools, replies with a fixed string) via `runAgent`; assert result.text non-empty and transcript.jsonl exists
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `runAgent` returns RunAgentResult with text + transcriptPath + durationMs
|
||||
- Aborting the signal cancels the child session without leaving it running
|
||||
- A child that errors throws `AgentRunError` carrying the result
|
||||
|
||||
validation:
|
||||
|
||||
- Invoke a smoke run from a temporary command and inspect the runs dir
|
||||
|
||||
notes:
|
||||
|
||||
- This is the most direct piolium port; keep it faithful to reduce risk
|
||||
- Do NOT load extensions in the child (footgun) — `noExtensions: true`
|
||||
45
tasks/04-hygiene-state.md
Normal file
45
tasks/04-hygiene-state.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 04. Resumable run-state persistence
|
||||
|
||||
meta:
|
||||
id: pygienium-04
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: [pygienium-01]
|
||||
tags: [infrastructure, state]
|
||||
|
||||
objective:
|
||||
|
||||
- Build `src/hygiene-state.ts`: a resumable state file (`pygienium/run-state.json`) tracking one run per invocation with per-check phase status, so interrupted runs can resume and `/pygienium-status` can report progress.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/hygiene-state.ts` exporting `initRun`, `latestRun`, `latestResumableRun`, `readRunState`, `applyPhaseStatus`, `markRunStatus`, `tallyPhases`
|
||||
- State schema: `{ runs: [{ run_id, mode, status, checks: { <check>: { status, attempt, last_error, artifacts, ... } } }] }`
|
||||
- Idempotent writes (read-modify-write with safe merge); file absent / unparseable handled gracefully
|
||||
|
||||
steps:
|
||||
|
||||
- Port piolium's `audit-state.ts` read/write helpers, renaming audit→run, phase→check
|
||||
- Implement `initRun(cwd, { mode })` returning a fresh run state record
|
||||
- `applyPhaseStatus(cwd, run, checkName, patch)` merges per-check status
|
||||
- `latestResumableRun` returns a run whose status is in_progress or failed (not complete)
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: init -> apply phase complete -> markRunStatus complete reads back correctly
|
||||
- Unit: unparseable file returns `parseError` without throwing
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `readRunState(cwd)` returns `{ exists, parseError?, state? }`
|
||||
- applyPhaseStatus persists and is readable by a subsequent read
|
||||
- Resumable selection picks in_progress > failed, ignores complete
|
||||
|
||||
validation:
|
||||
|
||||
- Run a check, kill mid-flight, inspect `pygienium/run-state.json`
|
||||
|
||||
notes:
|
||||
|
||||
- Keep schema forward-compatible-by-addition only within this build; no legacy migrations
|
||||
- This file is the source of truth for `/pygienium-status` and `/pygienium-resume`
|
||||
46
tasks/05-infrastructure-utils.md
Normal file
46
tasks/05-infrastructure-utils.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 05. Scheduler, retry, and command-target parsing
|
||||
|
||||
meta:
|
||||
id: pygienium-05
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: [pygienium-01]
|
||||
tags: [infrastructure, utilities, no-model]
|
||||
|
||||
objective:
|
||||
|
||||
- Build three small infra modules the check runner needs: a concurrency-bounding scheduler, a retry-with-backoff helper, and a command argument parser.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/scheduler.ts`: `Scheduler` with `enqueue({id, run})`, burst cap from env `PYGIENIUM_MAX_AGENTS` (default 3), `dispose()`
|
||||
- `src/retry.ts`: `runWithRetry`, `readPositiveIntEnv`, `readNonNegativeIntEnv`, `errorMessage`, `yieldToEventLoop`
|
||||
- `src/command-target.ts`: `parseCommandArgs(args, cwd, opts)` → `{ cwd, tokens, args, error? }` supporting `[path]`, `--fresh`, `--fix`, `--check=<name>`, `--scope=<path>`
|
||||
|
||||
steps:
|
||||
|
||||
- Port piolium's `Scheduler` (Promise.allSettled under a semaphore-like cap) and `retry.ts` env helpers
|
||||
- Implement `runWithRetry(fn, { maxRetries, backoffBaseMs, backoffMaxMs, onRetry, signal })`
|
||||
- Implement `parseCommandArgs`: first non-flag token = optional target path (default cwd); collect `--flag` and `--opt=val` tokens into a tokens array + option lookup; return error string for malformed input
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: scheduler caps concurrent runs at the configured value (spawn N no-op tasks, assert max in-flight)
|
||||
- Unit: retry exhausting throws the last error; onRetry invoked with backoff between attempts
|
||||
- Unit: parser handles `--fresh /repo --check=comments` → cwd=/repo, tokens=[--fresh, --check=comments], check=comments
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- Scheduler never exceeds the burst cap
|
||||
- retry respects maxRetries and aborts on signal
|
||||
- parser returns structured tokens with no ambiguity for the supported flags
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -n "PYGIENIUM_MAX_AGENTS" src/scheduler.ts` present
|
||||
- Unit test suite for the three modules passes (if a test runner is configured)
|
||||
|
||||
notes:
|
||||
|
||||
- These are pure utilities; keep them dependency-free beyond node builtins
|
||||
- Grouped into one task because each is small and they're mutually independent
|
||||
48
tasks/06-check-registry.md
Normal file
48
tasks/06-check-registry.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 06. Pluggable check registry and command wiring
|
||||
|
||||
meta:
|
||||
id: pygienium-06
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: [pygienium-02, pygienium-03, pygienium-04, pygienium-05]
|
||||
tags: [core, integration]
|
||||
|
||||
objective:
|
||||
|
||||
- Build the check registry + check runner that turns a `CheckDefinition` into a `/pygienium-<name>` command, wiring recon → sub-agent analysis → sub-agent fixes → verify → cleanup, and expose a phase-strip UI.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/checks/registry.ts`: `CheckDefinition` interface (`name`, `label`, `description`, `agentName`, `phaseId`, `buildScanTask(cwd,scope)`, `buildFixTask(cwd,scope,findings)`, `gate(cwd)`) and a `registerCheck(def)` / `getAllChecks()` registry
|
||||
- `src/modes/check-runner.ts`: `runCheck(opts)` orchestrating Q0 recon (shared) → analysis sub-agent → fix sub-agent (optional on `--fix`) → verify gate → cleanup transient artifacts; writes run-state via task 04
|
||||
- `src/agents.ts`: `loadAgents({cwd})` reading markdown agent defs from `agents/*.md` (name, systemPrompt, allowedTools, sourcePath)
|
||||
- `src/index.ts` updated: register `/pygienium-help` and auto-register one `/pygienium-<check>` command per registered CheckDefinition; phase-strip status UI helper
|
||||
- `src/help.ts`: command + flag help builder (skeleton, populated in task 14)
|
||||
|
||||
steps:
|
||||
|
||||
- Define `CheckDefinition` and a module-level `Map` registry with `registerCheck`
|
||||
- Implement `runCheck`: init/resolve run state → run shared recon if missing → spawn analysis agent via agent-runner (task 03) with `buildScanTask` → if `--fix`, spawn fix agent with `buildFixTask` → applyPhaseStatus complete/failed → markRunStatus
|
||||
- Implement phase-strip UI helper (status key, initial phase, console stream forwarding) adapted from piolium's createPhaseStripCommandUi (simplified)
|
||||
- In index.ts: iterate registry, `pi.registerCommand("pygienium-"+def.name, { description, handler: runCheck wrapper })`
|
||||
- Ship `agents/scanner.md` and `agents/fixer.md` generic agent definitions used by all checks (analysis + fix roles)
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: registry register/getAll returns inserted defs
|
||||
- Integration: register a no-op check whose agent writes a marker file; invoke its stub command; assert run-state marks it complete and the marker exists
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- A check registered via `registerCheck({name:"smoke", ...})` automatically exposes `/pygienium-smoke`
|
||||
- `runCheck` writes run-state and honors `--fix` vs scan-only
|
||||
- The phase strip UI shows the active phase and clears on completion
|
||||
|
||||
validation:
|
||||
|
||||
- Register a throwaway smoke check, run `/pygienium-smoke`, inspect `pygienium/run-state.json`
|
||||
|
||||
notes:
|
||||
|
||||
- This is the integration keystone; task 07 builds the first real check on top of it
|
||||
- Keep the registry open for extension: adding a check must NOT require editing index.ts command wiring
|
||||
46
tasks/07-check-comments.md
Normal file
46
tasks/07-check-comments.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 07. Comments hygiene check (first end-to-end check)
|
||||
|
||||
meta:
|
||||
id: pygienium-07
|
||||
feature: pygienium
|
||||
priority: P1
|
||||
depends_on: [pygienium-06]
|
||||
tags: [check, e2e-reference]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement the comments hygiene check as the first full end-to-end check, serving as the reference pattern for the remaining checks: remove low-value comments, tighten verbose ones, keep "why" comments.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/checks/comments.ts`: a `CheckDefinition` with `buildScanTask` and `buildFixTask`
|
||||
- `agents/comments-scanner.md` and `agents/comments-fixer.md` (or reuse generic scanner/fixer with a check-specific rubric embedded in the task text)
|
||||
- Rubric encoded in task text: comments that restate code = remove; verbose narration = tighten; `why` comments = keep; self-explanatory code = no comment needed; short + high value
|
||||
- `/pygienium-comments` runs E2E: recon → agent scans for comment smells → (on `--fix`) agent edits → report of changes
|
||||
|
||||
steps:
|
||||
|
||||
- Author the check definition: name `comments`, phaseId `C1`, allowedTools for analysis = read/bash/grep; for fix = read/edit/write/bash
|
||||
- Build scan task text instructing the agent to read candidates from recon, identify comment smells, write findings to `pygienium/checks/comments/findings.md` with per-file line refs
|
||||
- Build fix task text: apply safe removals/tightenings, leave `why` comments, write `changes.md` summarizing edits and anything needing human review
|
||||
- Register the check in index.ts via `registerCheck`
|
||||
- Implement `gate(cwd)`: findings.md exists
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: create a temp file with restating comments + a `why` comment; run `/pygienium-comments --fix`; assert restating comments removed, why comment kept, changes.md present
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `/pygienium-comments` produces findings.md without `--fix`
|
||||
- With `--fix`, low-value comments are removed and `why` comments survive
|
||||
- run-state marks the check complete and artifacts are recorded
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/checks/comments/{findings.md,changes.md}` after a run
|
||||
|
||||
notes:
|
||||
|
||||
- This task proves the whole framework works; prioritize getting it green before 08-11
|
||||
- The rubric is the user's spec: short + high value; what-comments bad, why-comments good
|
||||
42
tasks/08-check-deep-modules.md
Normal file
42
tasks/08-check-deep-modules.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 08. Deep-modules check
|
||||
|
||||
meta:
|
||||
id: pygienium-08
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-06]
|
||||
tags: [check]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement the "deep modules, not shallow ones" check: detect modules with shallow abstractions (thin pass-throughs, single-call wrappers, unnecessary indirection) and recommend/apply consolidation.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/checks/deep-modules.ts`: `CheckDefinition` with scan + fix tasks
|
||||
- Rubric: a module should provide a meaningful abstraction over its implementation; flag pass-through wrappers, one-line re-export modules, shallow classes with trivial getters, unnecessary adapter layers
|
||||
- `/pygienium-deep-modules` runs E2E
|
||||
|
||||
steps:
|
||||
|
||||
- Author `buildScanTask`: agent identifies shallow modules from recon candidates, writes findings to `pygienium/checks/deep-modules/findings.md`
|
||||
- Author `buildFixTask`: consolidate/inline where safe; flag risky consolidations for human review; write changes.md
|
||||
- Register the check
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: temp module that wraps a single lib call as a pass-through; run with `--fix`; assert it's flagged/removed and changes.md explains the consolidation
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `/pygienium-deep-modules` flags shallow modules in findings.md
|
||||
- With `--fix`, safe consolidations are applied; risky ones are listed for review, not auto-applied
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/checks/deep-modules/{findings.md,changes.md}`
|
||||
|
||||
notes:
|
||||
|
||||
- "Deep modules" = John Ousterhout's A Philosophy of Software Design; encode that definition in the rubric
|
||||
- Prefer conservative fixes: never auto-delete a module with external importers without confirmation
|
||||
42
tasks/09-check-dead-code.md
Normal file
42
tasks/09-check-dead-code.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 09. Dead code and obsolete paths check
|
||||
|
||||
meta:
|
||||
id: pygienium-09
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-06]
|
||||
tags: [check]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement the dead-code / obsolete-paths check: find unreferenced exports, dead files, obsolete compatibility shims, migration paths, and unused config; remove them (engineering rule: no backward-compat layers).
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/checks/dead-code.ts`: `CheckDefinition` with scan + fix tasks
|
||||
- Rubric: unreferenced functions/exports, files with zero importers (cross-checked against the review graph), deprecated/compat shims, migration helpers, unused dependencies
|
||||
- `/pygienium-dead-code` runs E2E
|
||||
|
||||
steps:
|
||||
|
||||
- Author `buildScanTask`: agent uses grep/import-graph + recon candidates to list dead code, writes `pygienium/checks/dead-code/findings.md` categorized by type
|
||||
- Author `buildFixTask`: remove clearly-dead items; list ambiguous ones (dynamic imports, runtime registration) for human review per pi-lens suspected-dead-weight semantics
|
||||
- Register the check
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: add an unused exported function + an obsolete compat wrapper; run `--fix`; assert both removed and changes.md lists them; assert a dynamically-imported shim is NOT auto-removed
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- findings.md categorizes dead code by type (export, file, shim, dep)
|
||||
- `--fix` removes clearly-dead items and preserves dynamic/runtime-registered ones with a review flag
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/checks/dead-code/{findings.md,changes.md}`
|
||||
|
||||
notes:
|
||||
|
||||
- Cross-reference the project_report "suspected dead weight" semantics — single-importer/zero-importer files
|
||||
- Engineering rule mandates removing obsolete paths rather than leaving compat shims
|
||||
53
tasks/10-check-complexity.md
Normal file
53
tasks/10-check-complexity.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 10. Excessive complexity check
|
||||
|
||||
meta:
|
||||
id: pygienium-10
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-06]
|
||||
tags: [check]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement the excessive-complexity check: detect high cyclomatic complexity with concrete thresholds, plus unnecessarily fancy code, non-conventional patterns, and over-abstraction; refactor toward the simplest implementation that meets requirements.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/checks/complexity.ts`: `CheckDefinition` with scan + fix tasks
|
||||
- Cyclomatic complexity thresholds (MUST enforce, not advisory):
|
||||
- **50+ → must refactor.** No exceptions. The function is too complex; break it up.
|
||||
- **35–49 → heavy skepticism.** Only keep if this is a massively critical point along the main path and the complexity genuinely must be here. Otherwise refactor. The agent must justify, in findings.md, why a 35–49 function is kept (critical path + why it can't be simplified).
|
||||
- **<35 → not flagged on cyclomatic grounds** (may still be flagged for other complexity smells like nesting/over-abstraction)
|
||||
- Rubric: speculative abstractions, premature config indirection, non-idiomatic patterns, over-engineered generics, unnecessary wrappers; refactor to common conventions and the simplest correct form
|
||||
- `/pygienium-complexity` runs E2E
|
||||
|
||||
steps:
|
||||
|
||||
- Author `buildScanTask`: agent identifies complexity hotspots:
|
||||
1. **Cyclomatic complexity** — compute via a deterministic tool when available (e.g. `lizard`/`radon`/`gocyclo`/language-native), fall back to counting decision points (if/else if/for/while/case/&&/||/catch) per function. Classify each function into the 50+ / 35–49 / below-35 bands above. Write per-function scores to `pygienium/checks/complexity/findings.md` with line refs.
|
||||
2. **Structural smells** — deep nesting (>3 levels), needless indirection, speculative abstractions — same findings.md, separate section.
|
||||
- findings.md includes a proposed simpler form for every flagged function.
|
||||
- Author `buildFixTask`: apply safe refactors (flatten nested conditionals, inline trivial wrappers, remove speculative config, split 50+ functions); for 35–49 functions, keep ONLY if the agent can justify critical-path necessity, else refactor. Flag risky refactors for review.
|
||||
- Register the check
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: temp file with a 55-decision-point function (must-refactor) + a 40-decision-point function (heavy-skepticism, must justify or refactor); run `--fix`; assert the 50+ is split, the 35–49 is either refactored or has a documented justification in changes.md
|
||||
- Integration: temp file with a needlessly abstracted config layer + deep nesting; run `--fix`; assert simplified
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- findings.md lists cyclomatic complexity scores per function banded as 50+/35-49/below-35
|
||||
- Every 50+ function is refactored by `--fix` (no 50+ remains post-fix)
|
||||
- Every kept 35–49 function has a documented justification (critical path + why-simpler-isn't-possible) in findings.md; unjustified ones are refactored
|
||||
- findings.md lists structural complexity hotspots with proposed simplifications
|
||||
- `--fix` applies safe refactors and preserves behavior (agent re-reads after edit)
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/checks/complexity/{findings.md,changes.md}`
|
||||
|
||||
notes:
|
||||
|
||||
- Encode the engineering rules directly in the rubric: simplest implementation, no speculative abstractions, grow in layers
|
||||
- The lens risk-hotspots (fan-in × complexity) and module_report complexity flags are signals to feed the agent
|
||||
42
tasks/11-check-defensive-guards.md
Normal file
42
tasks/11-check-defensive-guards.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 11. Redundant defensive guarding check
|
||||
|
||||
meta:
|
||||
id: pygienium-11
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-06]
|
||||
tags: [check]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement the redundant-defensive-guarding check: remove excessive null checks, unnecessary try/catch, fallback paths that mask bugs, defensive code guarding invariants the type system already guarantees.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/checks/defensive-guards.ts`: `CheckDefinition` with scan + fix tasks
|
||||
- Rubric: redundant null/undefined checks where types are non-nullable, try/catch that only rethrows or swallows, fallback values that hide errors, defensive guards on already-validated input, compatibility fallbacks (engineering rule: remove, don't layer)
|
||||
- `/pygienium-defensive-guards` runs E2E
|
||||
|
||||
steps:
|
||||
|
||||
- Author `buildScanTask`: agent identifies defensive smells, writes `pygienium/checks/defensive-guards/findings.md`
|
||||
- Author `buildFixTask`: remove redundant guards; preserve guards that protect real external boundaries (user input, IO, parsing); write changes.md distinguishing removed vs kept-with-reason
|
||||
- Register the check
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: temp file with a null check on a typed-non-null param + a try/catch that swallows; run `--fix`; assert removed; assert a JSON.parse guard is preserved
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- findings.md separates redundant guards from legitimate boundary guards
|
||||
- `--fix` removes redundant guards and keeps boundary guards (IO, parsing, untrusted input)
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/checks/defensive-guards/{findings.md,changes.md}`
|
||||
|
||||
notes:
|
||||
|
||||
- Key judgment: guarding external boundaries (IO, untrusted input, parsing) is correct; guarding internal invariants the type system guarantees is noise
|
||||
- Engineering rule: no compatibility layers or fallbacks meant to be replaced later
|
||||
44
tasks/12-orchestrator-all.md
Normal file
44
tasks/12-orchestrator-all.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 12. /pygienium-all master orchestrator
|
||||
|
||||
meta:
|
||||
id: pygienium-12
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-07, pygienium-08, pygienium-09, pygienium-10, pygienium-11]
|
||||
tags: [orchestration]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement `/pygienium-all`: run every registered check in sequence as ordered phases under a unified status strip, with resumable state and a final summary report.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/modes/all.ts`: `runAllChecks(opts)` iterating the registry, calling `runCheck` per check with shared recon, accumulating per-check status into run-state, writing `pygienium/all-summary.md`
|
||||
- `/pygienium-all` command wired in index.ts with phase strip listing all check names
|
||||
- Respects `--fresh`, `--fix`, and `--only=comments,complexity` to select a subset
|
||||
|
||||
steps:
|
||||
|
||||
- Implement `runAllChecks`: init a single run (mode "all"), run shared recon once, then for each registered check call `runCheck` with the existing run-state record (not a fresh one per check)
|
||||
- Build the phase strip from registry names; set initial phase to the first check
|
||||
- Aggregate final summary: per-check status, artifact paths, total findings/changes counts
|
||||
- Support `--only` filtering via the command-target parser
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: run `/pygienium-all` on a small repo; assert every check ran, run-state shows all complete, all-summary.md present
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `/pygienium-all` runs every registered check exactly once in registry order
|
||||
- Interrupted runs are resumable (in_progress/failed checks re-run; complete ones skipped unless `--fresh`)
|
||||
- all-summary.md lists per-check outcomes
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/run-state.json` and `pygienium/all-summary.md`
|
||||
|
||||
notes:
|
||||
|
||||
- This is the piolium "balanced"/"deep" mode analogue, but simpler: one run, sequential phases, shared recon
|
||||
- Ensure scheduler is not needed (sequential) unless we later parallelize independent checks
|
||||
43
tasks/13-resume-status-export.md
Normal file
43
tasks/13-resume-status-export.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 13. Resume, status, and export commands
|
||||
|
||||
meta:
|
||||
id: pygienium-13
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-04, pygienium-06]
|
||||
tags: [commands]
|
||||
|
||||
objective:
|
||||
|
||||
- Implement the operational commands: `/pygienium-resume` (continue the most recent non-complete run), `/pygienium-status` (show run progress), and `/pygienium-export` (export findings/changes with filters).
|
||||
|
||||
deliverables:
|
||||
|
||||
- `/pygienium-resume`: read run-state, pick latestResumableRun, re-dispatch that run's checks (complete ones skipped unless `--fresh`)
|
||||
- `/pygienium-status`: format run-state into a readable line list (per-check status, artifacts, errors)
|
||||
- `/pygienium-export`: gather all `pygienium/checks/*/findings.md` and `changes.md` with filters (`--check=`, `--status=`, `--out=`) into a single markdown or JSON bundle
|
||||
- All three wired in index.ts
|
||||
|
||||
steps:
|
||||
|
||||
- Port piolium's status/resume/export command shapes, adapting to run-state and check artifacts
|
||||
- `formatRunStatus(state)` builds the status line list
|
||||
- Export walks `pygienium/checks/*/` and applies filters before writing `pygienium/export.{md|json}`
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: start `/pygienium-all`, interrupt, run `/pygienium-status` (shows in_progress), `/pygienium-resume` (completes), `/pygienium-export`
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `/pygienium-status` reports accurate per-check progress
|
||||
- `/pygienium-resume` continues a non-complete run without re-running complete checks
|
||||
- `/pygienium-export` produces a filtered bundle
|
||||
|
||||
validation:
|
||||
|
||||
- Inspect `pygienium/export.md` and the status output
|
||||
|
||||
notes:
|
||||
|
||||
- These mirror piolium's status/resume/export almost directly; keep behavior faithful
|
||||
50
tasks/14-readme-help-integration.md
Normal file
50
tasks/14-readme-help-integration.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 14. README, help text, and end-to-end integration
|
||||
|
||||
meta:
|
||||
id: pygienium-14
|
||||
feature: pygienium
|
||||
priority: P2
|
||||
depends_on: [pygienium-12, pygienium-13]
|
||||
tags: [docs, integration]
|
||||
|
||||
objective:
|
||||
|
||||
- Author the README and full help text, then run an end-to-end integration pass over the whole extension to verify all commands, the registry extensibility claim, and the exit criteria.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `pygienium/README.md`: what it is, install, commands, flags, how to add a check (one file + registerCheck)
|
||||
- `src/help.ts` fully populated: per-command usage/does/example + CLI flag help, mirroring piolium's help.ts
|
||||
- `/pygienium-help` renders the full help block
|
||||
- Extensibility verification: add a new check in `checks/` + one registry line with NO index.ts command changes; confirm a new `/pygienium-<new>` command appears
|
||||
|
||||
steps:
|
||||
|
||||
- Write README sections: overview, the five checks, commands, flags, adding a check, architecture (sub-agent loops)
|
||||
- Populate help.ts COMMANDS + CLI_FLAGS arrays from the actual commands/flags implemented in 06-13
|
||||
- Extensibility test: create `checks/noop.ts` registering `registerCheck({name:"noop",...})`, reload, confirm `/pygienium-noop` exists and `/pygienium-help` lists it
|
||||
- Run `/pygienium-all` end-to-end on this repo and confirm the exit criteria
|
||||
|
||||
tests:
|
||||
|
||||
- Integration: `/pygienium-help` lists all 8+ commands and all flags
|
||||
- Integration: a newly registered check auto-creates its command without index.ts edits
|
||||
- Integration: `/pygienium-all` completes with all checks marked complete
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- README documents install, commands, flags, and the one-file extensibility workflow
|
||||
- `/pygienium-help` output matches the implemented commands/flags exactly
|
||||
- A new check file + registerCheck entry yields a working `/pygienium-<name>` command with zero index.ts changes
|
||||
- All exit criteria from the feature README pass
|
||||
|
||||
validation:
|
||||
|
||||
- `pi -p "/pygienium-help"` prints the full help
|
||||
- Add the noop check, `/reload`, run `/pygienium-noop`, confirm success
|
||||
- Run `/pygienium-all` and inspect the final summary
|
||||
|
||||
notes:
|
||||
|
||||
- This is the acceptance gate for the whole feature
|
||||
- If the extensibility claim fails here, refactor the registry in task 06 before declaring done
|
||||
42
tasks/README.md
Normal file
42
tasks/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Pygienium — Code Hygiene Extension
|
||||
|
||||
Objective: A pi extension (piolium-style sub-agent loops) that runs highly-structured code-hygiene passes over a repo to clean up common LLM-code quality issues.
|
||||
|
||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
Tasks
|
||||
|
||||
- [x] 01 — scaffolding → `01-scaffolding.md`
|
||||
- [x] 02 — recon → `02-recon.md`
|
||||
- [x] 03 — agent-runner → `03-agent-runner.md`
|
||||
- [x] 04 — hygiene-state → `04-hygiene-state.md`
|
||||
- [x] 05 — infrastructure-utils → `05-infrastructure-utils.md`
|
||||
- [x] 06 — check-registry → `06-check-registry.md`
|
||||
- [x] 07 — check-comments → `07-check-comments.md`
|
||||
- [x] 08 — check-deep-modules → `08-check-deep-modules.md`
|
||||
- [x] 09 — check-dead-code → `09-check-dead-code.md`
|
||||
- [x] 10 - check-complexity → `10-check-complexity.md` (incl. cyclomatic complexity: 50+ must-refactor, 35–49 heavy-skepticism)
|
||||
- [x] 11 — check-defensive-guards → `11-check-defensive-guards.md`
|
||||
- [x] 12 — orchestrator-all → `12-orchestrator-all.md`
|
||||
- [x] 13 — resume-status-export → `13-resume-status-export.md`
|
||||
- [x] 14 — readme-help-integration → `14-readme-help-integration.md`
|
||||
|
||||
Dependencies
|
||||
|
||||
- 02, 03, 04, 05 depend on 01
|
||||
- 06 depends on 02, 03, 04, 05
|
||||
- 07, 08, 09, 10, 11 depend on 06 (07 serves as the reference E2E check)
|
||||
- 12 depends on 07, 08, 09, 10, 11
|
||||
- 13 depends on 04, 06
|
||||
- 14 depends on 12, 13
|
||||
|
||||
Exit criteria
|
||||
|
||||
- The feature is complete when `/pygienium-help` lists all commands/flags, each `/pygienium-<check>` command runs an isolated sub-agent that scans a target, applies fixes, and emits a findings+changes report; `/pygienium-all` runs every registered check in sequence with a unified status strip and resumable state; `/pygienium-resume`, `/pygienium-status`, and `/pygienium-export` work; and adding a new check requires only a new file in `checks/` plus one registry entry (no index.ts command-wiring changes).
|
||||
|
||||
Architecture reference
|
||||
|
||||
- Style: piolium (`@vigolium/piolium`) sub-agent loops via `createAgentSession`
|
||||
- Distribution: local extension under `~/.pi/agent/extensions/pygienium/`, structured to be npm-publishable later
|
||||
- Each check = a "mode": deterministic recon → sub-agent analysis → sub-agent fixes → verify → cleanup
|
||||
- Check registry makes the system extensible: new check = new file + registry entry
|
||||
98
tests/agents.test.ts
Normal file
98
tests/agents.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* agents.test.ts — markdown agent-definition loader.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { extensionRoot, loadAgents } from "../src/agents.js";
|
||||
|
||||
/** Build an agent markdown file with frontmatter + body. */
|
||||
async function writeAgent(
|
||||
dir: string,
|
||||
file: string,
|
||||
name: string,
|
||||
body: string,
|
||||
tools?: string[],
|
||||
): Promise<string> {
|
||||
const lines = ["---", `name: ${name}`];
|
||||
if (tools) {
|
||||
lines.push("allowedTools:");
|
||||
for (const t of tools) lines.push(` - ${t}`);
|
||||
}
|
||||
lines.push("---", body, "");
|
||||
await writeFile(join(dir, file), lines.join("\n"), "utf8");
|
||||
return join(dir, file);
|
||||
}
|
||||
|
||||
describe("loadAgents", () => {
|
||||
it("loads scanner.md and fixer.md shipped with the extension", async () => {
|
||||
const agents = await loadAgents();
|
||||
expect(agents.has("scanner")).toBe(true);
|
||||
expect(agents.has("fixer")).toBe(true);
|
||||
const scanner = agents.get("scanner");
|
||||
expect(scanner).toBeDefined();
|
||||
expect(scanner?.systemPrompt).toContain("scanner");
|
||||
expect(scanner?.allowedTools).toContain("read");
|
||||
expect(scanner?.allowedTools).not.toContain("edit"); // scanner is read-only
|
||||
expect(scanner?.sourcePath).toContain("agents/scanner.md");
|
||||
const fixer = agents.get("fixer");
|
||||
expect(fixer?.allowedTools).toContain("edit");
|
||||
});
|
||||
|
||||
it("extensionRoot resolves to the package directory", () => {
|
||||
expect(extensionRoot()).toMatch(/(pygenium|pygienium)$/);
|
||||
});
|
||||
|
||||
it("project-local agents override the extension's by name", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
|
||||
try {
|
||||
await mkdir(join(cwd, "agents"));
|
||||
const path = await writeAgent(
|
||||
join(cwd, "agents"),
|
||||
"scanner.md",
|
||||
"scanner",
|
||||
"Project-tuned scanner prompt.",
|
||||
["read", "grep", "find", "edit"],
|
||||
);
|
||||
const agents = await loadAgents({ cwd });
|
||||
const scanner = agents.get("scanner");
|
||||
expect(scanner?.systemPrompt).toContain("Project-tuned");
|
||||
expect(scanner?.allowedTools).toContain("edit"); // repo override widens tools
|
||||
expect(scanner?.sourcePath).toBe(path);
|
||||
// Extension baseline is retained for agents the repo doesn't override.
|
||||
expect(agents.get("fixer")).toBeDefined();
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("project-local agents can add brand-new agents", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
|
||||
try {
|
||||
await mkdir(join(cwd, "agents"));
|
||||
await writeAgent(
|
||||
join(cwd, "agents"),
|
||||
"judge.md",
|
||||
"judge",
|
||||
"Scoring judge.",
|
||||
);
|
||||
const agents = await loadAgents({ cwd });
|
||||
expect(agents.has("judge")).toBe(true);
|
||||
expect(agents.get("judge")?.systemPrompt).toContain("Scoring judge");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a project without agents/ falls back to the extension agents", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-"));
|
||||
try {
|
||||
const agents = await loadAgents({ cwd });
|
||||
expect(agents.has("scanner")).toBe(true);
|
||||
expect(agents.has("fixer")).toBe(true);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
162
tests/all-integration.test.ts
Normal file
162
tests/all-integration.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* all-integration.test.ts — `/pygienium-all` end-to-end (task 14).
|
||||
*
|
||||
* Mirrors the spec scenario: run every registered check in sequence under one
|
||||
* resumable run-state and confirm the run completes with every check marked
|
||||
* `complete`. Uses the injectable fake agent runner (no model needed) and stub
|
||||
* checks whose `!write`/`!echo` task protocol produces deterministic artifacts.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import { canonicalChecksRoot } from "../src/export.js";
|
||||
|
||||
/** Stub check whose fake-runner task writes artifacts + echoes a line. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("/pygienium-all end-to-end (task 14)", () => {
|
||||
let cwd: string;
|
||||
let dispatched: string[];
|
||||
let runner: AgentRunner;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
dispatched = [];
|
||||
runner = async (opts) => {
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
setAgentRunner(runner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-all-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runs every registered check in sequence and marks the run complete", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleAllCommand("--fix", stubCtx(cwd)),
|
||||
);
|
||||
|
||||
// Every check was dispatched (scan + fix each, in registration order).
|
||||
expect(dispatched.filter((n) => n === "alpha").length).toBeGreaterThan(0);
|
||||
expect(dispatched.filter((n) => n === "beta").length).toBeGreaterThan(0);
|
||||
expect(dispatched.filter((n) => n === "gamma").length).toBeGreaterThan(0);
|
||||
|
||||
// Final run-state: complete, every check complete, recon shared once.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state).toBeDefined();
|
||||
expect(state!.status).toBe("complete");
|
||||
expect(state!.recon.complete).toBe(true);
|
||||
for (const name of ["alpha", "beta", "gamma"]) {
|
||||
expect(state!.checks[name]?.status).toBe("complete");
|
||||
}
|
||||
|
||||
// Per-check artifacts landed on disk under the canonical root.
|
||||
const alphaFindings = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "alpha", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(alphaFindings).toContain("alpha findings");
|
||||
const gammaChanges = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "gamma", "changes.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(gammaChanges).toContain("gamma changes");
|
||||
|
||||
// The summary line reports completion and the run-state path.
|
||||
const text = out.join("\n");
|
||||
expect(text).toContain("pygienium: all-run complete");
|
||||
});
|
||||
|
||||
it("completes cleanly in scan-only mode (no --fix)", async () => {
|
||||
registerCheck(fakeCheck("solo"));
|
||||
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state!.status).toBe("complete");
|
||||
expect(state!.checks["solo"]?.status).toBe("complete");
|
||||
expect(state!.checks["solo"]?.fix).toBe(false);
|
||||
// Scan-only still writes findings but not changes.
|
||||
const findings = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "solo", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(findings).toContain("solo findings");
|
||||
expect(out.join("\n")).toContain("pygienium: all-run complete");
|
||||
});
|
||||
|
||||
it("reports no checks when the registry is empty", async () => {
|
||||
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||
expect(out.join("\n")).toContain("no checks registered");
|
||||
});
|
||||
|
||||
it("is resumable: a second call reuses the existing run-state", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
const first = await loadRunState(cwd);
|
||||
const firstStarted = first!.startedAt;
|
||||
|
||||
// Second run reloads the existing run-state (same startedAt).
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
const second = await loadRunState(cwd);
|
||||
expect(second!.startedAt).toBe(firstStarted);
|
||||
expect(second!.status).toBe("complete");
|
||||
});
|
||||
});
|
||||
401
tests/all.test.ts
Normal file
401
tests/all.test.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* all.test.ts — integration test for the `/pygienium-all` orchestrator (task 12).
|
||||
*
|
||||
* Mirrors the spec scenario: run `/pygienium-all` on a small repo and assert:
|
||||
* - every registered check runs exactly once in registry order;
|
||||
* - run-state shows all checks complete and the overall run complete;
|
||||
* - `.pygienium/all-summary.md` is present and lists per-check outcomes;
|
||||
* - `--only=alpha,gamma` narrows the candidate set preserving order;
|
||||
* - interrupted/resumed runs re-dispatch non-terminal checks while skipping
|
||||
* terminal ones, unless `--fresh` resets everything.
|
||||
*
|
||||
* A tracker wraps the fake agent runner so we can assert dispatch order and
|
||||
* counts without a model.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import {
|
||||
parseAllArgs,
|
||||
runAllChecks,
|
||||
allSummaryPath,
|
||||
renderAllSummary,
|
||||
selectChecks,
|
||||
} from "../src/modes/all.js";
|
||||
import {
|
||||
loadRunState,
|
||||
markCheckStatus,
|
||||
applyPhaseStatus,
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
} from "../src/run-state.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
/** Build a deterministic check whose fake runner writes on-disk artifacts. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name.charAt(0).toUpperCase() + name.slice(1),
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||
const dispatched: string[] = [];
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, dispatched };
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mark a check as fully complete in the run-state (helper for seeding). */
|
||||
function markComplete(
|
||||
state: Parameters<typeof markCheckStatus>[0],
|
||||
name: string,
|
||||
): void {
|
||||
for (const phaseId of [
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
]) {
|
||||
applyPhaseStatus(state, name, phaseId, "complete");
|
||||
}
|
||||
markCheckStatus(state, name, "complete");
|
||||
}
|
||||
|
||||
describe("/pygienium-all orchestrator (task 12)", () => {
|
||||
let cwd: string;
|
||||
let track: ReturnType<typeof trackingRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-all-"));
|
||||
// Seed a source file so the gate passes and recon has something to scan.
|
||||
await mkdir(join(cwd, "src"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parseAllArgs parses path, --fix, --fresh, --no-gitignore, and --only", () => {
|
||||
const p = parseAllArgs("subdir --fix --only=alpha,beta --fresh", cwd);
|
||||
expect(p.target).toBe(join(cwd, "subdir"));
|
||||
expect(p.fix).toBe(true);
|
||||
expect(p.fresh).toBe(true);
|
||||
expect(p.gitignore).toBe(true); // default: keep the .gitignore guard on
|
||||
expect(p.only).toEqual(["alpha", "beta"]);
|
||||
const noGi = parseAllArgs("--no-gitignore", cwd);
|
||||
expect(noGi.gitignore).toBe(false);
|
||||
expect(noGi.target).toBe(cwd);
|
||||
});
|
||||
|
||||
it("selectChecks preserves registry order for the --only subset", () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
const subset = selectChecks(["gamma", "alpha"]); // order in --only is irrelevant
|
||||
expect(subset.map((c) => c.name)).toEqual(["alpha", "gamma"]);
|
||||
expect(selectChecks().length).toBe(3);
|
||||
expect(selectChecks([]).length).toBe(3);
|
||||
});
|
||||
|
||||
it("runs every registered check exactly once in registry order", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
|
||||
// Each check dispatched once for scan + once for fix (3 checks × 2 phases).
|
||||
const scanDispatches = track.dispatched.filter((n) => n !== undefined);
|
||||
expect(scanDispatches).toEqual([
|
||||
"alpha",
|
||||
"alpha",
|
||||
"beta",
|
||||
"beta",
|
||||
"gamma",
|
||||
"gamma",
|
||||
]);
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.status).toBe("complete");
|
||||
expect(state?.checks.alpha.status).toBe("complete");
|
||||
expect(state?.checks.beta.status).toBe("complete");
|
||||
expect(state?.checks.gamma.status).toBe("complete");
|
||||
expect(state?.recon.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("writes .pygienium/all-summary.md listing per-check outcomes", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
|
||||
const summary = await readFile(allSummaryPath(cwd), "utf8");
|
||||
expect(summary).toContain("# Pygienium all-run summary");
|
||||
expect(summary).toContain("- status: complete");
|
||||
expect(summary).toContain("## alpha — complete");
|
||||
expect(summary).toContain("## beta — complete");
|
||||
expect(summary).toContain("## alpha — complete (--fix)");
|
||||
// Artifact paths + line counts are referenced.
|
||||
expect(summary).toContain("findings:");
|
||||
expect(summary).toContain("changes:");
|
||||
// Artifacts actually exist on disk.
|
||||
const alphaFindings = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(alphaFindings).toContain("alpha findings");
|
||||
});
|
||||
|
||||
it("--only narrows the run to the named subset", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
registerCheck(fakeCheck("gamma"));
|
||||
|
||||
await captureStdout(() =>
|
||||
handleAllCommand("--only=alpha,gamma", stubCtx(cwd)),
|
||||
);
|
||||
|
||||
// Only alpha + gamma dispatched (beta never touched).
|
||||
expect(track.dispatched).toContain("alpha");
|
||||
expect(track.dispatched).toContain("gamma");
|
||||
expect(track.dispatched).not.toContain("beta");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.alpha.status).toBe("complete");
|
||||
expect(state?.checks.gamma.status).toBe("complete");
|
||||
// beta was not part of the selected set, so has no entry.
|
||||
expect(state?.checks.beta).toBeUndefined();
|
||||
|
||||
const summary = await readFile(allSummaryPath(cwd), "utf8");
|
||||
expect(summary).toContain("## alpha");
|
||||
expect(summary).toContain("## gamma");
|
||||
expect(summary).not.toContain("## beta");
|
||||
});
|
||||
|
||||
it("skips terminal checks on re-run; --fresh re-runs them", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
|
||||
// First run: both complete.
|
||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||
expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases
|
||||
const firstAlphaFix = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Second run without --fresh: both already terminal → skipped.
|
||||
track.dispatched.length = 0;
|
||||
const out = await captureStdout(() =>
|
||||
handleAllCommand("--fix", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).toHaveLength(0);
|
||||
expect(out.join("\n")).toContain("skipping");
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.status).toBe("complete");
|
||||
|
||||
// Third run with --fresh: both re-dispatched from scratch.
|
||||
track.dispatched.length = 0;
|
||||
await captureStdout(() => handleAllCommand("--fix --fresh", stubCtx(cwd)));
|
||||
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
|
||||
const state3 = await loadRunState(cwd);
|
||||
expect(state3?.status).toBe("complete");
|
||||
// The fresh re-run overwrote alpha's changes.md (still valid content).
|
||||
const alphaFix2 = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(alphaFix2).toContain("alpha changes");
|
||||
void firstAlphaFix;
|
||||
});
|
||||
|
||||
it("resumes an interrupted run (re-dispatches non-terminal checks only)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
|
||||
// Seed an interrupted run: alpha complete, beta pending (interrupted).
|
||||
const { initRunState, saveRunState } = await import("../src/run-state.js");
|
||||
const state = initRunState(cwd, [
|
||||
{ name: "alpha", label: "alpha", fix: true },
|
||||
{ name: "beta", label: "beta", fix: true },
|
||||
]);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: join(cwd, ".pygienium", "recon.json"),
|
||||
finishedAt: Date.now(),
|
||||
};
|
||||
// Pre-create alpha's on-disk artifacts so its completed entry has artifacts.
|
||||
const alphaDir = join(cwd, ".pygienium", "checks", "alpha");
|
||||
await mkdir(alphaDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(alphaDir, "findings.md"),
|
||||
"# alpha findings\nalpha-scan\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(alphaDir, "changes.md"),
|
||||
"# alpha changes\nalpha-fix\n",
|
||||
);
|
||||
markComplete(state, "alpha");
|
||||
// beta was interrupted mid-analysis — left at in_progress.
|
||||
applyPhaseStatus(state, "beta", PHASE_RECON, "complete");
|
||||
applyPhaseStatus(state, "beta", PHASE_ANALYSIS, "in_progress");
|
||||
await saveRunState(state);
|
||||
|
||||
// Resume via all-run: only beta should re-dispatch.
|
||||
const out = await captureStdout(() =>
|
||||
handleAllCommand("--fix", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).not.toContain("alpha");
|
||||
expect(track.dispatched).toContain("beta");
|
||||
expect(out.join("\n")).toContain("skipping");
|
||||
|
||||
const after = await loadRunState(cwd);
|
||||
expect(after?.status).toBe("complete");
|
||||
expect(after?.checks.alpha.status).toBe("complete");
|
||||
expect(after?.checks.beta.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("runAllChecks supports scan-only (no fix phase, no changes artifacts)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
const outcome = await runAllChecks({ cwd });
|
||||
expect(outcome.ran).toEqual(["alpha"]);
|
||||
expect(outcome.skipped).toEqual([]);
|
||||
expect(outcome.status).toBe("complete");
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.alpha.fix).toBe(false);
|
||||
expect(state?.checks.alpha.changes).toBeUndefined();
|
||||
// Summary still written.
|
||||
const summary = await readFile(outcome.summaryPath, "utf8");
|
||||
expect(summary).toContain("## alpha — complete");
|
||||
expect(outcome.summaryPath).toBe(allSummaryPath(cwd));
|
||||
});
|
||||
|
||||
it("renders a summary even when no checks are registered/selected", async () => {
|
||||
const outcome = await runAllChecks({ cwd, only: ["nonexistent"] });
|
||||
expect(outcome.ran).toEqual([]);
|
||||
const summary = await readFile(outcome.summaryPath, "utf8");
|
||||
expect(summary).toContain("# Pygienium all-run summary");
|
||||
expect(summary).toContain("- checks: 0");
|
||||
});
|
||||
|
||||
it("renderAllSummary reflects per-check statuses and fix tags", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await runAllChecks({ cwd, fix: true });
|
||||
const state = (await loadRunState(cwd))!;
|
||||
const md = renderAllSummary(state, selectChecks());
|
||||
expect(md).toContain("## alpha — complete (--fix)");
|
||||
expect(md).toContain("phases: recon:C");
|
||||
});
|
||||
|
||||
it("all-summary never shows a stale error under a complete check", async () => {
|
||||
const { initRunState } = await import("../src/run-state.js");
|
||||
const state = initRunState(cwd, [
|
||||
{ name: "alpha", label: "Alpha", fix: false },
|
||||
]);
|
||||
const alpha = fakeCheck("alpha");
|
||||
// A hand-edited/legacy state can carry an error on a completed check
|
||||
// (the MagnaFluo run showed exactly this shape).
|
||||
markCheckStatus(state, "alpha", "complete", "legacy verify error");
|
||||
const md = renderAllSummary(state, [alpha]);
|
||||
expect(md).toContain("## alpha — complete");
|
||||
expect(md).not.toContain("- error:");
|
||||
// A genuinely failed check still surfaces its error.
|
||||
markCheckStatus(state, "alpha", "failed", "scan exploded");
|
||||
const md2 = renderAllSummary(state, [alpha]);
|
||||
expect(md2).toContain("- error: scan exploded");
|
||||
});
|
||||
|
||||
it("the unified strip surfaces every check name over the run", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const out = await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||
const joined = out.join("\n");
|
||||
// Each check name appears in the strip line at least once.
|
||||
expect(joined).toContain("Alpha");
|
||||
expect(joined).toContain("Beta");
|
||||
expect(joined).toContain("all [");
|
||||
});
|
||||
|
||||
it("renders only the unified footer widget in UI mode (no per-check footer)", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const calls: Array<[string, string[] | undefined]> = [];
|
||||
const ui = {
|
||||
setWidget: (key: string, content: string[] | undefined) => {
|
||||
calls.push([key, content]);
|
||||
},
|
||||
} as never; // stub ExtensionUIContext (tests have no pi type imports)
|
||||
|
||||
await runAllChecks({ cwd, ui, hasUI: true });
|
||||
|
||||
const keys = new Set(calls.map(([k]) => k));
|
||||
// The all-run footer drives the belowEditor widget area under its own key…
|
||||
expect(keys.has("pygienium-all")).toBe(true);
|
||||
// …and per-check footers never claim their slot (footer:false), so two
|
||||
// overviews never compete over the same widget area.
|
||||
expect(keys.has("pygienium")).toBe(false);
|
||||
// The widget is cleared when the run completes.
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(last?.[0]).toBe("pygienium-all");
|
||||
expect(last?.[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
131
tests/check-runner.test.ts
Normal file
131
tests/check-runner.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* check-runner.test.ts — integration test for the orchestration keystone.
|
||||
*
|
||||
* Registers a throwaway "smoke" check whose fake sub-agent writes a marker
|
||||
* file, then invokes the per-check command handler with a stub context and
|
||||
* asserts the run-state marks the check complete and the marker exists.
|
||||
* Also asserts `--fix` runs the fix phase and records changes, while
|
||||
* scan-only does not.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState, runStatePath } from "../src/run-state.js";
|
||||
|
||||
function smokeCheck(): CheckDefinition {
|
||||
return {
|
||||
name: "smoke",
|
||||
label: "Smoke",
|
||||
description: "Throwaway smoke check for tests",
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/smoke.marker smoke-complete\n!echo smoke-findings for ${scope.target}`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!echo applied-fixes based on: ${findings.split("\n")[0] ?? ""}`,
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
describe("check-runner integration", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-smoke-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runs a smoke check, writes a marker, and marks the check complete", async () => {
|
||||
registerCheck(smokeCheck());
|
||||
const check = smokeCheck();
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state).toBeDefined();
|
||||
expect(state?.checks.smoke.status).toBe("complete");
|
||||
expect(state?.status).toBe("complete");
|
||||
|
||||
const marker = await readFile(
|
||||
join(cwd, ".pygienium", "smoke.marker"),
|
||||
"utf8",
|
||||
);
|
||||
expect(marker.trim()).toBe("smoke-complete");
|
||||
|
||||
expect(state?.checks.smoke.findings).toContain("smoke-findings");
|
||||
expect(
|
||||
state?.checks.smoke.phases.map((p) => `${p.id}=${p.status}`).join(","),
|
||||
).toContain("analysis=complete");
|
||||
expect(
|
||||
state?.checks.smoke.phases.find((p) => p.id === "fix"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scan-only does not produce changes and skips the fix phase", async () => {
|
||||
const check = smokeCheck();
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.smoke.fix).toBe(false);
|
||||
expect(state?.checks.smoke.changes).toBeUndefined();
|
||||
const fixPhase = state?.checks.smoke.phases.find((p) => p.id === "fix");
|
||||
expect(fixPhase).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--fix runs the fix phase and records changes", async () => {
|
||||
const check = smokeCheck();
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.smoke.status).toBe("complete");
|
||||
expect(state?.checks.smoke.fix).toBe(true);
|
||||
expect(state?.checks.smoke.changes).toContain("applied-fixes");
|
||||
expect(state?.checks.smoke.phases.find((p) => p.id === "fix")?.status).toBe(
|
||||
"complete",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists run-state.json at the expected path", async () => {
|
||||
const check = smokeCheck();
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
expect(runStatePath(cwd)).toBe(join(cwd, ".pygienium", "run-state.json"));
|
||||
const raw = await readFile(runStatePath(cwd), "utf8");
|
||||
expect(JSON.parse(raw).checks.smoke.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("marks a check skipped when the gate returns an error", async () => {
|
||||
const gated: CheckDefinition = {
|
||||
...smokeCheck(),
|
||||
name: "gated",
|
||||
label: "Gated",
|
||||
gate: () => "no source files matched",
|
||||
};
|
||||
await handleCheckCommand(gated, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.gated.status).toBe("skipped");
|
||||
expect(state?.checks.gated.error).toBe("no source files matched");
|
||||
});
|
||||
});
|
||||
61
tests/commands.test.ts
Normal file
61
tests/commands.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* commands.test.ts — auto-registration wiring.
|
||||
*
|
||||
* Asserts that `registerPygieniumCommands` exposes `/pygienium-help`, one
|
||||
* `/pygienium-<check>` per registered `CheckDefinition`, plus
|
||||
* `all`/`resume`/`status`/`export` — with no index.ts changes.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from "bun:test";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import { registerPygieniumCommands } from "../src/commands.js";
|
||||
|
||||
function stub(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
phaseId: "scan",
|
||||
buildScanTask: () => "scan",
|
||||
buildFixTask: () => "fix",
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerPygieniumCommands", () => {
|
||||
beforeEach(() => clearChecks());
|
||||
|
||||
it("auto-registers one /pygienium-<check> per registered check", () => {
|
||||
registerCheck(stub("smoke"));
|
||||
registerCheck(stub("comments"));
|
||||
const names: string[] = [];
|
||||
registerPygieniumCommands((name) => names.push(name));
|
||||
expect(names).toContain("pygienium-smoke");
|
||||
expect(names).toContain("pygienium-comments");
|
||||
expect(names.filter((n) => n === "pygienium-smoke")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("always registers help/all/resume/status/export", () => {
|
||||
const names: string[] = [];
|
||||
registerPygieniumCommands((name) => names.push(name));
|
||||
expect(names).toContain("pygienium-help");
|
||||
expect(names).toContain("pygienium-all");
|
||||
expect(names).toContain("pygienium-resume");
|
||||
expect(names).toContain("pygienium-status");
|
||||
expect(names).toContain("pygienium-export");
|
||||
});
|
||||
|
||||
it("registers with a description matching the check definition", () => {
|
||||
registerCheck(stub("smoke"));
|
||||
const seen: Record<string, string | undefined> = {};
|
||||
registerPygieniumCommands((name, opts) => {
|
||||
seen[name] = opts.description;
|
||||
});
|
||||
expect(seen["pygienium-smoke"]).toBe("smoke check");
|
||||
expect(seen["pygienium-help"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
258
tests/comments.test.ts
Normal file
258
tests/comments.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* comments.test.ts — integration test for the first end-to-end check.
|
||||
*
|
||||
* Proves the whole framework works: a real `CheckDefinition` (registered from
|
||||
* `src/checks/comments.ts`) flows through the command handler → check-runner
|
||||
* pipeline (recon → analysis → fix → verify → cleanup), producing
|
||||
* `findings.md` + `changes.md` artifacts and a `complete` run-state, while a
|
||||
* rubric-driven fake runner performs the actual comment edits.
|
||||
*
|
||||
* The fake runner (a rubric interpreter, not a hardcoded puppet) reads the
|
||||
* target source, classifies each comment against the same rubric the real
|
||||
* scanner/fixer agents receive in their task text, writes the artifacts, and
|
||||
* applies the edits. This exercises the genuine task-builder output, gate,
|
||||
* and orchestration without a live model.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
import { clearChecks, getCheck } from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import {
|
||||
check as commentsCheck,
|
||||
findingsPath,
|
||||
changesPath,
|
||||
} from "../src/checks/comments.js";
|
||||
import type { CheckScope } from "../src/checks/registry.js";
|
||||
|
||||
/** Sample source with mixed comment types for the rubric to classify. */
|
||||
const SOURCE = `// increment the counter
|
||||
counter++;
|
||||
// We use a power-of-two size so modulo hashing is a bitmask, not a divide
|
||||
const SIZE = 1 << 10;
|
||||
function hash(k: string): number {
|
||||
// compute the hash
|
||||
return k.split("").reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 0);
|
||||
}
|
||||
`;
|
||||
const FILENAME = "sample.ts";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
ui: undefined,
|
||||
} as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Extract the scan target path from a built scan task string. */
|
||||
function targetFromTask(task: string): string {
|
||||
const m = /Scan target: `([^`]+)`/.exec(task);
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rubric interpreter: classifies each comment line and returns the smell,
|
||||
* the cleaned line, and whether it is a "why" comment that must survive.
|
||||
*/
|
||||
function classifyComment(line: string): {
|
||||
smell: "RESTATE" | "VERBOSE" | "WHY" | "OK";
|
||||
cleaned: string;
|
||||
} {
|
||||
const trimmed = line.trim();
|
||||
// Treat the captured rationale comment as a WHY comment to preserve.
|
||||
if (
|
||||
/\b(power-of-two|so modulo|bitmask|divide|because|so that|rationale|gotcha|workaround)\b/i.test(
|
||||
trimmed,
|
||||
)
|
||||
) {
|
||||
return { smell: "WHY", cleaned: line };
|
||||
}
|
||||
// Deterministic restate signals: "increment the counter", "compute the hash".
|
||||
if (/increment|counter|^\/\/\s*compute/i.test(trimmed)) {
|
||||
return { smell: "RESTATE", cleaned: "" };
|
||||
}
|
||||
return { smell: "OK", cleaned: line };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake runner that applies the comments rubric deterministically. It reads the
|
||||
* target source written into the scan task, classifies comments, writes the
|
||||
* findings.md / changes.md artifacts, and edits the source in place. Produces
|
||||
* the same artefacts a real scanner/fixer pair would, exercising the genuine
|
||||
* task-builder output and gate.
|
||||
*/
|
||||
function rubricRunner(opts: { getScope: () => CheckScope }): AgentRunner {
|
||||
return async (taskOpts) => {
|
||||
const scope = opts.getScope();
|
||||
const target = targetFromTask(taskOpts.task) || scope.target;
|
||||
const isFix = taskOpts.agentName === "fixer";
|
||||
|
||||
let lines: string[] = [];
|
||||
try {
|
||||
lines = (await readFile(target, "utf8")).split("\n");
|
||||
} catch {
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
|
||||
const findings: string[] = [];
|
||||
const applied: string[] = [];
|
||||
const kept: string[] = [];
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const t = line.trim();
|
||||
const isComment = /^\s*(\/\/|#|\/\*)/.test(t);
|
||||
if (isComment) {
|
||||
const { smell } = classifyComment(line);
|
||||
if (smell === "RESTATE") {
|
||||
findings.push(`- L${i + 1}: RESTATE — ${t}`);
|
||||
applied.push(`- ${FILENAME}:L${i + 1} — removed comment (auto)`);
|
||||
// Drop the line entirely.
|
||||
continue;
|
||||
}
|
||||
if (smell === "WHY") {
|
||||
kept.push(`- ${FILENAME}:L${i + 1} — KEEP why comment`);
|
||||
findings.push(`- L${i + 1}: WHY — ${t}`);
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
const findingsText = `# comments — findings\n\n${findings.length} comment smell(s) across 1 file(s).\n\n## ${FILENAME}\n${findings.length ? findings.join("\n") : "(none)"}\n${kept.length ? `\n## kept (why)\n${kept.join("\n")}\n` : ""}`;
|
||||
const changesText = `# comments — changes\n\n${applied.length} edit(s) applied; 0 deferred for human review.\n\n## Applied\n${applied.length ? applied.join("\n") : "(none)"}\n`;
|
||||
|
||||
if (!isFix) {
|
||||
// Analysis phase: READ-ONLY. Write findings.md only; do not edit source.
|
||||
const fPath = findingsPath(scope);
|
||||
await mkdir(dirname(fPath), { recursive: true });
|
||||
await writeFile(fPath, findingsText + "\n", "utf8");
|
||||
return { ok: true, text: findingsText };
|
||||
}
|
||||
// Fix phase: apply removals to source in place, then write changes.md.
|
||||
await writeFile(target, out.join("\n"), "utf8");
|
||||
const cPath = changesPath(scope);
|
||||
await mkdir(dirname(cPath), { recursive: true });
|
||||
await writeFile(cPath, changesText + "\n", "utf8");
|
||||
return { ok: true, text: changesText };
|
||||
};
|
||||
}
|
||||
|
||||
describe("comments check (end-to-end)", () => {
|
||||
let cwd: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-comments-"));
|
||||
target = join(cwd, FILENAME);
|
||||
await writeFile(target, SOURCE, "utf8");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is registered and discoverable as /pygienium-comments", async () => {
|
||||
// clearChecks() wiped the registry in beforeEach; the module-level
|
||||
// self-registration ran once at import, so re-register explicitly to
|
||||
// exercise the self-registration path the way index.ts auto-discovery does.
|
||||
const { registerCheck } = await import("../src/checks/registry.js");
|
||||
registerCheck(commentsCheck);
|
||||
expect(commentsCheck.name).toBe("comments");
|
||||
const def = getCheck("comments");
|
||||
expect(def).toBeDefined();
|
||||
expect(def?.name).toBe("comments");
|
||||
});
|
||||
|
||||
it("produces findings.md without --fix and preserves why comments", async () => {
|
||||
const scope: CheckScope = {
|
||||
cwd,
|
||||
target,
|
||||
fix: false,
|
||||
rest: [],
|
||||
};
|
||||
setAgentRunner(rubricRunner({ getScope: () => scope }));
|
||||
|
||||
const def = commentsCheck;
|
||||
await handleCheckCommand(def, target, stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state).toBeDefined();
|
||||
expect(state?.checks.comments.status).toBe("complete");
|
||||
expect(state?.status).toBe("complete");
|
||||
|
||||
// scan-only: no fix phase, no changes artifact, source left untouched
|
||||
expect(state?.checks.comments.fix).toBe(false);
|
||||
expect(state?.checks.comments.changes).toBeUndefined();
|
||||
expect(state?.checks.comments.findings).toContain("RESTATE");
|
||||
const fText = await readFile(findingsPath(scope), "utf8");
|
||||
expect(fText).toContain("findings");
|
||||
expect(fText).toContain("WHY");
|
||||
|
||||
// scan is read-only: restating comments still present in the source
|
||||
const unchanged = await readFile(target, "utf8");
|
||||
expect(unchanged).toContain("// increment the counter");
|
||||
expect(unchanged).toContain("// compute the hash");
|
||||
expect(unchanged).toContain("power-of-two");
|
||||
});
|
||||
|
||||
it("with --fix: removes restating, keeps why, writes changes.md", async () => {
|
||||
const scope: CheckScope = {
|
||||
cwd,
|
||||
target,
|
||||
fix: true,
|
||||
rest: [],
|
||||
};
|
||||
setAgentRunner(rubricRunner({ getScope: () => scope }));
|
||||
|
||||
const def = commentsCheck;
|
||||
await handleCheckCommand(def, `--fix ${target}`, stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.comments.status).toBe("complete");
|
||||
expect(state?.checks.comments.fix).toBe(true);
|
||||
expect(state?.checks.comments.changes).toContain("removed comment (auto)");
|
||||
|
||||
// both artifacts present
|
||||
const fText = await readFile(findingsPath(scope), "utf8");
|
||||
const cText = await readFile(changesPath(scope), "utf8");
|
||||
expect(fText).toContain("findings");
|
||||
expect(cText).toContain("changes");
|
||||
|
||||
const cleaned = await readFile(target, "utf8");
|
||||
expect(cleaned).not.toContain("// increment the counter");
|
||||
expect(cleaned).not.toContain("// compute the hash");
|
||||
expect(cleaned).toContain("power-of-two"); // why comment survives
|
||||
expect(cleaned).toContain("counter++"); // real code intact
|
||||
expect(cleaned).toContain("return k.split"); // logic untouched
|
||||
});
|
||||
|
||||
it("marks phases complete and records artifacts in run-state", async () => {
|
||||
const scope: CheckScope = { cwd, target, fix: true, rest: [] };
|
||||
setAgentRunner(rubricRunner({ getScope: () => scope }));
|
||||
|
||||
await handleCheckCommand(commentsCheck, `--fix ${target}`, stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
const check = state?.checks.comments;
|
||||
expect(check).toBeDefined();
|
||||
const statuses = check!.phases.map((p) => `${p.id}=${p.status}`);
|
||||
expect(statuses).toContain("analysis=complete");
|
||||
expect(statuses).toContain("fix=complete");
|
||||
expect(statuses).toContain("verify=complete");
|
||||
expect(statuses).toContain("cleanup=complete");
|
||||
expect(check!.findings).toBeDefined();
|
||||
expect(check!.changes).toBeDefined();
|
||||
});
|
||||
});
|
||||
105
tests/complexity.test.ts
Normal file
105
tests/complexity.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* complexity.test.ts — integration tests for the complexity hygiene check.
|
||||
*
|
||||
* Validates:
|
||||
* 1. A function with cyclomatic complexity 50+ is flagged and refactored
|
||||
* 2. A function with complexity 35-49 is flagged with justification required
|
||||
* 3. Deep nesting and over-abstraction are simplified
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
check as complexityCheck,
|
||||
buildComplexityScanTask,
|
||||
buildComplexityFixTask,
|
||||
} from "../src/checks/complexity.js";
|
||||
import { registerCheck, clearChecks } from "../src/checks/registry.js";
|
||||
import {
|
||||
runAgentTask,
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
|
||||
describe("complexity check", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(tmpdir(), "pygienium-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered with correct properties", () => {
|
||||
expect(complexityCheck.name).toBe("complexity");
|
||||
expect(complexityCheck.label).toBe("Complexity");
|
||||
expect(complexityCheck.agentName).toBe("scanner");
|
||||
expect(complexityCheck.fixAgentName).toBe("fixer");
|
||||
});
|
||||
|
||||
it("builds scan task with complexity thresholds", () => {
|
||||
const scope = {
|
||||
cwd: tempDir,
|
||||
target: tempDir,
|
||||
fix: false,
|
||||
rest: [],
|
||||
};
|
||||
const task = buildComplexityScanTask(tempDir, scope);
|
||||
|
||||
expect(task).toContain("cyclomatic complexity");
|
||||
expect(task).toContain("50+");
|
||||
expect(task).toContain("35-49");
|
||||
expect(task).toContain("MUST refactor");
|
||||
expect(task).toContain("findings.md");
|
||||
});
|
||||
|
||||
it("builds fix task from findings", () => {
|
||||
const scope = {
|
||||
cwd: tempDir,
|
||||
target: tempDir,
|
||||
fix: true,
|
||||
rest: [],
|
||||
};
|
||||
const findings =
|
||||
"# complexity findings\n\n- myFunction: complexity 65 - must refactor";
|
||||
const task = buildComplexityFixTask(tempDir, scope, findings);
|
||||
|
||||
expect(task).toContain("complexity fix");
|
||||
expect(task).toContain("myFunction");
|
||||
expect(task).toContain("changes.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent loading", () => {
|
||||
it("loads scanner agent from agents directory", async () => {
|
||||
const { loadAgents } = await import("../src/agents.js");
|
||||
const agents = await loadAgents();
|
||||
|
||||
expect(agents.has("scanner")).toBe(true);
|
||||
const scanner = agents.get("scanner");
|
||||
expect(scanner).toBeDefined();
|
||||
expect(scanner?.systemPrompt).toContain("scanner");
|
||||
});
|
||||
|
||||
it("loads fixer agent from agents directory", async () => {
|
||||
const { loadAgents } = await import("../src/agents.js");
|
||||
const agents = await loadAgents();
|
||||
|
||||
expect(agents.has("fixer")).toBe(true);
|
||||
const fixer = agents.get("fixer");
|
||||
expect(fixer).toBeDefined();
|
||||
expect(fixer?.allowedTools).toContain("edit");
|
||||
});
|
||||
});
|
||||
623
tests/dead-code.test.ts
Normal file
623
tests/dead-code.test.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* dead-code.test.ts — integration tests for the dead-code check.
|
||||
*
|
||||
* Covers:
|
||||
* - registry/help: registering the check auto-binds `/pygienium-dead-code`
|
||||
* (zero index.ts wiring changes).
|
||||
* - deterministic detection: unused export, dead file, obsolete compat shim,
|
||||
* and unused dependency are classified; a dynamically-imported module is
|
||||
* classified `review`.
|
||||
* - E2E `--fix`: clearly-dead items are removed and listed in changes.md;
|
||||
* the dynamically-imported module is preserved and flagged for review.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
getAllChecks,
|
||||
registerCheck,
|
||||
getCheck,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
check as deadCodeCheck,
|
||||
detectDeadCode,
|
||||
findingsPath,
|
||||
changesPath,
|
||||
renderFindingsMd,
|
||||
applyDeadCodeFixes,
|
||||
} from "../src/checks/dead-code.js";
|
||||
import { buildPygieniumHelpLines } from "../src/help.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
async function writeFixture(root: string): Promise<void> {
|
||||
await mkdir(join(root, "src", "routes"), { recursive: true });
|
||||
|
||||
// package.json — carries an unused dependency + an entry `main`.
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "fixture",
|
||||
version: "1.0.0",
|
||||
main: "src/index.ts",
|
||||
dependencies: {
|
||||
leftoverpkg: "^1.0.0",
|
||||
typescript: "^5.0.0",
|
||||
},
|
||||
devDependencies: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
// util.ts — one used export and one unused export.
|
||||
await writeFile(
|
||||
join(root, "src", "util.ts"),
|
||||
[
|
||||
"export function add(a: number, b: number): number {",
|
||||
" return a + b;",
|
||||
"}",
|
||||
"",
|
||||
"export function unusedHelper(): string {",
|
||||
' return "never called anywhere";',
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// index.ts — the entry (reaches util and routes), its name is entry-like
|
||||
// so the zero-importer rule must NOT flag it as dead.
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
[
|
||||
'import { add } from "./util";',
|
||||
'import { load } from "./routes";',
|
||||
"add(1, 2);",
|
||||
"load();",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// routes/index.ts — live barrel that exposes a lazy loader.
|
||||
await writeFile(
|
||||
join(root, "src", "routes", "index.ts"),
|
||||
['export const load = () => import("./lazy-route");', ""].join("\n"),
|
||||
);
|
||||
|
||||
// lazy-route.ts — a shim reachable ONLY through a dynamic import. It must
|
||||
// be classified `review` and never auto-removed.
|
||||
await writeFile(
|
||||
join(root, "src", "routes", "lazy-route.ts"),
|
||||
["export function registerRoute(): void {", " return;", "}", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
|
||||
// compat.ts — an obsolete deprecated compat wrapper with zero importers.
|
||||
await writeFile(
|
||||
join(root, "src", "compat.ts"),
|
||||
[
|
||||
"// @deprecated obsolete compatibility wrapper — scheduled for removal.",
|
||||
"export function legacyFormat(x: string): string {",
|
||||
" return x;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// orphan.ts — a completely unreferenced module (dead file).
|
||||
await writeFile(
|
||||
join(root, "src", "orphan.ts"),
|
||||
["export function orphan(): void {", " return;", "}", ""].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("dead-code registry + help", () => {
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
// Re-register after clearChecks (module-level registration runs on import).
|
||||
registerCheck(deadCodeCheck);
|
||||
});
|
||||
|
||||
it("registers a single dead-code check with a serialisable name", () => {
|
||||
expect(getCheck("dead-code")).toBeDefined();
|
||||
expect(getAllChecks().filter((c) => c.name === "dead-code")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("appears in /pygienium-help with its description (auto discovery)", () => {
|
||||
const help = buildPygieniumHelpLines();
|
||||
expect(help.join("\n")).toContain("/pygienium-dead-code");
|
||||
expect(help.join("\n")).toContain(deadCodeCheck.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code detection", () => {
|
||||
it("classifies unused export, dead file, compat shim, unused dep; preserves dynamic import", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-"));
|
||||
try {
|
||||
await writeFixture(root);
|
||||
const report = await detectDeadCode(root);
|
||||
|
||||
expect(report.items.length).toBeGreaterThan(0);
|
||||
|
||||
const unusedExport = report.items.find(
|
||||
(i) => i.category === "export" && i.name === "unusedHelper",
|
||||
);
|
||||
expect(unusedExport).toBeDefined();
|
||||
expect(unusedExport?.review).toBe(false);
|
||||
expect(unusedExport?.target).toBe("symbol");
|
||||
|
||||
const deadShim = report.items.find(
|
||||
(i) => i.category === "shim" && i.rel === "src/compat.ts",
|
||||
);
|
||||
expect(deadShim).toBeDefined();
|
||||
expect(deadShim?.review).toBe(false);
|
||||
expect(deadShim?.target).toBe("file");
|
||||
|
||||
const deadFile = report.items.find(
|
||||
(i) => i.category === "file" && i.rel === "src/orphan.ts",
|
||||
);
|
||||
expect(deadFile).toBeDefined();
|
||||
expect(deadFile?.review).toBe(false);
|
||||
|
||||
const unusedDep = report.items.find(
|
||||
(i) => i.category === "dep" && i.name === "leftoverpkg",
|
||||
);
|
||||
expect(unusedDep).toBeDefined();
|
||||
|
||||
// dynamic-import shim → review, always preserved
|
||||
const dynamic = report.items.find(
|
||||
(i) => i.rel === "src/routes/lazy-route.ts",
|
||||
);
|
||||
expect(dynamic).toBeDefined();
|
||||
expect(dynamic?.review).toBe(true);
|
||||
expect(dynamic?.target).toBe("file");
|
||||
|
||||
// used export + entry file are NOT flagged.
|
||||
expect(report.items.find((i) => i.name === "add")).toBeUndefined();
|
||||
expect(
|
||||
report.items.find((i) => i.rel === "src/index.ts"),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renderFindingsMd groups findings under the four category headings", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-"));
|
||||
try {
|
||||
await writeFixture(root);
|
||||
const report = await detectDeadCode(root);
|
||||
const md = renderFindingsMd(report);
|
||||
expect(md).toContain("## Unused exports");
|
||||
expect(md).toContain("## Dead files (zero importers)");
|
||||
expect(md).toContain("## Obsolete shims / migration helpers");
|
||||
expect(md).toContain("## Unused dependencies");
|
||||
expect(md).toMatch(/\[review\] src\/routes\/lazy-route\.ts/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code E2E (--fix)", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
registerCheck(deadCodeCheck);
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-dead-e2e-"));
|
||||
await writeFixture(cwd);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes clearly-dead items, preserves dynamically-imported shim, and writes findings.md + changes.md", async () => {
|
||||
// Make the pre-existing compat.ts scan-detected before the run to also
|
||||
// prove the deterministic scan picks it up regardless of run order.
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd));
|
||||
|
||||
// --- Clearly-dead items are removed --------------------------------
|
||||
// Unused export: removed from util.ts, live export preserved.
|
||||
const util = await readFile(join(cwd, "src", "util.ts"), "utf8");
|
||||
expect(util).not.toContain("unusedHelper");
|
||||
expect(util).toContain("add");
|
||||
|
||||
// Obsolete compat shim: file deleted.
|
||||
expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(false);
|
||||
|
||||
// Dead file: deleted.
|
||||
expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(false);
|
||||
|
||||
// Unused dependency: removed from package.json.
|
||||
const pkg = JSON.parse(
|
||||
await readFile(join(cwd, "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> };
|
||||
expect(pkg.dependencies.leftoverpkg).toBeUndefined();
|
||||
|
||||
// Dynamically-imported shim: preserved, untouched.
|
||||
expect(existsSync(join(cwd, "src", "routes", "lazy-route.ts"))).toBe(true);
|
||||
const lazy = await readFile(
|
||||
join(cwd, "src", "routes", "lazy-route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(lazy).toContain("registerRoute");
|
||||
|
||||
// findings.md exists and is categorized.
|
||||
expect(existsSync(findingsPath(cwd))).toBe(true);
|
||||
const findingsMd = await readFile(findingsPath(cwd), "utf8");
|
||||
expect(findingsMd).toContain("## Unused exports");
|
||||
expect(findingsMd).toContain("## Dead files (zero importers)");
|
||||
expect(findingsMd).toContain("## Obsolete shims / migration helpers");
|
||||
expect(findingsMd).toContain("## Unused dependencies");
|
||||
|
||||
// changes.md lists the removals and the preserved review item.
|
||||
expect(existsSync(changesPath(cwd))).toBe(true);
|
||||
const changesMd = await readFile(changesPath(cwd), "utf8");
|
||||
expect(changesMd).toContain("## Removed (auto)");
|
||||
expect(changesMd).toContain("src/compat.ts");
|
||||
expect(changesMd).toContain("unusedHelper");
|
||||
expect(changesMd).toContain("leftoverpkg");
|
||||
expect(changesMd).toContain("## Preserved for review (manual)");
|
||||
expect(changesMd).toContain("lazy-route.ts"); // dynamic import preserved
|
||||
});
|
||||
|
||||
it("records the run in run-state and marks the check complete", async () => {
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["dead-code"].status).toBe("complete");
|
||||
expect(state?.checks["dead-code"].findings).toBeDefined();
|
||||
expect(state?.checks["dead-code"].changes).toBeDefined();
|
||||
});
|
||||
|
||||
it("runs a dry scan (no --fix) and does not write changes.md or touch files", async () => {
|
||||
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
|
||||
|
||||
// Nothing removed without --fix.
|
||||
expect(existsSync(join(cwd, "src", "compat.ts"))).toBe(true);
|
||||
expect(existsSync(join(cwd, "src", "orphan.ts"))).toBe(true);
|
||||
const utils = await readFile(join(cwd, "src", "util.ts"), "utf8");
|
||||
expect(utils).toContain("unusedHelper");
|
||||
|
||||
// Findings recorded, but no changes yet.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["dead-code"].status).toBe("complete");
|
||||
expect(state?.checks["dead-code"].changes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code shim auto-delete safety (entry points + prose)", () => {
|
||||
beforeEach(() => {
|
||||
clearChecks();
|
||||
registerCheck(deadCodeCheck);
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAgentRunner();
|
||||
});
|
||||
|
||||
it("never auto-deletes an entry point whose prose mentions 'legacy'", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-entry-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n",
|
||||
);
|
||||
// Entry point, zero importers, prose contains 'legacy' — must survive.
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
[
|
||||
"// handles legacy payloads",
|
||||
"export function main(): void {}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("never auto-deletes a *.test.ts whose description mentions 'deprecated'", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-test-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "foo.test.ts"),
|
||||
[
|
||||
"import { describe, it } from 'bun:test';",
|
||||
"describe('app', () => {",
|
||||
" it('still supports the deprecated API', () => {});",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
expect(existsSync(join(root, "src", "foo.test.ts"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("flags an entry-like file tagged @deprecated for review, not deletion", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-tag-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "app", main: "src/index.ts" }) + "\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
["/** @deprecated */", "export function main(): void {}", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
|
||||
const report = await detectDeadCode(root);
|
||||
const entry = report.items.find(
|
||||
(i) => i.rel === "src/index.ts" && i.category === "shim",
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.review).toBe(true);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code barrel re-export retention", () => {
|
||||
it("keeps modules reachable only through `export * from` / `export {…} from` barrels", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-"));
|
||||
try {
|
||||
// index.ts is entry-like; it aggregates barrelA, which aggregates
|
||||
// barrelB both by star and by name. Neither barrel may be treated as
|
||||
// a zero-importer dead file, and symbols only reachable through the
|
||||
// star re-export must stay behind a review flag.
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
['export * from "./barrelA";', ""].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelA.ts"),
|
||||
[
|
||||
'export * from "./barrelB";',
|
||||
'export { namedB } from "./barrelB";',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelB.ts"),
|
||||
[
|
||||
"export const value = 1;",
|
||||
"export const namedB = 2;",
|
||||
"export const starOnly = 3;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const report = await detectDeadCode(root);
|
||||
|
||||
// Neither barrel is a dead-file candidate.
|
||||
expect(
|
||||
report.items.find(
|
||||
(i) => i.category === "file" && i.rel === "src/barrelA.ts",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
report.items.find(
|
||||
(i) => i.category === "file" && i.rel === "src/barrelB.ts",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// Symbols in the star/named re-export target stay `review` — the
|
||||
// deterministic fixer must not auto-delete them.
|
||||
const value = report.items.find(
|
||||
(i) =>
|
||||
i.category === "export" &&
|
||||
i.rel === "src/barrelB.ts" &&
|
||||
i.name === "value",
|
||||
);
|
||||
expect(value).toBeDefined();
|
||||
expect(value?.review).toBe(true);
|
||||
const starOnly = report.items.find(
|
||||
(i) =>
|
||||
i.category === "export" &&
|
||||
i.rel === "src/barrelB.ts" &&
|
||||
i.name === "starOnly",
|
||||
);
|
||||
expect(starOnly).toBeDefined();
|
||||
expect(starOnly?.review).toBe(true);
|
||||
|
||||
// The named re-export is referenced (by barrelA) so it is not dead.
|
||||
expect(
|
||||
report.items.find(
|
||||
(i) =>
|
||||
i.category === "export" &&
|
||||
i.rel === "src/barrelB.ts" &&
|
||||
i.name === "namedB",
|
||||
),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("`--fix` never deletes a barrel-exported module", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-barrel-e2e-"));
|
||||
try {
|
||||
clearChecks();
|
||||
registerCheck(deadCodeCheck);
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "index.ts"),
|
||||
['export * from "./barrelA";', ""].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelA.ts"),
|
||||
[
|
||||
'export * from "./barrelB";',
|
||||
'export { namedB } from "./barrelB";',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "src", "barrelB.ts"),
|
||||
[
|
||||
"export const value = 1;",
|
||||
"export const namedB = 2;",
|
||||
"export const starOnly = 3;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await handleCheckCommand(deadCodeCheck, "--fix", stubCtx(root));
|
||||
|
||||
expect(existsSync(join(root, "src", "barrelA.ts"))).toBe(true);
|
||||
expect(existsSync(join(root, "src", "barrelB.ts"))).toBe(true);
|
||||
expect(existsSync(join(root, "src", "index.ts"))).toBe(true);
|
||||
} finally {
|
||||
resetAgentRunner();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-code symbol removal (multi-statement bodies)", () => {
|
||||
it("removes arrow-block consts, object consts, and inline-closing functions whole", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-shapes-"));
|
||||
try {
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "src", "math.ts"),
|
||||
[
|
||||
"export const build = () => {",
|
||||
" const a = 1;",
|
||||
" return a + 2;",
|
||||
"};",
|
||||
"export function packed() {",
|
||||
' return "x"; }',
|
||||
'export const config = { retries: 3, label: "cfg" };',
|
||||
"export function keep(): string {",
|
||||
' return "keep";',
|
||||
"}",
|
||||
"export const keepVar = 9;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
// Referenced exports keep math.ts alive and `keep`/`keepVar` used.
|
||||
await writeFile(
|
||||
join(root, "src", "app.ts"),
|
||||
[
|
||||
'import { keep, keepVar } from "./math";',
|
||||
"console.log(keep(), keepVar);",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const report = await detectDeadCode(root);
|
||||
const names = report.items
|
||||
.filter((i) => i.category === "export" && i.rel === "src/math.ts")
|
||||
.map((i) => i.name);
|
||||
expect(names).toContain("build");
|
||||
expect(names).toContain("packed");
|
||||
expect(names).toContain("config");
|
||||
expect(names).not.toContain("keep");
|
||||
expect(names).not.toContain("keepVar");
|
||||
|
||||
const { applied } = await applyDeadCodeFixes(report);
|
||||
expect(applied.map((i) => i.name)).toEqual(
|
||||
expect.arrayContaining(["build", "packed", "config"]),
|
||||
);
|
||||
|
||||
const out = await readFile(join(root, "src", "math.ts"), "utf8");
|
||||
expect(out).not.toContain("build");
|
||||
expect(out).not.toContain("packed");
|
||||
expect(out).not.toContain("config");
|
||||
expect(out).toContain("keep");
|
||||
expect(out).toContain("keepVar");
|
||||
// No leftover arrow body from the removed declaration.
|
||||
expect(out).not.toContain("return a + 2");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a symbol it cannot safely remove instead of corrupting source", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pygienium-dead-guard-"));
|
||||
try {
|
||||
const file = join(root, "weird.ts");
|
||||
await writeFile(
|
||||
file,
|
||||
[
|
||||
'export const rx = () => /[{;}]/.test("a;");',
|
||||
"export const keep = 1;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const original = await readFile(file, "utf8");
|
||||
// Hand-built report forces an auto removal attempt on a shape the
|
||||
// scanner does not fully model (regex with braces/semicolons).
|
||||
const { applyDeadCodeFixes: apply } = await import(
|
||||
"../src/checks/dead-code.js"
|
||||
);
|
||||
const report = {
|
||||
target: root,
|
||||
scannedAt: new Date().toISOString(),
|
||||
items: [
|
||||
{
|
||||
category: "export" as const,
|
||||
path: file,
|
||||
rel: "weird.ts",
|
||||
name: "rx",
|
||||
line: 1,
|
||||
target: "symbol" as const,
|
||||
review: false,
|
||||
reason: "test",
|
||||
},
|
||||
],
|
||||
};
|
||||
const { applied } = await apply(report);
|
||||
const after = await readFile(file, "utf8");
|
||||
// Either the removal succeeded cleanly, or the file is untouched —
|
||||
// never a truncated/corrupt intermediate.
|
||||
if (applied.length === 0) {
|
||||
expect(after).toBe(original);
|
||||
} else {
|
||||
expect(after).not.toContain("rx");
|
||||
expect(after).toContain("keep");
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
171
tests/deep-modules.test.ts
Normal file
171
tests/deep-modules.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* deep-modules.test.ts — integration test for the deep-modules check.
|
||||
*
|
||||
* Seeds a temp workspace with a pass-through wrapper module (a shallow
|
||||
* abstraction), runs the check with the deterministic fake agent runner, and
|
||||
* asserts:
|
||||
* - the scan persists `findings.md` flagging the wrapper as a pass-through;
|
||||
* - with `--fix`, the safe consolidation is applied (wrapper rewritten/
|
||||
* removed) and `changes.md` records it (auto), while a risky
|
||||
* external-importer case is listed for review (manual), never auto-applied.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
getCheck,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import {
|
||||
findingsPath,
|
||||
changesPath,
|
||||
check as deepModulesCheck,
|
||||
} from "../src/checks/deep-modules.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Drop a pass-through wrapper module that forwards a single lib call. */
|
||||
async function seedPassThrough(
|
||||
dir: string,
|
||||
): Promise<{ wrapper: string; lib: string }> {
|
||||
const wrapper = join(dir, "wrapper.ts");
|
||||
const lib = join(dir, "lib.ts");
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
lib,
|
||||
`export function compute(x: number): number { return x * 2; }\n`,
|
||||
"utf8",
|
||||
);
|
||||
// Shallow pass-through: forwards every argument to `lib` with zero added logic.
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`import { compute } from "./lib";\nexport function run(x: number) { return compute(x); }\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { wrapper, lib };
|
||||
}
|
||||
|
||||
describe("deep-modules check", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
// Re-register explicitly: the module's import-time registerCheck only
|
||||
// runs once (module cache), so clearChecks + registerCheck restores it
|
||||
// deterministically for each test.
|
||||
registerCheck(deepModulesCheck);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-deep-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered and uses the deep-modules scanner agent", () => {
|
||||
const check = getCheck("deep-modules");
|
||||
expect(check).toBeDefined();
|
||||
expect((check as CheckDefinition)?.agentName).toBe("deep-modules");
|
||||
});
|
||||
|
||||
it("flags the pass-through wrapper in findings.md", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
const check = getCheck("deep-modules")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const findings = await readFile(findingsPath(cwd), "utf8");
|
||||
expect(findings).toContain("wrapper.ts");
|
||||
expect(findings).toContain("pass-through-wrapper");
|
||||
expect(findings).toMatch(/importers:\s*0/);
|
||||
|
||||
// Run state records the scan summary as findings text.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["deep-modules"]?.status).toBe("complete");
|
||||
expect(state?.checks["deep-modules"]?.findings).toContain(
|
||||
"deep-modules: 1 issue",
|
||||
);
|
||||
});
|
||||
|
||||
it("scan-only does not write changes.md", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
const check = getCheck("deep-modules")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
expect(existsSync(changesPath(cwd))).toBe(false);
|
||||
});
|
||||
|
||||
it("--fix applies the safe consolidation and records changes.md (auto), and defers the risky one (manual)", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
// Also drop a "risky" adapter so we can assert it is NOT auto-applied.
|
||||
await writeFile(
|
||||
join(cwd, "risky-adapter.ts"),
|
||||
`// adapter-layer with external importers — should be listed for review only\nexport const risky = true;\n`,
|
||||
"utf8",
|
||||
);
|
||||
const check = getCheck("deep-modules")!;
|
||||
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const changes = await readFile(changesPath(cwd), "utf8");
|
||||
// Safe pass-through: consolidation applied (auto).
|
||||
expect(changes).toContain("wrapper.ts");
|
||||
expect(changes).toMatch(/auto/);
|
||||
expect(changes).toMatch(/consolidat/i);
|
||||
|
||||
// Risky adapter: listed for review, not auto-applied (manual).
|
||||
expect(changes).toContain("risky-adapter.ts");
|
||||
expect(changes).toMatch(/manual/);
|
||||
|
||||
// The safe wrapper was rewritten — no longer a pass-through.
|
||||
const wrapperContent = await readFile(join(cwd, "wrapper.ts"), "utf8");
|
||||
expect(wrapperContent).not.toContain("import { compute }");
|
||||
expect(wrapperContent).toContain("Consolidated");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["deep-modules"]?.fix).toBe(true);
|
||||
expect(state?.checks["deep-modules"]?.changes).toContain(
|
||||
"1 auto-applied, 1 deferred",
|
||||
);
|
||||
expect(state?.checks["deep-modules"]?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("findings.md and changes.md live under .pygienium/checks/deep-modules/", async () => {
|
||||
await seedPassThrough(cwd);
|
||||
const check = getCheck("deep-modules")!;
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
expect(findingsPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "deep-modules", "findings.md"),
|
||||
);
|
||||
expect(changesPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "deep-modules", "changes.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when the target has no source files", async () => {
|
||||
const empty = await mkdtemp(join(tmpdir(), "pygienium-empty-"));
|
||||
try {
|
||||
const check = getCheck("deep-modules")!;
|
||||
await handleCheckCommand(check, empty, stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["deep-modules"]?.status).toBe("skipped");
|
||||
} finally {
|
||||
await rm(empty, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
232
tests/defensive-guards.test.ts
Normal file
232
tests/defensive-guards.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* defensive-guards.test.ts — integration test for the defensive-guards check.
|
||||
*
|
||||
* Seeds a temp workspace with:
|
||||
* - noise.ts: a redundant null check on a typed-non-null parameter PLUS a
|
||||
* swallowing try/catch (both redundant);
|
||||
* - boundary.ts: a try/catch around JSON.parse (a legitimate parsing
|
||||
* boundary guard).
|
||||
*
|
||||
* Runs the check with the deterministic fake agent runner and asserts:
|
||||
* - the scan persists findings.md separating redundant guards from boundary
|
||||
* guards;
|
||||
* - with --fix, the redundant guards are removed from noise.ts and changes.md
|
||||
* records them (auto), while the JSON.parse guard in boundary.ts is
|
||||
* preserved untouched (kept — boundary).
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
getCheck,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import {
|
||||
findingsPath,
|
||||
changesPath,
|
||||
check as defensiveGuardsCheck,
|
||||
} from "../src/checks/defensive-guards.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `noise.ts`: a redundant null check on a typed-non-null param plus a
|
||||
* swallowing try/catch. Both are redundant — the type system guarantees
|
||||
* `name` is a string, and the catch silently swallows the error.
|
||||
*/
|
||||
async function seedNoise(dir: string): Promise<string> {
|
||||
const noise = join(dir, "noise.ts");
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
noise,
|
||||
[
|
||||
`export function greet(name: string) {`,
|
||||
` if (name === null) return "";`,
|
||||
` return \`hello \${name}\`;`,
|
||||
`}`,
|
||||
``,
|
||||
`export function swallow() {`,
|
||||
` try {`,
|
||||
` doThing();`,
|
||||
` } catch (e) {`,
|
||||
` // swallowed`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
`function doThing() {}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return noise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `boundary.ts`: a try/catch around JSON.parse of untrusted input. This is
|
||||
* a legitimate parsing boundary guard and must be PRESERVED by --fix.
|
||||
*/
|
||||
async function seedBoundary(dir: string): Promise<string> {
|
||||
const boundary = join(dir, "boundary.ts");
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
boundary,
|
||||
[
|
||||
`export function parse(input: string) {`,
|
||||
` try {`,
|
||||
` return JSON.parse(input);`,
|
||||
` } catch (e) {`,
|
||||
` return null;`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return boundary;
|
||||
}
|
||||
|
||||
describe("defensive-guards check", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
// Re-register explicitly: the module's import-time registerCheck only
|
||||
// runs once (module cache), so clearChecks + registerCheck restores it
|
||||
// deterministically for each test.
|
||||
registerCheck(defensiveGuardsCheck);
|
||||
// Fresh empty tempdir per test; each test seeds itself so the skip
|
||||
// test gets a genuinely empty cwd (the gate inspects cwd, not target).
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-dg-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered and uses the defensive-guards scanner agent", () => {
|
||||
const check = getCheck("defensive-guards");
|
||||
expect(check).toBeDefined();
|
||||
expect((check as CheckDefinition)?.agentName).toBe("defensive-guards");
|
||||
});
|
||||
|
||||
it("flags the redundant null check and swallowing try/catch, and keeps the JSON.parse boundary guard, in findings.md", async () => {
|
||||
await seedNoise(cwd);
|
||||
await seedBoundary(cwd);
|
||||
const check = getCheck("defensive-guards")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const findings = await readFile(findingsPath(cwd), "utf8");
|
||||
// Redundant guards are flagged with their kind.
|
||||
expect(findings).toContain("noise.ts");
|
||||
expect(findings).toContain("redundant-null-check");
|
||||
expect(findings).toContain("swallowing-try-catch");
|
||||
// The JSON.parse guard is classified as a boundary guard (kept).
|
||||
expect(findings).toContain("boundary.ts");
|
||||
expect(findings).toContain("keep-boundary");
|
||||
expect(findings).toContain("parsing-guard");
|
||||
|
||||
// Run state records the scan summary as findings text.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
|
||||
expect(state?.checks["defensive-guards"]?.findings).toContain(
|
||||
"defensive-guards: 2 redundant",
|
||||
);
|
||||
});
|
||||
|
||||
it("scan-only does not write changes.md and does not touch source files", async () => {
|
||||
const noise = await seedNoise(cwd);
|
||||
const boundary = await seedBoundary(cwd);
|
||||
const before = await readFile(noise, "utf8");
|
||||
const beforeBoundary = await readFile(boundary, "utf8");
|
||||
|
||||
const check = getCheck("defensive-guards")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
expect(existsSync(changesPath(cwd))).toBe(false);
|
||||
// Source files untouched by a scan-only run.
|
||||
expect(await readFile(noise, "utf8")).toBe(before);
|
||||
expect(await readFile(boundary, "utf8")).toBe(beforeBoundary);
|
||||
});
|
||||
|
||||
it("--fix removes the redundant guards from noise.ts and records changes.md (auto), and preserves the JSON.parse boundary guard", async () => {
|
||||
const noise = await seedNoise(cwd);
|
||||
const boundary = await seedBoundary(cwd);
|
||||
const check = getCheck("defensive-guards")!;
|
||||
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const changes = await readFile(changesPath(cwd), "utf8");
|
||||
// Redundant guards: removed (auto).
|
||||
expect(changes).toContain("noise.ts");
|
||||
expect(changes).toMatch(/auto/);
|
||||
expect(changes).toMatch(/redundant-null-check/);
|
||||
expect(changes).toMatch(/swallowing-try-catch/);
|
||||
// JSON.parse boundary guard: kept (boundary — with reason).
|
||||
expect(changes).toContain("boundary.ts");
|
||||
expect(changes).toMatch(/boundary/);
|
||||
expect(changes).toMatch(/JSON.parse/);
|
||||
|
||||
// noise.ts no longer contains the redundant null check or the swallowing
|
||||
// try/catch. The fixer leaves a header marker noting the cleanup.
|
||||
const cleaned = await readFile(noise, "utf8");
|
||||
expect(cleaned).not.toContain("=== null");
|
||||
expect(cleaned).not.toMatch(/try\s*\{/);
|
||||
expect(cleaned).toContain("Cleaned by pygienium-defensive-guards");
|
||||
// The happy-path behaviour is preserved.
|
||||
expect(cleaned).toContain("greet");
|
||||
expect(cleaned).toContain("hello");
|
||||
|
||||
// boundary.ts is PRESERVED — the JSON.parse guard is untouched.
|
||||
const keptBoundary = await readFile(boundary, "utf8");
|
||||
expect(keptBoundary).toContain("JSON.parse");
|
||||
expect(keptBoundary).toMatch(/try\s*\{/);
|
||||
expect(keptBoundary).toMatch(/catch/);
|
||||
// And it still returns null on parse failure (unchanged behaviour).
|
||||
expect(keptBoundary).toContain("return null");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["defensive-guards"]?.fix).toBe(true);
|
||||
expect(state?.checks["defensive-guards"]?.changes).toContain(
|
||||
"2 removed, 1 kept",
|
||||
);
|
||||
expect(state?.checks["defensive-guards"]?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("findings.md and changes.md live under .pygienium/checks/defensive-guards/", async () => {
|
||||
await seedNoise(cwd);
|
||||
await seedBoundary(cwd);
|
||||
const check = getCheck("defensive-guards")!;
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
expect(findingsPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "defensive-guards", "findings.md"),
|
||||
);
|
||||
expect(changesPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "defensive-guards", "changes.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when the target has no source files", async () => {
|
||||
// cwd is a fresh empty tempdir (no seeding) → the gate finds no source
|
||||
// files and skips the check without spawning an agent.
|
||||
const check = getCheck("defensive-guards")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["defensive-guards"]?.status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
62
tests/extensibility.test.ts
Normal file
62
tests/extensibility.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* extensibility.test.ts — the registry extensibility claim (task 14).
|
||||
*
|
||||
* Proves a NEW check registered via the public API yields a working
|
||||
* `/pygienium-<name>` command with ZERO `index.ts` command-wiring changes: an
|
||||
* in-test `registerCheck()` call makes the generic command-binding path
|
||||
* (`registerPygieniumCommands`, the exact function `index.ts` calls) expose
|
||||
* `/pygienium-witness` and `/pygienium-help` lists it. The shipped checks are
|
||||
* each exercised by their own test files, so this suite only needs a synthetic
|
||||
* witness.
|
||||
*/
|
||||
import { describe, expect, it, afterEach } from "bun:test";
|
||||
import {
|
||||
clearChecks,
|
||||
getCheck,
|
||||
getAllChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import { registerPygieniumCommands } from "../src/commands.js";
|
||||
import { buildPygieniumHelpLines } from "../src/help.js";
|
||||
|
||||
/** A synthetic check registered only for this suite. */
|
||||
const witnessCheck: CheckDefinition = {
|
||||
name: "witness",
|
||||
label: "Witness",
|
||||
description: "Test-only check proving zero-wiring extensibility.",
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "witness",
|
||||
buildScanTask: () =>
|
||||
"# Task: witness scan\nwrite findings.md: witness: 0 issues",
|
||||
buildFixTask: () => "# Task: witness fix\nwrite changes.md: witness: 0 edits",
|
||||
gate: () => undefined,
|
||||
};
|
||||
|
||||
describe("registry extensibility (task 14)", () => {
|
||||
afterEach(() => clearChecks());
|
||||
|
||||
it("a registered check is visible via getCheck/getAllChecks", () => {
|
||||
registerCheck(witnessCheck);
|
||||
expect(getCheck("witness")).toBe(witnessCheck);
|
||||
expect(getAllChecks().some((c) => c.name === "witness")).toBe(true);
|
||||
});
|
||||
|
||||
it("registerPygieniumCommands exposes /pygienium-<name> (zero wiring)", () => {
|
||||
registerCheck(witnessCheck);
|
||||
const names: string[] = [];
|
||||
registerPygieniumCommands((name) => names.push(name));
|
||||
expect(names).toContain("pygienium-witness");
|
||||
// And the operator commands are still wired.
|
||||
expect(names).toContain("pygienium-help");
|
||||
expect(names).toContain("pygienium-all");
|
||||
});
|
||||
|
||||
it("/pygienium-help lists a registered check", () => {
|
||||
registerCheck(witnessCheck);
|
||||
const text = buildPygieniumHelpLines().join("\n");
|
||||
expect(text).toContain("/pygienium-witness");
|
||||
expect(text).toContain(witnessCheck.description);
|
||||
});
|
||||
});
|
||||
269
tests/footer.test.ts
Normal file
269
tests/footer.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* footer.test.ts — unit tests for the pipeline-overview footer.
|
||||
*
|
||||
* The footer is a presentation-only multi-line `belowEditor` widget: with no
|
||||
* UI it tracks items but writes nothing; with a stub UI it pushes a string[]
|
||||
* of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
|
||||
* and clears on `done()`. The pure {@link renderFooterList} core is asserted
|
||||
* directly (layout + theming); a light widget-glue test covers the wiring.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
createPipelineFooter,
|
||||
footerPhaseItems,
|
||||
footerColor,
|
||||
renderFooterList,
|
||||
FOOTER_MARKER,
|
||||
FOOTER_STATUS_KEY,
|
||||
type FooterItem,
|
||||
type FooterTheme,
|
||||
} from "../src/footer.js";
|
||||
import { PHASE_LABELS } from "../src/phases.js";
|
||||
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
|
||||
|
||||
/** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
|
||||
function fakeTheme(): FooterTheme {
|
||||
return { fg: (color, text) => `${color}:${text}` };
|
||||
}
|
||||
|
||||
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
|
||||
function stubUi(theme: FooterTheme = fakeTheme()): {
|
||||
ui: {
|
||||
theme: FooterTheme;
|
||||
setWidget: (
|
||||
key: string,
|
||||
content: string[] | undefined,
|
||||
options?: { placement?: string },
|
||||
) => void;
|
||||
};
|
||||
calls: {
|
||||
key: string;
|
||||
content: string[] | undefined;
|
||||
placement?: string;
|
||||
}[];
|
||||
} {
|
||||
const calls: {
|
||||
key: string;
|
||||
content: string[] | undefined;
|
||||
placement?: string;
|
||||
}[] = [];
|
||||
return {
|
||||
calls,
|
||||
ui: {
|
||||
theme,
|
||||
setWidget(key, content, options) {
|
||||
calls.push({ key, content, placement: options?.placement });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the canonical phase-id list a scan-only check uses. */
|
||||
function scanPhaseIds(): string[] {
|
||||
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
|
||||
}
|
||||
|
||||
/** Items for the canonical scan-only pipeline. */
|
||||
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
|
||||
return scanPhaseIds().map((id) => ({
|
||||
label: PHASE_LABELS[id] ?? id,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("renderFooterList", () => {
|
||||
it("renders one bulleted, numbered, themed line per phase", () => {
|
||||
const lines = renderFooterList(scanItems(), -1, fakeTheme());
|
||||
expect(lines).toHaveLength(3);
|
||||
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
|
||||
expect(lines[0]).toBe("dim:• · 1. Recon");
|
||||
expect(lines[1]).toBe("dim:• · 2. Scanning");
|
||||
expect(lines[2]).toBe("dim:• · 3. Fixing");
|
||||
});
|
||||
|
||||
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
|
||||
const lines = renderFooterList(scanItems(), 1, fakeTheme());
|
||||
expect(lines[0]).toBe("dim:• · 1. Recon");
|
||||
// cursor (index 1) is pending-but-current → accent.
|
||||
expect(lines[1]).toBe("accent:• · 2. Scanning");
|
||||
expect(lines[2]).toBe("dim:• · 3. Fixing");
|
||||
});
|
||||
|
||||
it("themes terminal statuses with success/error/warning colors", () => {
|
||||
const items: FooterItem[] = [
|
||||
{ label: "Recon", status: "complete" },
|
||||
{ label: "Scan", status: "running" },
|
||||
{ label: "Fix", status: "failed" },
|
||||
{ label: "Verify", status: "skipped" },
|
||||
];
|
||||
const lines = renderFooterList(items, -1, fakeTheme());
|
||||
expect(lines[0]).toBe("success:• ✓ 1. Recon");
|
||||
expect(lines[1]).toBe("accent:• ● 2. Scan");
|
||||
expect(lines[2]).toBe("error:• ✗ 3. Fix");
|
||||
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
|
||||
});
|
||||
|
||||
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
|
||||
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
|
||||
label: `S${i}`,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
const lines = renderFooterList(items, -1, fakeTheme());
|
||||
expect(lines[0]).toContain("01. S0");
|
||||
expect(lines[10]).toContain("11. S10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("footerColor", () => {
|
||||
it("maps each status to its piolium-style color token", () => {
|
||||
expect(footerColor("complete", false)).toBe("success");
|
||||
expect(footerColor("failed", false)).toBe("error");
|
||||
expect(footerColor("skipped", false)).toBe("warning");
|
||||
expect(footerColor("running", false)).toBe("accent");
|
||||
expect(footerColor("pending", false)).toBe("dim");
|
||||
// A pending item under the cursor reads as accent (current).
|
||||
expect(footerColor("pending", true)).toBe("accent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPipelineFooter", () => {
|
||||
it("is a no-op without a UI but still tracks item state", () => {
|
||||
// hasUI false: setWidget must never be called.
|
||||
const footer = createPipelineFooter({ hasUI: false });
|
||||
footer.setPipeline("pygienium smoke", [
|
||||
{ label: "Recon", status: "pending" },
|
||||
]);
|
||||
footer.setCursor(0);
|
||||
footer.done();
|
||||
// No UI → no observable side effect, but getItems reflects state.
|
||||
expect(footer.getItems()[0]?.status).toBe("running");
|
||||
expect(footer.getTitle()).toBe("pygienium smoke");
|
||||
});
|
||||
|
||||
it("renders the full pipeline as a belowEditor widget and clears on done", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
|
||||
|
||||
footer.setPipeline("pygienium smoke", items, 0);
|
||||
|
||||
// One setWidget call, under the canonical key, placement belowEditor.
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
|
||||
expect(calls[0]?.placement).toBe("belowEditor");
|
||||
const lines = calls[0]?.content ?? [];
|
||||
// Title line first (dim), then one bulleted line per phase.
|
||||
expect(lines[0]).toBe("dim:pygienium smoke");
|
||||
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
|
||||
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
|
||||
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. Fixing`);
|
||||
// The cursor item is marked running.
|
||||
expect(footer.getItems()[0]?.status).toBe("running");
|
||||
|
||||
footer.done();
|
||||
// done() pushes an undefined to clear the slot, then resets state.
|
||||
const last = calls[calls.length - 1]!;
|
||||
expect(last.content).toBeUndefined();
|
||||
expect(last.placement).toBe("belowEditor");
|
||||
expect(footer.getItems()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("demotes the previous running item to pending when the cursor moves", () => {
|
||||
const { ui } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
footer.setPipeline(
|
||||
"pygienium smoke",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
0,
|
||||
);
|
||||
// recon → complete, then advance to analysis.
|
||||
footer.setItem(0, "complete");
|
||||
footer.setCursor(1);
|
||||
|
||||
const items = footer.getItems();
|
||||
expect(items[0]?.status).toBe("complete");
|
||||
expect(items[1]?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does not demote a terminal item when the cursor advances past it", () => {
|
||||
const { ui } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
footer.setPipeline(
|
||||
"pygienium smoke",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
0,
|
||||
);
|
||||
footer.setCursor(0); // recon running
|
||||
footer.setItem(0, "complete");
|
||||
footer.setCursor(1); // analysis running
|
||||
footer.setItem(1, "complete");
|
||||
footer.setCursor(2); // fix running
|
||||
const items = footer.getItems();
|
||||
expect(items[0]?.status).toBe("complete");
|
||||
expect(items[1]?.status).toBe("complete");
|
||||
expect(items[2]?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("marks a skipped gate as every item skipped", () => {
|
||||
const { ui } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true });
|
||||
footer.setPipeline(
|
||||
"pygienium comments",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
);
|
||||
for (let i = 0; i < footer.getItems().length; i++) {
|
||||
footer.setItem(i, "skipped");
|
||||
}
|
||||
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
|
||||
});
|
||||
|
||||
it("can be disabled so it never touches the widget slot", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({ ui, hasUI: true, enabled: false });
|
||||
footer.setPipeline("pygienium smoke", [
|
||||
{ label: "Recon", status: "pending" },
|
||||
]);
|
||||
footer.setCursor(0);
|
||||
footer.done();
|
||||
// enabled:false suppresses every setWidget call (used by /pygienium-all
|
||||
// which owns its own footer).
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes under a custom widget key (all-run owns its slot)", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({
|
||||
ui,
|
||||
hasUI: true,
|
||||
statusKey: "pygienium-all",
|
||||
});
|
||||
footer.setPipeline(
|
||||
"pygienium: all",
|
||||
[
|
||||
{ label: "comments", status: "pending" },
|
||||
{ label: "dead-code", status: "pending" },
|
||||
],
|
||||
0,
|
||||
);
|
||||
expect(calls[0]?.key).toBe("pygienium-all");
|
||||
expect(calls[0]?.placement).toBe("belowEditor");
|
||||
const lines = calls[0]?.content ?? [];
|
||||
expect(lines[0]).toBe("dim:pygienium: all");
|
||||
// Cursor on the first check; second still pending (to come).
|
||||
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
|
||||
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("footerPhaseItems", () => {
|
||||
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
|
||||
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
|
||||
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
|
||||
expect(items.every((i) => i.status === "pending")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the raw id for unknown phases", () => {
|
||||
const items = footerPhaseItems(["custom"], PHASE_LABELS);
|
||||
expect(items[0]?.label).toBe("custom");
|
||||
});
|
||||
});
|
||||
101
tests/help.test.ts
Normal file
101
tests/help.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* help.test.ts — `/pygienium-help` content (task 14).
|
||||
*
|
||||
* Asserts the help block lists every operator command, every implemented flag,
|
||||
* and at least 8 commands once a handful of checks are registered. The
|
||||
* per-check command family and the dynamic checks list are registry-driven, so
|
||||
* these tests register stub checks rather than importing the real ones.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from "bun:test";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
COMMANDS,
|
||||
CLI_FLAGS,
|
||||
PYGIENIUM_FLAGS,
|
||||
buildPygieniumHelpLines,
|
||||
} from "../src/help.js";
|
||||
|
||||
function stub(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
phaseId: "scan",
|
||||
buildScanTask: () => "scan",
|
||||
buildFixTask: () => "fix",
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("/pygienium-help content (task 14)", () => {
|
||||
beforeEach(() => clearChecks());
|
||||
|
||||
it("COMMANDS lists every operator command with usage/description/example", () => {
|
||||
const usages = COMMANDS.map((c) => c.usage);
|
||||
expect(usages).toContain("pygienium-help");
|
||||
expect(usages).toContain("pygienium-<check> [path] [--fix] [--fresh]");
|
||||
expect(usages).toContain(
|
||||
"pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
||||
);
|
||||
expect(usages).toContain("pygienium-status [path]");
|
||||
expect(usages).toContain("pygienium-resume [path] [--fresh]");
|
||||
expect(usages).toContain(
|
||||
"pygienium-export [path] [--check=] [--status=] [--out=md|json]",
|
||||
);
|
||||
for (const cmd of COMMANDS) {
|
||||
expect(cmd.description.length).toBeGreaterThan(0);
|
||||
expect(cmd.example.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("CLI_FLAGS lists every implemented flag", () => {
|
||||
const names = CLI_FLAGS.map((f) => f.name);
|
||||
expect(names).toContain("[path]");
|
||||
expect(names).toContain("--fix");
|
||||
expect(names).toContain("--fresh");
|
||||
expect(names).toContain("--check=");
|
||||
expect(names).toContain("--status=");
|
||||
expect(names).toContain("--out=");
|
||||
expect(CLI_FLAGS.length).toBeGreaterThanOrEqual(6);
|
||||
// Back-compat alias points at the same array.
|
||||
expect(PYGIENIUM_FLAGS).toBe(CLI_FLAGS);
|
||||
});
|
||||
|
||||
it("buildPygieniumHelpLines surfaces every command usage and flag name", () => {
|
||||
const text = buildPygieniumHelpLines().join("\n");
|
||||
for (const cmd of COMMANDS) {
|
||||
expect(text).toContain(`/${cmd.usage}`);
|
||||
}
|
||||
for (const flag of CLI_FLAGS) {
|
||||
expect(text).toContain(flag.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists 8+ commands once several checks are registered", () => {
|
||||
registerCheck(stub("alpha"));
|
||||
registerCheck(stub("beta"));
|
||||
registerCheck(stub("gamma"));
|
||||
const lines = buildPygieniumHelpLines();
|
||||
// Any line that begins ` /pygienium-` is a command/check listing row.
|
||||
const commandRows = lines.filter((l) => l.startsWith(" /pygienium-"));
|
||||
// 6 operator command rows + 3 registered checks = 9.
|
||||
expect(commandRows.length).toBeGreaterThanOrEqual(8);
|
||||
// Each registered check is listed by name with its description.
|
||||
const text = lines.join("\n");
|
||||
expect(text).toContain("/pygienium-alpha");
|
||||
expect(text).toContain("/pygienium-beta");
|
||||
expect(text).toContain("/pygienium-gamma");
|
||||
expect(text).toContain("alpha check");
|
||||
});
|
||||
|
||||
it("notes the one-file + registerCheck extensibility workflow", () => {
|
||||
const text = buildPygieniumHelpLines().join("\n");
|
||||
expect(text).toContain("registerCheck");
|
||||
expect(text).toContain("No index.ts command-wiring changes");
|
||||
});
|
||||
});
|
||||
235
tests/per-check-resume.test.ts
Normal file
235
tests/per-check-resume.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* per-check-resume.test.ts — `/pygienium-<check>` resume semantics.
|
||||
*
|
||||
* The per-check command must be resume-aware (parity with
|
||||
* `/pygienium-all` and `/pygienium-resume`): a check already terminal
|
||||
* (`complete`/`skipped`) is skipped unless `--fresh`, and a failed check is
|
||||
* re-dispatched. This is what lets "running the original command again"
|
||||
* recover a partial run instead of blindly re-running every phase.
|
||||
*
|
||||
* A stateful tracker wraps the fake runner: the first call produces no
|
||||
* artifact (verify fails), the second writes findings.md (verify passes) —
|
||||
* modelling the intermittent empty-output bug recovering on retry.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Check whose fake runner writes findings.md and (with --fix) changes.md. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
// Verify hook asserting findings.md exists — like the real checks.
|
||||
verify: async (scope) => {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const f = join(scope.cwd, ".pygienium", "checks", name, "findings.md");
|
||||
try {
|
||||
await stat(f);
|
||||
} catch {
|
||||
return `${name} verify: expected findings.md at ${f} after scan, none found.`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||
const dispatched: string[] = [];
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, dispatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful runner: the first call returns ok with no artifact (verify fails),
|
||||
* the second writes findings.md (verify passes). Models the empty-output bug
|
||||
* recovering on retry.
|
||||
*/
|
||||
function flakyThenOkRunner(name: string): {
|
||||
runner: AgentRunner;
|
||||
calls: number;
|
||||
} {
|
||||
let calls = 0;
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, calls: 0 };
|
||||
}
|
||||
|
||||
describe("/pygienium-<check> resume semantics", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-pcr-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("skips an already-complete check and does not re-dispatch the agent", async () => {
|
||||
const track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
const check = fakeCheck("alpha");
|
||||
registerCheck(check);
|
||||
|
||||
// First run: completes and writes findings.md.
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
expect(track.dispatched).toEqual(["alpha"]);
|
||||
const state1 = await loadRunState(cwd);
|
||||
expect(state1?.checks.alpha.status).toBe("complete");
|
||||
|
||||
// Second run: terminal → skipped, no agent dispatch.
|
||||
const out = await captureStdout(() =>
|
||||
handleCheckCommand(check, "", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).toEqual(["alpha"]); // unchanged
|
||||
expect(out.join("\n")).toContain("already complete");
|
||||
expect(out.join("\n")).toContain("--fresh");
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.checks.alpha.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("--fresh re-runs a completed check from scratch", async () => {
|
||||
const track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
const check = fakeCheck("beta");
|
||||
registerCheck(check);
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
expect(track.dispatched).toEqual(["beta"]);
|
||||
|
||||
await captureStdout(() =>
|
||||
handleCheckCommand(check, "--fresh", stubCtx(cwd)),
|
||||
);
|
||||
// Dispatched again (now twice total).
|
||||
expect(track.dispatched).toEqual(["beta", "beta"]);
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks.beta.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("re-runs a failed check and recovers when the agent produces the artifact on retry", async () => {
|
||||
const flaky = flakyThenOkRunner("gamma");
|
||||
// Expose the live call count via closure read after the run.
|
||||
let calls = 0;
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
void flaky; // (flakyThenOkRunner kept as a reference shape; use `runner` below)
|
||||
setAgentRunner(runner);
|
||||
|
||||
const check = fakeCheck("gamma");
|
||||
registerCheck(check);
|
||||
|
||||
// First run: agent returns ok with no artifact → verify fails.
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state1 = await loadRunState(cwd);
|
||||
expect(state1?.checks.gamma.status).toBe("failed");
|
||||
expect(state1?.checks.gamma.error).toContain("verify");
|
||||
expect(state1?.checks.gamma.error).toContain("findings.md");
|
||||
|
||||
// Resume: re-dispatch the failed check. Agent writes findings.md this
|
||||
// time → verify passes → complete.
|
||||
await captureStdout(() => handleCheckCommand(check, "", stubCtx(cwd)));
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.checks.gamma.status).toBe("complete");
|
||||
expect(state2?.checks.gamma.error).toBeUndefined();
|
||||
// The verify phase is now complete (not failed).
|
||||
const verify = state2?.checks.gamma.phases.find((p) => p.id === "verify");
|
||||
expect(verify?.status).toBe("complete");
|
||||
// And the artifact exists on disk.
|
||||
const findings = await readFile(
|
||||
join(cwd, ".pygienium", "checks", "gamma", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(findings).toContain("gamma findings");
|
||||
});
|
||||
|
||||
it("does not treat a failed check as terminal (resume re-dispatches it)", async () => {
|
||||
const track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
const check = fakeCheck("delta");
|
||||
// Override verify to always fail so the check lands in `failed`.
|
||||
const alwaysFailing: CheckDefinition = {
|
||||
...check,
|
||||
name: "delta",
|
||||
verify: async () => "delta verify: forced failure",
|
||||
};
|
||||
registerCheck(alwaysFailing);
|
||||
|
||||
await handleCheckCommand(alwaysFailing, "", stubCtx(cwd));
|
||||
expect(track.dispatched).toEqual(["delta"]);
|
||||
const state1 = await loadRunState(cwd);
|
||||
expect(state1?.checks.delta.status).toBe("failed");
|
||||
|
||||
// Re-running the command re-dispatches (failed is NOT terminal).
|
||||
await captureStdout(() =>
|
||||
handleCheckCommand(alwaysFailing, "", stubCtx(cwd)),
|
||||
);
|
||||
expect(track.dispatched).toEqual(["delta", "delta"]);
|
||||
const state2 = await loadRunState(cwd);
|
||||
expect(state2?.checks.delta.status).toBe("failed"); // still failing
|
||||
});
|
||||
});
|
||||
|
||||
void mkdir; // silence unused-import lint under some configs
|
||||
69
tests/registry.test.ts
Normal file
69
tests/registry.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* registry.test.ts — unit tests for the check registry.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from "bun:test";
|
||||
import {
|
||||
clearChecks,
|
||||
getAllChecks,
|
||||
getCheck,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
|
||||
function stubCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
phaseId: "scan",
|
||||
buildScanTask: () => "scan",
|
||||
buildFixTask: () => "fix",
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check registry", () => {
|
||||
beforeEach(() => clearChecks());
|
||||
|
||||
it("registerCheck inserts and getAllChecks returns it", () => {
|
||||
registerCheck(stubCheck("comments"));
|
||||
const all = getAllChecks();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.name).toBe("comments");
|
||||
});
|
||||
|
||||
it("getCheck looks up by name", () => {
|
||||
registerCheck(stubCheck("complexity"));
|
||||
expect(getCheck("complexity")?.label).toBe("complexity");
|
||||
expect(getCheck("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registerCheck throws on duplicate names", () => {
|
||||
registerCheck(stubCheck("dup"));
|
||||
expect(() => registerCheck(stubCheck("dup"))).toThrow(/Duplicate/);
|
||||
});
|
||||
|
||||
it("registerCheck throws on invalid names", () => {
|
||||
expect(() => registerCheck(stubCheck("Bad-Name"))).toThrow(/Invalid/);
|
||||
expect(() => registerCheck(stubCheck("with space"))).toThrow(/Invalid/);
|
||||
expect(() => registerCheck(stubCheck(""))).toThrow(/Invalid/);
|
||||
});
|
||||
|
||||
it("clearChecks empties the registry", () => {
|
||||
registerCheck(stubCheck("a"));
|
||||
clearChecks();
|
||||
expect(getAllChecks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves insertion order", () => {
|
||||
registerCheck(stubCheck("alpha"));
|
||||
registerCheck(stubCheck("beta"));
|
||||
registerCheck(stubCheck("gamma"));
|
||||
expect(getAllChecks().map((c) => c.name)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
]);
|
||||
});
|
||||
});
|
||||
137
tests/run-state.test.ts
Normal file
137
tests/run-state.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* run-state.test.ts — run-state reconciliation, error hygiene, and the
|
||||
* .gitignore guard (issues surfaced by the MagnaFluo all-run: "partial" for
|
||||
* all-failed runs, stale errors on completed checks, staged artifacts).
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ensureRunStateIgnored,
|
||||
initRunState,
|
||||
markCheckStatus,
|
||||
reconcileRunStatus,
|
||||
} from "../src/run-state.js";
|
||||
|
||||
/** Build a run state whose checks carry the given statuses. */
|
||||
function stateWith(
|
||||
...statuses: Array<[name: string, status: string]>
|
||||
): ReturnType<typeof initRunState> {
|
||||
const state = initRunState(
|
||||
"/virtual/cwd",
|
||||
statuses.map(([name]) => ({ name, label: name })),
|
||||
);
|
||||
for (const [name, status] of statuses) {
|
||||
markCheckStatus(
|
||||
state,
|
||||
name,
|
||||
status as "complete" | "failed" | "skipped",
|
||||
status === "failed" ? "boom" : undefined,
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("reconcileRunStatus", () => {
|
||||
it("is in_progress while nothing is terminal", () => {
|
||||
expect(reconcileRunStatus(stateWith())).toBe("in_progress");
|
||||
expect(
|
||||
reconcileRunStatus(initRunState("/virt", [{ name: "a", label: "a" }])),
|
||||
).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("is complete only when every check is complete", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "complete"], ["b", "complete"])),
|
||||
).toBe("complete");
|
||||
});
|
||||
|
||||
it("is partial when some checks failed and others completed", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "complete"], ["b", "failed"])),
|
||||
).toBe("partial");
|
||||
});
|
||||
|
||||
it("is partial when checks were skipped", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "complete"], ["b", "skipped"])),
|
||||
).toBe("partial");
|
||||
});
|
||||
|
||||
it("is failed when every check failed (not partial)", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "failed"], ["b", "failed"])),
|
||||
).toBe("failed");
|
||||
expect(reconcileRunStatus(stateWith(["a", "failed"]))).toBe("failed");
|
||||
});
|
||||
|
||||
it("is partial for a mixed failed/skipped run (some degraded, none ok)", () => {
|
||||
expect(
|
||||
reconcileRunStatus(stateWith(["a", "failed"], ["b", "skipped"])),
|
||||
).toBe("partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("check error hygiene", () => {
|
||||
it("a failed check records its error", () => {
|
||||
const s = stateWith(["a", "failed"]);
|
||||
expect(s.checks.a?.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("a later success clears the stale error (resume-complete invariant)", () => {
|
||||
const s = stateWith(["a", "failed"]);
|
||||
expect(s.checks.a?.error).toBe("boom");
|
||||
markCheckStatus(s, "a", "complete");
|
||||
expect(s.checks.a?.error).toBeUndefined();
|
||||
expect(s.checks.a?.status).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRunStateIgnored", () => {
|
||||
let cwd: string;
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygium-git-"));
|
||||
await mkdir(join(cwd, ".git"), { recursive: true }); // pretend it's a work tree
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates .gitignore with .pygienium/ when absent", async () => {
|
||||
expect(await ensureRunStateIgnored(cwd)).toBe(true);
|
||||
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||
expect(content).toContain(".pygienium/");
|
||||
});
|
||||
|
||||
it("appends to an existing .gitignore without the marker", async () => {
|
||||
await writeFile(join(cwd, ".gitignore"), "node_modules/\n", "utf8");
|
||||
expect(await ensureRunStateIgnored(cwd)).toBe(true);
|
||||
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||
expect(content).toContain(".pygienium/");
|
||||
expect(content).toContain("node_modules/");
|
||||
});
|
||||
|
||||
it("leaves an existing marker untouched and reports no change", async () => {
|
||||
await writeFile(
|
||||
join(cwd, ".gitignore"),
|
||||
".pygienium/\nnode_modules/\n",
|
||||
"utf8",
|
||||
);
|
||||
expect(await ensureRunStateIgnored(cwd)).toBe(false);
|
||||
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||
expect(content).toBe(".pygienium/\nnode_modules/\n");
|
||||
});
|
||||
|
||||
it("is a no-op outside a git work tree", async () => {
|
||||
const plain = await mkdtemp(join(tmpdir(), "pygium-nogit-"));
|
||||
try {
|
||||
expect(await ensureRunStateIgnored(plain)).toBe(false);
|
||||
await expect(
|
||||
readFile(join(plain, ".gitignore"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(plain, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
422
tests/status-resume-export.test.ts
Normal file
422
tests/status-resume-export.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* status-resume-export.test.ts — integration test for task 13.
|
||||
*
|
||||
* Mirrors the spec scenario: start a hypothetical `/pygienium-all`, treat it as
|
||||
* interrupted (one check complete, one pending), then exercise
|
||||
* `/pygienium-status`, `/pygienium-resume`, and `/pygienium-export` and assert:
|
||||
*
|
||||
* - status reports accurate per-check progress (one complete, one pending,
|
||||
* run still in_progress);
|
||||
* - resume re-dispatches the pending check and DOES NOT re-run the complete
|
||||
* one (proven via the agent-runner call log), and the run ends complete;
|
||||
* - --fresh re-dispatches even the complete check (proven via call log);
|
||||
* - export produces a filtered markdown bundle on disk, and the --check=
|
||||
* and --out=json filters work.
|
||||
*
|
||||
* A tracker wraps the fake agent runner so we can assert which checks were
|
||||
* actually dispatched without depending on timing or a model.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import {
|
||||
handleStatusCommand,
|
||||
handleResumeCommand,
|
||||
handleExportCommand,
|
||||
type PygieniumCtx,
|
||||
} from "../src/commands.js";
|
||||
import {
|
||||
initRunState,
|
||||
loadRunState,
|
||||
saveRunState,
|
||||
markCheckStatus,
|
||||
recordCheckOutput,
|
||||
applyPhaseStatus,
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
} from "../src/run-state.js";
|
||||
import { formatRunStatus } from "../src/status.js";
|
||||
import {
|
||||
exportRun,
|
||||
gatherExportEntries,
|
||||
renderExportJson,
|
||||
renderExportMarkdown,
|
||||
parseExportFilters,
|
||||
canonicalChecksRoot,
|
||||
} from "../src/export.js";
|
||||
import type { AgentTaskOptions } from "../src/agent-runner.js";
|
||||
|
||||
/** Build a deterministic check whose fake runner writes on-disk artifacts. */
|
||||
function fakeCheck(name: string): CheckDefinition {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} check`,
|
||||
agentName: "scanner",
|
||||
fixAgentName: "fixer",
|
||||
phaseId: "scan",
|
||||
buildScanTask: (_cwd, scope) =>
|
||||
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||
buildFixTask: (_cwd, _scope, findings) =>
|
||||
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||
gate: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||
const dispatched: string[] = [];
|
||||
const runner: AgentRunner = async (opts) => {
|
||||
// Tag by check name from the task text (`!write .pygienium/checks/<name>/`).
|
||||
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||
if (m) dispatched.push(m[1] as string);
|
||||
return fakeAgentRunner(opts);
|
||||
};
|
||||
return { runner, dispatched };
|
||||
}
|
||||
|
||||
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||
chunk: unknown,
|
||||
) => {
|
||||
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mark a check as fully complete in the run-state with captured output. */
|
||||
function markComplete(
|
||||
state: Parameters<typeof markCheckStatus>[0],
|
||||
name: string,
|
||||
findings: string,
|
||||
changes: string,
|
||||
): void {
|
||||
for (const phaseId of [
|
||||
PHASE_RECON,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_FIX,
|
||||
PHASE_VERIFY,
|
||||
PHASE_CLEANUP,
|
||||
]) {
|
||||
applyPhaseStatus(state, name, phaseId, "complete");
|
||||
}
|
||||
recordCheckOutput(state, name, { findings, changes });
|
||||
markCheckStatus(state, name, "complete");
|
||||
}
|
||||
|
||||
describe("status / resume / export (task 13)", () => {
|
||||
let cwd: string;
|
||||
let track: ReturnType<typeof trackingRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
track = trackingRunner();
|
||||
setAgentRunner(track.runner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-resume-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Seed an interrupted two-check run: `alpha` complete, `beta` pending. */
|
||||
async function seedInterruptedRun(
|
||||
fix = true,
|
||||
): Promise<{ state: ReturnType<typeof initRunState> }> {
|
||||
const state = initRunState(cwd, [
|
||||
{ name: "alpha", label: "alpha", fix },
|
||||
{ name: "beta", label: "beta", fix },
|
||||
]);
|
||||
state.recon = {
|
||||
complete: true,
|
||||
path: `${cwd}/.pygienium/recon.json`,
|
||||
finishedAt: Date.now(),
|
||||
};
|
||||
// Simulate alpha fully complete with on-disk artifacts + captured text.
|
||||
markComplete(
|
||||
state,
|
||||
"alpha",
|
||||
"# alpha findings\nalpha-scan",
|
||||
"# alpha changes\nalpha-fix",
|
||||
);
|
||||
const alphaDir = join(canonicalChecksRoot(cwd), "alpha");
|
||||
await mkdir(alphaDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(alphaDir, "findings.md"),
|
||||
"# alpha findings\nalpha-scan\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(alphaDir, "changes.md"),
|
||||
"# alpha changes\nalpha-fix\n",
|
||||
"utf8",
|
||||
);
|
||||
// beta left pending (the interruption). For alpha's recon phase, mark it too.
|
||||
applyPhaseStatus(state, "beta", PHASE_RECON, "complete");
|
||||
await saveRunState(state);
|
||||
return { state };
|
||||
}
|
||||
|
||||
it("formatRunStatus reports accurate per-check progress (alpha complete, beta pending)", async () => {
|
||||
const { state } = await seedInterruptedRun();
|
||||
const lines = formatRunStatus(state);
|
||||
expect(lines.join("\n")).toContain("pygienium run — in_progress");
|
||||
expect(lines.join("\n")).toContain("alpha — complete");
|
||||
expect(lines.join("\n")).toContain("beta — pending");
|
||||
// Artifacts captured on alpha appear; beta has none.
|
||||
expect(lines.join("\n")).toContain("findings: 2 line(s)");
|
||||
expect(lines.join("\n")).toContain("changes: 2 line(s)");
|
||||
});
|
||||
|
||||
it("/pygienium-status prints the status line list end to end", async () => {
|
||||
const { state } = await seedInterruptedRun();
|
||||
const out = await captureStdout(() =>
|
||||
handleStatusCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out.join("\n")).toContain("alpha — complete");
|
||||
expect(out.join("\n")).toContain("beta — pending");
|
||||
expect(out.join("\n")).toContain("pygienium run — in_progress");
|
||||
void state;
|
||||
});
|
||||
|
||||
it("/pygienium-status with no run state prints a not-found message", async () => {
|
||||
const out = await captureStdout(() =>
|
||||
handleStatusCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("no run state found");
|
||||
});
|
||||
|
||||
it("/pygienium-resume re-dispatches beta without re-running complete alpha", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
void state;
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleResumeCommand("", stubCtx(cwd)),
|
||||
);
|
||||
|
||||
// alpha is complete and must NOT be re-dispatched; beta was pending.
|
||||
expect(track.dispatched).not.toContain("alpha");
|
||||
expect(track.dispatched).toContain("beta");
|
||||
|
||||
// The run should now be complete.
|
||||
const after = await loadRunState(cwd);
|
||||
expect(after?.status).toBe("complete");
|
||||
expect(after?.checks.alpha.status).toBe("complete");
|
||||
expect(after?.checks.beta.status).toBe("complete");
|
||||
|
||||
// beta's artifacts now exist on disk.
|
||||
const betaFindings = await readFile(
|
||||
join(canonicalChecksRoot(cwd), "beta", "findings.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(betaFindings).toContain("beta findings");
|
||||
|
||||
// Summary mentions re-dispatched/skipped counts.
|
||||
expect(out.join("\n")).toContain("re-dispatched 1");
|
||||
expect(out.join("\n")).toContain("skipped 1");
|
||||
});
|
||||
|
||||
it("/pygienium-resume --fresh re-dispatches the complete check too", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await seedInterruptedRun();
|
||||
|
||||
await captureStdout(() => handleResumeCommand("--fresh", stubCtx(cwd)));
|
||||
|
||||
expect(track.dispatched).toContain("alpha");
|
||||
expect(track.dispatched).toContain("beta");
|
||||
const after = await loadRunState(cwd);
|
||||
expect(after?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("/pygienium-resume with no state prints nothing-to-resume", async () => {
|
||||
const out = await captureStdout(() =>
|
||||
handleResumeCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("no run state to resume");
|
||||
});
|
||||
|
||||
it("/pygienium-resume on an already-complete run refuses without --fresh", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
// Complete beta too so the whole run is complete.
|
||||
markComplete(
|
||||
state,
|
||||
"beta",
|
||||
"# beta findings\nbeta-scan",
|
||||
"# beta changes\nbeta-fix",
|
||||
);
|
||||
await saveRunState(state);
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleResumeCommand("", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("nothing to resume");
|
||||
expect(track.dispatched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/pygienium-export writes a markdown bundle with both checks", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
// Run beta via resume so its artifacts land on disk.
|
||||
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
|
||||
void state;
|
||||
|
||||
await captureStdout(() => handleExportCommand("", stubCtx(cwd)));
|
||||
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
|
||||
expect(bundle).toContain("# Pygienium export");
|
||||
expect(bundle).toContain("## alpha (complete)");
|
||||
expect(bundle).toContain("## beta (complete)");
|
||||
expect(bundle).toContain("# alpha findings");
|
||||
expect(bundle).toContain("# beta findings");
|
||||
});
|
||||
|
||||
it("/pygienium-export --check=beta produces a filtered bundle", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await seedInterruptedRun();
|
||||
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
|
||||
|
||||
await captureStdout(() =>
|
||||
handleExportCommand("--check=beta", stubCtx(cwd)),
|
||||
);
|
||||
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
|
||||
expect(bundle).toContain("## beta (complete)");
|
||||
expect(bundle).not.toContain("## alpha");
|
||||
});
|
||||
|
||||
it("/pygienium-export --status=failed includes only failed checks", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
const { state } = await seedInterruptedRun();
|
||||
// Mark alpha failed (artifacts already on disk from the seed); leave beta pending.
|
||||
markCheckStatus(state, "alpha", "failed", "fake failure");
|
||||
await saveRunState(state);
|
||||
|
||||
await captureStdout(() =>
|
||||
handleExportCommand("--status=failed", stubCtx(cwd)),
|
||||
);
|
||||
const bundle = await readFile(join(cwd, ".pygienium", "export.md"), "utf8");
|
||||
expect(bundle).toContain("## alpha (failed)");
|
||||
expect(bundle).not.toContain("## beta");
|
||||
});
|
||||
|
||||
it("/pygienium-export --out=json writes JSON matching renderExportJson", async () => {
|
||||
registerCheck(fakeCheck("alpha"));
|
||||
registerCheck(fakeCheck("beta"));
|
||||
await seedInterruptedRun();
|
||||
await captureStdout(() => handleResumeCommand("", stubCtx(cwd)));
|
||||
|
||||
const out = await captureStdout(() =>
|
||||
handleExportCommand("--out=json", stubCtx(cwd)),
|
||||
);
|
||||
expect(out.join("\n")).toContain("export.json");
|
||||
const raw = await readFile(join(cwd, ".pygienium", "export.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
checks: Array<{ name: string; status: string; findings: string }>;
|
||||
};
|
||||
const names = parsed.checks.map((c) => c.name).sort();
|
||||
expect(names).toEqual(["alpha", "beta"]);
|
||||
expect(parsed.checks.find((c) => c.name === "alpha")?.findings).toContain(
|
||||
"alpha findings",
|
||||
);
|
||||
|
||||
// renderExportJson matches the on-disk content for the gathered set.
|
||||
const state = await loadRunState(cwd);
|
||||
const entries = await gatherExportEntries(cwd, state);
|
||||
expect(renderExportJson(state, entries).trim()).toBe(raw.trim());
|
||||
});
|
||||
|
||||
it("parseExportFilters splits comma lists and trims values", () => {
|
||||
const f = parseExportFilters(
|
||||
"--check=alpha,beta --status=complete,failed --out=json",
|
||||
);
|
||||
expect(f.check).toEqual(["alpha", "beta"]);
|
||||
expect(f.status).toEqual(["complete", "failed"]);
|
||||
expect(f.out).toBe("json");
|
||||
});
|
||||
|
||||
it("gatherExportEntries reads only the canonical .pygienium/checks/ root", async () => {
|
||||
await mkdir(join(cwd, ".pygienium", "checks", "alpha"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
|
||||
"# alpha findings\n",
|
||||
"utf8",
|
||||
);
|
||||
// A stray pygienium/checks/ dir (the old non-hidden root) is ignored now
|
||||
// that all checks write to the single canonical `.pygienium/checks/` root.
|
||||
await mkdir(join(cwd, "pygienium", "checks", "ghost"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(cwd, "pygienium", "checks", "ghost", "findings.md"),
|
||||
"# ghost findings\n",
|
||||
"utf8",
|
||||
);
|
||||
const entries = await gatherExportEntries(cwd, undefined);
|
||||
const alpha = entries.find((e) => e.name === "alpha");
|
||||
expect(alpha).toBeDefined();
|
||||
expect(alpha?.findings).toContain("alpha findings");
|
||||
expect(alpha?.status).toBe("unknown");
|
||||
expect(alpha?.findingsPath).toBe(
|
||||
join(cwd, ".pygienium", "checks", "alpha", "findings.md"),
|
||||
);
|
||||
expect(entries.find((e) => e.name === "ghost")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renderExportMarkdown includes a 'no artifacts' note for empty checks", async () => {
|
||||
const entries = [{ name: "ghost", status: "unknown" }];
|
||||
const md = renderExportMarkdown(undefined, entries as never);
|
||||
expect(md).toContain("## ghost (unknown)");
|
||||
expect(md).toContain("no findings.md or changes.md on disk");
|
||||
// And renderExportJson emits nulls for the empty check.
|
||||
const json = renderExportJson(undefined, entries as never);
|
||||
const parsed = JSON.parse(json) as {
|
||||
checks: Array<{ findings: unknown; changes: unknown }>;
|
||||
};
|
||||
expect(parsed.checks[0]!.findings).toBeNull();
|
||||
expect(parsed.checks[0]!.changes).toBeNull();
|
||||
});
|
||||
|
||||
it("exportRun writes nothing useful and reports zero entries cleanly", async () => {
|
||||
const result = await exportRun(cwd, undefined, {});
|
||||
expect(result.entries).toHaveLength(0);
|
||||
expect(relative(cwd, result.path)).toBe(join(".pygienium", "export.md"));
|
||||
});
|
||||
});
|
||||
337
tests/todos.test.ts
Normal file
337
tests/todos.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* todos.test.ts — unit + integration tests for the todos (TODOs & stubs) check.
|
||||
*
|
||||
* Unit: `detectTodoStubs` over a multi-language fixture tree — markers, silent
|
||||
* stubs (placeholder return / empty body / pass-only body), loud stubs, and
|
||||
* the negative cases (a real adder, a `return null` catch handler, in-string
|
||||
* "TODO" flagged for recall, clean code never flagged).
|
||||
*
|
||||
* Integration (deterministic fake agent runner): the scan persists findings.md
|
||||
* with the three sections + machine-readable summary and a new/resolved delta
|
||||
* vs the previous run; --fix converts silent stubs to loud throws, preserves
|
||||
* markers, leaves loud stubs untouched, and records changes.md.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
getCheck,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
fakeAgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import {
|
||||
findingsPath,
|
||||
changesPath,
|
||||
check as todosCheck,
|
||||
detectTodoStubs,
|
||||
todosPriorCounts,
|
||||
buildTodosScanTask,
|
||||
} from "../src/checks/todos.js";
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `stubs.ts` with the full taxonomy: a TODO marker, a silent stub with a
|
||||
* placeholder return (getPrice), a silent stub with an empty body (notify),
|
||||
* and a loud stub (connect throws "Not implemented").
|
||||
*/
|
||||
async function seedStubs(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await writeFile(
|
||||
join(dir, "stubs.ts"),
|
||||
[
|
||||
`// TODO: add pagination`,
|
||||
`export function getPrice(): number {`,
|
||||
` return 0;`,
|
||||
`}`,
|
||||
``,
|
||||
`export function notify(): void {}`,
|
||||
``,
|
||||
`export function connect(): Promise<void> {`,
|
||||
` throw new Error("Not implemented");`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/** Seed the multi-language fixture tree for the deterministic-detector tests. */
|
||||
async function seedTree(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(dir, "math.ts"),
|
||||
"export function add(a: number, b: number): number {\n return a + b;\n}\n",
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
join(dir, "parse.ts"),
|
||||
[
|
||||
`export function parse(input: string) {`,
|
||||
` try {`,
|
||||
` return JSON.parse(input);`,
|
||||
` } catch {`,
|
||||
` return null;`,
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(join(dir, "stringlit.ts"), 'export const op = "TODO";\n', "utf8"),
|
||||
writeFile(
|
||||
join(dir, "stubs.ts"),
|
||||
[
|
||||
`// TODO: add pagination`,
|
||||
`export function getPrice(): number {`,
|
||||
` return 0;`,
|
||||
`}`,
|
||||
``,
|
||||
`export function notify(): void {}`,
|
||||
``,
|
||||
`export function connect(): Promise<void> {`,
|
||||
` throw new Error("Not implemented");`,
|
||||
`}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
join(dir, "repo.py"),
|
||||
[
|
||||
`class Repository:`,
|
||||
` def find(self, uid):`,
|
||||
` raise NotImplementedError # interface method`,
|
||||
``,
|
||||
` def save(self, record):`,
|
||||
` pass`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
join(dir, "fetch.rs"),
|
||||
"fn fetch() -> Result<u32, String> {\n todo!()\n}\n",
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("detectTodoStubs", () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "pygienium-todos-unit-"));
|
||||
await seedTree(dir);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("flags markers, silent stubs, and loud stubs across languages", async () => {
|
||||
const hits = await detectTodoStubs(dir);
|
||||
const marker = hits.filter((h) => h.kind === "marker");
|
||||
const silent = hits.filter((h) => h.kind === "silent-stub");
|
||||
const loud = hits.filter((h) => h.kind === "loud-stub");
|
||||
|
||||
// Clean code is never flagged: the adder and the `return null` catch
|
||||
// handler (a boundary handler, not a stub) produce zero hits.
|
||||
expect(
|
||||
hits.filter((h) => /(?:math\.ts|parse\.ts)$/.test(h.path)),
|
||||
).toHaveLength(0);
|
||||
|
||||
// Markers: the stubs.ts TODO comment, and the in-string "TODO" (recall —
|
||||
// the scan agent's job is to drop the string-literal noise).
|
||||
expect(
|
||||
marker.some(
|
||||
(h) =>
|
||||
h.path.endsWith("stubs.ts") && h.line === 1 && h.snippet === "TODO",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
marker.some(
|
||||
(h) => h.path.endsWith("stringlit.ts") && h.snippet === "TODO",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Silent stubs: lone placeholder return + empty body in stubs.ts,
|
||||
// pass-only body in repo.py — each with its enclosing function.
|
||||
const stubsSilent = silent.filter((h) => h.path.endsWith("stubs.ts"));
|
||||
expect(
|
||||
stubsSilent.some(
|
||||
(h) => h.snippet === "placeholder-return" && h.context === "getPrice",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
stubsSilent.some(
|
||||
(h) => h.snippet === "empty-body" && h.context === "notify",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
silent.some(
|
||||
(h) =>
|
||||
h.path.endsWith("repo.py") &&
|
||||
h.snippet === "pass-only" &&
|
||||
h.context === "save",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Loud stubs: "Not implemented" throw, raise NotImplementedError,
|
||||
// rust todo!() — reported as tracked debt.
|
||||
expect(
|
||||
loud.some(
|
||||
(h) => h.path.endsWith("stubs.ts") && h.snippet === "Not implemented",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loud.some(
|
||||
(h) =>
|
||||
h.path.endsWith("repo.py") && h.snippet === "NotImplementedError",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("todos check", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(fakeAgentRunner);
|
||||
// Re-register explicitly: the module's import-time registerCheck only
|
||||
// runs once (module cache), so clearChecks + registerCheck restores it
|
||||
// deterministically for each test.
|
||||
registerCheck(todosCheck);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-todos-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("is registered and uses the todos scanner agent", () => {
|
||||
const check = getCheck("todos");
|
||||
expect(check).toBeDefined();
|
||||
expect((check as CheckDefinition)?.agentName).toBe("todos");
|
||||
});
|
||||
|
||||
it("scan-only writes findings.md with the three sections and leaves sources untouched", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
const findings = await readFile(findingsPath(cwd), "utf8");
|
||||
expect(findings).toContain(
|
||||
"summary: 1 marker(s), 2 silent stub(s), 1 loud stub(s)",
|
||||
);
|
||||
expect(findings).toContain("new: 4"); // first run: everything is new
|
||||
expect(findings).toContain("## TODO markers");
|
||||
expect(findings).toContain("## Silent stubs (actionable)");
|
||||
expect(findings).toContain("placeholder-return");
|
||||
expect(findings).toContain("Not implemented");
|
||||
|
||||
// Scan-only: no changes.md, sources untouched.
|
||||
expect(existsSync(changesPath(cwd))).toBe(false);
|
||||
const untouched = await readFile(join(cwd, "stubs.ts"), "utf8");
|
||||
expect(untouched).toContain("return 0;");
|
||||
|
||||
// Run state records the scan summary as findings text.
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["todos"]?.status).toBe("complete");
|
||||
expect(state?.checks["todos"]?.findings).toContain(
|
||||
"todos: 2 silent stub(s), 1 loud stub(s), 1 marker(s)",
|
||||
);
|
||||
});
|
||||
|
||||
it("--fix converts silent stubs to loud throws, preserves markers and loud stubs, records changes.md", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
|
||||
const cleaned = await readFile(join(cwd, "stubs.ts"), "utf8");
|
||||
// Silent stubs now throw loudly, naming the function.
|
||||
expect(cleaned).toContain('throw new Error("todos: getPrice() is a stub")');
|
||||
expect(cleaned).toContain('throw new Error("todos: notify() is a stub")');
|
||||
// Markers are NEVER deleted and loud stubs are NEVER touched.
|
||||
expect(cleaned).toContain("// TODO: add pagination");
|
||||
expect(cleaned).toContain('throw new Error("Not implemented")');
|
||||
expect(cleaned).toContain("Cleaned by pygienium-todos");
|
||||
// The placeholder bodies are gone.
|
||||
expect(cleaned).not.toContain("return 0;");
|
||||
expect(cleaned).not.toContain("notify(): void {}");
|
||||
|
||||
const changes = await readFile(changesPath(cwd), "utf8");
|
||||
expect(changes).toContain(
|
||||
"summary: 2 silent stub(s) converted to loud, 0 kept",
|
||||
);
|
||||
expect(changes).toContain("getPrice()");
|
||||
expect(changes).toContain("notify()");
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["todos"]?.fix).toBe(true);
|
||||
expect(state?.checks["todos"]?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("findings.md and changes.md live under .pygienium/checks/todos/", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
||||
expect(findingsPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "todos", "findings.md"),
|
||||
);
|
||||
expect(changesPath(cwd)).toBe(
|
||||
join(cwd, ".pygienium", "checks", "todos", "changes.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when the target has no source files", async () => {
|
||||
// cwd is a fresh empty tempdir (no seeding) → the gate finds no source
|
||||
// files and skips the check without spawning an agent.
|
||||
const check = getCheck("todos")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
expect(state?.checks["todos"]?.status).toBe("skipped");
|
||||
});
|
||||
|
||||
it("reports a new/resolved delta against the previous run's counts", async () => {
|
||||
await seedStubs(cwd);
|
||||
const check = getCheck("todos")!;
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
// The previous run's verified counts are parseable from run-state.
|
||||
const prior = await todosPriorCounts(cwd);
|
||||
expect(prior).toEqual({ silent: 2, loud: 1, marker: 1 });
|
||||
|
||||
// Nobody flagged anything new and everything was resolved: the next scan
|
||||
// sees zero candidates → new: 0, resolved: 4 (all prior items).
|
||||
await rm(join(cwd, "stubs.ts"));
|
||||
const task = await buildTodosScanTask(cwd, {
|
||||
cwd,
|
||||
target: cwd,
|
||||
fix: false,
|
||||
rest: [],
|
||||
});
|
||||
expect(task).toContain(
|
||||
"summary: 0 marker(s), 0 silent stub(s), 0 loud stub(s)",
|
||||
);
|
||||
expect(task).toContain("| new: 0 | resolved: 4 |");
|
||||
});
|
||||
});
|
||||
146
tests/verify-hooks.test.ts
Normal file
146
tests/verify-hooks.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* verify-hooks.test.ts — proves every artifact-producing check fails loudly
|
||||
* when its sub-agent returns ok without writing findings.md.
|
||||
*
|
||||
* This is the exact failure mode the MagniFluo run exposed: complexity,
|
||||
* deep-modules, and defensive-guards returned ok with empty text in
|
||||
* milliseconds, produced no findings.md, and — because they had no `verify`
|
||||
* hook — were stamped `complete` by the fallback gate re-run. todos was the
|
||||
* only one that failed, solely because it already had a verify hook.
|
||||
*
|
||||
* Each check now carries a `verify` hook asserting its artifacts landed. A
|
||||
* no-op agent runner (ok + empty text + no writes) must fail at verify with a
|
||||
* message naming the missing findings.md, and the check status must be
|
||||
* `failed` — never `complete`.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
clearChecks,
|
||||
registerCheck,
|
||||
type CheckDefinition,
|
||||
} from "../src/checks/registry.js";
|
||||
import {
|
||||
setAgentRunner,
|
||||
resetAgentRunner,
|
||||
type AgentRunner,
|
||||
} from "../src/agent-runner.js";
|
||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||
import { loadRunState } from "../src/run-state.js";
|
||||
import { check as complexityCheck } from "../src/checks/complexity.js";
|
||||
import { check as deadCodeCheck } from "../src/checks/dead-code.js";
|
||||
import { check as deepModulesCheck } from "../src/checks/deep-modules.js";
|
||||
import { check as defensiveGuardsCheck } from "../src/checks/defensive-guards.js";
|
||||
|
||||
/** Agent runner that simulates the MagniFluo bug: ok, empty, no writes. */
|
||||
const noopRunner: AgentRunner = async () => ({
|
||||
ok: true,
|
||||
text: "",
|
||||
});
|
||||
|
||||
function stubCtx(cwd: string): PygieniumCtx {
|
||||
return { cwd, hasUI: false, ui: undefined } as PygieniumCtx;
|
||||
}
|
||||
|
||||
describe("verify hooks fail loudly on empty agent output", () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
clearChecks();
|
||||
setAgentRunner(noopRunner);
|
||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-verify-"));
|
||||
// Seed one source file so the source-file gates (deep-modules,
|
||||
// defensive-guards, dead-code) pass and the check reaches analysis.
|
||||
await writeFile(join(cwd, "sample.ts"), "export const x = 1;\n", "utf8");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentRunner();
|
||||
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
* Run a check with the no-op runner and assert it fails at verify — for
|
||||
* checks where the sub-agent (not the task builder) is responsible for
|
||||
* writing findings.md.
|
||||
*/
|
||||
async function assertFailsVerify(
|
||||
check: CheckDefinition,
|
||||
findingsNeedle: string,
|
||||
): Promise<void> {
|
||||
registerCheck(check);
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
const entry = state?.checks[check.name];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.status).toBe("failed");
|
||||
expect(entry?.error).toContain("verify");
|
||||
expect(entry?.error).toContain("findings.md");
|
||||
// The verify phase itself is marked failed (not analysis).
|
||||
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
|
||||
expect(verifyPhase?.status).toBe("failed");
|
||||
expect(verifyPhase?.error).toContain(findingsNeedle);
|
||||
// Analysis reported ok (the bug: ok + empty), but no findings captured.
|
||||
const analysisPhase = entry?.phases.find((p) => p.id === "analysis");
|
||||
expect(analysisPhase?.status).toBe("complete");
|
||||
expect(entry?.findings).toBe("");
|
||||
}
|
||||
|
||||
it("complexity fails verify when findings.md is missing", async () => {
|
||||
await assertFailsVerify(complexityCheck, "complexity verify");
|
||||
});
|
||||
|
||||
it("deep-modules fails verify when findings.md is missing", async () => {
|
||||
await assertFailsVerify(deepModulesCheck, "deep-modules verify");
|
||||
});
|
||||
|
||||
it("defensive-guards fails verify when findings.md is missing", async () => {
|
||||
await assertFailsVerify(defensiveGuardsCheck, "defensive-guards verify");
|
||||
});
|
||||
|
||||
/**
|
||||
* dead-code is hybrid: its `buildDeadCodeScanTask` deterministically
|
||||
* writes findings.md via a pre-scan BEFORE the agent runs. So a no-op
|
||||
* agent still leaves the artifact, and verify correctly passes — proving
|
||||
* the hook does not false-positive on dead-code's robust design. The
|
||||
* grep on the verify hook is still live: delete the pre-written file and
|
||||
* the same hook fails (asserted in the --fix case below for changes.md).
|
||||
*/
|
||||
it("dead-code verify passes with a no-op agent (deterministic pre-scan wrote findings.md)", async () => {
|
||||
registerCheck(deadCodeCheck);
|
||||
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
|
||||
const state = await loadRunState(cwd);
|
||||
const entry = state?.checks["dead-code"];
|
||||
expect(entry?.status).toBe("complete");
|
||||
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
|
||||
expect(verifyPhase?.status).toBe("complete");
|
||||
// The findings.md the pre-scan wrote is on disk.
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const { findingsPath } = await import("../src/checks/dead-code.js");
|
||||
await expect(stat(findingsPath(cwd))).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("with --fix, a missing changes.md fails verify even when findings.md exists", async () => {
|
||||
// Defensive-guards: write findings.md ourselves so the findings check
|
||||
// passes, but leave changes.md absent — verify must still fail.
|
||||
const { mkdir, writeFile: wf } = await import("node:fs/promises");
|
||||
const { dirname } = await import("node:path");
|
||||
const { findingsPath } = await import("../src/checks/defensive-guards.js");
|
||||
const f = findingsPath(cwd);
|
||||
await mkdir(dirname(f), { recursive: true });
|
||||
await wf(f, "# findings\n", "utf8");
|
||||
|
||||
registerCheck(defensiveGuardsCheck);
|
||||
// Runner writes changes.md content into its text but never to disk.
|
||||
setAgentRunner(async () => ({ ok: true, text: "" }));
|
||||
await handleCheckCommand(defensiveGuardsCheck, "--fix", stubCtx(cwd));
|
||||
|
||||
const state = await loadRunState(cwd);
|
||||
const entry = state?.checks["defensive-guards"];
|
||||
expect(entry?.status).toBe("failed");
|
||||
expect(entry?.error).toContain("changes.md");
|
||||
expect(entry?.error).toContain("verify");
|
||||
});
|
||||
});
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user