From 2caeb2f790c56c092116339b59adf7f8d95d59ee Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 9 Aug 2026 16:45:29 -0400 Subject: [PATCH] feat(checks): unify inspection scope in checks/scope.ts Single source of truth for what pygienium inspects (implementation-code extensions, exclude dirs, .d.ts/.min.* exclusions) shared by recon, dead-code, deep-modules, defensive-guards, comments, and complexity. Every scan task and agent prompt injects the shared scope rules instead of copy-pasted per-check lists. --- agents/deep-modules.md | 33 ++++++++ agents/defensive-guards.md | 33 ++++++++ agents/scanner.md | 33 ++++++++ src/checks/comments.ts | 2 + src/checks/complexity.ts | 2 + src/checks/dead-code.ts | 55 +++---------- src/checks/deep-modules.ts | 27 +----- src/checks/defensive-guards.ts | 27 +----- src/checks/scope.ts | 145 +++++++++++++++++++++++++++++++++ src/recon.ts | 23 +----- 10 files changed, 268 insertions(+), 112 deletions(-) create mode 100644 src/checks/scope.ts diff --git a/agents/deep-modules.md b/agents/deep-modules.md index 7253ef4..cbe18fd 100644 --- a/agents/deep-modules.md +++ b/agents/deep-modules.md @@ -58,6 +58,39 @@ aggregates many scattered modules), or public API stability boundaries. importers or that sits on a public API boundary is riskier to consolidate — note the importer count so the fixer can decide. +# Scope of inspection + +**Only inspect implementation source files.** Do not analyse documentation, +config, type declarations, build output, or dependencies — flagging those is +noise the user cannot act on. + +## Inspect (extensions) + +`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`, +`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx` + +## Skip (directory names — never descend into) + +`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, +`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) + +## Skip (file patterns) + +- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl +- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs` +- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code +- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env` +- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg` +- Lock files: `package-lock.json`, `*.lock`, `bun.lockb` + +## File discovery preference + +1. Prefer the recon snapshot at `/.pygienium/recon.json` when it exists. +2. Otherwise enumerate files yourself, applying the rules above. +3. When using `find`/`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 diff --git a/agents/defensive-guards.md b/agents/defensive-guards.md index 92d347d..8e5bca8 100644 --- a/agents/defensive-guards.md +++ b/agents/defensive-guards.md @@ -74,6 +74,39 @@ guarantees is noise. checked (`grep` for its declaration/annotation). A `null` check on a `string | null` union is legitimate; on a bare `string` it is redundant. +# Scope of inspection + +**Only inspect implementation source files.** Do not analyse documentation, +config, type declarations, build output, or dependencies — flagging those is +noise the user cannot act on. + +## Inspect (extensions) + +`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`, +`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx` + +## Skip (directory names — never descend into) + +`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, +`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) + +## Skip (file patterns) + +- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl +- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs` +- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code +- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env` +- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg` +- Lock files: `package-lock.json`, `*.lock`, `bun.lockb` + +## File discovery preference + +1. Prefer the recon snapshot at `/.pygienium/recon.json` when it exists. +2. Otherwise enumerate files yourself, applying the rules above. +3. When using `find`/`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 diff --git a/agents/scanner.md b/agents/scanner.md index e187e44..ccadb43 100644 --- a/agents/scanner.md +++ b/agents/scanner.md @@ -22,6 +22,39 @@ files; that is the fixer's job. You only inspect and report. Never mutate files. - Emit a concise findings report as your final message. +# Scope of inspection + +**Only inspect implementation source files.** Do not analyse documentation, +config, type declarations, build output, or dependencies — flagging those is +noise the user cannot act on. + +## Inspect (extensions) + +`.cs`, `.cjs`, `.go`, `.java`, `.js`, `.jsx`, `.kt`, `.lua`, `.mjs`, `.php`, +`.py`, `.rb`, `.rs`, `.swift`, `.ts`, `.tsx` + +## Skip (directory names — never descend into) + +`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, +`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) + +## Skip (file patterns) + +- Type declarations: `*.d.ts`, `*.d.mts`, `*.d.cts` — generated contracts, not impl +- Minified bundles: `*.min.js`, `*.min.mjs`, `*.min.cjs` +- Docs: `*.md`, `*.txt`, `*.rst` — prose, not code +- Config: `*.json`, `*.yaml`, `*.yml`, `*.toml`, `*.ini`, `*.env` +- Styles/markup: `*.css`, `*.scss`, `*.html`, `*.svg` +- Lock files: `package-lock.json`, `*.lock`, `bun.lockb` + +## File discovery preference + +1. Prefer the recon snapshot at `/.pygienium/recon.json` when it exists. +2. Otherwise enumerate files yourself, applying the rules above. +3. When using `find`/`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: diff --git a/src/checks/comments.ts b/src/checks/comments.ts index c6f714e..63c8fea 100644 --- a/src/checks/comments.ts +++ b/src/checks/comments.ts @@ -21,6 +21,7 @@ */ import { registerCheck, type CheckScope } from "./registry.js"; +import { scopeRulesMarkdown } from "./scope.js"; /** Phase-strip phase this check belongs to. */ export const COMMENTS_PHASE_ID = "C1"; @@ -82,6 +83,7 @@ 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. diff --git a/src/checks/complexity.ts b/src/checks/complexity.ts index 1589bc1..fb94908 100644 --- a/src/checks/complexity.ts +++ b/src/checks/complexity.ts @@ -22,6 +22,7 @@ */ import { registerCheck, type CheckScope } from "./registry.js"; +import { scopeRulesMarkdown } from "./scope.js"; /** Phase-strip phase this check belongs to. */ export const COMPLEXITY_PHASE_ID = "C4"; @@ -98,6 +99,7 @@ You are running the **complexity** hygiene check. ## Target - Scan target: \`${scope.target}\` +${scopeRulesMarkdown()} ## What to do ### 1. Compute cyclomatic complexity diff --git a/src/checks/dead-code.ts b/src/checks/dead-code.ts index 14d6019..db24844 100644 --- a/src/checks/dead-code.ts +++ b/src/checks/dead-code.ts @@ -34,6 +34,11 @@ import { type CheckDefinition, type CheckScope, } from "./registry.js"; +import { + isScopeSource, + SCOPE_EXCLUDE_DIRS, + scopeRulesMarkdown, +} from "./scope.js"; /** Dead-code categories recognised by the rubric. */ export type DeadCategory = "export" | "file" | "shim" | "dep"; @@ -82,43 +87,6 @@ export const CATEGORY_LABELS: Record = { dep: "Unused dependencies", }; -/** Source extensions worth scanning for import graphs. */ -const SOURCE_EXTENSIONS = new Set([ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".py", - ".rb", - ".go", - ".rs", - ".java", - ".kt", - ".swift", - ".php", - ".cs", - ".lua", -]); - -/** Directories never scanned (build output, deps, tooling). */ -const IGNORED_DIRS = new Set([ - "node_modules", - ".git", - ".pygienium", - "dist", - "build", - "out", - "coverage", - ".next", - ".nuxt", - ".venv", - "__pycache__", - "vendor", - ".ralpi", -]); - /** Extensions tried when resolving a bare import specifier. */ const RESOLVE_EXTENSIONS = [ ".ts", @@ -131,11 +99,6 @@ const RESOLVE_EXTENSIONS = [ ".cts", ]; -const EXT = (name: string): string => { - const i = name.lastIndexOf("."); - return i === -1 ? "" : name.slice(i).toLowerCase(); -}; - /** Filename signals of a compat/deprecated/migration shim. */ const SHIM_NAME_RE = /(?:compat|legacy|deprecated|obsolete|obsoleted|migration|migrate|backcompat|back-compat|fallback|shim)/i; @@ -161,7 +124,7 @@ function depStem(spec: string): string { async function walkSourceFiles(root: string): Promise { const st = await stat(root).catch(() => undefined); if (!st) return []; - if (st.isFile()) return SOURCE_EXTENSIONS.has(EXT(root)) ? [root] : []; + if (st.isFile()) return isScopeSource(root) ? [root] : []; const out: string[] = []; const stack = [root]; while (stack.length > 0) { @@ -175,9 +138,9 @@ async function walkSourceFiles(root: string): Promise { for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { - if (IGNORED_DIRS.has(entry.name)) continue; + if (SCOPE_EXCLUDE_DIRS.has(entry.name)) continue; stack.push(full); - } else if (entry.isFile() && SOURCE_EXTENSIONS.has(EXT(entry.name))) { + } else if (entry.isFile() && isScopeSource(entry.name)) { out.push(full); } } @@ -1069,6 +1032,8 @@ export async function buildDeadCodeScanTask( "```", ``, `Target root: ${scope.target}. Work dir: ${cwd}.`, + ``, + scopeRulesMarkdown(), ].join("\n"); } diff --git a/src/checks/deep-modules.ts b/src/checks/deep-modules.ts index 9c0dc0b..da40831 100644 --- a/src/checks/deep-modules.ts +++ b/src/checks/deep-modules.ts @@ -28,6 +28,7 @@ import { type CheckDefinition, type CheckScope, } from "./registry.js"; +import { isScopeSource, scopeRulesMarkdown } from "./scope.js"; /** Output directory for this check's persistent reports. */ export function deepModulesOutputDir(cwd: string): string { @@ -44,26 +45,6 @@ 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. @@ -73,9 +54,7 @@ function deepModulesGate(cwd: string): string | undefined { 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)) { + if (isScopeSource(entry)) { found = true; break; } @@ -116,6 +95,8 @@ function buildDeepScanTask(cwd: string, scope: CheckScope): string { `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}`, diff --git a/src/checks/defensive-guards.ts b/src/checks/defensive-guards.ts index e977b42..ea63225 100644 --- a/src/checks/defensive-guards.ts +++ b/src/checks/defensive-guards.ts @@ -48,6 +48,7 @@ import { type CheckDefinition, type CheckScope, } from "./registry.js"; +import { isScopeSource, scopeRulesMarkdown } from "./scope.js"; /** Output directory for this check's persistent reports. */ export function defensiveGuardsOutputDir(cwd: string): string { @@ -64,26 +65,6 @@ 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. @@ -93,9 +74,7 @@ function defensiveGuardsGate(cwd: string): string | undefined { 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)) { + if (isScopeSource(entry)) { found = true; break; } @@ -137,6 +116,8 @@ function buildDefensiveGuardsScanTask(cwd: string, scope: CheckScope): string { `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}`, diff --git a/src/checks/scope.ts b/src/checks/scope.ts new file mode 100644 index 0000000..e81d9d8 --- /dev/null +++ b/src/checks/scope.ts @@ -0,0 +1,145 @@ +/** + * scope.ts — canonical source-of-truth for what pygienium checks inspect. + * + * Every check (recon, dead-code, deep-modules, defensive-guards, complexity, + * comments) and every scanner agent prompt shares these definitions so the + * "only inspect implementation code" rule is stated once, not copy-pasted + * across four files that drift apart. + * + * @module pygienium/checks/scope + */ + +/** + * Implementation-code file extensions pygienium inspects. + * + * Deliberately excludes documentation (`.md`, `.txt`, `.rst`), config + * (`.json`, `.yaml`, `.yml`, `.toml`, `.env`, `.ini`), type declarations + * (`.d.ts`), styles (`.css`, `.scss`), markup (`.html`, `.svg`), and lock + * files. These are not implementation code — a comments or complexity check + * flagging prose in a `.md` or a key in `package.json` is noise. + */ +export const SCOPE_EXTENSIONS: ReadonlySet = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".swift", + ".php", + ".cs", + ".lua", +]); + +/** + * Directories pygienium never descends into — build output, dependency caches, + * tooling state, and VCS metadata. When walking the tree with `find`/`grep`/ + * `readdir`, skip these by name to avoid wasting tokens on vendored code and + * generated artifacts the user can't act on. + */ +export const SCOPE_EXCLUDE_DIRS: ReadonlySet = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "dist", + "build", + "out", + "coverage", + ".next", + ".nuxt", + ".turbo", + ".svelte-kit", + "__pycache__", + ".venv", + "venv", + "vendor", + ".cache", + ".pygienium", + ".ralpi", + ".idea", + ".vscode", +]); + +/** + * Compound extensions (checked after the simple extension lookup) that should + * be treated as non-source even though their tail extension appears in + * {@link SCOPE_EXTENSIONS}. The primary case: `.d.ts` type declarations are + * generated contracts, not implementation code. + */ +export const SCOPE_EXCLUDE_SUFFIXES: ReadonlySet = new Set([ + ".d.ts", + ".d.mts", + ".d.cts", + ".min.js", + ".min.mjs", + ".min.cjs", +]); + +/** + * Test if a file path is implementation source pygienium should inspect. + * + * Returns `true` when the extension is in {@link SCOPE_EXTENSIONS} AND the + * path does not end with a {@link SCOPE_EXCLUDE_SUFFIXES} pattern (e.g. + * `.d.ts`). + */ +export function isScopeSource(path: string): boolean { + const lower = path.toLowerCase(); + for (const suffix of SCOPE_EXCLUDE_SUFFIXES) { + if (lower.endsWith(suffix)) return false; + } + const dot = lower.lastIndexOf("."); + if (dot === -1) return false; + return SCOPE_EXTENSIONS.has(lower.slice(dot)); +} + +/** + * Markdown section injected into every scan task string so the sub-agent knows + * exactly what to inspect and what to skip — stated once here, not copy-pasted + * into each task builder. + * + * Agents that use `find`/`grep`/`readdir` for their own file discovery read + * this before exploring, so the exclusion list governs their search too. + */ +export function scopeRulesMarkdown(): string { + const extensions = [...SCOPE_EXTENSIONS] + .sort((a, b) => a.localeCompare(b)) + .join("`, `"); + const excludeDirs = [...SCOPE_EXCLUDE_DIRS] + .sort((a, b) => a.localeCompare(b)) + .join("`, `"); + return `## Scope of inspection + +**Only inspect implementation source files.** Do not analyse documentation, +config, type declarations, build output, or dependencies — flagging those is +noise the user cannot act on. + +### Inspect (extensions) +\`${extensions}\` + +### Skip (directory names — never descend into) +\`${excludeDirs}\` + +### Skip (file patterns) +- Type declarations: \`*.d.ts\`, \`*.d.mts\`, \`*.d.cts\` — generated contracts, not impl +- Minified bundles: \`*.min.js\`, \`*.min.mjs\`, \`*.min.cjs\` — generated, not editable +- Docs: \`*.md\`, \`*.txt\`, \`*.rst\` — prose, not code +- Config: \`*.json\`, \`*.yaml\`, \`*.yml\`, \`*.toml\`, \`*.ini\`, \`*.env\` +- Styles/markup: \`*.css\`, \`*.scss\`, \`*.html\`, \`*.svg\` +- Lock files: \`package-lock.json\`, \`*.lock\`, \`bun.lockb\` + +### File discovery preference +1. **Prefer the recon snapshot** at \`/.pygienium/recon.json\` when it + exists — it is the authoritative source inventory (git-tracked, extension- + filtered, exclude-aware). Read its \`fileCounts\` for the quick picture. +2. Otherwise enumerate files yourself, applying the rules above. +3. When using \`find\`/\`grep\`, add prune clauses for the skip directories + (e.g. \`find . -type d -name node_modules -prune -o -name '*.ts' -print\`). +`; +} diff --git a/src/recon.ts b/src/recon.ts index e723d2e..23a4b5c 100644 --- a/src/recon.ts +++ b/src/recon.ts @@ -15,6 +15,7 @@ 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); @@ -28,26 +29,6 @@ export interface ReconSnapshot { totalSourceFiles: number; } -/** Source extensions worth inventorying for hygiene checks. */ -const SOURCE_EXTENSIONS = new Set([ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".py", - ".rb", - ".go", - ".rs", - ".java", - ".kt", - ".swift", - ".php", - ".cs", - ".lua", -]); - /** Run `git ls-files` when possible to get a clean source inventory. */ async function listSourceFiles(cwd: string): Promise { try { @@ -62,7 +43,7 @@ async function listSourceFiles(cwd: string): Promise { .filter((l) => { const dot = l.lastIndexOf("."); if (dot === -1) return false; - return SOURCE_EXTENSIONS.has(l.slice(dot).toLowerCase()); + return SCOPE_EXTENSIONS.has(l.slice(dot).toLowerCase()); }); } catch { /* not a git repo or git unavailable — empty inventory */