From a908bd7efe4b85f30d6b41372dee142d5c3760f5 Mon Sep 17 00:00:00 2001 From: omp-port Date: Wed, 12 Aug 2026 22:03:00 -0400 Subject: [PATCH] port: sync from Mike/pygienium@63c2f73a --- src/checks/deep-modules.ts | 17 +++------------- src/checks/defensive-guards.ts | 17 +++------------- src/checks/scope.ts | 36 ++++++++++++++++++++++++++++++++++ src/checks/todos.ts | 16 +++------------ tests/deep-modules.test.ts | 13 ++++++++++++ tests/defensive-guards.test.ts | 13 ++++++++++++ tests/todos.test.ts | 13 ++++++++++++ 7 files changed, 84 insertions(+), 41 deletions(-) diff --git a/src/checks/deep-modules.ts b/src/checks/deep-modules.ts index 72038c4..9ceb722 100644 --- a/src/checks/deep-modules.ts +++ b/src/checks/deep-modules.ts @@ -21,14 +21,13 @@ * @module pygienium/checks/deep-modules */ -import { readdirSync } from "node:fs"; import { join } from "node:path"; import { registerCheck, type CheckDefinition, type CheckScope, } from "./registry.js"; -import { isScopeSource, scopeRulesMarkdown } from "./scope.js"; +import { hasScopeSources, scopeRulesMarkdown } from "./scope.js"; /** Output directory for this check's persistent reports. */ export function deepModulesOutputDir(cwd: string): string { @@ -50,23 +49,13 @@ export function changesPath(cwd: string): string { * 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; - } - } + if (hasScopeSources(cwd)) return undefined; } catch { // unreadable cwd → let the agent decide; don't block. return undefined; } - if (!found) { - return "no source files found to inspect"; - } - return undefined; + return "no source files found to inspect"; } /** diff --git a/src/checks/defensive-guards.ts b/src/checks/defensive-guards.ts index 66c7631..6251859 100644 --- a/src/checks/defensive-guards.ts +++ b/src/checks/defensive-guards.ts @@ -41,14 +41,13 @@ * @module pygienium/checks/defensive-guards */ -import { readdirSync } from "node:fs"; import { join } from "node:path"; import { registerCheck, type CheckDefinition, type CheckScope, } from "./registry.js"; -import { isScopeSource, scopeRulesMarkdown } from "./scope.js"; +import { hasScopeSources, scopeRulesMarkdown } from "./scope.js"; /** Output directory for this check's persistent reports. */ export function defensiveGuardsOutputDir(cwd: string): string { @@ -70,23 +69,13 @@ export function changesPath(cwd: string): string { * 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; - } - } + if (hasScopeSources(cwd)) return undefined; } catch { // unreadable cwd → let the agent decide; don't block. return undefined; } - if (!found) { - return "no source files found to inspect"; - } - return undefined; + return "no source files found to inspect"; } /** diff --git a/src/checks/scope.ts b/src/checks/scope.ts index 5a3ef5a..69924bc 100644 --- a/src/checks/scope.ts +++ b/src/checks/scope.ts @@ -9,6 +9,10 @@ * @module pygienium/checks/scope */ +import { readdirSync, statSync } from "node:fs"; +import type { Dirent } from "node:fs"; +import { join } from "node:path"; + /** * Implementation-code file extensions pygienium inspects. * @@ -106,6 +110,38 @@ export function isScopeSource(path: string): boolean { return SCOPE_EXTENSIONS.has(lower.slice(dot)); } +/** + * True when the tree rooted at `root` contains at least one in-scope source + * file. Walks recursively (honoring {@link SCOPE_EXCLUDE_DIRS}) — a top-level + * entry scan alone would skip any repo whose source lives in subdirectories, + * e.g. `game/` or `src/`, even though recon's git inventory finds hundreds of + * files. A file root is judged by {@link isScopeSource} directly. + */ +export function hasScopeSources(root: string): boolean { + const st = statSync(root, { throwIfNoEntry: false }); + if (!st) return false; + if (st.isFile()) return isScopeSource(root); + const stack = [root]; + while (stack.length > 0) { + const dir = stack.pop() as string; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (SCOPE_EXCLUDE_DIRS.has(entry.name)) continue; + stack.push(join(dir, entry.name)); + } else if (entry.isFile() && isScopeSource(entry.name)) { + return true; + } + } + } + return false; +} + /** * 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 diff --git a/src/checks/todos.ts b/src/checks/todos.ts index 97e66bd..5de5778 100644 --- a/src/checks/todos.ts +++ b/src/checks/todos.ts @@ -36,7 +36,6 @@ * @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"; @@ -46,6 +45,7 @@ import { type CheckScope, } from "./registry.js"; import { + hasScopeSources, isScopeSource, SCOPE_EXCLUDE_DIRS, scopeRulesMarkdown, @@ -405,23 +405,13 @@ function renderFindings( * 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; - } - } + if (hasScopeSources(cwd)) return undefined; } catch { // unreadable cwd → let the agent decide; don't block. return undefined; } - if (!found) { - return "no source files found to inspect"; - } - return undefined; + return "no source files found to inspect"; } /** diff --git a/tests/deep-modules.test.ts b/tests/deep-modules.test.ts index ae34688..aae553e 100644 --- a/tests/deep-modules.test.ts +++ b/tests/deep-modules.test.ts @@ -168,4 +168,17 @@ describe("deep-modules check", () => { await rm(empty, { recursive: true, force: true }).catch(() => {}); } }); + + it("does not skip when sources live in subdirectories (gate scans recursively)", async () => { + // Top level holds only a directory — the old shallow gate skipped any + // repo whose source lives under `game/`/`src/`-style subdirs. + await mkdir(join(cwd, "game"), { recursive: true }); + await writeFile(join(cwd, "game", "main.lua"), "return {}\n", "utf8"); + const check = getCheck("deep-modules")!; + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks["deep-modules"]?.status).toBe("complete"); + }); }); diff --git a/tests/defensive-guards.test.ts b/tests/defensive-guards.test.ts index f8b2529..53bc789 100644 --- a/tests/defensive-guards.test.ts +++ b/tests/defensive-guards.test.ts @@ -229,4 +229,17 @@ describe("defensive-guards check", () => { const state = await loadRunState(cwd); expect(state?.checks["defensive-guards"]?.status).toBe("skipped"); }); + + it("does not skip when sources live in subdirectories (gate scans recursively)", async () => { + // Top level holds only a directory — the old shallow gate skipped any + // repo whose source lives under `game/`/`src/`-style subdirs. + await mkdir(join(cwd, "game"), { recursive: true }); + await writeFile(join(cwd, "game", "main.lua"), "return {}\n", "utf8"); + const check = getCheck("defensive-guards")!; + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks["defensive-guards"]?.status).toBe("complete"); + }); }); diff --git a/tests/todos.test.ts b/tests/todos.test.ts index 4da877f..c386045 100644 --- a/tests/todos.test.ts +++ b/tests/todos.test.ts @@ -335,6 +335,19 @@ describe("todos check", () => { expect(state?.checks["todos"]?.status).toBe("skipped"); }); + it("does not skip when sources live in subdirectories (gate scans recursively)", async () => { + // Top level holds only a directory — the old shallow gate skipped any + // repo whose source lives under `game/`/`src/`-style subdirs. + await mkdir(join(cwd, "game"), { recursive: true }); + await writeFile(join(cwd, "game", "main.lua"), "return {}\n", "utf8"); + const check = getCheck("todos")!; + + await handleCheckCommand(check, "", stubCtx(cwd)); + + const state = await loadRunState(cwd); + expect(state?.checks["todos"]?.status).toBe("complete"); + }); + it("reports a new/resolved delta against the previous run's counts", async () => { await seedStubs(cwd); const check = getCheck("todos")!;