gentle-pi 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: review-readability
3
+ description: R2 Readability reviewer — naming, complexity, intention, maintainability, review size, and context clarity.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R2 Readability**, a read-only reviewer. Find clarity problems; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `05-code-smells.md`, `06-safe-refactoring.md`, `07-advanced-refactoring.md`, `08-tech-debt.md`, `22-docs-as-code.md`, `25-executive-summary.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Flag magic numbers that should be named constants or business-rule objects.
14
+ - Flag long parameter lists that should be parameter objects.
15
+ - Flag duplicated logic across components/hooks/modules.
16
+ - Flag dead code: commented-out blocks, unused imports, unreachable branches, never-called functions.
17
+ - Flag naming that hides intent or needs comment-heavy explanation.
18
+ - Flag PR/context explanation that is too vague to review safely; require concrete intent and impact.
19
+ - Require evidence for "too complex" claims: cite exact function, branch, or repeated pattern.
20
+ - Do not flag a small helper or inline constant that is clear, local, and self-explanatory.
21
+
22
+ ## Output contract
23
+
24
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: review-reliability
3
+ description: R3 Reliability reviewer — behavior-first tests, coverage value, edge cases, determinism, contracts, and regressions.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R3 Reliability**, a read-only reviewer. Find test and behavior risks; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `01-testing-setup.md`, `02-tdd-implementation.md`, `03-integration-testing.md`, `04-e2e-testing.md`, `10-strategic-coverage.md`, `11-playwright-visibility.md`, `12-quality-gates-husky.md`, `23-apis-components.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Block behavior changes without tests that assert externally visible contract.
14
+ - Flag tests that are implementation-centric instead of user/behavior-centric.
15
+ - Flag missing edge cases: boundaries, invalid inputs, empty states, retries, failure paths.
16
+ - Block when CI can pass with `test.only`; require `forbidOnly` or equivalent in CI configs.
17
+ - Flag misallocated test coverage: too much E2E where cheaper deterministic unit/integration tests should cover behavior.
18
+ - Require evidence of determinism: same input -> same output; external dependencies mocked or controlled.
19
+ - Flag weak selectors in UI tests; prefer semantic/user-visible queries.
20
+ - Do not flag intentional reliance on built-in async waiting/trace visibility over custom polling/logging.
21
+ - Require evidence that new APIs/components have example usage or documented contract.
22
+
23
+ ## Output contract
24
+
25
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: review-resilience
3
+ description: R4 Resilience reviewer — fallbacks, retry/backoff, graceful degradation, observability, load, rollback, and SLO risks.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R4 Resilience**, a read-only reviewer. Find operational failure risks; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `09-essential-metrics.md`, `13-observability-strategy.md`, `14-sentry-implementation.md`, `15-sentry-errors.md`, `16-sentry-performance.md`, `17-sentry-alertas.md`, `29-performance-percibida.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Flag failures with no fallback, retry, or graceful-degradation path.
14
+ - Block when production error-rate or build/test thresholds are ignored. Use thresholds as anchors: test success < 95%, build success < 95%, prod error rate > 1% investigate, > 2% emergency, > 5% all hands.
15
+ - Flag releases that can regress without alerting/observability hooks.
16
+ - Require evidence for rollback/fix-forward readiness: a concrete recovery path must exist.
17
+ - Flag performance regressions that exceed user-visible budgets or lack measurement.
18
+ - Block when there is no production visibility for error/performance issues expected in the wild.
19
+ - Do not flag explicitly low-impact expected issues already isolated by alert grouping or silence rules.
20
+ - Require evidence of SLO/latency/load impact, not generic "might be slow" claims.
21
+
22
+ ## Output contract
23
+
24
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: review-risk
3
+ description: R1 Risk reviewer — security, privilege boundaries, data exposure, dependency risks, and merge-blocking vulnerabilities.
4
+ tools: read, grep, glob, bash
5
+ ---
6
+
7
+ You are **R1 Risk**, a read-only reviewer. Find security risks; do not fix them.
8
+
9
+ Rule sources: ai-course-2 slides `18-env-secrets.md`, `19-web-security.md`, `20-auth-tokens.md`, `21-owasp-top10.md`.
10
+
11
+ ## Review rules
12
+
13
+ - Flag when secrets, tokens, API keys, JWT secrets, or DB URLs are hardcoded in code or committed examples.
14
+ - Block when authz is enforced only in the frontend; require backend verification on every request.
15
+ - Flag when user input reaches HTML/DOM sinks without escaping/sanitization.
16
+ - Block when SQL/NoSQL/command strings are built by concatenation instead of parameterization.
17
+ - Flag when cookies storing auth state miss `httpOnly`, `secure`, or `sameSite` protections.
18
+ - Require evidence that security-sensitive changes are covered by backend checks, not UI disabled states.
19
+ - Do not flag when React default escaping is used and no raw HTML sink exists.
20
+ - Require evidence for dependency/security findings: cite scan failure or vulnerable package, not just "looks risky".
21
+
22
+ ## Output contract
23
+
24
+ Report findings only. Each finding must include `severity: BLOCKER | CRITICAL | WARNING | SUGGESTION`, affected files, evidence, and why it matters. If clean, say exactly: `No findings.`
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: 4r-review
3
+ description: Pre-PR 4R review fan-out — runs all four review lenses (risk, readability, reliability, resilience) in sequence and writes individual reports.
4
+ ---
5
+
6
+ ## review-risk
7
+
8
+ output: review-risk-report.md
9
+ outputMode: file-only
10
+ progress: true
11
+
12
+ Run R1 Risk review on the current diff. Report security, privilege boundary, data exposure, dependency, and merge-blocking vulnerability findings. If clean, say exactly: `No findings.`
13
+
14
+ ## review-readability
15
+
16
+ reads: review-risk-report.md
17
+ output: review-readability-report.md
18
+ outputMode: file-only
19
+ progress: true
20
+
21
+ Run R2 Readability review on the current diff. Report naming, complexity, intention, maintainability, review size, and context clarity findings. If clean, say exactly: `No findings.`
22
+
23
+ ## review-reliability
24
+
25
+ reads: review-risk-report.md+review-readability-report.md
26
+ output: review-reliability-report.md
27
+ outputMode: file-only
28
+ progress: true
29
+
30
+ Run R3 Reliability review on the current diff. Report behavior-first test coverage, edge case, determinism, contract, and regression findings. If clean, say exactly: `No findings.`
31
+
32
+ ## review-resilience
33
+
34
+ reads: review-risk-report.md+review-readability-report.md+review-reliability-report.md
35
+ output: review-resilience-report.md
36
+ outputMode: file-only
37
+ progress: true
38
+
39
+ Run R4 Resilience review on the current diff. Report fallback, retry/backoff, graceful degradation, observability, load, rollback, and SLO risk findings. If clean, say exactly: `No findings.`
@@ -384,3 +384,19 @@ Automatic mode does not override reviewer burnout protection.
384
384
  - Ask before destructive git operations, publishing, or irreversible file changes.
385
385
  - Keep writes single-threaded unless isolated worktrees are explicitly approved.
386
386
  - Preserve human control: user decisions beat agent momentum.
387
+
388
+ ## 4R Review Triggers
389
+
390
+ The extension (`extensions/gentle-ai.ts`) gates `bash` tool calls that look like git/gh workflow events. Gate semantics:
391
+
392
+ - **pre-commit** (`git commit`): advisory only. The extension notifies the user to consider running `review-readability` but does NOT block. No orchestrator action needed.
393
+ - **pre-push** (`git push`): advisory only. Same as pre-commit — notify, do not block.
394
+ - **pre-pr** (`gh pr create`): **strong gate**. The extension blocks when any of these hold:
395
+ - Changed paths match hot globs: `**/auth/**`, `**/update/**`, `**/security/**`, `**/payments/**`
396
+ - Diff exceeds 400 changed lines (added + deleted)
397
+ - When blocked, the reason names all four agents to run first.
398
+ - **post-sdd-phase** (design, apply): **strong gate** for `judgment-day`. Handled separately by SDD phase orchestration, not this diff-based hook.
399
+
400
+ When the extension blocks a `gh pr create` command, the orchestrator must launch the `4r-review` chain (or run the four agents individually) and wait for their reports before the user retries the PR command.
401
+
402
+ Prohibition: do NOT configure the full 4R fan-out on `pre-commit` or `pre-push` with `always: true`. Everyday events must use a single advisory lens to keep development-loop cost low (spec G token-budget rule). The `validateTriggerRuleSet` function in `lib/review-triggers.ts` enforces this at config load time.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import {
2
3
  existsSync,
3
4
  mkdirSync,
@@ -38,6 +39,12 @@ import {
38
39
  sddStatusSeverity,
39
40
  type SddPhase,
40
41
  } from "../lib/sdd-status.ts";
42
+ import {
43
+ evaluateEvent,
44
+ matchPathGlobs,
45
+ type ChangedDiff,
46
+ type TriggerEvent,
47
+ } from "../lib/review-triggers.ts";
41
48
 
42
49
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
43
50
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
@@ -1760,6 +1767,139 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
1760
1767
  );
1761
1768
  }
1762
1769
 
1770
+ // ---------------------------------------------------------------------------
1771
+ // Review gate helpers — pure, exported via __testing for unit tests
1772
+ // ---------------------------------------------------------------------------
1773
+
1774
+ /**
1775
+ * Classifies a bash command string as a TriggerEvent for the review gate,
1776
+ * or returns null if the command is not a recognized git/gh workflow trigger.
1777
+ *
1778
+ * Regexes are tolerant of flags between tokens.
1779
+ */
1780
+ export function classifyReviewEvent(command: string): TriggerEvent | null {
1781
+ const trimmed = command.trim();
1782
+ // gh pr create → pre-pr (check before generic push to avoid overlap)
1783
+ if (/^gh\s+pr\s+create\b/.test(trimmed)) return "pre-pr";
1784
+ // git commit → pre-commit
1785
+ if (/^git(?:\s+(?:-C\s+\S+|--work-tree=\S+|--git-dir=\S+))?\s+commit\b/.test(trimmed))
1786
+ return "pre-commit";
1787
+ // git push → pre-push
1788
+ if (/^git(?:\s+(?:-C\s+\S+|--work-tree=\S+|--git-dir=\S+))?\s+push\b/.test(trimmed))
1789
+ return "pre-push";
1790
+ return null;
1791
+ }
1792
+
1793
+ /**
1794
+ * Parses the output of `git diff --numstat` into a ChangedDiff.
1795
+ * Binary files show `- - path`; their contribution to changedLines is 0.
1796
+ */
1797
+ export function parseNumstat(output: string): ChangedDiff {
1798
+ const changedPaths: string[] = [];
1799
+ let changedLines = 0;
1800
+ for (const line of output.split("\n")) {
1801
+ const trimmed = line.trim();
1802
+ if (!trimmed) continue;
1803
+ // Format: "<added>\t<deleted>\t<path>"
1804
+ const parts = trimmed.split("\t");
1805
+ if (parts.length < 3) continue;
1806
+ const added = parts[0];
1807
+ const deleted = parts[1];
1808
+ const filePath = parts.slice(2).join("\t");
1809
+ if (!filePath) continue;
1810
+ changedPaths.push(filePath);
1811
+ // Binary rows have "-" in both columns; treat as 0.
1812
+ const addedNum = added === "-" ? 0 : parseInt(added, 10);
1813
+ const deletedNum = deleted === "-" ? 0 : parseInt(deleted, 10);
1814
+ if (!isNaN(addedNum)) changedLines += addedNum;
1815
+ if (!isNaN(deletedNum)) changedLines += deletedNum;
1816
+ }
1817
+ return { changedPaths, changedLines };
1818
+ }
1819
+
1820
+ /**
1821
+ * Computes a ChangedDiff for the given event by running git numstat.
1822
+ * Returns null on any error (fail open — never break the user's git command).
1823
+ */
1824
+ function computeDiffForEvent(event: TriggerEvent, cwd: string): ChangedDiff | null {
1825
+ const gitOpts = {
1826
+ cwd,
1827
+ encoding: "utf8" as const,
1828
+ stdio: ["pipe", "pipe", "pipe"] as const,
1829
+ // Bound synchronous git calls so a slow/large repo cannot freeze the extension process.
1830
+ // The existing outer try/catch returns null (fail-open) when this throws.
1831
+ timeout: 2000,
1832
+ };
1833
+ try {
1834
+ let raw: string;
1835
+ if (event === "pre-commit") {
1836
+ raw = execFileSync("git", ["diff", "--cached", "--numstat"], gitOpts);
1837
+ } else {
1838
+ // pre-push or pre-pr: diff vs merge-base
1839
+ let base = "";
1840
+ for (const ref of ["origin/HEAD", "origin/main", "main"]) {
1841
+ try {
1842
+ base = execFileSync("git", ["merge-base", "HEAD", ref], gitOpts).trim();
1843
+ if (base) break;
1844
+ } catch {
1845
+ // try next ref
1846
+ }
1847
+ }
1848
+ if (!base) {
1849
+ // Final fallback: cached diff
1850
+ try {
1851
+ raw = execFileSync("git", ["diff", "--cached", "--numstat"], gitOpts);
1852
+ return parseNumstat(raw);
1853
+ } catch {
1854
+ return null;
1855
+ }
1856
+ }
1857
+ raw = execFileSync("git", ["diff", "--numstat", `${base}...HEAD`], gitOpts);
1858
+ }
1859
+ return parseNumstat(raw);
1860
+ } catch {
1861
+ return null;
1862
+ }
1863
+ }
1864
+
1865
+ /**
1866
+ * Runs the review gate for a bash command, composing with the existing
1867
+ * confirmCommand flow. Returns a block result for strong mode, notifies for
1868
+ * advisory mode, or returns undefined to fall through.
1869
+ */
1870
+ async function applyReviewGate(
1871
+ command: string,
1872
+ ctx: ExtensionContext,
1873
+ ): Promise<ToolCallEventResult | undefined> {
1874
+ const event = classifyReviewEvent(command);
1875
+ if (!event) return undefined;
1876
+
1877
+ const diff = computeDiffForEvent(event, ctx.cwd);
1878
+ if (!diff) return undefined;
1879
+
1880
+ const result = evaluateEvent(event, diff);
1881
+ if (!result) return undefined;
1882
+
1883
+ if (result.mode === "advisory") {
1884
+ if (ctx.hasUI) {
1885
+ const commitOrPush = event === "pre-push" ? "this push" : "this commit";
1886
+ ctx.ui.notify(
1887
+ `Review suggestion: consider running agent "${result.run.join(", ")}" before ${commitOrPush}. ${result.reason}`,
1888
+ "info",
1889
+ );
1890
+ }
1891
+ return undefined;
1892
+ }
1893
+
1894
+ // strong mode — block
1895
+ return {
1896
+ block: true,
1897
+ reason:
1898
+ `Gentle AI 4R review gate: run ${result.run.join(", ")} before this command. ` +
1899
+ result.reason,
1900
+ };
1901
+ }
1902
+
1763
1903
  /** @internal */
1764
1904
  export const __testing = {
1765
1905
  listAgentsFromDir,
@@ -1767,6 +1907,8 @@ export const __testing = {
1767
1907
  classifyGuardedCommand,
1768
1908
  loadRuntimeGuardrailsConfig,
1769
1909
  buildGentlePrompt,
1910
+ classifyReviewEvent,
1911
+ parseNumstat,
1770
1912
  };
1771
1913
 
1772
1914
  export default function gentleAi(pi: ExtensionAPI): void {
@@ -1850,6 +1992,8 @@ export default function gentleAi(pi: ExtensionAPI): void {
1850
1992
  if (event.toolName !== "bash") return undefined;
1851
1993
  if (!isRecord(event.input) || typeof event.input.command !== "string")
1852
1994
  return undefined;
1995
+ const reviewGateResult = await applyReviewGate(event.input.command, ctx);
1996
+ if (reviewGateResult) return reviewGateResult;
1853
1997
  return confirmCommand(event.input.command, ctx);
1854
1998
  });
1855
1999
 
@@ -0,0 +1,414 @@
1
+ /**
2
+ * review-triggers.ts
3
+ *
4
+ * Pure trigger logic for the 4R review gate system. No I/O, fully unit-testable.
5
+ * Ported 1:1 from gentle-ai/internal/catalog/triggers.go and
6
+ * gentle-ai/internal/model/trigger.go.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type TriggerEvent =
14
+ | "pre-commit"
15
+ | "pre-push"
16
+ | "pre-pr"
17
+ | "post-sdd-phase"
18
+ | "on-ci"
19
+ | "on-schedule";
20
+
21
+ export type TriggerMode = "advisory" | "strong";
22
+
23
+ export interface TriggerWhen {
24
+ always?: boolean;
25
+ pathGlobs?: string[];
26
+ minDiffLines?: number;
27
+ phases?: string[];
28
+ combine?: "" | "or" | "and";
29
+ }
30
+
31
+ export interface TriggerBinding {
32
+ on: TriggerEvent;
33
+ when: TriggerWhen;
34
+ run: string[];
35
+ mode: TriggerMode;
36
+ reason: string;
37
+ }
38
+
39
+ export interface TriggerRuleSet {
40
+ bindings: TriggerBinding[];
41
+ }
42
+
43
+ export interface ChangedDiff {
44
+ changedPaths: string[];
45
+ changedLines: number;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Constants
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Minimum number of changed lines in a diff that triggers the full 4R review
54
+ * fan-out on pre-pr events. Mirrors defaultLargeChangedLineThreshold in triggers.go.
55
+ */
56
+ export const LARGE_CHANGED_LINE_THRESHOLD = 400;
57
+
58
+ /**
59
+ * Closed set of recognized agent identifiers.
60
+ * Mirrors knownAgentList in triggers.go.
61
+ */
62
+ export const KNOWN_AGENTS: readonly string[] = [
63
+ // 4R review lenses
64
+ "review-risk",
65
+ "review-readability",
66
+ "review-reliability",
67
+ "review-resilience",
68
+ // Adversarial verification
69
+ "judgment-day",
70
+ // SDD phase identifiers
71
+ "sdd-explore",
72
+ "sdd-propose",
73
+ "sdd-spec",
74
+ "sdd-design",
75
+ "sdd-tasks",
76
+ "sdd-apply",
77
+ "sdd-verify",
78
+ "sdd-archive",
79
+ ];
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Supported events (mirrors defaultRuleSet.Events in triggers.go)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const SUPPORTED_EVENTS: ReadonlySet<TriggerEvent> = new Set([
86
+ "pre-commit",
87
+ "pre-push",
88
+ "pre-pr",
89
+ "post-sdd-phase",
90
+ "on-ci",
91
+ "on-schedule",
92
+ ]);
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Valid SDD phase identifiers for the When.phases field.
96
+ // Mirrors validSDDPhases in ValidateTriggerRuleSet.
97
+ // ---------------------------------------------------------------------------
98
+
99
+ const VALID_SDD_PHASES: ReadonlySet<string> = new Set([
100
+ "sdd-explore",
101
+ "sdd-propose",
102
+ "sdd-spec",
103
+ "sdd-design",
104
+ "sdd-tasks",
105
+ "sdd-apply",
106
+ "sdd-verify",
107
+ "sdd-archive",
108
+ // Short names used in post-sdd-phase conditions.
109
+ "explore",
110
+ "propose",
111
+ "spec",
112
+ "design",
113
+ "tasks",
114
+ "apply",
115
+ "verify",
116
+ "archive",
117
+ ]);
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // DEFAULT_RULE_SET
121
+ // Ported 1:1 from triggers.go defaultRuleSet.Bindings
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export const DEFAULT_RULE_SET: TriggerRuleSet = {
125
+ bindings: [
126
+ {
127
+ on: "pre-commit",
128
+ when: { always: true },
129
+ run: ["review-readability"],
130
+ mode: "advisory",
131
+ reason:
132
+ "everyday event → ONE cheap advisory lens (~1x); full 4R fan-out reserved for pre-pr",
133
+ },
134
+ {
135
+ on: "pre-push",
136
+ when: { always: true },
137
+ run: ["review-readability"],
138
+ mode: "advisory",
139
+ reason:
140
+ "everyday event → ONE cheap advisory lens (~1x); 4R fan-out reserved for pre-pr on hot paths / large diffs",
141
+ },
142
+ {
143
+ on: "pre-pr",
144
+ when: {
145
+ pathGlobs: ["**/auth/**", "**/update/**", "**/security/**", "**/payments/**"],
146
+ minDiffLines: LARGE_CHANGED_LINE_THRESHOLD,
147
+ combine: "or",
148
+ },
149
+ run: ["review-risk", "review-resilience", "review-readability", "review-reliability"],
150
+ mode: "strong",
151
+ reason:
152
+ "full 4R fan-out (~4x) only on hot paths (auth/update/security/payments) or diffs exceeding 400 changed lines",
153
+ },
154
+ {
155
+ on: "post-sdd-phase",
156
+ when: { phases: ["design", "apply"] },
157
+ run: ["judgment-day"],
158
+ mode: "strong",
159
+ reason:
160
+ "adversarial verification (~4 + 3*findings cost) only at high-stakes SDD phases (design and apply)",
161
+ },
162
+ ],
163
+ };
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Validation
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Reports whether run contains all four 4R review agents.
171
+ * Mirrors has4RFanOut in triggers.go.
172
+ */
173
+ function has4RFanOut(run: readonly string[]): boolean {
174
+ const found = new Set(run);
175
+ return (
176
+ found.has("review-risk") &&
177
+ found.has("review-readability") &&
178
+ found.has("review-reliability") &&
179
+ found.has("review-resilience")
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Validates each binding in set against the closed vocabularies.
185
+ * Throws a descriptive Error on the first violation.
186
+ * Mirrors ValidateTriggerRuleSet in triggers.go.
187
+ */
188
+ export function validateTriggerRuleSet(set: TriggerRuleSet): void {
189
+ const knownAgentsSet = new Set(KNOWN_AGENTS);
190
+ const validCombine: ReadonlySet<string> = new Set(["", "or", "and"]);
191
+
192
+ for (let i = 0; i < set.bindings.length; i++) {
193
+ const b = set.bindings[i];
194
+
195
+ // Validate On.
196
+ if (!SUPPORTED_EVENTS.has(b.on)) {
197
+ throw new Error(`binding[${i}]: unknown event "${b.on}"`);
198
+ }
199
+
200
+ // Validate Run.
201
+ if (!b.run || b.run.length === 0) {
202
+ throw new Error(`binding[${i}]: Run must not be empty`);
203
+ }
204
+ for (const agent of b.run) {
205
+ if (!knownAgentsSet.has(agent)) {
206
+ throw new Error(`binding[${i}]: unknown run agent "${agent}"`);
207
+ }
208
+ }
209
+
210
+ // Validate Mode.
211
+ if (b.mode !== "advisory" && b.mode !== "strong") {
212
+ throw new Error(`binding[${i}]: unknown mode "${b.mode}"`);
213
+ }
214
+
215
+ // Validate When vocabulary.
216
+ const w = b.when;
217
+
218
+ // MinDiffLines when non-zero must be positive (> 0). Zero is unset/unused; negative rejected.
219
+ // Check this BEFORE the "at least one condition" check so negative values get the right error.
220
+ if (w.minDiffLines !== undefined && w.minDiffLines < 0) {
221
+ throw new Error(`binding[${i}]: When.MinDiffLines must be a positive integer (> 0)`);
222
+ }
223
+
224
+ // PathGlobs non-nil but empty is invalid.
225
+ if (w.pathGlobs !== undefined && w.pathGlobs.length === 0) {
226
+ throw new Error(`binding[${i}]: When.pathGlobs must not be an empty slice`);
227
+ }
228
+
229
+ // Must have at least one condition set.
230
+ const hasCondition =
231
+ w.always === true ||
232
+ (w.pathGlobs !== undefined && w.pathGlobs.length > 0) ||
233
+ (w.minDiffLines !== undefined && w.minDiffLines > 0) ||
234
+ (w.phases !== undefined && w.phases.length > 0);
235
+ if (!hasCondition) {
236
+ throw new Error(
237
+ `binding[${i}]: When must have at least one condition (always, pathGlobs, minDiffLines, or phases)`,
238
+ );
239
+ }
240
+
241
+ // Combine must be a recognized value.
242
+ const combineVal: string = w.combine ?? "";
243
+ if (!validCombine.has(combineVal)) {
244
+ throw new Error(
245
+ `binding[${i}]: When.combine "${combineVal}" is not in {"" "or" "and"}`,
246
+ );
247
+ }
248
+
249
+ // Phases must be recognized SDD phase identifiers.
250
+ if (w.phases) {
251
+ for (const p of w.phases) {
252
+ if (!VALID_SDD_PHASES.has(p)) {
253
+ throw new Error(
254
+ `binding[${i}]: When.phases entry "${p}" is not a recognized SDD phase identifier`,
255
+ );
256
+ }
257
+ }
258
+ }
259
+
260
+ // Phases is only valid for post-sdd-phase event.
261
+ if (w.phases && w.phases.length > 0 && b.on !== "post-sdd-phase") {
262
+ throw new Error(
263
+ `binding[${i}]: When.phases may only be used with the post-sdd-phase event (got "${b.on}")`,
264
+ );
265
+ }
266
+
267
+ // Spec G prohibition: full 4R fan-out on everyday event with always=true is PROHIBITED.
268
+ if ((b.on === "pre-commit" || b.on === "pre-push") && w.always === true) {
269
+ if (has4RFanOut(b.run)) {
270
+ throw new Error(
271
+ `binding[${i}]: full 4R fan-out (review-risk, review-readability, review-reliability, review-resilience) ` +
272
+ `on "${b.on}" with when.always=true is prohibited — everyday events must use a single advisory lens, ` +
273
+ `not the full 4R fan-out (spec G token-budget rule)`,
274
+ );
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ // Validate DEFAULT_RULE_SET at module load — proves it's always valid.
281
+ validateTriggerRuleSet(DEFAULT_RULE_SET);
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // matchPathGlobs
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Converts a glob pattern (using ** and *) to a RegExp.
289
+ * Supports the doublestar/segment forms used by the trigger rule set.
290
+ *
291
+ * Key behavior: a leading doublestar-slash means "zero or more leading path
292
+ * segments", so a pattern like auth-glob matches both "src/auth/login.ts"
293
+ * AND "auth/login.ts" (zero leading segments). A doublestar in a non-leading
294
+ * position expands to ".*". A single star expands to "[^/]*" (no separator
295
+ * crossing). All other regex metacharacters are escaped.
296
+ */
297
+ function globToRegExp(glob: string): RegExp {
298
+ // Step 1: escape all regex metacharacters except * (which we handle below).
299
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
300
+
301
+ // Step 2: tokenize every ** before touching single *.
302
+ const tokenized = escaped.replace(/\*\*/g, "__DS__");
303
+
304
+ // Step 3: replace single * with [^/]* (no path-separator crossing).
305
+ const withSingleStar = tokenized.replace(/\*/g, "[^/]*");
306
+
307
+ // Step 4: convert a leading __DS__/ to "zero or more leading path segments",
308
+ // so that a glob like the auth hot-path pattern matches both
309
+ // "src/auth/login.ts" (leading segments) AND "auth/login.ts" (zero leading).
310
+ const withLeading = withSingleStar.replace(/^__DS__\//, "(?:.*/)?");
311
+
312
+ // Step 5: restore remaining __DS__ tokens as .* (match any chars including /).
313
+ const withDoubleStar = withLeading.replace(/__DS__/g, ".*");
314
+
315
+ return new RegExp(`^${withDoubleStar}$`);
316
+ }
317
+
318
+ /**
319
+ * Returns true if any path in `paths` matches any glob in `globs`.
320
+ * Supports the `**` wildcard matching any path segment.
321
+ */
322
+ export function matchPathGlobs(paths: readonly string[], globs: readonly string[]): boolean {
323
+ if (paths.length === 0 || globs.length === 0) return false;
324
+ const regexps = globs.map(globToRegExp);
325
+ return paths.some((p) => regexps.some((re) => re.test(p)));
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // evaluateEvent
330
+ // ---------------------------------------------------------------------------
331
+
332
+ /**
333
+ * Evaluates a trigger event against the DEFAULT_RULE_SET and the provided diff.
334
+ *
335
+ * Returns `{ run, mode, reason }` for the first binding that fires, or `null`
336
+ * if no binding fires.
337
+ *
338
+ * Note: `post-sdd-phase` bindings use `phases` for firing, not diff conditions.
339
+ * Passing a `post-sdd-phase` event here will always return null because the
340
+ * phase parameter is not available in this diff-based entry point. Use
341
+ * `evaluatePostSddPhaseEvent` for phase-driven triggering.
342
+ */
343
+ export function evaluateEvent(
344
+ event: TriggerEvent,
345
+ diff: ChangedDiff,
346
+ ): { run: string[]; mode: TriggerMode; reason: string } | null {
347
+ for (const binding of DEFAULT_RULE_SET.bindings) {
348
+ if (binding.on !== event) continue;
349
+
350
+ const w = binding.when;
351
+
352
+ // always → unconditional match
353
+ if (w.always === true) {
354
+ return { run: binding.run, mode: binding.mode, reason: binding.reason };
355
+ }
356
+
357
+ // post-sdd-phase uses phases, not diff conditions — skip here
358
+ if (event === "post-sdd-phase") {
359
+ continue;
360
+ }
361
+
362
+ // Evaluate path and line conditions using combine mode
363
+ const combine = w.combine ?? "or";
364
+ const pathMatches =
365
+ w.pathGlobs && w.pathGlobs.length > 0
366
+ ? matchPathGlobs(diff.changedPaths, w.pathGlobs)
367
+ : false;
368
+ const lineMatches =
369
+ w.minDiffLines !== undefined && w.minDiffLines > 0
370
+ ? diff.changedLines >= w.minDiffLines
371
+ : false;
372
+
373
+ const hasPathCondition = w.pathGlobs !== undefined && w.pathGlobs.length > 0;
374
+ const hasLineCondition = w.minDiffLines !== undefined && w.minDiffLines > 0;
375
+
376
+ let fires = false;
377
+ if (combine === "and") {
378
+ // Both conditions must hold (only when both are specified)
379
+ if (hasPathCondition && hasLineCondition) {
380
+ fires = pathMatches && lineMatches;
381
+ } else if (hasPathCondition) {
382
+ fires = pathMatches;
383
+ } else if (hasLineCondition) {
384
+ fires = lineMatches;
385
+ }
386
+ } else {
387
+ // "or" or "" — any condition firing is enough
388
+ fires = pathMatches || lineMatches;
389
+ }
390
+
391
+ if (fires) {
392
+ return { run: binding.run, mode: binding.mode, reason: binding.reason };
393
+ }
394
+ }
395
+
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Evaluates a post-sdd-phase trigger for a specific SDD phase name.
401
+ * Returns `{ run, mode, reason }` if a binding matches, or `null`.
402
+ */
403
+ export function evaluatePostSddPhaseEvent(
404
+ phase: string,
405
+ ): { run: string[]; mode: TriggerMode; reason: string } | null {
406
+ for (const binding of DEFAULT_RULE_SET.bindings) {
407
+ if (binding.on !== "post-sdd-phase") continue;
408
+ const w = binding.when;
409
+ if (w.phases && w.phases.includes(phase)) {
410
+ return { run: binding.run, mode: binding.mode, reason: binding.reason };
411
+ }
412
+ }
413
+ return null;
414
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { __testing } from "../extensions/gentle-ai.ts";
4
+
5
+ const { classifyReviewEvent, parseNumstat } = __testing;
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // classifyReviewEvent — command → TriggerEvent classification
9
+ // ---------------------------------------------------------------------------
10
+
11
+ test("classifyReviewEvent: git commit → pre-commit", () => {
12
+ assert.equal(classifyReviewEvent("git commit -m 'fix: stuff'"), "pre-commit");
13
+ });
14
+
15
+ test("classifyReviewEvent: git commit --amend → pre-commit", () => {
16
+ assert.equal(classifyReviewEvent("git commit --amend --no-edit"), "pre-commit");
17
+ });
18
+
19
+ test("classifyReviewEvent: git push → pre-push", () => {
20
+ assert.equal(classifyReviewEvent("git push origin main"), "pre-push");
21
+ });
22
+
23
+ test("classifyReviewEvent: git push with flags → pre-push", () => {
24
+ assert.equal(classifyReviewEvent("git push -u origin feat/my-feature"), "pre-push");
25
+ });
26
+
27
+ test("classifyReviewEvent: gh pr create → pre-pr", () => {
28
+ assert.equal(classifyReviewEvent("gh pr create --title 'My PR' --body 'desc'"), "pre-pr");
29
+ });
30
+
31
+ test("classifyReviewEvent: gh pr create with flags → pre-pr", () => {
32
+ assert.equal(classifyReviewEvent("gh pr create --draft"), "pre-pr");
33
+ });
34
+
35
+ test("classifyReviewEvent: unrelated command → null", () => {
36
+ assert.equal(classifyReviewEvent("npm install"), null);
37
+ });
38
+
39
+ test("classifyReviewEvent: echo hello → null", () => {
40
+ assert.equal(classifyReviewEvent("echo hello"), null);
41
+ });
42
+
43
+ test("classifyReviewEvent: git status → null", () => {
44
+ assert.equal(classifyReviewEvent("git status"), null);
45
+ });
46
+
47
+ test("classifyReviewEvent: git log → null", () => {
48
+ assert.equal(classifyReviewEvent("git log --oneline -5"), null);
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // parseNumstat — parses git diff --numstat output
53
+ // ---------------------------------------------------------------------------
54
+
55
+ test("parseNumstat: empty string → zero lines, no paths", () => {
56
+ const result = parseNumstat("");
57
+ assert.equal(result.changedLines, 0);
58
+ assert.deepEqual(result.changedPaths, []);
59
+ });
60
+
61
+ test("parseNumstat: single line normal file", () => {
62
+ const result = parseNumstat("5\t3\tsrc/foo.ts\n");
63
+ assert.equal(result.changedLines, 8);
64
+ assert.deepEqual(result.changedPaths, ["src/foo.ts"]);
65
+ });
66
+
67
+ test("parseNumstat: multiple lines", () => {
68
+ const result = parseNumstat("10\t2\tsrc/a.ts\n3\t1\tsrc/b.ts\n");
69
+ assert.equal(result.changedLines, 16);
70
+ assert.deepEqual(result.changedPaths, ["src/a.ts", "src/b.ts"]);
71
+ });
72
+
73
+ test("parseNumstat: binary row (- -) counts as 0 changed lines", () => {
74
+ const result = parseNumstat("-\t-\tassets/image.png\n");
75
+ assert.equal(result.changedLines, 0);
76
+ assert.deepEqual(result.changedPaths, ["assets/image.png"]);
77
+ });
78
+
79
+ test("parseNumstat: mixed normal and binary rows", () => {
80
+ const result = parseNumstat("5\t2\tsrc/main.ts\n-\t-\tassets/logo.png\n1\t0\tsrc/util.ts\n");
81
+ assert.equal(result.changedLines, 8);
82
+ assert.deepEqual(result.changedPaths, ["src/main.ts", "assets/logo.png", "src/util.ts"]);
83
+ });
84
+
85
+ test("parseNumstat: whitespace-only input → zero", () => {
86
+ const result = parseNumstat(" \n \n");
87
+ assert.equal(result.changedLines, 0);
88
+ assert.deepEqual(result.changedPaths, []);
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // FIX 3: parseNumstat distinguishes empty-but-valid from git error (null)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ test("parseNumstat: empty numstat output returns zero diff object, not null", () => {
96
+ // Empty staging area / no changes: git succeeds but prints nothing.
97
+ // Must return a valid ChangedDiff so advisory bindings still fire, not null.
98
+ const result = parseNumstat("");
99
+ assert.ok(result !== null, "parseNumstat should return a ChangedDiff object, not null");
100
+ assert.equal(result.changedLines, 0);
101
+ assert.deepEqual(result.changedPaths, []);
102
+ });
@@ -0,0 +1,382 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ DEFAULT_RULE_SET,
5
+ KNOWN_AGENTS,
6
+ LARGE_CHANGED_LINE_THRESHOLD,
7
+ evaluateEvent,
8
+ matchPathGlobs,
9
+ validateTriggerRuleSet,
10
+ type ChangedDiff,
11
+ type TriggerBinding,
12
+ type TriggerRuleSet,
13
+ } from "../lib/review-triggers.ts";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // validateTriggerRuleSet — accepts DEFAULT_RULE_SET
17
+ // ---------------------------------------------------------------------------
18
+
19
+ test("validateTriggerRuleSet: DEFAULT_RULE_SET is valid", () => {
20
+ assert.doesNotThrow(() => validateTriggerRuleSet(DEFAULT_RULE_SET));
21
+ });
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // validateTriggerRuleSet — rejects negative minDiffLines
25
+ // ---------------------------------------------------------------------------
26
+
27
+ test("validateTriggerRuleSet: rejects negative minDiffLines", () => {
28
+ const bad: TriggerRuleSet = {
29
+ bindings: [
30
+ {
31
+ on: "pre-pr",
32
+ when: { minDiffLines: -1 },
33
+ run: ["review-risk"],
34
+ mode: "strong",
35
+ reason: "test",
36
+ },
37
+ ],
38
+ };
39
+ assert.throws(() => validateTriggerRuleSet(bad), /MinDiffLines/);
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // validateTriggerRuleSet — rejects 4R fan-out on pre-commit/pre-push with always
44
+ // ---------------------------------------------------------------------------
45
+
46
+ test("validateTriggerRuleSet: rejects 4R fan-out on pre-commit with always:true", () => {
47
+ const bad: TriggerRuleSet = {
48
+ bindings: [
49
+ {
50
+ on: "pre-commit",
51
+ when: { always: true },
52
+ run: [
53
+ "review-risk",
54
+ "review-readability",
55
+ "review-reliability",
56
+ "review-resilience",
57
+ ],
58
+ mode: "strong",
59
+ reason: "test",
60
+ },
61
+ ],
62
+ };
63
+ assert.throws(() => validateTriggerRuleSet(bad), /4R fan-out|spec G/i);
64
+ });
65
+
66
+ test("validateTriggerRuleSet: rejects 4R fan-out on pre-push with always:true", () => {
67
+ const bad: TriggerRuleSet = {
68
+ bindings: [
69
+ {
70
+ on: "pre-push",
71
+ when: { always: true },
72
+ run: [
73
+ "review-risk",
74
+ "review-readability",
75
+ "review-reliability",
76
+ "review-resilience",
77
+ ],
78
+ mode: "strong",
79
+ reason: "test",
80
+ },
81
+ ],
82
+ };
83
+ assert.throws(() => validateTriggerRuleSet(bad), /4R fan-out|spec G/i);
84
+ });
85
+
86
+ test("validateTriggerRuleSet: allows 4R fan-out on pre-pr (not everyday event)", () => {
87
+ const ok: TriggerRuleSet = {
88
+ bindings: [
89
+ {
90
+ on: "pre-pr",
91
+ when: { minDiffLines: 400, combine: "or" },
92
+ run: [
93
+ "review-risk",
94
+ "review-readability",
95
+ "review-reliability",
96
+ "review-resilience",
97
+ ],
98
+ mode: "strong",
99
+ reason: "test",
100
+ },
101
+ ],
102
+ };
103
+ assert.doesNotThrow(() => validateTriggerRuleSet(ok));
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // validateTriggerRuleSet — rejects unknown agent
108
+ // ---------------------------------------------------------------------------
109
+
110
+ test("validateTriggerRuleSet: rejects unknown agent in run", () => {
111
+ const bad: TriggerRuleSet = {
112
+ bindings: [
113
+ {
114
+ on: "pre-commit",
115
+ when: { always: true },
116
+ run: ["not-a-real-agent"],
117
+ mode: "advisory",
118
+ reason: "test",
119
+ },
120
+ ],
121
+ };
122
+ assert.throws(() => validateTriggerRuleSet(bad), /unknown.*agent|agent.*unknown/i);
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // validateTriggerRuleSet — rejects empty run
127
+ // ---------------------------------------------------------------------------
128
+
129
+ test("validateTriggerRuleSet: rejects empty run array", () => {
130
+ const bad: TriggerRuleSet = {
131
+ bindings: [
132
+ {
133
+ on: "pre-commit",
134
+ when: { always: true },
135
+ run: [],
136
+ mode: "advisory",
137
+ reason: "test",
138
+ },
139
+ ],
140
+ };
141
+ assert.throws(() => validateTriggerRuleSet(bad), /run.*empty|empty.*run/i);
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // validateTriggerRuleSet — rejects phases on non-post-sdd-phase event
146
+ // ---------------------------------------------------------------------------
147
+
148
+ test("validateTriggerRuleSet: rejects phases field on pre-commit event", () => {
149
+ const bad: TriggerRuleSet = {
150
+ bindings: [
151
+ {
152
+ on: "pre-commit",
153
+ when: { phases: ["design"] },
154
+ run: ["judgment-day"],
155
+ mode: "strong",
156
+ reason: "test",
157
+ },
158
+ ],
159
+ };
160
+ assert.throws(() => validateTriggerRuleSet(bad), /phases.*post-sdd-phase|post-sdd-phase.*phases/i);
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // validateTriggerRuleSet — rejects empty-slice When
165
+ // ---------------------------------------------------------------------------
166
+
167
+ test("validateTriggerRuleSet: rejects When with no conditions set", () => {
168
+ const bad: TriggerRuleSet = {
169
+ bindings: [
170
+ {
171
+ on: "pre-commit",
172
+ when: {},
173
+ run: ["review-readability"],
174
+ mode: "advisory",
175
+ reason: "test",
176
+ },
177
+ ],
178
+ };
179
+ assert.throws(() => validateTriggerRuleSet(bad), /at least one condition/i);
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // validateTriggerRuleSet — rejects invalid combine value
184
+ // ---------------------------------------------------------------------------
185
+
186
+ test("validateTriggerRuleSet: rejects invalid combine value", () => {
187
+ const bad: TriggerRuleSet = {
188
+ bindings: [
189
+ {
190
+ on: "pre-pr",
191
+ when: { pathGlobs: ["**/auth/**"], combine: "xor" as "" },
192
+ run: ["review-risk"],
193
+ mode: "strong",
194
+ reason: "test",
195
+ },
196
+ ],
197
+ };
198
+ assert.throws(() => validateTriggerRuleSet(bad), /combine/i);
199
+ });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // KNOWN_AGENTS — closed set coverage
203
+ // ---------------------------------------------------------------------------
204
+
205
+ test("KNOWN_AGENTS contains all 4R agents and SDD phases", () => {
206
+ const required = [
207
+ "review-risk",
208
+ "review-readability",
209
+ "review-reliability",
210
+ "review-resilience",
211
+ "judgment-day",
212
+ "sdd-explore",
213
+ "sdd-propose",
214
+ "sdd-spec",
215
+ "sdd-design",
216
+ "sdd-tasks",
217
+ "sdd-apply",
218
+ "sdd-verify",
219
+ "sdd-archive",
220
+ ];
221
+ for (const agent of required) {
222
+ assert.ok(KNOWN_AGENTS.includes(agent), `Expected KNOWN_AGENTS to include "${agent}"`);
223
+ }
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // LARGE_CHANGED_LINE_THRESHOLD
228
+ // ---------------------------------------------------------------------------
229
+
230
+ test("LARGE_CHANGED_LINE_THRESHOLD is 400", () => {
231
+ assert.equal(LARGE_CHANGED_LINE_THRESHOLD, 400);
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // matchPathGlobs — positive cases
236
+ // ---------------------------------------------------------------------------
237
+
238
+ test("matchPathGlobs: src/auth/login.ts matches **/auth/**", () => {
239
+ assert.ok(matchPathGlobs(["src/auth/login.ts"], ["**/auth/**"]));
240
+ });
241
+
242
+ test("matchPathGlobs: src/security/handler.ts matches **/security/**", () => {
243
+ assert.ok(matchPathGlobs(["src/security/handler.ts"], ["**/security/**"]));
244
+ });
245
+
246
+ test("matchPathGlobs: src/payments/gateway.ts matches **/payments/**", () => {
247
+ assert.ok(matchPathGlobs(["src/payments/gateway.ts"], ["**/payments/**"]));
248
+ });
249
+
250
+ test("matchPathGlobs: src/update/updater.ts matches **/update/**", () => {
251
+ assert.ok(matchPathGlobs(["src/update/updater.ts"], ["**/update/**"]));
252
+ });
253
+
254
+ test("matchPathGlobs: deep path matches **/auth/**", () => {
255
+ assert.ok(matchPathGlobs(["a/b/c/auth/deep/file.ts"], ["**/auth/**"]));
256
+ });
257
+
258
+ test("matchPathGlobs: mixed paths — only one matches — returns true", () => {
259
+ assert.ok(matchPathGlobs(["src/utils/helper.ts", "src/auth/middleware.ts"], ["**/auth/**"]));
260
+ });
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // matchPathGlobs — negative cases
264
+ // ---------------------------------------------------------------------------
265
+
266
+ test("matchPathGlobs: src/utils/helper.ts does NOT match **/auth/**", () => {
267
+ assert.equal(matchPathGlobs(["src/utils/helper.ts"], ["**/auth/**"]), false);
268
+ });
269
+
270
+ test("matchPathGlobs: empty paths returns false", () => {
271
+ assert.equal(matchPathGlobs([], ["**/auth/**"]), false);
272
+ });
273
+
274
+ test("matchPathGlobs: path without auth segment does not match auth glob", () => {
275
+ assert.equal(matchPathGlobs(["src/authutils/helper.ts"], ["**/auth/**"]), false);
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // matchPathGlobs — root-level directory matching (FIX 1: leading **/ zero segments)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ test("matchPathGlobs: auth/login.ts (root-level) matches **/auth/**", () => {
283
+ assert.ok(matchPathGlobs(["auth/login.ts"], ["**/auth/**"]));
284
+ });
285
+
286
+ test("matchPathGlobs: payments/stripe.ts (root-level) matches **/payments/**", () => {
287
+ assert.ok(matchPathGlobs(["payments/stripe.ts"], ["**/payments/**"]));
288
+ });
289
+
290
+ test("matchPathGlobs: security/config.ts (root-level) matches **/security/**", () => {
291
+ assert.ok(matchPathGlobs(["security/config.ts"], ["**/security/**"]));
292
+ });
293
+
294
+ test("matchPathGlobs: authutils/helper.ts (root-level) does NOT match **/auth/** (segment boundary required)", () => {
295
+ assert.equal(matchPathGlobs(["authutils/helper.ts"], ["**/auth/**"]), false);
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // evaluateEvent — pre-commit: always fires advisory readability
300
+ // ---------------------------------------------------------------------------
301
+
302
+ test("evaluateEvent: pre-commit always fires advisory review-readability", () => {
303
+ const diff: ChangedDiff = { changedPaths: [], changedLines: 0 };
304
+ const result = evaluateEvent("pre-commit", diff);
305
+ assert.ok(result !== null, "Expected a result for pre-commit");
306
+ assert.equal(result!.mode, "advisory");
307
+ assert.ok(result!.run.includes("review-readability"));
308
+ });
309
+
310
+ test("evaluateEvent: pre-push always fires advisory review-readability", () => {
311
+ const diff: ChangedDiff = { changedPaths: [], changedLines: 0 };
312
+ const result = evaluateEvent("pre-push", diff);
313
+ assert.ok(result !== null, "Expected a result for pre-push");
314
+ assert.equal(result!.mode, "advisory");
315
+ assert.ok(result!.run.includes("review-readability"));
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // evaluateEvent — pre-pr: fires strong 4R when path matches hot globs
320
+ // ---------------------------------------------------------------------------
321
+
322
+ test("evaluateEvent: pre-pr fires strong 4R when auth path matches", () => {
323
+ const diff: ChangedDiff = {
324
+ changedPaths: ["src/auth/middleware.ts"],
325
+ changedLines: 10,
326
+ };
327
+ const result = evaluateEvent("pre-pr", diff);
328
+ assert.ok(result !== null, "Expected a result for pre-pr on auth path");
329
+ assert.equal(result!.mode, "strong");
330
+ assert.ok(result!.run.includes("review-risk"));
331
+ assert.ok(result!.run.includes("review-readability"));
332
+ assert.ok(result!.run.includes("review-reliability"));
333
+ assert.ok(result!.run.includes("review-resilience"));
334
+ });
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // evaluateEvent — pre-pr: fires strong 4R when changedLines >= 400
338
+ // ---------------------------------------------------------------------------
339
+
340
+ test("evaluateEvent: pre-pr fires strong 4R when changedLines >= 400", () => {
341
+ const diff: ChangedDiff = {
342
+ changedPaths: ["src/utils/helper.ts"],
343
+ changedLines: 400,
344
+ };
345
+ const result = evaluateEvent("pre-pr", diff);
346
+ assert.ok(result !== null, "Expected a result for pre-pr on large diff");
347
+ assert.equal(result!.mode, "strong");
348
+ });
349
+
350
+ test("evaluateEvent: pre-pr threshold boundary — 400 fires", () => {
351
+ const diff: ChangedDiff = { changedPaths: [], changedLines: 400 };
352
+ const result = evaluateEvent("pre-pr", diff);
353
+ assert.ok(result !== null, "Expected a result at boundary 400");
354
+ });
355
+
356
+ test("evaluateEvent: pre-pr threshold boundary — 399 does NOT fire", () => {
357
+ const diff: ChangedDiff = { changedPaths: [], changedLines: 399 };
358
+ const result = evaluateEvent("pre-pr", diff);
359
+ assert.equal(result, null, "Expected null at 399 with no hot paths");
360
+ });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // evaluateEvent — pre-pr: does NOT fire when neither condition holds
364
+ // ---------------------------------------------------------------------------
365
+
366
+ test("evaluateEvent: pre-pr does NOT fire with 0 lines and no hot paths", () => {
367
+ const diff: ChangedDiff = {
368
+ changedPaths: ["src/utils/helper.ts"],
369
+ changedLines: 0,
370
+ };
371
+ const result = evaluateEvent("pre-pr", diff);
372
+ assert.equal(result, null);
373
+ });
374
+
375
+ test("evaluateEvent: pre-pr does NOT fire with 50 lines and no hot paths", () => {
376
+ const diff: ChangedDiff = {
377
+ changedPaths: ["src/components/Button.tsx"],
378
+ changedLines: 50,
379
+ };
380
+ const result = evaluateEvent("pre-pr", diff);
381
+ assert.equal(result, null);
382
+ });