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.
- code_forge/__init__.py +14 -0
- code_forge/__main__.py +8 -0
- code_forge/autofix.py +78 -0
- code_forge/baseline.py +216 -0
- code_forge/cli.py +983 -0
- code_forge/delta.py +65 -0
- code_forge/diagnose.py +109 -0
- code_forge/diff.py +82 -0
- code_forge/disposition.py +32 -0
- code_forge/e2e_check.py +641 -0
- code_forge/env_resolver.py +91 -0
- code_forge/errors.py +34 -0
- code_forge/exit_codes.py +37 -0
- code_forge/factories.py +191 -0
- code_forge/falsify.py +85 -0
- code_forge/gate_check.py +466 -0
- code_forge/git.py +351 -0
- code_forge/hold.py +126 -0
- code_forge/install_hooks.py +331 -0
- code_forge/lock.py +162 -0
- code_forge/machine.py +792 -0
- code_forge/mode_resolver.py +60 -0
- code_forge/mutation.py +380 -0
- code_forge/parsers/__init__.py +56 -0
- code_forge/parsers/_sarif.py +77 -0
- code_forge/parsers/base.py +65 -0
- code_forge/parsers/checkpatch.py +66 -0
- code_forge/parsers/clippy.py +85 -0
- code_forge/parsers/non_ascii.py +47 -0
- code_forge/parsers/ruff.py +18 -0
- code_forge/parsers/semgrep.py +18 -0
- code_forge/parsers/shellcheck.py +56 -0
- code_forge/registry.py +153 -0
- code_forge/reporter.py +133 -0
- code_forge/runner.py +205 -0
- code_forge/sarif.py +226 -0
- code_forge/skills/adversarial-qe/SKILL.md +272 -0
- code_forge/skills/code-forge/SKILL.md +1193 -0
- code_forge/skills/code-review-expert/SKILL.md +162 -0
- code_forge/skills/code-review-expert/references/code-quality-checklist.md +130 -0
- code_forge/skills/code-review-expert/references/removal-plan.md +52 -0
- code_forge/skills/code-review-expert/references/security-checklist.md +118 -0
- code_forge/skills/code-review-expert/references/solid-checklist.md +65 -0
- code_forge/skills/kernel-fp-verify/SKILL.md +101 -0
- code_forge/skills/qodo-review/SKILL.md +135 -0
- code_forge/skills/smoke-test/SKILL.md +253 -0
- code_forge/skills/smoke-test/references/boundary-cases.md +114 -0
- code_forge/skills/smoke-test/references/concurrency-patterns.md +306 -0
- code_forge/skills/smoke-test/references/injection-payloads.md +124 -0
- code_forge/skills/smoke-test/test-library/shell/README.md +271 -0
- code_forge/skills/smoke-test/test-library/shell/primitives.sh +352 -0
- code_forge/skills/smoke-test/test-library/shell/primitives_test.sh +324 -0
- code_forge/snapshot.py +196 -0
- code_forge/source.py +64 -0
- code_forge/state.py +246 -0
- code_forge/verdict.py +43 -0
- code_review_forge-2.0.0a1.dist-info/METADATA +237 -0
- code_review_forge-2.0.0a1.dist-info/RECORD +62 -0
- code_review_forge-2.0.0a1.dist-info/WHEEL +5 -0
- code_review_forge-2.0.0a1.dist-info/entry_points.txt +2 -0
- code_review_forge-2.0.0a1.dist-info/licenses/LICENSE +179 -0
- code_review_forge-2.0.0a1.dist-info/top_level.txt +1 -0
code_forge/gate_check.py
ADDED
|
@@ -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
|