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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""STATE-06 mode resolution.
|
|
4
|
+
|
|
5
|
+
Pure precedence function: CLI flag > FORGE_MODE env > TTY default.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from .state import Mode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
VALID_MODE_STRINGS = {"local": Mode.LOCAL, "ci": Mode.CI}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_mode(
|
|
18
|
+
cli_arg: Optional[str],
|
|
19
|
+
env: Mapping[str, str],
|
|
20
|
+
stdout_isatty: bool,
|
|
21
|
+
) -> Mode:
|
|
22
|
+
"""Resolve effective Mode given inputs.
|
|
23
|
+
|
|
24
|
+
Precedence (highest first):
|
|
25
|
+
1. cli_arg if not None (CLI --mode flag value)
|
|
26
|
+
2. env["FORGE_MODE"] if present and non-empty
|
|
27
|
+
3. default: Mode.LOCAL if stdout_isatty else Mode.CI
|
|
28
|
+
|
|
29
|
+
String values are case-insensitive ("local", "Local", "LOCAL" all OK).
|
|
30
|
+
Invalid string (anywhere) raises ValueError with the offending value.
|
|
31
|
+
Empty FORGE_MODE ("") falls through to TTY default (M3 fix).
|
|
32
|
+
Whitespace-only FORGE_MODE (" ") raises ValueError (L1 fix).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
cli_arg: value from argparse --mode (None if not provided)
|
|
36
|
+
env: os.environ or test-injected mapping
|
|
37
|
+
stdout_isatty: result of sys.stdout.isatty() at process start
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Mode.LOCAL or Mode.CI
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: cli_arg or env value not in {"local", "ci"}
|
|
44
|
+
"""
|
|
45
|
+
if cli_arg is not None:
|
|
46
|
+
return _parse_mode_string(cli_arg, source="--mode")
|
|
47
|
+
env_value = env.get("FORGE_MODE")
|
|
48
|
+
if env_value is not None and env_value != "":
|
|
49
|
+
return _parse_mode_string(env_value, source="FORGE_MODE env")
|
|
50
|
+
return Mode.LOCAL if stdout_isatty else Mode.CI
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_mode_string(value: str, source: str) -> Mode:
|
|
54
|
+
"""Lower + lookup; raise with source attribution on miss."""
|
|
55
|
+
key = value.strip().lower()
|
|
56
|
+
if key not in VALID_MODE_STRINGS:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"invalid mode %r from %s (expected: local|ci)" % (value, source)
|
|
59
|
+
)
|
|
60
|
+
return VALID_MODE_STRINGS[key]
|
code_forge/mutation.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Mutation testing integration via mutmut subprocess.
|
|
4
|
+
|
|
5
|
+
Python-only MVP. Runs mutmut on diff-scoped files and parses survivors.
|
|
6
|
+
Swappable design: keep subprocess calls in one place so future language
|
|
7
|
+
runners (cargo-mutants, go-mutesting) can replace implementation without
|
|
8
|
+
changing the l2_runner interface.
|
|
9
|
+
|
|
10
|
+
mutmut 3.x integration notes:
|
|
11
|
+
- No --paths-to-mutate CLI flag; use setup.cfg [mutmut] paths_to_mutate.
|
|
12
|
+
- Run cwd MUST be the project root (where src/ and tests/ live).
|
|
13
|
+
- paths_to_mutate must be relative to the project root.
|
|
14
|
+
- PYTHONPATH must point into src/ so imports resolve without src. prefix.
|
|
15
|
+
- results format: " module.fn__mutmut_N: status" (one per line).
|
|
16
|
+
- Any non-zero exit code from mutmut run is a hard error.
|
|
17
|
+
- A temporary setup.cfg is written to project root and cleaned up after.
|
|
18
|
+
- mutants/ directory created by mutmut is also cleaned up after each run.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .disposition import Disposition
|
|
31
|
+
from .state import StateFinding
|
|
32
|
+
|
|
33
|
+
# Marker in setup.cfg so we never accidentally overwrite user config
|
|
34
|
+
_CODE_FORGE_CFG_MARKER = "# managed-by-code-forge-mutation"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Survivor:
|
|
39
|
+
"""A mutant that survived (no test killed it)."""
|
|
40
|
+
mutant_name: str # mutmut 3.x identifier e.g. "code_forge.mutation.x_run__mutmut_1"
|
|
41
|
+
file: str # source file (empty; mutmut 3.x results omit file paths)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_mutmut_results(stdout: str) -> tuple[list[Survivor], list[str]]:
|
|
45
|
+
"""Parse mutmut 3.x results output to extract surviving mutants.
|
|
46
|
+
|
|
47
|
+
Expected format from mutmut 3.x results command:
|
|
48
|
+
{indent}{module}.{mangled_fn}__mutmut_{N}: {status}
|
|
49
|
+
|
|
50
|
+
Where status is one of: survived, killed, no tests, not checked,
|
|
51
|
+
timeout, suspicious, check was interrupted by user.
|
|
52
|
+
|
|
53
|
+
Only lines with status "survived" are returned as Survivor objects.
|
|
54
|
+
|
|
55
|
+
The mutant_name is the full identifier (e.g. "add.x_add__mutmut_1").
|
|
56
|
+
The file field is empty string since mutmut 3.x results do not include
|
|
57
|
+
file paths; callers that need file attribution must use py_files list.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
stdout: raw stdout from mutmut results subprocess
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
tuple[list[Survivor], list[str]] where second element is warnings
|
|
64
|
+
about unparseable lines. Never raises.
|
|
65
|
+
"""
|
|
66
|
+
survivors = []
|
|
67
|
+
warnings = []
|
|
68
|
+
|
|
69
|
+
for line in stdout.split("\n"):
|
|
70
|
+
stripped = line.strip()
|
|
71
|
+
if not stripped:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Format: "module.fn__mutmut_N: status"
|
|
75
|
+
if ": " not in stripped:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
mutant_name, _, status = stripped.partition(": ")
|
|
79
|
+
mutant_name = mutant_name.strip()
|
|
80
|
+
status = status.strip()
|
|
81
|
+
|
|
82
|
+
if not mutant_name:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if status == "survived":
|
|
86
|
+
survivors.append(Survivor(mutant_name=mutant_name, file=""))
|
|
87
|
+
|
|
88
|
+
return survivors, warnings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_mutation(
|
|
92
|
+
diff_files: list[str],
|
|
93
|
+
baseline_cmd: list[str],
|
|
94
|
+
timeout: int = 600,
|
|
95
|
+
cwd: Path | None = None,
|
|
96
|
+
) -> tuple[list[StateFinding], list[str]]:
|
|
97
|
+
"""Run mutation testing on diff-scoped files.
|
|
98
|
+
|
|
99
|
+
Returns tuple[list[StateFinding], list[str]] matching l0_runner
|
|
100
|
+
signature for consistency.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
diff_files: changed files from git diff --name-only (relative to cwd)
|
|
104
|
+
baseline_cmd: test command to run for flaky guard and mutmut
|
|
105
|
+
timeout: mutmut run timeout in seconds (default 600)
|
|
106
|
+
cwd: project root for mutmut (default: Path.cwd()). Must be the
|
|
107
|
+
directory containing src/ and tests/.
|
|
108
|
+
|
|
109
|
+
Implementation note:
|
|
110
|
+
mutmut 3.x requires cwd to be the project root. A temporary
|
|
111
|
+
setup.cfg is written there with paths_to_mutate pointing to the
|
|
112
|
+
diff-scoped files. The mutants/ directory and temporary setup.cfg
|
|
113
|
+
are cleaned up after each run.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
(findings, infra_errors) where findings contains:
|
|
117
|
+
- CONFIRMED MUTANT findings for survivors
|
|
118
|
+
- DISMISSED MUTATION_SKIPPED findings for skip conditions
|
|
119
|
+
"""
|
|
120
|
+
findings: list[StateFinding] = []
|
|
121
|
+
infra_errors: list[str] = []
|
|
122
|
+
|
|
123
|
+
if cwd is None:
|
|
124
|
+
cwd = Path.cwd()
|
|
125
|
+
repo_root = str(cwd.resolve())
|
|
126
|
+
|
|
127
|
+
# Empty files: no work
|
|
128
|
+
if not diff_files:
|
|
129
|
+
return ([], [])
|
|
130
|
+
|
|
131
|
+
# Filter to .py files only (Python MVP)
|
|
132
|
+
py_files = [f for f in diff_files if f.endswith(".py")]
|
|
133
|
+
if not py_files:
|
|
134
|
+
findings.append(
|
|
135
|
+
StateFinding(
|
|
136
|
+
id="MUTATION_SKIPPED",
|
|
137
|
+
fingerprint="mutation-no-python",
|
|
138
|
+
source="MUTANT",
|
|
139
|
+
disposition=Disposition.DISMISSED,
|
|
140
|
+
file="",
|
|
141
|
+
line_range=[],
|
|
142
|
+
description="no Python files in diff (mutation is Python-only MVP)",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
infra_errors.append("no Python files in diff")
|
|
146
|
+
return (findings, infra_errors)
|
|
147
|
+
|
|
148
|
+
# Flaky guard: run baseline 3x from repo root
|
|
149
|
+
run_env = os.environ.copy()
|
|
150
|
+
run_env["PYTHONPATH"] = os.path.join(repo_root, "src")
|
|
151
|
+
|
|
152
|
+
for run_num in range(1, 4):
|
|
153
|
+
try:
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
baseline_cmd,
|
|
156
|
+
env=run_env,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
timeout=120,
|
|
160
|
+
check=False,
|
|
161
|
+
cwd=repo_root,
|
|
162
|
+
)
|
|
163
|
+
if result.returncode != 0:
|
|
164
|
+
findings.append(
|
|
165
|
+
StateFinding(
|
|
166
|
+
id="MUTATION_SKIPPED",
|
|
167
|
+
fingerprint="mutation-flaky",
|
|
168
|
+
source="MUTANT",
|
|
169
|
+
disposition=Disposition.DISMISSED,
|
|
170
|
+
file="",
|
|
171
|
+
line_range=[],
|
|
172
|
+
description=(
|
|
173
|
+
"run %d: tests flaky, mutation unreliable "
|
|
174
|
+
"(3x baseline check)" % run_num
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
infra_errors.append(
|
|
179
|
+
"flaky guard: baseline failed on run %d" % run_num
|
|
180
|
+
)
|
|
181
|
+
return (findings, infra_errors)
|
|
182
|
+
except subprocess.TimeoutExpired:
|
|
183
|
+
findings.append(
|
|
184
|
+
StateFinding(
|
|
185
|
+
id="MUTATION_SKIPPED",
|
|
186
|
+
fingerprint="mutation-baseline-timeout",
|
|
187
|
+
source="MUTANT",
|
|
188
|
+
disposition=Disposition.DISMISSED,
|
|
189
|
+
file="",
|
|
190
|
+
line_range=[],
|
|
191
|
+
description="baseline tests timed out (flaky guard)",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
infra_errors.append(
|
|
195
|
+
"flaky guard: baseline timeout on run %d" % run_num
|
|
196
|
+
)
|
|
197
|
+
return (findings, infra_errors)
|
|
198
|
+
|
|
199
|
+
# Check mutmut availability
|
|
200
|
+
if shutil.which("mutmut") is None:
|
|
201
|
+
findings.append(
|
|
202
|
+
StateFinding(
|
|
203
|
+
id="MUTATION_SKIPPED",
|
|
204
|
+
fingerprint="mutation-unavailable",
|
|
205
|
+
source="MUTANT",
|
|
206
|
+
disposition=Disposition.DISMISSED,
|
|
207
|
+
file="",
|
|
208
|
+
line_range=[],
|
|
209
|
+
description="mutmut not installed (soft dependency)",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
return (findings, [])
|
|
213
|
+
|
|
214
|
+
# Refuse to overwrite user's existing setup.cfg or [tool.mutmut] config.
|
|
215
|
+
setup_cfg_path = os.path.join(repo_root, "setup.cfg")
|
|
216
|
+
pyproject_path = os.path.join(repo_root, "pyproject.toml")
|
|
217
|
+
wrote_setup_cfg = False
|
|
218
|
+
try:
|
|
219
|
+
has_user_setup_cfg = False
|
|
220
|
+
if os.path.exists(setup_cfg_path):
|
|
221
|
+
with open(setup_cfg_path, encoding="utf-8") as _fh:
|
|
222
|
+
has_user_setup_cfg = _CODE_FORGE_CFG_MARKER not in _fh.read()
|
|
223
|
+
has_user_pyproject_mutmut = False
|
|
224
|
+
if os.path.exists(pyproject_path):
|
|
225
|
+
try:
|
|
226
|
+
with open(pyproject_path, encoding="utf-8") as fh:
|
|
227
|
+
raw = fh.read()
|
|
228
|
+
has_user_pyproject_mutmut = "[tool.mutmut]" in raw
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
if has_user_setup_cfg or has_user_pyproject_mutmut:
|
|
233
|
+
infra_errors.append(
|
|
234
|
+
"mutmut config conflict: project already has [mutmut] or "
|
|
235
|
+
"[tool.mutmut] config; forge cannot override it safely"
|
|
236
|
+
)
|
|
237
|
+
findings.append(
|
|
238
|
+
StateFinding(
|
|
239
|
+
id="MUTATION_SKIPPED",
|
|
240
|
+
fingerprint="mutation-config-conflict",
|
|
241
|
+
source="MUTANT",
|
|
242
|
+
disposition=Disposition.DISMISSED,
|
|
243
|
+
file="",
|
|
244
|
+
line_range=[],
|
|
245
|
+
description=(
|
|
246
|
+
"mutmut config conflict: existing setup.cfg or "
|
|
247
|
+
"pyproject.toml [tool.mutmut] detected"
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return (findings, infra_errors)
|
|
252
|
+
|
|
253
|
+
# Write temporary setup.cfg to project root.
|
|
254
|
+
# paths_to_mutate are relative to the project root.
|
|
255
|
+
config_content = (
|
|
256
|
+
"%s\n"
|
|
257
|
+
"[mutmut]\n"
|
|
258
|
+
"paths_to_mutate=%s\n"
|
|
259
|
+
% (_CODE_FORGE_CFG_MARKER, ",".join(py_files))
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Write to a temp file first, then rename for atomicity
|
|
263
|
+
fd, tmp_cfg = tempfile.mkstemp(
|
|
264
|
+
prefix=".code-forge-mutation-cfg-",
|
|
265
|
+
dir=repo_root,
|
|
266
|
+
suffix=".cfg",
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
270
|
+
fh.write(config_content)
|
|
271
|
+
os.rename(tmp_cfg, setup_cfg_path)
|
|
272
|
+
wrote_setup_cfg = True
|
|
273
|
+
except OSError:
|
|
274
|
+
try:
|
|
275
|
+
os.unlink(tmp_cfg)
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
raise
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
result = subprocess.run(
|
|
282
|
+
["mutmut", "run"],
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
timeout=timeout,
|
|
286
|
+
check=False,
|
|
287
|
+
env=run_env,
|
|
288
|
+
cwd=repo_root,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Any non-zero exit is an error (attempt 2 bug: only caught ==2)
|
|
292
|
+
if result.returncode != 0:
|
|
293
|
+
findings.append(
|
|
294
|
+
StateFinding(
|
|
295
|
+
id="MUTATION_ERROR",
|
|
296
|
+
fingerprint="mutation-invocation-error",
|
|
297
|
+
source="MUTANT",
|
|
298
|
+
disposition=Disposition.CONFIRMED,
|
|
299
|
+
file="",
|
|
300
|
+
line_range=[],
|
|
301
|
+
description=(
|
|
302
|
+
"mutmut run failed (exit %d): %s"
|
|
303
|
+
% (result.returncode, result.stderr[:200])
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
infra_errors.append(
|
|
308
|
+
"mutmut error (exit %d): %s"
|
|
309
|
+
% (result.returncode, result.stderr[:100])
|
|
310
|
+
)
|
|
311
|
+
return (findings, infra_errors)
|
|
312
|
+
|
|
313
|
+
except subprocess.TimeoutExpired:
|
|
314
|
+
findings.append(
|
|
315
|
+
StateFinding(
|
|
316
|
+
id="MUTATION_SKIPPED",
|
|
317
|
+
fingerprint="mutation-timeout",
|
|
318
|
+
source="MUTANT",
|
|
319
|
+
disposition=Disposition.DISMISSED,
|
|
320
|
+
file="",
|
|
321
|
+
line_range=[],
|
|
322
|
+
description="mutmut timed out after %ds" % timeout,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
return (findings, [])
|
|
326
|
+
|
|
327
|
+
# Parse results from repo_root
|
|
328
|
+
try:
|
|
329
|
+
results_proc = subprocess.run(
|
|
330
|
+
["mutmut", "results"],
|
|
331
|
+
capture_output=True,
|
|
332
|
+
text=True,
|
|
333
|
+
timeout=10,
|
|
334
|
+
check=False,
|
|
335
|
+
cwd=repo_root,
|
|
336
|
+
env=run_env,
|
|
337
|
+
)
|
|
338
|
+
survivors, parse_warnings = parse_mutmut_results(results_proc.stdout)
|
|
339
|
+
infra_errors.extend(parse_warnings)
|
|
340
|
+
except subprocess.TimeoutExpired:
|
|
341
|
+
findings.append(
|
|
342
|
+
StateFinding(
|
|
343
|
+
id="MUTATION_SKIPPED",
|
|
344
|
+
fingerprint="mutation-results-timeout",
|
|
345
|
+
source="MUTANT",
|
|
346
|
+
disposition=Disposition.DISMISSED,
|
|
347
|
+
file="",
|
|
348
|
+
line_range=[],
|
|
349
|
+
description="mutmut results timed out",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
return (findings, [])
|
|
353
|
+
|
|
354
|
+
# Convert survivors to findings
|
|
355
|
+
for survivor in survivors:
|
|
356
|
+
findings.append(
|
|
357
|
+
StateFinding(
|
|
358
|
+
id="mutant-%s" % survivor.mutant_name,
|
|
359
|
+
fingerprint="mutant:%s" % survivor.mutant_name,
|
|
360
|
+
source="MUTANT",
|
|
361
|
+
disposition=Disposition.CONFIRMED,
|
|
362
|
+
file=survivor.file,
|
|
363
|
+
line_range=[0, 0], # mutmut 3.x results omit line numbers
|
|
364
|
+
description=(
|
|
365
|
+
"mutant survived: %s" % survivor.mutant_name
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
finally:
|
|
371
|
+
# Clean up temporary setup.cfg and mutants/ directory
|
|
372
|
+
if wrote_setup_cfg:
|
|
373
|
+
try:
|
|
374
|
+
os.unlink(setup_cfg_path)
|
|
375
|
+
except OSError:
|
|
376
|
+
pass
|
|
377
|
+
mutants_dir = os.path.join(repo_root, "mutants")
|
|
378
|
+
shutil.rmtree(mutants_dir, ignore_errors=True)
|
|
379
|
+
|
|
380
|
+
return (findings, infra_errors)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parser subsystem -- tool output to Finding conversion.
|
|
4
|
+
|
|
5
|
+
Exports PARSER_DISPATCH (format -> parser function) and parse_output()
|
|
6
|
+
for dispatch by output_format string.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
10
|
+
from code_forge.parsers.shellcheck import parse_shellcheck
|
|
11
|
+
from code_forge.parsers.ruff import parse_ruff
|
|
12
|
+
from code_forge.parsers.semgrep import parse_semgrep
|
|
13
|
+
from code_forge.parsers.clippy import parse_clippy
|
|
14
|
+
from code_forge.parsers.checkpatch import parse_checkpatch
|
|
15
|
+
from code_forge.parsers.non_ascii import parse_non_ascii
|
|
16
|
+
from code_forge.parsers._sarif import _parse_sarif
|
|
17
|
+
|
|
18
|
+
# 5 keys map to 6 tools: ruff and semgrep both use output_format="sarif"
|
|
19
|
+
# in tools.yaml, dispatching to the shared _parse_sarif function.
|
|
20
|
+
# The tool_name parameter distinguishes them in the Finding objects.
|
|
21
|
+
PARSER_DISPATCH: dict = {
|
|
22
|
+
"shellcheck_json": parse_shellcheck,
|
|
23
|
+
"sarif": _parse_sarif,
|
|
24
|
+
"clippy_json": parse_clippy,
|
|
25
|
+
"checkpatch_emacs": parse_checkpatch,
|
|
26
|
+
"grep_line": parse_non_ascii,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_output(
|
|
31
|
+
output: str,
|
|
32
|
+
output_format: str,
|
|
33
|
+
tool_name: str,
|
|
34
|
+
exit_code: int = 0,
|
|
35
|
+
) -> list[Finding | ToolError]:
|
|
36
|
+
"""Dispatch to the correct parser by output_format.
|
|
37
|
+
|
|
38
|
+
Raises KeyError on unknown format (registry validation happens
|
|
39
|
+
at dispatch time per Mimo F-01).
|
|
40
|
+
"""
|
|
41
|
+
parser_fn = PARSER_DISPATCH[output_format]
|
|
42
|
+
return parser_fn(output, tool_name, exit_code)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Finding",
|
|
47
|
+
"ToolError",
|
|
48
|
+
"PARSER_DISPATCH",
|
|
49
|
+
"parse_output",
|
|
50
|
+
"parse_shellcheck",
|
|
51
|
+
"parse_ruff",
|
|
52
|
+
"parse_semgrep",
|
|
53
|
+
"parse_clippy",
|
|
54
|
+
"parse_checkpatch",
|
|
55
|
+
"parse_non_ascii",
|
|
56
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Shared SARIF 2.1.0 parser for ruff and semgrep (DRY)."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_sarif(
|
|
11
|
+
output: str,
|
|
12
|
+
tool_name: str,
|
|
13
|
+
exit_code: int = 0,
|
|
14
|
+
) -> list[Finding | ToolError]:
|
|
15
|
+
"""Parse SARIF 2.1.0 JSON into Finding objects.
|
|
16
|
+
|
|
17
|
+
Shared by ruff and semgrep -- tool_name distinguishes them.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
[] on empty string (clean run).
|
|
21
|
+
[Finding, ...] on valid SARIF with results.
|
|
22
|
+
[ToolError] on malformed/unparseable output.
|
|
23
|
+
"""
|
|
24
|
+
if not output.strip():
|
|
25
|
+
return []
|
|
26
|
+
try:
|
|
27
|
+
sarif = json.loads(output)
|
|
28
|
+
except (json.JSONDecodeError, ValueError):
|
|
29
|
+
return [ToolError(
|
|
30
|
+
tool_name=tool_name,
|
|
31
|
+
exit_code=exit_code,
|
|
32
|
+
stderr="",
|
|
33
|
+
message=f"Failed to parse {tool_name} SARIF output",
|
|
34
|
+
)]
|
|
35
|
+
|
|
36
|
+
findings = []
|
|
37
|
+
try:
|
|
38
|
+
for run in sarif.get("runs", []):
|
|
39
|
+
for result in run.get("results", []):
|
|
40
|
+
for location in result.get("locations", []):
|
|
41
|
+
phys = location.get("physicalLocation", {})
|
|
42
|
+
artifact = phys.get("artifactLocation", {})
|
|
43
|
+
region = phys.get("region", {})
|
|
44
|
+
uri = artifact.get("uri", "")
|
|
45
|
+
# Strip file:// prefix
|
|
46
|
+
if uri.startswith("file:///"):
|
|
47
|
+
uri = uri[len("file:///"):]
|
|
48
|
+
elif uri.startswith("file://"):
|
|
49
|
+
uri = uri[len("file://"):]
|
|
50
|
+
start_line = region.get("startLine", 0)
|
|
51
|
+
end_line_raw = region.get("endLine")
|
|
52
|
+
findings.append(Finding(
|
|
53
|
+
file=uri,
|
|
54
|
+
line=start_line,
|
|
55
|
+
end_line=(
|
|
56
|
+
end_line_raw if end_line_raw is not None
|
|
57
|
+
else start_line
|
|
58
|
+
),
|
|
59
|
+
column=(region.get("startColumn") or 0),
|
|
60
|
+
rule_id=result.get("ruleId", "unknown"),
|
|
61
|
+
level=result.get("level", "warning"),
|
|
62
|
+
message=(
|
|
63
|
+
result.get("message", {}).get("text", "")
|
|
64
|
+
),
|
|
65
|
+
tool_name=tool_name,
|
|
66
|
+
))
|
|
67
|
+
except (KeyError, TypeError, AttributeError):
|
|
68
|
+
return [ToolError(
|
|
69
|
+
tool_name=tool_name,
|
|
70
|
+
exit_code=exit_code,
|
|
71
|
+
stderr="",
|
|
72
|
+
message=(
|
|
73
|
+
f"Failed to parse {tool_name} SARIF output: "
|
|
74
|
+
"unexpected structure"
|
|
75
|
+
),
|
|
76
|
+
)]
|
|
77
|
+
return findings
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Base types for the forge parser subsystem.
|
|
4
|
+
|
|
5
|
+
Finding: frozen dataclass representing a single tool finding.
|
|
6
|
+
ToolError: sentinel type representing a tool execution failure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, asdict
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Finding:
|
|
15
|
+
"""A single finding from a deterministic tool.
|
|
16
|
+
|
|
17
|
+
All fields are immutable (frozen=True). The `fix` field is optional
|
|
18
|
+
and defaults to None when no suggested fix is available.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
file: str # relative path from repo root
|
|
22
|
+
line: int # 1-based start line
|
|
23
|
+
end_line: int # 1-based end line (same as line if single-line)
|
|
24
|
+
column: int # 1-based start column (0 if unknown)
|
|
25
|
+
rule_id: str # tool-specific rule identifier
|
|
26
|
+
level: str # "error" | "warning" | "note"
|
|
27
|
+
message: str # human-readable description
|
|
28
|
+
tool_name: str # which tool produced this
|
|
29
|
+
fix: Optional[str] = None # suggested fix text
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
"""Serialize to plain dict for JSON state persistence."""
|
|
33
|
+
return asdict(self)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ToolError:
|
|
38
|
+
"""Sentinel returned by parsers when tool execution failed.
|
|
39
|
+
|
|
40
|
+
Distinguishes 'no findings' (empty list) from 'tool crashed'
|
|
41
|
+
(ToolError in list). Addresses review Consensus #4: tool crash
|
|
42
|
+
must not produce false PASS.
|
|
43
|
+
|
|
44
|
+
Downstream code checks ``isinstance(item, ToolError)`` to
|
|
45
|
+
distinguish tool failure from a clean run.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
tool_name: str # which tool failed
|
|
49
|
+
exit_code: int # tool's exit code
|
|
50
|
+
stderr: str # stderr output (for diagnostics)
|
|
51
|
+
message: str # human-readable error description
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
"""Serialize to plain dict for JSON state persistence.
|
|
55
|
+
|
|
56
|
+
Addresses Round 3 C-3: state.py needs to serialize ToolError
|
|
57
|
+
to state.json via this method. Without it, json.dumps crashes
|
|
58
|
+
on ToolError objects.
|
|
59
|
+
"""
|
|
60
|
+
return {
|
|
61
|
+
"tool_name": self.tool_name,
|
|
62
|
+
"exit_code": self.exit_code,
|
|
63
|
+
"stderr": self.stderr,
|
|
64
|
+
"message": self.message,
|
|
65
|
+
}
|