diff --git a/dist/core/guardians.js b/dist/core/guardians.js index 5034aef..131cb7c 100644 --- a/dist/core/guardians.js +++ b/dist/core/guardians.js @@ -16,19 +16,57 @@ const ALL = [ ]; const fileExists = (p) => existsSync(p) && statSync(p).isFile(); const dirHasEntries = (p) => existsSync(p) && statSync(p).isDirectory() && readdirSync(p).length > 0; +const STRYKER_CONFIG_NAMES = new Set([ + "stryker.conf.json", + "stryker.conf.js", + "stryker.conf.mjs", + "stryker.conf.cjs", + "stryker.config.json", + "stryker.config.js", + "stryker.config.mjs", + "stryker.config.cjs", + "stryker.config.ts", +]); +// Dirs never worth descending into for a config file — keeps the monorepo scan cheap and +// avoids false positives from vendored/generated trees. +const SCAN_SKIP_DIRS = new Set([ + "node_modules", + "dist", + "build", + "coverage", + ".stryker-tmp", +]); +// The mutation ratchet lives at the repo root in a single-package repo, but PER-SERVICE in a +// monorepo (e.g. services//api/stryker.config.json — the bsk pilot layout). A root-only +// check was a systematic false negative there (forcing guardian overrides). Do a bounded, +// node_modules-skipping scan so a monorepo that has adopted the ratchet for at least one package +// registers — the coarse repo-level "the guardian stands" signal, consistent with the other +// detectors. Hidden dirs are skipped (configs never live in .git/.github/.claude worktrees). +function hasStrykerConfig(dir, depth) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } + catch { + return false; // unreadable dir (perms/race) — treat as absent, never throw + } + for (const e of entries) { + if (e.isFile() && STRYKER_CONFIG_NAMES.has(e.name)) + return true; + } + if (depth <= 0) + return false; + for (const e of entries) { + if (e.isDirectory() && !e.name.startsWith(".") && !SCAN_SKIP_DIRS.has(e.name)) { + if (hasStrykerConfig(join(dir, e.name), depth - 1)) + return true; + } + } + return false; +} function hasMutationRatchet(repo) { - const names = [ - "stryker.conf.json", - "stryker.conf.js", - "stryker.conf.mjs", - "stryker.conf.cjs", - "stryker.config.json", - "stryker.config.js", - "stryker.config.mjs", - "stryker.config.cjs", - "stryker.config.ts", - ]; - return names.some((n) => fileExists(join(repo, n))); + // depth 3 reaches services//api/ and packages// monorepo layouts. + return hasStrykerConfig(repo, 3); } // Does any CI workflow reference `needle` (i.e. actually run that guard)? export function workflowReferences(repo, needle) { diff --git a/src/core/guardians.ts b/src/core/guardians.ts index cf5f215..12840bb 100644 --- a/src/core/guardians.ts +++ b/src/core/guardians.ts @@ -6,7 +6,7 @@ // during the Erprobung phase (design §0.1/§5: "measured, not set freehand"). The signals // here are presence checks of each guardian's primary artifact; tighten per Obol later. -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; export type GuardianId = @@ -32,19 +32,56 @@ const fileExists = (p: string): boolean => existsSync(p) && statSync(p).isFile() const dirHasEntries = (p: string): boolean => existsSync(p) && statSync(p).isDirectory() && readdirSync(p).length > 0; +const STRYKER_CONFIG_NAMES = new Set([ + "stryker.conf.json", + "stryker.conf.js", + "stryker.conf.mjs", + "stryker.conf.cjs", + "stryker.config.json", + "stryker.config.js", + "stryker.config.mjs", + "stryker.config.cjs", + "stryker.config.ts", +]); + +// Dirs never worth descending into for a config file — keeps the monorepo scan cheap and +// avoids false positives from vendored/generated trees. +const SCAN_SKIP_DIRS = new Set([ + "node_modules", + "dist", + "build", + "coverage", + ".stryker-tmp", +]); + +// The mutation ratchet lives at the repo root in a single-package repo, but PER-SERVICE in a +// monorepo (e.g. services//api/stryker.config.json — the bsk pilot layout). A root-only +// check was a systematic false negative there (forcing guardian overrides). Do a bounded, +// node_modules-skipping scan so a monorepo that has adopted the ratchet for at least one package +// registers — the coarse repo-level "the guardian stands" signal, consistent with the other +// detectors. Hidden dirs are skipped (configs never live in .git/.github/.claude worktrees). +function hasStrykerConfig(dir: string, depth: number): boolean { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; // unreadable dir (perms/race) — treat as absent, never throw + } + for (const e of entries) { + if (e.isFile() && STRYKER_CONFIG_NAMES.has(e.name)) return true; + } + if (depth <= 0) return false; + for (const e of entries) { + if (e.isDirectory() && !e.name.startsWith(".") && !SCAN_SKIP_DIRS.has(e.name)) { + if (hasStrykerConfig(join(dir, e.name), depth - 1)) return true; + } + } + return false; +} + function hasMutationRatchet(repo: string): boolean { - const names = [ - "stryker.conf.json", - "stryker.conf.js", - "stryker.conf.mjs", - "stryker.conf.cjs", - "stryker.config.json", - "stryker.config.js", - "stryker.config.mjs", - "stryker.config.cjs", - "stryker.config.ts", - ]; - return names.some((n) => fileExists(join(repo, n))); + // depth 3 reaches services//api/ and packages// monorepo layouts. + return hasStrykerConfig(repo, 3); } // Does any CI workflow reference `needle` (i.e. actually run that guard)? diff --git a/test/core/guardians.test.ts b/test/core/guardians.test.ts index 0acc6f9..cbacddd 100644 --- a/test/core/guardians.test.ts +++ b/test/core/guardians.test.ts @@ -41,6 +41,14 @@ test("semgrep wired via CI (Obol-style tools/semgrep-*.yml, no .semgrep dir) is expect(r).toEqual({ ok: true, missing: [] }); }); +test("monorepo per-service stryker config (services//api/) is detected", () => { + // bsk pilot layout: the mutation ratchet lives per-service, not at the repo root. A + // root-only check was a systematic false negative that forced guardian overrides. + const r = checkGuardians(fx("repo-stryker-in-service")); + expect(r.missing).not.toContain("mutation-ratchet"); + expect(r).toEqual({ ok: true, missing: [] }); +}); + test("each missing-fixture reports exactly its one missing guardian", () => { expect(checkGuardians(fx("repo-missing-stryker")).missing).toEqual(["mutation-ratchet"]); expect(checkGuardians(fx("repo-missing-semgrep")).missing).toEqual(["semgrep-escape-hatch"]); diff --git a/test/fixtures/repo-stryker-in-service/.github/CODEOWNERS b/test/fixtures/repo-stryker-in-service/.github/CODEOWNERS new file mode 100644 index 0000000..70ee8a1 --- /dev/null +++ b/test/fixtures/repo-stryker-in-service/.github/CODEOWNERS @@ -0,0 +1,4 @@ +* @team +/stryker.conf.json @owners +/.semgrep/ @owners +/constitution.md @owners diff --git a/test/fixtures/repo-stryker-in-service/.github/workflows/ci.yml b/test/fixtures/repo-stryker-in-service/.github/workflows/ci.yml new file mode 100644 index 0000000..39dd344 --- /dev/null +++ b/test/fixtures/repo-stryker-in-service/.github/workflows/ci.yml @@ -0,0 +1,7 @@ +name: ci +on: [pull_request] +jobs: + gate: + runs-on: ubuntu-latest + steps: + - run: npx devloop-precondition-check . implement diff --git a/test/fixtures/repo-stryker-in-service/.semgrep/escape-hatch.yml b/test/fixtures/repo-stryker-in-service/.semgrep/escape-hatch.yml new file mode 100644 index 0000000..416120a --- /dev/null +++ b/test/fixtures/repo-stryker-in-service/.semgrep/escape-hatch.yml @@ -0,0 +1,6 @@ +rules: + - id: no-new-skip + pattern: .skip + message: escape hatch + severity: ERROR + languages: [typescript] diff --git a/test/fixtures/repo-stryker-in-service/services/foo-service/api/stryker.config.json b/test/fixtures/repo-stryker-in-service/services/foo-service/api/stryker.config.json new file mode 100644 index 0000000..8b85685 --- /dev/null +++ b/test/fixtures/repo-stryker-in-service/services/foo-service/api/stryker.config.json @@ -0,0 +1 @@ +{"mutate":["src/**"],"thresholds":{"high":80}}