diff --git a/packages/gittensory-engine/src/review/guardrail-config.ts b/packages/gittensory-engine/src/review/guardrail-config.ts index e08f36401f..521f2460c3 100644 --- a/packages/gittensory-engine/src/review/guardrail-config.ts +++ b/packages/gittensory-engine/src/review/guardrail-config.ts @@ -1,12 +1,59 @@ import type { RepositorySettings } from "../types/predicted-gate-types.js"; +export const CONFIG_AS_CODE_GUARDRAIL_GLOBS = [ + ".gittensory.yml", + ".gittensory.yaml", + ".gittensory.json", + ".github/gittensory.yml", + ".github/gittensory.yaml", + ".github/gittensory.json", + "**/codecov.yml", + "**/codecov.yaml", + "**/.codecov.yml", +]; + +export const WORKFLOW_AND_RUNTIME_GUARDRAIL_GLOBS = [ + ".github/workflows/**", + "scripts/**", + "wrangler.jsonc", + "src/selfhost/**", +]; + +export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ + "src/rules/**", + "src/services/**", + "src/settings/agent-actions.ts", + "src/settings/agent-execution.ts", + "src/settings/agent-sweep.ts", + "src/settings/autonomy.ts", + "src/queue/**", + "src/github/pr-actions.ts", + "src/github/app.ts", + "src/github/backfill.ts", + "src/scoring/**", + "src/auth/**", + "src/review/safety.ts", + "src/review/guardrail-config.ts", + "src/review/cutover-gate.ts", + "src/review/linked-issue-hard-rules.ts", + "src/review/outcomes-wire.ts", +]; + +export const DEFAULT_HARD_GUARDRAIL_GLOBS = [ + ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, + ...WORKFLOW_AND_RUNTIME_GUARDRAIL_GLOBS, + ...ENGINE_DECISION_GUARDRAIL_GLOBS, +]; + /** - * Resolve hard-guardrail path globs from the already-effective repo settings. Path holds are config-as-code only: - * omitted/null settings mean no path guardrails, and arrays replace lower layers wholesale. + * Resolve hard-guardrail path globs from the already-effective repo settings. Built-in config-as-code, + * workflow/runtime, and engine decision guardrails are invariants; repo settings may only add globs. */ export function resolveHardGuardrailGlobs( settings: Pick | null | undefined, ): string[] { const configured = settings?.hardGuardrailGlobs; - return Array.isArray(configured) ? [...configured] : []; + return Array.from( + new Set([...DEFAULT_HARD_GUARDRAIL_GLOBS, ...(Array.isArray(configured) ? configured : [])]), + ); } diff --git a/src/review/guardrail-config.ts b/src/review/guardrail-config.ts index aa1c12f58e..718d535a8d 100644 --- a/src/review/guardrail-config.ts +++ b/src/review/guardrail-config.ts @@ -1,12 +1,59 @@ import type { RepositorySettings } from "../types"; +export const CONFIG_AS_CODE_GUARDRAIL_GLOBS = [ + ".gittensory.yml", + ".gittensory.yaml", + ".gittensory.json", + ".github/gittensory.yml", + ".github/gittensory.yaml", + ".github/gittensory.json", + "**/codecov.yml", + "**/codecov.yaml", + "**/.codecov.yml", +]; + +export const WORKFLOW_AND_RUNTIME_GUARDRAIL_GLOBS = [ + ".github/workflows/**", + "scripts/**", + "wrangler.jsonc", + "src/selfhost/**", +]; + +export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ + "src/rules/**", + "src/services/**", + "src/settings/agent-actions.ts", + "src/settings/agent-execution.ts", + "src/settings/agent-sweep.ts", + "src/settings/autonomy.ts", + "src/queue/**", + "src/github/pr-actions.ts", + "src/github/app.ts", + "src/github/backfill.ts", + "src/scoring/**", + "src/auth/**", + "src/review/safety.ts", + "src/review/guardrail-config.ts", + "src/review/cutover-gate.ts", + "src/review/linked-issue-hard-rules.ts", + "src/review/outcomes-wire.ts", +]; + +export const DEFAULT_HARD_GUARDRAIL_GLOBS = [ + ...CONFIG_AS_CODE_GUARDRAIL_GLOBS, + ...WORKFLOW_AND_RUNTIME_GUARDRAIL_GLOBS, + ...ENGINE_DECISION_GUARDRAIL_GLOBS, +]; + /** - * Resolve hard-guardrail path globs from the already-effective repo settings. Path holds are config-as-code only: - * omitted/null settings mean no path guardrails, and arrays replace lower layers wholesale. + * Resolve hard-guardrail path globs from the already-effective repo settings. Built-in config-as-code, + * workflow/runtime, and engine decision guardrails are invariants; repo settings may only add globs. */ export function resolveHardGuardrailGlobs( settings: Pick | null | undefined, ): string[] { const configured = settings?.hardGuardrailGlobs; - return Array.isArray(configured) ? [...configured] : []; + return Array.from( + new Set([...DEFAULT_HARD_GUARDRAIL_GLOBS, ...(Array.isArray(configured) ? configured : [])]), + ); } diff --git a/test/unit/guardrail-config.test.ts b/test/unit/guardrail-config.test.ts index 0b8be8b5e1..96e8613b02 100644 --- a/test/unit/guardrail-config.test.ts +++ b/test/unit/guardrail-config.test.ts @@ -1,26 +1,29 @@ import { describe, expect, it } from "vitest"; -import { resolveHardGuardrailGlobs } from "../../src/review/guardrail-config"; +import { + DEFAULT_HARD_GUARDRAIL_GLOBS, + resolveHardGuardrailGlobs, +} from "../../src/review/guardrail-config"; describe("resolveHardGuardrailGlobs", () => { - it("does not invent path guardrails when effective settings omit hardGuardrailGlobs", () => { - expect(resolveHardGuardrailGlobs(undefined)).toEqual([]); - expect(resolveHardGuardrailGlobs(null)).toEqual([]); - expect(resolveHardGuardrailGlobs({})).toEqual([]); - expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: null })).toEqual([]); + it("uses invariant guardrails when effective settings omit hardGuardrailGlobs", () => { + expect(resolveHardGuardrailGlobs(undefined)).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); + expect(resolveHardGuardrailGlobs(null)).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); + expect(resolveHardGuardrailGlobs({})).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); + expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: null })).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); }); - it("returns a clone of the configured guardrail globs", () => { - const configured = ["src/settings/**", ".github/workflows/**"]; + it("adds configured guardrail globs without allowing them to replace invariants", () => { + const configured = ["src/custom/**", ".github/workflows/**"]; const resolved = resolveHardGuardrailGlobs({ hardGuardrailGlobs: configured }); - expect(resolved).toEqual(configured); + expect(resolved).toEqual([...DEFAULT_HARD_GUARDRAIL_GLOBS, "src/custom/**"]); expect(resolved).not.toBe(configured); resolved.push("mutated/**"); - expect(configured).toEqual(["src/settings/**", ".github/workflows/**"]); + expect(configured).toEqual(["src/custom/**", ".github/workflows/**"]); }); - it("preserves an explicit empty list as no path guardrails", () => { - expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: [] })).toEqual([]); + it("keeps invariant guardrails when configured globs are explicitly empty", () => { + expect(resolveHardGuardrailGlobs({ hardGuardrailGlobs: [] })).toEqual(DEFAULT_HARD_GUARDRAIL_GLOBS); }); }); diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index e3c8cf79ba..417be2e712 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -451,20 +451,20 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers).toHaveLength(0); }); - it("does NOT predict a guardrail hold when hardGuardrailGlobs is omitted", () => { + it("predicts a guardrail hold for invariant guardrails when hardGuardrailGlobs is omitted", () => { const result = verdict({ gate: { duplicates: "block" }, changedPaths: [".github/workflows/ci.yml"] }); - expect(result.conclusion).toBe("success"); - expect(result.warnings.some((w) => w.code === "guardrail_hold")).toBe(false); + expect(result.conclusion).toBe("neutral"); + expect(result.warnings.some((w) => w.code === "guardrail_hold")).toBe(true); }); - it("does NOT predict a guardrail hold when hardGuardrailGlobs is explicitly empty", () => { + it("REGRESSION: predicts a guardrail hold for invariant guardrails when hardGuardrailGlobs is explicitly empty", () => { const result = verdict({ gate: { duplicates: "block" }, manifestExtra: { settings: { hardGuardrailGlobs: [] } }, changedPaths: [".github/workflows/ci.yml"], }); - expect(result.conclusion).toBe("success"); - expect(result.warnings.some((w) => w.code === "guardrail_hold")).toBe(false); + expect(result.conclusion).toBe("neutral"); + expect(result.warnings.some((w) => w.code === "guardrail_hold")).toBe(true); }); it("does NOT predict a guardrail hold for an ordinary changed path", () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index f2644194e3..63bc095190 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -11366,7 +11366,10 @@ describe("queue processors", () => { if (/\/pulls\/\d+(?:\?|$)/.test(url) && method === "GET" && !url.includes(`/pulls/${prNumber}/`)) { return Response.json({ number: prNumber, state: "open", user: { login: "contributor" }, head: { sha: "sha1" }, base: { ref: "main", sha: "base" }, mergeable_state: "clean", labels: [] }); } - if (url.includes(`/pulls/${prNumber}/files`)) return Response.json([{ filename: "src/queue/webhook-retry.ts", status: "modified", additions: 5, deletions: 0, changes: 5, patch: "@@\n+dedupe retries" }]); + // src/github/webhook.ts (not src/queue/**): this block tests the unlinked-issue guardrail specifically, + // and src/queue/** is one of ENGINE_DECISION_GUARDRAIL_GLOBS' built-in invariants (guardrail-config.ts) — + // a diff touching it would unconditionally hold regardless of this guardrail's own on/off setting. + if (url.includes(`/pulls/${prNumber}/files`)) return Response.json([{ filename: "src/github/webhook.ts", status: "modified", additions: 5, deletions: 0, changes: 5, patch: "@@\n+dedupe retries" }]); if (url.includes(`/commits/sha1/check-runs`)) return Response.json({ total_count: 0, check_runs: [] }); if (url.includes(`/commits/sha1/status`)) return Response.json({ state: "success", statuses: [{ context: "ci/build", state: "success", description: "ok" }] }); if (url.includes(`/commits/sha1/check-suites`)) return Response.json({ check_suites: [] });