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,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
+ }