Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 50 additions & 12 deletions dist/core/guardians.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<svc>/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/<svc>/api/ and packages/<pkg>/ monorepo layouts.
return hasStrykerConfig(repo, 3);
}
// Does any CI workflow reference `needle` (i.e. actually run that guard)?
export function workflowReferences(repo, needle) {
Expand Down
63 changes: 50 additions & 13 deletions src/core/guardians.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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/<svc>/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/<svc>/api/ and packages/<pkg>/ monorepo layouts.
return hasStrykerConfig(repo, 3);
}

// Does any CI workflow reference `needle` (i.e. actually run that guard)?
Expand Down
8 changes: 8 additions & 0 deletions test/core/guardians.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<svc>/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"]);
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/repo-stryker-in-service/.github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
* @team
/stryker.conf.json @owners
/.semgrep/ @owners
/constitution.md @owners
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: ci
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- run: npx devloop-precondition-check . implement
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
rules:
- id: no-new-skip
pattern: .skip
message: escape hatch
severity: ERROR
languages: [typescript]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"mutate":["src/**"],"thresholds":{"high":80}}
Loading