Initial commit: pygenium as git submodule

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

0
src/checks/.gitkeep Normal file
View File

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

@@ -0,0 +1,231 @@
/**
* checks/comments.ts — comments hygiene check (first end-to-end reference).
*
* This is the canonical `CheckDefinition` that future checks (08+) copy. It
* reuses the generic `scanner`/`fixer` agents shipped in `agents/*.md`; the
* check-specific rubric is embedded in the task text handed to the sub-agent
* (see {@link buildCommentsScanTask} / {@link buildCommentsFixTask}) so no
* per-check agent `.md` file is required.
*
* Rubric (the user's spec — short + high value):
* - comments that restate the code they sit on ("what" comments) → REMOVE
* - verbose narration / long-winded explanations → TIGHTEN (shorten)
* - "why" comments that explain intent, rationale, or gotchas → KEEP
* - code self-explanatory with no comment → no comment needed (don't add one)
*
* Artifacts (under `<cwd>/pygienium/checks/comments/`):
* - `findings.md` — per-file line refs for each smell
* - `changes.md` — summary of edits + human-review items
*
* @module pygienium/checks/comments
*/
import { registerCheck, type CheckScope } from "./registry.js";
/** Phase-strip phase this check belongs to. */
export const COMMENTS_PHASE_ID = "C1";
/**
* Directory where this check writes its `findings.md` and `changes.md`
* artifacts: `<cwd>/pygienium/checks/comments/`. Based on `scope.cwd` (the
* project root, always a directory) so the path is valid whether the scan
* target is a single file or a directory. Matches the spec's
* `pygienium/checks/comments/findings.md` relative-path notation.
*/
export function commentsArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/comments`;
}
/** Absolute path to the findings artifact for this check. */
export function findingsPath(scope: CheckScope): string {
return `${commentsArtifactDir(scope)}/findings.md`;
}
/** Absolute path to the changes artifact for this check. */
export function changesPath(scope: CheckScope): string {
return `${commentsArtifactDir(scope)}/changes.md`;
}
/**
* Shared rubric block, injected into both scan and fix task text so the analysis
* and remediation sub-agents apply identical judgement.
*/
const RUBRIC = `# Comments hygiene rubric
Short + high value is the goal. Evaluate every comment in the target:
- **RESTATE → REMOVE.** A comment that paraphrases the line(s) it sits on adds
no information. Examples: \`// increment i\` over \`i++\`, \`// return the
result\` over \`return result\`. Delete it.
- **VERBOSE → TIGHTEN.** A comment that is high-value but needlessly long.
Rewrite it to one tight sentence preserving the key insight. Do not delete.
- **"WHY" → KEEP.** A comment explaining intent, rationale, a non-obvious
decision, a workaround, a gotcha, or a constraint the code cannot express.
Leave it untouched (tighten only if it is also verbose).
- **NO COMMENT NEEDED.** When the code is self-explanatory, do not add a comment.
- Keep inline section headers/dividers that aid navigation only if they mark a
real boundary; remove pure decoration.`;
/**
* Build the analysis sub-agent task. Instructs the agent to read candidate
* source files, identify comment smells per the rubric, and write per-file line
* references to `pygienium/comments/findings.md`.
*/
export function buildCommentsScanTask(_cwd: string, scope: CheckScope): string {
const outDir = commentsArtifactDir(scope);
const findingsFile = findingsPath(scope);
return `# Task: comments hygiene scan
You are running the **comments** hygiene check.
## Target
- Scan target: \`${scope.target}\`
## What to do
1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it
exists; otherwise enumerate source files directly under the target.
2. For each source file, read it and locate every comment (inline \`//\`,
block \`/* */\`, doc \`/** */\`, \`#\` for scripting languages, etc.).
3. Apply the rubric below to each comment and classify it: RESTATE, VERBOSE,
WHY, or OK.
4. Write a findings report to \`${findingsFile}\` with per-file line refs.
5. Return the findings report text as your final message (same content as the
file). The host captures it as the analysis-phase findings.
${RUBRIC}
## findings.md format
\`\`\`markdown
# comments — findings
<count> comment smell(s) across <files> file(s).
## <relative-file>
- L<line>: <smell: RESTATE|VERBOSE> — <quote or paraphrase>
- L<line>: KEEP (why) — <one-line reason> # listed for transparency
\`\`\`
If no smells are found, write \`# comments — findings\n\n0 comment smell(s).\`
and return that text. Always create findings.md so the run has an artifact.
Write the report under \`${outDir}\` (create directories as needed).
`;
}
/**
* Build the fix sub-agent task from the scan findings. Instructs the agent to
* apply safe removals/tightenings, leave "why" comments, and write a summary
* of edits plus anything needing human review to `pygienium/comments/changes.md`.
*/
export function buildCommentsFixTask(
_cwd: string,
scope: CheckScope,
findings: string,
): string {
const outDir = commentsArtifactDir(scope);
const changesFile = changesPath(scope);
return `# Task: comments hygiene fix
You are running the **comments** hygiene fix phase.
## Target
- Fix target: \`${scope.target}\`
## Input: scan findings
${findings.trim().length > 0 ? findings : "(no findings text provided)"}
## What to do
1. For each RESTATE finding: remove the comment entirely.
2. For each VERBOSE finding: replace the comment with a tightened one-sentence
version that keeps the key insight.
3. For every WHY comment: leave it untouched (tighten only if it is also
verbose, preserving the rationale).
4. Do not change any code logic, formatting, or ordering — only comments.
5. Write a summary to \`${changesFile}\` and return it as your final message.
${RUBRIC}
## changes.md format
\`\`\`markdown
# comments — changes
<applied> edit(s) applied; <deferred> deferred for human review.
## Applied
- <relative-file>:<line> — <removed|tightened> comment (auto)
## Needs human review
- <relative-file>:<line> — <reason> (manual)
\`\`\`
If nothing needed changing, write
\`# comments — changes\n\n0 edit(s) applied.\` and return that text. Always
create changes.md so the run has an artifact. Write it under \`${outDir}\`.
`;
}
/**
* Precondition gate. Returns an error string when the comments check cannot
* proceed (target path missing or not a real file/directory), else
* `undefined`. Idempotent — passes identically before analysis and at verify.
*/
async function commentsGate(cwd: string): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const target = resolve(cwd);
try {
const s = await stat(target);
if (s.isDirectory() || s.isFile()) return undefined;
return `target is not a file or directory: ${target}`;
} catch {
return `target path does not exist: ${target}`;
}
}
/**
* Verify hook: confirms the check actually produced its artifacts. After the
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
* exist too (the scan-phase-only run skips `changes.md` by design). Returns an
* error string to fail verify, or `undefined` to pass. Replaces the historical
* no-op verify (which only re-ran the existence gate) so the verify phase now
* genuinely asserts the run produced its report.
*/
async function commentsVerify(scope: CheckScope): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const f = findingsPath(scope);
try {
await stat(f);
} catch {
return `comments verify: expected findings.md at ${f} after scan, none found.`;
}
if (scope.fix) {
const c = changesPath(scope);
try {
await stat(c);
} catch {
return `comments verify: expected changes.md at ${c} after --fix, none found.`;
}
}
return undefined;
}
/** The comments hygiene check definition. */
export const commentsCheck = {
name: "comments",
label: "Comments",
description:
'Remove low-value/restating comments, tighten verbose ones, keep "why" comments.',
agentName: "scanner",
fixAgentName: "fixer",
phaseId: COMMENTS_PHASE_ID,
buildScanTask: buildCommentsScanTask,
buildFixTask: buildCommentsFixTask,
gate: commentsGate,
verify: commentsVerify,
} as const;
// Self-register on import so index.ts auto-discovery picks it up.
registerCheck(commentsCheck);

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

@@ -0,0 +1,287 @@
/**
* checks/complexity.ts — excessive complexity check.
*
* Detects high cyclomatic complexity and structural complexity smells, then
* refactors toward the simplest implementation that meets requirements.
*
* Cyclomatic complexity thresholds (MUST enforce, not advisory):
* - 50+ → must refactor. No exceptions.
* - 3549 → heavy skepticism. Only keep if critical path + justified.
* - <35 → not flagged on cyclomatic grounds (may still be flagged for other
* structural smells).
*
* Structural smells detected:
* - deep nesting (>3 levels)
* - speculative abstractions
* - premature config indirection
* - non-idiomatic patterns
* - over-engineered generics
* - unnecessary wrappers
*
* @module pygienium/checks/complexity
*/
import { registerCheck, type CheckScope } from "./registry.js";
/** Phase-strip phase this check belongs to. */
export const COMPLEXITY_PHASE_ID = "C4";
/**
* Artifact directory: `<cwd>/pygienium/checks/complexity/`.
*/
export function complexityArtifactDir(scope: CheckScope): string {
const base = scope.cwd.replace(/\/+$/, "");
return `${base}/pygienium/checks/complexity`;
}
/** Absolute path to findings artifact. */
export function findingsPath(scope: CheckScope): string {
return `${complexityArtifactDir(scope)}/findings.md`;
}
/** Absolute path to changes artifact. */
export function changesPath(scope: CheckScope): string {
return `${complexityArtifactDir(scope)}/changes.md`;
}
/**
* Shared rubric for complexity analysis, injected into both scan and fix tasks.
*/
const RUBRIC = `# Complexity hygiene rubric
## Cyclomatic complexity thresholds
Cyclomatic complexity counts the number of independent paths through a function.
Compute via language-native tools when available (lizard, radon, gocyclo), or
count decision points (if/else if/for/while/case/&&/||/catch) per function.
| Score | Action |
|-------|--------|
| 50+ | **MUST refactor.** No exceptions. Break the function into smaller pieces. |
| 3549 | **Heavy skepticism.** Only keep if this is a massively critical point along the main path AND the complexity genuinely must be here. Document justification in findings.md; otherwise refactor. |
| <35 | Not flagged on cyclomatic grounds (may still be flagged for other structural smells). |
## Structural complexity smells
- **Deep nesting (>3 levels).** Flatten with early returns, guard clauses, or extracting to named helpers.
- **Speculative abstractions.** Remove abstractions created "just in case" — no concrete use case yet.
- **Premature config indirection.** Remove configuration layers that add no value yet.
- **Non-idiomatic patterns.** Replace with common conventions for the language.
- **Over-engineered generics.** Simplify to concrete types when only one type is used.
- **Unnecessary wrappers.** Inline trivial wrappers that add no logic.
## Refactoring principles
1. **Simplest implementation.** Choose the simplest implementation that fully meets current requirements.
2. **No backward-compat baggage.** Remove obsolete paths rather than adding compatibility layers.
3. **Grow in layers.** Build on a product that already works; don't trade a working product for unfinished complexity.
4. **Use existing libraries.** Lean on well-maintained libraries when they reduce complexity or improve reliability.
5. **Long-term decisions.** Make architectural decisions for the long term, not stopgaps meant to be replaced later.
`;
/**
* Build the analysis sub-agent task. Instructs the agent to:
* 1. Compute cyclomatic complexity per function
* 2. Identify structural complexity smells
* 3. Write findings to pygienium/checks/complexity/findings.md
*/
export function buildComplexityScanTask(
_cwd: string,
scope: CheckScope,
): string {
const outDir = complexityArtifactDir(scope);
const findingsFile = findingsPath(scope);
return `# Task: excessive complexity scan
You are running the **complexity** hygiene check.
## Target
- Scan target: \`${scope.target}\`
## What to do
### 1. Compute cyclomatic complexity
For each source file in the target:
1. Read the recon snapshot at \`${scope.cwd}/.pygienium/recon.json\` if it
exists; otherwise enumerate source files directly under the target.
2. For each file, identify every function/method/class.
3. Compute cyclomatic complexity:
- Prefer language-native tools (lizard, radon, gocyclo, etc.) when available
- Fall back to counting decision points: if/else if/for/while/case/&&/||/catch
4. Classify each function into bands:
- **50+** = MUST refactor (no exceptions)
- **3549** = heavy skepticism (must justify or refactor)
- **<35** = not flagged on cyclomatic grounds
### 2. Identify structural complexity smells
For each file, identify:
- Deep nesting (>3 levels)
- Speculative abstractions
- Premature config indirection
- Non-idiomatic patterns
- Over-engineered generics
- Unnecessary wrappers
### 3. Write findings
Write a findings report to \`${findingsFile}\` with per-function scores and
structural smell locations. Include a proposed simpler form for every flagged
function.
${RUBRIC}
## findings.md format
\`\`\`markdown
# complexity — findings
## Cyclomatic complexity
| File | Function | Score | Band | Action |
|------|----------|-------|------|--------|
| path/to/file:42 | myFunction | 65 | 50+ | MUST refactor |
| path/to/file:100 | otherFunction | 42 | 35-49 | Skepticism — justify or refactor |
| path/to/file:150 | simpleFunction | 8 | <35 | OK |
## Structural smells
- [severity] <file>:<line> — <smell type> — <description>
- <proposed simplification>
## Justifications (3549 band)
For each function kept at 3549 complexity:
- **Function:** <name> at <file>:<line>
- **Score:** <score>
- **Justification:** <why this is critical path and can't be simplified>
\`\`\`
If no issues found, write:
\`# complexity — findings\n\n0 complexity issues found.\`
Always create findings.md so the run has an artifact.
`;
}
/**
* Build the fix sub-agent task from the scan findings. Instructs the agent to:
* 1. Split 50+ complexity functions
* 2. Refactor or justify 3549 functions
* 3. Apply safe refactors for structural smells
* 4. Write changes summary to pygienium/checks/complexity/changes.md
*/
export function buildComplexityFixTask(
_cwd: string,
scope: CheckScope,
findings: string,
): string {
const outDir = complexityArtifactDir(scope);
const changesFile = changesPath(scope);
return `# Task: excessive complexity fix
You are running the **complexity** hygiene fix phase.
## Target
- Fix target: \`${scope.target}\`
## Input: scan findings
${findings.trim().length > 0 ? findings : "(no findings text provided)"}
## What to do
### 1. Handle 50+ functions (MUST refactor)
For each function with cyclomatic complexity ≥ 50:
- Split into smaller, focused functions
- Extract complex conditional branches into named helper functions
- Use early returns and guard clauses to reduce nesting
- Preserve behavior after refactoring
### 2. Handle 3549 functions
For each function in the 3549 band:
- If no justified critical-path reason exists, refactor
- If kept, ensure justification is documented in findings.md
- Prefer refactoring over keeping
### 3. Apply structural refactors
- Flatten deep nesting (>3 levels)
- Remove speculative abstractions
- Inline trivial wrappers
- Replace non-idiomatic patterns with conventional ones
- Simplify over-engineered generics to concrete types
### 4. Write changes summary
Write a summary to \`${changesFile}\` and return it as your final message.
${RUBRIC}
## changes.md format
\`\`\`markdown
# complexity — changes
<applied> refactoring(s) applied; <deferred> deferred for human review.
## Applied
- <file>:<line> — <function> split (was <score>, now <scores>)
- <file>:<line> — nested conditionals flattened
- <file>:<line> — trivial wrapper inlined
- <file>:<line> — speculative abstraction removed
## Deferred (needs human review)
- <file>:<line> — <function> — <reason> (manual)
## Justified (kept at 3549)
- <file>:<line> — <function> (<score>) — <justification>
\`\`\`
If nothing needed changing, write:
\`# complexity — changes\n\n0 refactoring(s) applied.\`
Always create changes.md so the run has an artifact. Write it under \`${outDir}\`.
`;
}
/**
* Precondition gate. Returns an error string when the complexity check cannot
* proceed (target path missing or not a real file/directory), else
* `undefined`.
*/
async function complexityGate(cwd: string): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const target = resolve(cwd);
try {
const s = await stat(target);
if (s.isDirectory() || s.isFile()) return undefined;
return `target is not a file or directory: ${target}`;
} catch {
return `target path does not exist: ${target}`;
}
}
/** The excessive complexity check definition. */
export const complexityCheck = {
name: "complexity",
label: "Complexity",
description:
"Detect and refactor excessive complexity: high cyclomatic complexity (50+ must refactor, 35-49 needs justification), deep nesting, and speculative abstractions.",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: COMPLEXITY_PHASE_ID,
buildScanTask: buildComplexityScanTask,
buildFixTask: buildComplexityFixTask,
gate: complexityGate,
} as const;
// Self-register on import so index.ts auto-discovery picks it up.
registerCheck(complexityCheck);

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,181 @@
/**
* checks/deep-modules.ts — "deep modules, not shallow ones" check.
*
* Detects modules with shallow abstractions (thin pass-throughs, one-line
* re-export barrels, trivial getter classes, unnecessary adapter layers) and
* recommends/applies consolidation. The rubric encodes John Ousterhout's
* "deep modules" definition from *A Philosophy of Software Design*: a module
* is valuable when it hides a substantial implementation behind a small
* interface; a shallow one exposes as much complexity as it hides, so its
* indirection adds cost without abstraction payoff.
*
* Lifecycle:
* gate (need source files) → recon (shared) → scan sub-agent writes
* `<cwd>/pygienium/checks/deep-modules/findings.md` → [with --fix] fix
* sub-agent writes `changes.md`, inlines safe pass-throughs, and lists
* risky consolidations (external importers / public API) for human review.
*
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-deep-modules`.
*
* @module pygienium/checks/deep-modules
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import {
registerCheck,
type CheckDefinition,
type CheckScope,
} from "./registry.js";
/** Output directory for this check's persistent reports. */
export function deepModulesOutputDir(cwd: string): string {
return join(cwd, "pygienium", "checks", "deep-modules");
}
/** `findings.md` path for this check. */
export function findingsPath(cwd: string): string {
return join(deepModulesOutputDir(cwd), "findings.md");
}
/** `changes.md` path for this check. */
export function changesPath(cwd: string): string {
return join(deepModulesOutputDir(cwd), "changes.md");
}
/** Source extensions this check inspects. */
const DEEP_MODULES_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/**
* Gate: skip when the cwd has no inspectable source files at all. A workspace
* with zero source files gives the scanner nothing to classify.
*/
function deepModulesGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
const dot = entry.lastIndexOf(".");
const ext = dot === -1 ? "" : entry.slice(dot).toLowerCase();
if (DEEP_MODULES_EXTENSIONS.has(ext)) {
found = true;
break;
}
}
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
return undefined;
}
/**
* Build the scan task. The deep-modules scanner agent inspects the target,
* classifies modules by abstraction depth against the rubric, and writes a
* structured findings report to `findings.md`. The output path is passed into
* the task so both the real agent (which uses its `write` tool) and the
* deterministic fake runner (which understands `!write <path> <text>`) persist
* the report to the same location.
*
* Note: the `!write`/`!echo` lines are the deterministic fallback the fake
* runner executes for tests/smoke runs; a real model-driven agent receives the
* whole prompt and writes a real analysis.
*/
function buildDeepScanTask(cwd: string, scope: CheckScope): string {
const findings = findingsPath(cwd);
const target = scope.target;
// The expected findings document shape, shown to a real model-driven agent
// as the format spec. The `!write`/`!echo` lines below are the deterministic
// fallback the fake runner executes for tests/smoke runs.
return [
`Inspect the target "${target}" (cwd: ${cwd}) for shallow modules.`,
`Classify every source module by abstraction depth (see your rubric).`,
`Write your full findings report to: ${findings}.`,
`findings.md must list each flagged module with: kind, evidence, importer`,
`count, recommendation, and risk (low if no external importers, high else).`,
`Then emit a one-line summary referencing the findings path.`,
"",
`# Deterministic fallback (executed by the fake runner in tests):`,
`!write ${findings} # Deep-modules findings | summary: 1 shallow module(s) flagged | ## 1. ${target}/wrapper.ts | kind: pass-through-wrapper | evidence: forwards every argument to a single library call with no added logic | importers: 0 | recommendation: inline-and-remove | risk: low`,
`!echo deep-modules: 1 issue — see ${findings}`,
].join("\n");
}
/**
* Build the fix task. The fixer consumes the scan findings and applies ONLY safe
* consolidations: inline-and-remove pass-through wrappers that have **zero**
* external importers. Risky consolidations (any importer, or unclear
* ownership) are listed in `changes.md` as `review-manual` and NOT applied.
* Every action — applied or deferred — is recorded in `changes.md`.
*/
function buildDeepFixTask(
cwd: string,
scope: CheckScope,
findings: string,
): string {
const changes = changesPath(cwd);
const findingsFile = findingsPath(cwd);
const target = scope.target;
return [
`Consolidate shallow modules found in the scan.`,
`cwd: ${cwd} target: ${target}`,
`Findings report (also persisted at ${findingsFile}):`,
`---`,
findings,
`---`,
``,
`Rules:`,
`- Apply ONLY safe consolidations: a pass-through wrapper with zero external`,
` importers may be inlined at its single use site and the wrapper removed.`,
`- NEVER auto-delete or rewrite a module with any external importer — list it`,
` for human review instead.`,
`- Preserve public API boundaries; when in doubt, defer to manual review.`,
`- Write changes.md to ${changes} describing every action (auto | manual) with`,
` the file, the finding, and the disposition.`,
``,
`# Deterministic consolidation (executed by the fake runner in tests):`,
`# Safe: zero-importer pass-through rewritten/removed (auto).`,
`# Risky: external-importer adapter left in place (manual).`,
`!write ${target}/wrapper.ts // Consolidated by pygienium-deep-modules: pass-through wrapper removed; callers now use the underlying implementation directly.`,
`!write ${changes} # Deep-modules changes | 1. ${target}/wrapper.ts — pass-through-wrapper — consolidated: inlined the underlying call at the use site and removed the wrapper module (auto) | 2. ${target}/risky-adapter.ts — adapter-layer — 2 external importer(s): left in place; listed for review (manual)`,
`!echo deep-modules: 1 auto-applied, 1 deferred to review — see ${changes}`,
].join("\n");
}
/** The check definition; registers itself on import. */
const deepModulesCheck: CheckDefinition = {
name: "deep-modules",
label: "Deep modules",
description:
"Detect shallow modules (pass-throughs, trivial wrappers, re-export barrels) and consolidate the safe ones.",
agentName: "deep-modules",
phaseId: "analysis",
buildScanTask: buildDeepScanTask,
buildFixTask: buildDeepFixTask,
gate: deepModulesGate,
};
registerCheck(deepModulesCheck);
export { deepModulesCheck };

View File

@@ -0,0 +1,211 @@
/**
* checks/defensive-guards.ts — "redundant defensive guarding" check.
*
* Detects defensive code that guards invariants the type system or an
* upstream validation already guarantees, and removes the redundant guards
* while preserving guards that protect genuine external boundaries (user
* input, IO, parsing, untrusted data). The rubric encodes the engineering rule:
* no compatibility layers or fallbacks meant to be "replaced later" — remove
* them outright rather than layering over them.
*
* Flagged smells (non-exhaustive):
* - redundant-null-check — null/undefined check on a value whose declared
* type is already non-nullable.
* - swallowing-try-catch — try/catch that silently discards the error
* (empty catch, catch that only logs, or catch returning a fallback that
* hides the failure).
* - rethrow-only-try-catch — try/catch whose body only rethrows the exact
* error, adding nothing.
* - error-masking-fallback — `return defaultValue` / `|| fallback` in a
* catch that masks a real failure with a plausible-but-wrong value.
* - defensive-guard-on-validated-input — re-checking input that a caller or
* parser already validated (e.g. asserting a parsed enum is in range).
* - compatibility-fallback — a fallback branch kept "for now" / "to be
* replaced later" (engineering rule: remove, don't layer).
*
* Kept (legitimate boundary guards):
* - untrusted input (HTTP params, CLI args, env vars, files on disk).
* - IO (network, filesystem, subprocess) where failures are expected.
* - parsing (`JSON.parse`, `parseInt`, `Date.parse`, schema decoders).
*
* Lifecycle:
* gate (need source files) → recon (shared) → scan sub-agent writes
* `<cwd>/pygienium/checks/defensive-guards/findings.md` separating redundant
* guards from boundary guards → [with --fix] fix sub-agent removes redundant
* guards, preserves boundary guards, and writes `changes.md` distinguishing
* removed vs kept-with-reason.
*
* Registering this file is the ONLY wiring needed: `index.ts` auto-discovers
* `src/checks/*.ts`, so dropping this file exposes `/pygienium-defensive-guards`.
*
* @module pygienium/checks/defensive-guards
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import {
registerCheck,
type CheckDefinition,
type CheckScope,
} from "./registry.js";
/** Output directory for this check's persistent reports. */
export function defensiveGuardsOutputDir(cwd: string): string {
return join(cwd, "pygienium", "checks", "defensive-guards");
}
/** `findings.md` path for this check. */
export function findingsPath(cwd: string): string {
return join(defensiveGuardsOutputDir(cwd), "findings.md");
}
/** `changes.md` path for this check. */
export function changesPath(cwd: string): string {
return join(defensiveGuardsOutputDir(cwd), "changes.md");
}
/** Source extensions this check inspects. */
const DEFENSIVE_GUARDS_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".swift",
".php",
".cs",
".lua",
]);
/**
* Gate: skip when the cwd has no inspectable source files at all — a workspace
* with zero source files gives the scanner nothing to analyse.
*/
function defensiveGuardsGate(cwd: string): string | undefined {
let found = false;
try {
const entries = readdirSync(cwd);
for (const entry of entries) {
const dot = entry.lastIndexOf(".");
const ext = dot === -1 ? "" : entry.slice(dot).toLowerCase();
if (DEFENSIVE_GUARDS_EXTENSIONS.has(ext)) {
found = true;
break;
}
}
} catch {
// unreadable cwd → let the agent decide; don't block.
return undefined;
}
if (!found) {
return "no source files found to inspect";
}
return undefined;
}
/**
* Build the scan task. The defensive-guards scanner agent inspects the target,
* classifies each guard as redundant or a legitimate boundary guard against the
* rubric, and writes a structured findings report to `findings.md`. The output
* path is passed into the task so both the real agent (which uses its `write`
* tool) and the deterministic fake runner (which understands `!write <path>
* <text>`) persist the report to the same location.
*
* The `!write`/`!echo` lines are the deterministic fallback the fake runner
* executes for tests/smoke runs; a real model-driven agent receives the whole
* prompt and writes a real analysis.
*/
function buildDefensiveGuardsScanTask(cwd: string, scope: CheckScope): string {
const findings = findingsPath(cwd);
const target = scope.target;
return [
`Inspect the target "${target}" (cwd: ${cwd}) for redundant defensive guarding.`,
`Classify every guard (null check, try/catch, fallback) against your rubric as`,
`either REDUNDANT (remove) or BOUNDARY (keep). Boundary guards protect real`,
`external boundaries: untrusted input, IO, and parsing. Redundant guards protect`,
`invariants the type system or upstream validation already guarantees.`,
`Write your full findings report to: ${findings}.`,
`findings.md must separate redundant guards from legitimate boundary guards,`,
`listing each with: kind, evidence, disposition (remove | keep-boundary), and`,
`reason.`,
`Then emit a one-line summary referencing the findings path.`,
``,
`# Deterministic fallback (executed by the fake runner in tests):`,
`!write ${findings} # Defensive-guards findings | summary: 2 redundant guard(s) flagged, 1 boundary guard kept | ## 1. ${target}/noise.ts:2 | kind: redundant-null-check | evidence: \`if (name === null)\` on \`name\` whose declared type is \`string\` (non-nullable) | disposition: remove | reason: type system already guarantees non-null | ## 2. ${target}/noise.ts:7 | kind: swallowing-try-catch | evidence: try/catch around doThing() discards the error silently (empty catch body) | disposition: remove | reason: masks bugs; no error mapping or recovery logic | ## 3. ${target}/boundary.ts:2 | kind: parsing-guard | evidence: try/catch around JSON.parse(input) | disposition: keep-boundary | reason: protects an external parsing boundary (JSON.parse of untrusted input)`,
`!echo defensive-guards: 2 redundant, 1 boundary kept — see ${findings}`,
].join("\n");
}
/**
* Build the fix task. The fixer consumes the scan findings and removes ONLY
* redundant guards — those whose protected invariant is already guaranteed by
* the type system or upstream validation. Boundary guards (IO, parsing,
* untrusted input) are preserved untouched. Every action — removed or kept —
* is recorded in `changes.md`, distinguishing removed (auto) from kept with a
* reason (boundary).
*
* The fixer rewrites the affected source files with the redundant guards
* excised; compatibility fallbacks are removed outright (engineering rule:
* remove, don't layer), never left behind as a transitional shim.
*/
function buildDefensiveGuardsFixTask(
cwd: string,
scope: CheckScope,
findings: string,
): string {
const changes = changesPath(cwd);
const findingsFile = findingsPath(cwd);
const target = scope.target;
return [
`Remove redundant defensive guards found in the scan.`,
`cwd: ${cwd} target: ${target}`,
`Findings report (also persisted at ${findingsFile}):`,
`---`,
findings,
`---`,
``,
`Rules:`,
`- Remove ONLY redundant guards: null/undefined checks on non-nullable types,`,
` try/catch that only rethrows or swallows, fallback values that hide errors,`,
` defensive guards on already-validated input, and compatibility fallbacks.`,
`- PRESERVE boundary guards: anything protecting untrusted input, IO, or parsing`,
` (e.g. JSON.parse, network, filesystem, subprocess errors). Do not touch them.`,
`- No compatibility layers: remove fallbacks outright — never leave a shim meant`,
` to be "replaced later".`,
`- Apply the smallest diff that removes the guard without changing behaviour for`,
` the happy path. Preserve tests and existing conventions.`,
`- Write changes.md to ${changes} distinguishing removed (auto) from kept`,
` (boundary — with reason) for every finding.`,
``,
`# Deterministic removal (executed by the fake runner in tests):`,
`# Redundant null check + swallowing try/catch removed from noise.ts (auto).`,
`# JSON.parse boundary guard in boundary.ts preserved (boundary).`,
`!write ${target}/noise.ts // Cleaned by pygienium-defensive-guards: removed redundant null check on non-nullable \`name\` and the swallowing try/catch around doThing(). export function greet(name: string) { return \`hello \${name}\`; } export function swallow() { doThing(); } function doThing() {}`,
`!write ${changes} # Defensive-guards changes | summary: 2 removed, 1 kept (boundary) | ## Removed (auto) | 1. ${target}/noise.ts:2 — redundant-null-check — removed \`if (name === null) return ""\`; type system guarantees non-null | 2. ${target}/noise.ts:7 — swallowing-try-catch — removed the try/catch around doThing(); the error is no longer silently swallowed | ## Kept (boundary — with reason) | 1. ${target}/boundary.ts:2 — parsing-guard — kept: try/catch around JSON.parse protects an external parsing boundary (untrusted input)`,
`!echo defensive-guards: 2 removed, 1 kept (boundary) — see ${changes}`,
].join("\n");
}
/** The check definition; registers itself on import. */
const defensiveGuardsCheck: CheckDefinition = {
name: "defensive-guards",
label: "Defensive guards",
description:
"Remove redundant defensive guarding (null checks on non-nullable types, swallowing try/catch, masking fallbacks) while keeping boundary guards (IO, parsing, untrusted input).",
agentName: "defensive-guards",
phaseId: "analysis",
buildScanTask: buildDefensiveGuardsScanTask,
buildFixTask: buildDefensiveGuardsFixTask,
gate: defensiveGuardsGate,
};
registerCheck(defensiveGuardsCheck);
export { defensiveGuardsCheck };

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

@@ -0,0 +1,121 @@
/**
* checks/noop.ts — the reference check and living extensibility template.
*
* This is the smallest complete `CheckDefinition`: it self-registers on import,
* passes its gate for any real file/dir target, and asks the scanner/fixer
* sub-agents to write empty `findings.md`/`changes.md` artifacts. It exists so
* that:
*
* 1. The "add a check = one file in `checks/` + one `registerCheck()` call,
* zero `index.ts` changes" claim has a verifiable witness — `index.ts`
* auto-discovers every `checks/*.ts` (except the registry barrel), so this
* file makes `/pygienium-noop` appear with no wiring edits.
* 2. New check authors have a copy-paste starting point: clone this file,
* rename, swap the rubric, ship.
*
* Artifacts (under `<cwd>/pygienium/checks/noop/`):
* - `findings.md` — `noop: 0 issues`
* - `changes.md` — `noop: 0 edits`
*
* @module pygienium/checks/noop
*/
import { registerCheck, type CheckScope } from "./registry.js";
/** Directory this check writes its artifacts to. */
function noopArtifactDir(scope: CheckScope): string {
return `${scope.cwd.replace(/\/+$/, "")}/pygienium/checks/noop`;
}
/**
* Analysis task: scan nothing meaningful, write a zero-issue findings report.
* Mirrors the structure a real check's scan task uses so this file reads as a
* faithful template.
*/
function buildNoopScanTask(_cwd: string, scope: CheckScope): string {
const outDir = noopArtifactDir(scope);
return `# Task: noop scan
You are running the **noop** hygiene check (a no-op reference check).
## Target
- Scan target: \`${scope.target}\`
## What to do
1. Confirm the target is reachable (no real analysis is needed).
2. Write a findings report to \`${outDir}/findings.md\` with the content:
\`\`\`markdown
# noop — findings
noop: 0 issues
\`\`\`
3. Return that report text as your final message so the host records it as the
analysis-phase findings.
If the target is missing, write \`noop: target missing\` to findings.md instead.
`;
}
/** Fix task: apply zero edits, write an empty changes report. */
function buildNoopFixTask(
_cwd: string,
scope: CheckScope,
_findings: string,
): string {
const outDir = noopArtifactDir(scope);
return `# Task: noop fix
You are running the **noop** hygiene fix phase.
## Target
- Fix target: \`${scope.target}\`
## What to do
1. Make no source edits (this is a no-op check).
2. Write a changes report to \`${outDir}/changes.md\` with the content:
\`\`\`markdown
# noop — changes
noop: 0 edits
\`\`\`
3. Return that report text as your final message.
`;
}
/**
* Precondition gate: pass when the target path exists as a file or directory.
* Idempotent — used for both the pre-analysis check and the post-fix verify.
*/
async function noopGate(cwd: string): Promise<string | undefined> {
const { stat } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const target = resolve(cwd);
try {
const s = await stat(target);
if (s.isDirectory() || s.isFile()) return undefined;
return `target is not a file or directory: ${target}`;
} catch {
return `target path does not exist: ${target}`;
}
}
/** The noop check definition. Self-registers on import. */
export const noopCheck = {
name: "noop",
label: "No-op",
description:
"Reference/template check — performs no analysis, writes zero-issue artifacts. Clone it to start a new check.",
agentName: "scanner",
fixAgentName: "fixer",
phaseId: "noop",
buildScanTask: buildNoopScanTask,
buildFixTask: buildNoopFixTask,
gate: noopGate,
} as const;
// Self-register on import so index.ts auto-discovery picks it up — no wiring.
registerCheck(noopCheck);

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

@@ -0,0 +1,139 @@
/**
* checks/registry.ts — pluggable check registry.
*
* A `CheckDefinition` describes one hygiene check (e.g. `comments`, `complexity`).
* The registry is a module-level `Map` so that adding a check only requires a
* new file in `src/checks/` plus one `registerCheck(def)` call — no changes to
* `index.ts` command wiring. At startup, `index.ts` iterates the registry and
* auto-registers a `/pygienium-<name>` command per definition.
*
* Lifecycle of a single check run (orchestrated by `src/modes/check-runner.ts`):
* Q0 recon (shared) → analysis sub-agent (buildScanTask) →
* fix sub-agent (buildFixTask, only with --fix) → verify gate → cleanup.
*
* @module pygienium/checks/registry
*/
/**
* Scope passed to scan/fix task builders. Resolved from the command args:
* a positional path (absolute or relative to `cwd`) plus parsed flags.
*/
export interface CheckScope {
/** Absolute working directory the check operates on. */
cwd: string;
/** Target path (absolute) the check scans; defaults to `cwd` when none given. */
target: string;
/** Whether fixes should be applied (the `--fix` flag). */
fix: boolean;
/** Remaining raw tokens after flag parsing, for check-specific use. */
rest: string[];
}
/**
* Verifies preconditions before a check runs its analysis phase. Returns an
* error string when the check cannot proceed (e.g. no source files match),
* or `undefined` when the gate passes. Implemented per-check so generic
* checks can bail early without spawning an agent.
*/
export type CheckGate = (
cwd: string,
) => Promise<string | undefined> | string | undefined;
/**
* Optional post-analysis (+ optional fix) verify hook. Confirms the check
* actually produced its artifacts (e.g. `findings.md`/`changes.md`). Returns an
* error string to fail the verify phase, or `undefined` to pass. When omitted,
* the verify phase falls back to re-running {@link CheckDefinition.gate},
* preserving the historical behaviour for checks that have nothing to verify.
*/
export type CheckVerify = (
scope: CheckScope,
) => Promise<string | undefined> | string | undefined;
/**
* The structured task string handed to a sub-agent. `buildScanTask` produces
* the analysis prompt; `buildFixTask` consumes the findings text the scan
* agent emitted and produces a fix prompt.
*
* Task builders may be async: a check can pre-compute deterministic candidates
* (e.g. an import-graph scan) before assembling the prompt, so the sub-agent's
* job is to verify/refine rather than re-derive everything from scratch.
*/
export type BuildScanTask = (
cwd: string,
scope: CheckScope,
) => string | Promise<string>;
export type BuildFixTask = (
cwd: string,
scope: CheckScope,
findings: string,
) => string | Promise<string>;
/**
* Definition of a single pluggable hygiene check.
*/
export interface CheckDefinition {
/** Lowercase kebab command suffix → `/pygienium-<name>`. Must be unique. */
name: string;
/** Human label shown in help and status strips. */
label: string;
/** One-line description for `/pygienium-help`. */
description: string;
/**
* Name of the agent definition (from `agents/*.md`) used for the analysis
* phase. The fix phase uses the `fixer` agent unless `fixAgentName`
* overrides it.
*/
agentName: string;
/** Optional override for the fix-phase agent (defaults to `fixer`). */
fixAgentName?: string;
/** Identifier of the phase-strip phase this check belongs to (task 05). */
phaseId: string;
/** Builds the analysis sub-agent task. */
buildScanTask: BuildScanTask;
/** Builds the fix sub-agent task from scan findings. */
buildFixTask: BuildFixTask;
/**
* Precondition gate. Returning a string skips the check (recorded as
* `skipped`); returning `undefined` proceeds normally.
*/
gate: CheckGate;
/**
* Optional verify hook confirming artifacts landed (see {@link CheckVerify}).
* Falls back to re-running `gate` when omitted.
*/
verify?: CheckVerify;
}
const registry = new Map<string, CheckDefinition>();
/**
* Register a check. Throws on duplicate names so wiring mistakes surface
* loudly at startup rather than silently shadowing a command.
*/
export function registerCheck(def: CheckDefinition): void {
if (!def.name || !/^[a-z0-9][a-z0-9-]*$/.test(def.name)) {
throw new Error(
`Invalid check name "${def.name}": must be lowercase kebab (e.g. "comments").`,
);
}
if (registry.has(def.name)) {
throw new Error(`Duplicate pygienium check name: "${def.name}".`);
}
registry.set(def.name, def);
}
/** Look up a registered check by name. */
export function getCheck(name: string): CheckDefinition | undefined {
return registry.get(name);
}
/** All registered checks in insertion order. */
export function getAllChecks(): CheckDefinition[] {
return [...registry.values()];
}
/** Test-only: reset the registry between tests. */
export function clearChecks(): void {
registry.clear();
}