code-review-forge 2.0.0a1__py3-none-any.whl

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.
Files changed (62) hide show
  1. code_forge/__init__.py +14 -0
  2. code_forge/__main__.py +8 -0
  3. code_forge/autofix.py +78 -0
  4. code_forge/baseline.py +216 -0
  5. code_forge/cli.py +983 -0
  6. code_forge/delta.py +65 -0
  7. code_forge/diagnose.py +109 -0
  8. code_forge/diff.py +82 -0
  9. code_forge/disposition.py +32 -0
  10. code_forge/e2e_check.py +641 -0
  11. code_forge/env_resolver.py +91 -0
  12. code_forge/errors.py +34 -0
  13. code_forge/exit_codes.py +37 -0
  14. code_forge/factories.py +191 -0
  15. code_forge/falsify.py +85 -0
  16. code_forge/gate_check.py +466 -0
  17. code_forge/git.py +351 -0
  18. code_forge/hold.py +126 -0
  19. code_forge/install_hooks.py +331 -0
  20. code_forge/lock.py +162 -0
  21. code_forge/machine.py +792 -0
  22. code_forge/mode_resolver.py +60 -0
  23. code_forge/mutation.py +380 -0
  24. code_forge/parsers/__init__.py +56 -0
  25. code_forge/parsers/_sarif.py +77 -0
  26. code_forge/parsers/base.py +65 -0
  27. code_forge/parsers/checkpatch.py +66 -0
  28. code_forge/parsers/clippy.py +85 -0
  29. code_forge/parsers/non_ascii.py +47 -0
  30. code_forge/parsers/ruff.py +18 -0
  31. code_forge/parsers/semgrep.py +18 -0
  32. code_forge/parsers/shellcheck.py +56 -0
  33. code_forge/registry.py +153 -0
  34. code_forge/reporter.py +133 -0
  35. code_forge/runner.py +205 -0
  36. code_forge/sarif.py +226 -0
  37. code_forge/skills/adversarial-qe/SKILL.md +272 -0
  38. code_forge/skills/code-forge/SKILL.md +1193 -0
  39. code_forge/skills/code-review-expert/SKILL.md +162 -0
  40. code_forge/skills/code-review-expert/references/code-quality-checklist.md +130 -0
  41. code_forge/skills/code-review-expert/references/removal-plan.md +52 -0
  42. code_forge/skills/code-review-expert/references/security-checklist.md +118 -0
  43. code_forge/skills/code-review-expert/references/solid-checklist.md +65 -0
  44. code_forge/skills/kernel-fp-verify/SKILL.md +101 -0
  45. code_forge/skills/qodo-review/SKILL.md +135 -0
  46. code_forge/skills/smoke-test/SKILL.md +253 -0
  47. code_forge/skills/smoke-test/references/boundary-cases.md +114 -0
  48. code_forge/skills/smoke-test/references/concurrency-patterns.md +306 -0
  49. code_forge/skills/smoke-test/references/injection-payloads.md +124 -0
  50. code_forge/skills/smoke-test/test-library/shell/README.md +271 -0
  51. code_forge/skills/smoke-test/test-library/shell/primitives.sh +352 -0
  52. code_forge/skills/smoke-test/test-library/shell/primitives_test.sh +324 -0
  53. code_forge/snapshot.py +196 -0
  54. code_forge/source.py +64 -0
  55. code_forge/state.py +246 -0
  56. code_forge/verdict.py +43 -0
  57. code_review_forge-2.0.0a1.dist-info/METADATA +237 -0
  58. code_review_forge-2.0.0a1.dist-info/RECORD +62 -0
  59. code_review_forge-2.0.0a1.dist-info/WHEEL +5 -0
  60. code_review_forge-2.0.0a1.dist-info/entry_points.txt +2 -0
  61. code_review_forge-2.0.0a1.dist-info/licenses/LICENSE +179 -0
  62. code_review_forge-2.0.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,466 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
3
+ """gate-check subcommand: test-based commit gate.
4
+
5
+ Parses .code-forge/gate.yaml, runs the configured test command, translates
6
+ exit codes, and blocks on new failures vs a baseline.
7
+
8
+ run_gate_check returns ONLY 0 or 1, NEVER 2 (EXIT_CLI_ERROR).
9
+ If it returned 2, the pre-commit hook's exit-code translation would
10
+ treat 2 as "allow+warn", causing FAIL-OPEN on config errors.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import IO, Mapping, Optional
21
+
22
+ import yaml
23
+
24
+ from .exit_codes import EXIT_FAIL, EXIT_PASS
25
+
26
+
27
+ # Known test runners for command safety validation
28
+ KNOWN_RUNNERS = {
29
+ "python3", "python", "pytest",
30
+ "cargo", "go", "make",
31
+ "npm", "npx", "node",
32
+ }
33
+
34
+ # Shell metacharacters that must not appear in command args
35
+ SHELL_METACHARACTERS = set("|;&$><`")
36
+
37
+
38
+ def load_gate_config(
39
+ config_path: str | Path,
40
+ fs_open=open,
41
+ ) -> dict:
42
+ """Load and validate gate.yaml config.
43
+
44
+ Args:
45
+ config_path: path to gate.yaml
46
+ fs_open: file open callable (injected for testing)
47
+
48
+ Returns:
49
+ dict with validated test config
50
+
51
+ Raises:
52
+ FileNotFoundError: if config_path does not exist
53
+ ValueError: if YAML is malformed or required fields missing
54
+ """
55
+ try:
56
+ with fs_open(config_path, "r", encoding="utf-8") as f:
57
+ data = yaml.safe_load(f)
58
+ except FileNotFoundError:
59
+ raise
60
+ except yaml.YAMLError as e:
61
+ raise ValueError("Invalid YAML in gate.yaml: %s" % e) from e
62
+
63
+ if not isinstance(data, dict) or "test" not in data:
64
+ raise ValueError("gate.yaml must have a 'test' section")
65
+
66
+ test = data["test"]
67
+ if not isinstance(test, dict):
68
+ raise ValueError("'test' section must be a mapping")
69
+
70
+ # Validate required fields
71
+ if "command" not in test:
72
+ raise ValueError("'test.command' is required")
73
+ if not isinstance(test["command"], list):
74
+ raise ValueError("'test.command' must be a list")
75
+ if not test["command"]:
76
+ raise ValueError("'test.command' cannot be empty")
77
+
78
+ # Optional fields with defaults
79
+ if "env" in test and not isinstance(test.get("env"), dict):
80
+ raise ValueError("'test.env' must be a mapping if present")
81
+
82
+ if "timeout_seconds" in test:
83
+ if not isinstance(test["timeout_seconds"], int):
84
+ raise ValueError("'test.timeout_seconds' must be an integer")
85
+ if test["timeout_seconds"] <= 0:
86
+ raise ValueError("'test.timeout_seconds' must be positive")
87
+
88
+ if "cwd" in test and not isinstance(test["cwd"], str):
89
+ raise ValueError("'test.cwd' must be a string if present")
90
+
91
+ if "source_patterns" in test:
92
+ if not isinstance(test["source_patterns"], list):
93
+ raise ValueError("'test.source_patterns' must be a list if present")
94
+
95
+ return data
96
+
97
+
98
+ def validate_command_safety(command: list[str]) -> None:
99
+ """Validate test command for safety.
100
+
101
+ Args:
102
+ command: test command list
103
+
104
+ Raises:
105
+ ValueError: if command is unsafe
106
+ """
107
+ if not command:
108
+ raise ValueError("command cannot be empty")
109
+ if not isinstance(command, list):
110
+ raise ValueError("command must be a list")
111
+
112
+ # First element must be a known runner
113
+ if command[0] not in KNOWN_RUNNERS:
114
+ raise ValueError(
115
+ "Unknown test runner: %s (expected one of: %s)"
116
+ % (command[0], ", ".join(sorted(KNOWN_RUNNERS)))
117
+ )
118
+
119
+ # No element may contain shell metacharacters
120
+ for arg in command:
121
+ if not isinstance(arg, str):
122
+ raise ValueError("command elements must be strings")
123
+ for char in SHELL_METACHARACTERS:
124
+ if char in arg:
125
+ raise ValueError(
126
+ "Shell metacharacter %r not allowed in command args"
127
+ % char
128
+ )
129
+
130
+
131
+ def is_ci_mode(env: Mapping[str, str]) -> bool:
132
+ """Detect if running in CI mode.
133
+
134
+ Args:
135
+ env: environment variables (os.environ or test fixture)
136
+
137
+ Returns:
138
+ True if in CI mode, False otherwise
139
+ """
140
+ # FORGE_MODE=ci (case-insensitive)
141
+ forge_mode = env.get("FORGE_MODE", "").strip().lower()
142
+ if forge_mode == "ci":
143
+ return True
144
+
145
+ # Platform CI vars (any non-empty value means CI)
146
+ ci_vars = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILD_URL"]
147
+ for var in ci_vars:
148
+ if env.get(var, "").strip():
149
+ return True
150
+
151
+ return False
152
+
153
+
154
+ def match_source_patterns(
155
+ staged_files: list[str],
156
+ patterns: list[str],
157
+ ) -> bool:
158
+ """Check if any staged file matches any pattern.
159
+
160
+ Args:
161
+ staged_files: list of file paths from git diff --cached
162
+ patterns: list of glob patterns (e.g. ["*.py", "*.sh"])
163
+
164
+ Returns:
165
+ True if any file matches any pattern (run tests),
166
+ False if no matches (skip tests).
167
+
168
+ Special cases:
169
+ - Empty staged_files -> False (no source changes, skip tests)
170
+ - Empty patterns list + non-empty files -> True (always run tests)
171
+ """
172
+ if not staged_files:
173
+ return False # No files staged, skip tests
174
+ if not patterns:
175
+ return True # No filter, always run
176
+
177
+ for file_path in staged_files:
178
+ for pattern in patterns:
179
+ if fnmatch.fnmatch(file_path, pattern):
180
+ return True
181
+ return False
182
+
183
+
184
+ def load_test_baseline(
185
+ baseline_path: str | Path,
186
+ fs_open=open,
187
+ ) -> dict | None:
188
+ """Load test baseline from JSON.
189
+
190
+ Args:
191
+ baseline_path: path to test_baseline.json
192
+ fs_open: file open callable (injected for testing)
193
+
194
+ Returns:
195
+ dict with baseline data, or None if file does not exist
196
+
197
+ Raises:
198
+ ValueError: if JSON is malformed or missing schema_version
199
+ """
200
+ try:
201
+ with fs_open(baseline_path, "r", encoding="utf-8") as f:
202
+ data = json.load(f)
203
+ except FileNotFoundError:
204
+ return None
205
+ except json.JSONDecodeError as e:
206
+ raise ValueError("Invalid JSON in baseline: %s" % e) from e
207
+
208
+ if not isinstance(data, dict):
209
+ raise ValueError("Baseline must be a JSON object")
210
+ if "schema_version" not in data:
211
+ raise ValueError("Baseline missing 'schema_version' field")
212
+
213
+ return data
214
+
215
+
216
+ def compute_baseline_delta(
217
+ test_output: str,
218
+ baseline: dict | None,
219
+ ) -> tuple[bool, list[str]]:
220
+ """Compute NEW failures vs baseline.
221
+
222
+ Args:
223
+ test_output: stdout from pytest -q (or other test runner)
224
+ baseline: loaded baseline dict, or None
225
+
226
+ Returns:
227
+ (should_block: bool, new_failure_names: list[str])
228
+
229
+ Logic:
230
+ - No baseline -> (False, []) -- bootstrap, allow
231
+ - Test not in baseline + fails -> NEW failure -> BLOCK
232
+ - Test in baseline as "passed" + now fails -> NEW failure -> BLOCK
233
+ - Test in baseline as "failed" + still fails -> known, not new
234
+ - Test not in baseline + passes -> not a failure, allow
235
+ """
236
+ if baseline is None:
237
+ return (False, []) # No baseline, allow (bootstrap)
238
+
239
+ # Parse pytest -q output (simplified: look for FAILED lines)
240
+ # Real implementation would parse pytest's output format
241
+ # For now, stub: extract test names from "FAILED test_name" lines
242
+ new_failures = []
243
+ baseline_results = baseline.get("test_results", {})
244
+
245
+ # Simple parser: lines like "FAILED tests/test_foo.py::test_bar"
246
+ for line in test_output.split("\n"):
247
+ if line.startswith("FAILED "):
248
+ test_name = line.split()[1] if len(line.split()) > 1 else ""
249
+ if not test_name:
250
+ continue
251
+
252
+ # Check against baseline
253
+ if test_name not in baseline_results:
254
+ # New test that fails -> BLOCK
255
+ new_failures.append(test_name)
256
+ elif baseline_results[test_name] == "passed":
257
+ # Was passing, now fails -> regression -> BLOCK
258
+ new_failures.append(test_name)
259
+ # else: was already failing in baseline -> known, not new
260
+
261
+ should_block = len(new_failures) > 0
262
+ return (should_block, new_failures)
263
+
264
+
265
+ def translate_exit_code(test_returncode: int) -> int:
266
+ """Translate test exit code to hook exit code.
267
+
268
+ Args:
269
+ test_returncode: exit code from test subprocess
270
+
271
+ Returns:
272
+ 0 (allow) or 1 (BLOCK) for the pre-commit hook
273
+
274
+ Mapping:
275
+ 0 -> 0 (allow)
276
+ 1 -> 1 (BLOCK - real test failure)
277
+ 2, 3 -> 0 (allow - pytest interrupt/internal error)
278
+ 4 -> 1 (BLOCK - usage error, misconfigured command)
279
+ 5 -> 1 (BLOCK - no tests collected, toothless gate)
280
+ timeout or >5 -> 1 (BLOCK)
281
+ """
282
+ if test_returncode == 0:
283
+ return 0 # Pass
284
+ if test_returncode == 1:
285
+ return 1 # Real failure
286
+ if test_returncode in (2, 3):
287
+ return 0 # Interrupt/internal error, warn but allow
288
+ if test_returncode in (4, 5):
289
+ return 1 # Usage error / no tests collected
290
+ # timeout or unknown (>5)
291
+ return 1 # Block
292
+
293
+
294
+ def run_gate_check(
295
+ args=None,
296
+ env=None,
297
+ cwd=None,
298
+ stdout: Optional[IO] = None,
299
+ stderr: Optional[IO] = None,
300
+ ) -> int:
301
+ """Main gate-check entry point.
302
+
303
+ Args:
304
+ args: parsed argparse Namespace; reads args.quiet if present
305
+ env: environment variables (os.environ if None)
306
+ cwd: working directory (Path.cwd() if None)
307
+ stdout: output stream (sys.stdout if None)
308
+ stderr: error stream (sys.stderr if None)
309
+
310
+ Returns:
311
+ EXIT_PASS (0) or EXIT_FAIL (1)
312
+
313
+ CRITICAL: NEVER returns EXIT_CLI_ERROR (2). Config/parse errors
314
+ return EXIT_FAIL (1) to enforce FAIL-OPEN guard.
315
+ """
316
+ if env is None:
317
+ env = os.environ
318
+ if cwd is None:
319
+ cwd = Path.cwd()
320
+ if stdout is None:
321
+ stdout = sys.stdout
322
+ if stderr is None:
323
+ stderr = sys.stderr
324
+
325
+ quiet = getattr(args, "quiet", False)
326
+
327
+ def warn(msg):
328
+ if not quiet:
329
+ print(msg, file=stderr)
330
+
331
+ # FAIL-OPEN guard: catch config/parse errors -> BLOCK (exit 1)
332
+ try:
333
+ config_path = cwd / ".code-forge" / "gate.yaml"
334
+ config = load_gate_config(config_path)
335
+ test_config = config["test"]
336
+
337
+ # Validate command safety
338
+ validate_command_safety(test_config["command"])
339
+
340
+ # Load baseline (OK if None)
341
+ baseline_path = cwd / ".code-forge" / "test_baseline.json"
342
+ baseline = load_test_baseline(baseline_path)
343
+
344
+ except (FileNotFoundError, ValueError) as e:
345
+ print("forge: error: %s" % e, file=stderr)
346
+ return EXIT_FAIL # BLOCK on config error (FAIL-OPEN guard)
347
+
348
+ # Check FORGE_SKIP_TESTS (only in local mode, ignored in CI)
349
+ if env.get("FORGE_SKIP_TESTS") == "1":
350
+ if is_ci_mode(env):
351
+ warn("forge: CI mode: FORGE_SKIP_TESTS ignored")
352
+ else:
353
+ warn("forge: FORGE_SKIP_TESTS=1, skipping tests")
354
+ return EXIT_PASS # Allow
355
+
356
+ # Get staged files via git diff --cached --name-only
357
+ try:
358
+ result = subprocess.run(
359
+ ["git", "diff", "--cached", "--name-only"],
360
+ capture_output=True,
361
+ text=True,
362
+ check=False,
363
+ timeout=5,
364
+ )
365
+ if result.returncode != 0:
366
+ print(
367
+ "forge: error: git diff --cached failed: %s"
368
+ % result.stderr.strip(),
369
+ file=stderr
370
+ )
371
+ return EXIT_FAIL # BLOCK on git error
372
+
373
+ staged_files = [
374
+ line.strip()
375
+ for line in result.stdout.strip().split("\n")
376
+ if line.strip()
377
+ ]
378
+ except subprocess.TimeoutExpired:
379
+ print("forge: error: git diff --cached timed out", file=stderr)
380
+ return EXIT_FAIL
381
+ except FileNotFoundError:
382
+ print("forge: error: git not found on PATH", file=stderr)
383
+ return EXIT_FAIL
384
+
385
+ # Filter on source_patterns
386
+ source_patterns = test_config.get("source_patterns", [])
387
+ if not match_source_patterns(staged_files, source_patterns):
388
+ warn("forge: no source files staged, skipping tests")
389
+ return EXIT_PASS # Allow
390
+
391
+ # Run test command
392
+ command = test_config["command"]
393
+ test_env = {**env, **test_config.get("env", {})}
394
+ timeout = test_config.get("timeout_seconds", 120)
395
+ test_cwd = cwd / test_config.get("cwd", ".")
396
+
397
+ try:
398
+ test_result = subprocess.run(
399
+ command,
400
+ capture_output=True,
401
+ text=True,
402
+ env=test_env,
403
+ cwd=str(test_cwd),
404
+ timeout=timeout,
405
+ check=False,
406
+ )
407
+ test_returncode = test_result.returncode
408
+ test_stdout = test_result.stdout
409
+ except subprocess.TimeoutExpired:
410
+ print(
411
+ "forge: error: tests timed out after %d seconds" % timeout,
412
+ file=stderr
413
+ )
414
+ return EXIT_FAIL # BLOCK on timeout
415
+ except FileNotFoundError:
416
+ print(
417
+ "forge: error: test runner not found: %s" % command[0],
418
+ file=stderr
419
+ )
420
+ return EXIT_FAIL
421
+
422
+ # Translate exit code
423
+ translated = translate_exit_code(test_returncode)
424
+
425
+ # Special handling for exit 2-3 (warn but allow)
426
+ if test_returncode == 2:
427
+ warn(
428
+ "forge: warning: tests exited with code 2 "
429
+ "(keyboard interrupt); allowing commit"
430
+ )
431
+ elif test_returncode == 3:
432
+ warn(
433
+ "forge: warning: tests exited with code 3 "
434
+ "(internal error); allowing commit"
435
+ )
436
+
437
+ # Baseline delta applies ONLY to real test failures (exit 1).
438
+ # Exit 4 (usage error), exit 5 (no tests collected), and timeout BLOCK
439
+ # directly -- vacuous delta would otherwise downgrade them to PASS.
440
+ if translated == EXIT_FAIL and test_returncode == 1:
441
+ # Real test failure -> check baseline delta
442
+ should_block, new_failures = compute_baseline_delta(
443
+ test_stdout, baseline
444
+ )
445
+ if not should_block:
446
+ if baseline is None:
447
+ warn(
448
+ "forge: warning: no baseline; tests failed but allowing commit"
449
+ )
450
+ else:
451
+ warn(
452
+ "forge: all failures are known (in baseline); "
453
+ "allowing commit"
454
+ )
455
+ return EXIT_PASS # Downgrade to allow
456
+ else:
457
+ # NEW failures detected
458
+ print(
459
+ "forge: NEW test failures detected (not in baseline):",
460
+ file=stderr
461
+ )
462
+ for test_name in new_failures:
463
+ print(" - %s" % test_name, file=stderr)
464
+ return EXIT_FAIL # BLOCK
465
+
466
+ return translated