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
code_forge/snapshot.py ADDED
@@ -0,0 +1,196 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
3
+ """Snapshot persistence per BASELINE-02 + invalidation per BASELINE-03.
4
+
5
+ SCHEMA_VERSION is independent of state.SCHEMA_VERSION (snapshot evolves
6
+ on different cadence from state.json).
7
+
8
+ B2 fix: NO Disposition import. finding_dispositions: dict[str, str] stores
9
+ disposition values as strings; state machine (02-02) converts to/from
10
+ Disposition at the read/write boundary.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import warnings
17
+ from dataclasses import asdict, dataclass, field
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from .errors import (
22
+ BaselineResolutionError,
23
+ CorruptedSnapshotError,
24
+ SnapshotSchemaMismatchError,
25
+ )
26
+
27
+ SNAPSHOT_SCHEMA_VERSION: int = 1
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SnapshotEntry:
32
+ """One file's recorded state in the snapshot."""
33
+
34
+ path: str # path-as-posix relative to snapshot root (H3)
35
+ content_hash: str # SHA256 (text: normalized; binary: raw bytes)
36
+
37
+
38
+ @dataclass
39
+ class Snapshot:
40
+ """A persisted baseline snapshot. BASELINE-02 + BASELINE-03."""
41
+
42
+ schema_version: int = SNAPSHOT_SCHEMA_VERSION
43
+ source_hash: str = ""
44
+ files: list[SnapshotEntry] = field(default_factory=list)
45
+ finding_dispositions: dict[str, str] = field(default_factory=dict)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class InvalidationResult:
50
+ """BASELINE-03 partial-invalidation result.
51
+
52
+ missing = files in snapshot but absent from current source.
53
+ changed = files present in both but content hash differs.
54
+ unchanged = files where snapshot hash matches current.
55
+ added = files in current but not in snapshot.
56
+ """
57
+
58
+ missing: list[str]
59
+ changed: list[str]
60
+ unchanged: list[str]
61
+ added: list[str]
62
+
63
+
64
+ def snapshot_path_for(source_hash: str, cwd: Path) -> Path:
65
+ """Standard location: .code-forge/snapshots/<source-hash>.json under cwd."""
66
+ return cwd / ".code-forge" / "snapshots" / ("%s.json" % source_hash)
67
+
68
+
69
+ def find_existing_snapshot(source_hash: str, cwd: Path) -> Optional[Path]:
70
+ """H5 fix: snapshot auto-discovery helper.
71
+
72
+ Returns the snapshot path if it exists, else None.
73
+ """
74
+ p = snapshot_path_for(source_hash, cwd)
75
+ return p if p.exists() else None
76
+
77
+
78
+ SNAPSHOT_COUNT_WARN_THRESHOLD: int = 50
79
+
80
+
81
+ def save_snapshot(snapshot: Snapshot, path: Path) -> None:
82
+ """Atomic write of snapshot file. Auto-creates parent dirs (D1).
83
+
84
+ OQ2 fix: after write, count snapshot files in directory; if above
85
+ threshold, warn user about manual cleanup.
86
+ """
87
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
88
+ tmp = path.with_suffix(".tmp")
89
+ tmp.write_text(json.dumps(asdict(snapshot), indent=2))
90
+ tmp.replace(path)
91
+
92
+ snapshots = list(path.parent.glob("*.json"))
93
+ if len(snapshots) > SNAPSHOT_COUNT_WARN_THRESHOLD:
94
+ warnings.warn(
95
+ "forge: %d snapshot files in %s; "
96
+ "consider manual cleanup (no auto-GC in v2.0)"
97
+ % (len(snapshots), path.parent),
98
+ stacklevel=2,
99
+ )
100
+
101
+
102
+ def load_snapshot(path: Path) -> Optional[Snapshot]:
103
+ """Load snapshot. Returns None on missing file (BASELINE-03).
104
+
105
+ Raises:
106
+ CorruptedSnapshotError: JSON parse failure or missing/invalid
107
+ fields in snapshot data.
108
+ SnapshotSchemaMismatchError: schema_version mismatch.
109
+ """
110
+ if not path.exists():
111
+ return None
112
+ try:
113
+ data = json.loads(path.read_text())
114
+ except json.JSONDecodeError as e:
115
+ raise CorruptedSnapshotError(
116
+ "cannot parse %s: %s" % (path, e)
117
+ ) from e
118
+
119
+ sv = data.get("schema_version")
120
+ if sv != SNAPSHOT_SCHEMA_VERSION:
121
+ raise SnapshotSchemaMismatchError(
122
+ "snapshot schema_version=%s, forge expects %s; "
123
+ "remove %s to start fresh" % (sv, SNAPSHOT_SCHEMA_VERSION, path)
124
+ )
125
+
126
+ try:
127
+ return Snapshot(
128
+ schema_version=data["schema_version"],
129
+ source_hash=data["source_hash"],
130
+ files=[SnapshotEntry(**e) for e in data.get("files", [])],
131
+ finding_dispositions=dict(
132
+ data.get("finding_dispositions", {})
133
+ ),
134
+ )
135
+ except (KeyError, TypeError) as e:
136
+ raise CorruptedSnapshotError(
137
+ "invalid snapshot data in %s: %s" % (path, e)
138
+ ) from e
139
+
140
+
141
+ def validate_snapshot(
142
+ snapshot: Snapshot, current_files: list[Path], root: Path
143
+ ) -> InvalidationResult:
144
+ """BASELINE-03: classify files as unchanged/changed/added/missing.
145
+
146
+ H6 fix: files outside root raise BaselineResolutionError with
147
+ explicit file and root context.
148
+ """
149
+ snapshot_map = {e.path: e.content_hash for e in snapshot.files}
150
+ current_map: dict[str, str] = {}
151
+ for f in current_files:
152
+ try:
153
+ rel = f.relative_to(root).as_posix()
154
+ except ValueError as e:
155
+ raise BaselineResolutionError(
156
+ "file %s is outside snapshot root %s "
157
+ "-- cannot classify against snapshot" % (f, root)
158
+ ) from e
159
+ current_map[rel] = _hash_file(f)
160
+
161
+ missing = sorted(p for p in snapshot_map if p not in current_map)
162
+ added = sorted(p for p in current_map if p not in snapshot_map)
163
+ changed = sorted(
164
+ p
165
+ for p in current_map
166
+ if p in snapshot_map and current_map[p] != snapshot_map[p]
167
+ )
168
+ unchanged = sorted(
169
+ p
170
+ for p in current_map
171
+ if p in snapshot_map and current_map[p] == snapshot_map[p]
172
+ )
173
+
174
+ return InvalidationResult(
175
+ missing=missing,
176
+ changed=changed,
177
+ unchanged=unchanged,
178
+ added=added,
179
+ )
180
+
181
+
182
+ def _hash_file(path: Path) -> str:
183
+ """SHA256 of file content.
184
+
185
+ Text files use normalize_text (LF + trailing-ws strip).
186
+ Binary files hash raw bytes (H1 fix).
187
+ """
188
+ from .source import normalize_text
189
+
190
+ try:
191
+ content = path.read_text(encoding="utf-8")
192
+ return hashlib.sha256(
193
+ normalize_text(content).encode("utf-8")
194
+ ).hexdigest()
195
+ except UnicodeDecodeError:
196
+ return hashlib.sha256(path.read_bytes()).hexdigest()
code_forge/source.py ADDED
@@ -0,0 +1,64 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
3
+ """source_hash computation per STATE-07.
4
+
5
+ Whitespace normalization: trailing-ws strip + LF line endings.
6
+ H1/H3 fixes applied: binary files hashed as raw bytes (preserves invalidation
7
+ correctness); path serialization uses as_posix() for cross-platform determinism.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+
16
+ def normalize_text(text: str) -> str:
17
+ """Strip trailing whitespace per line; force LF endings.
18
+
19
+ No trailing blank line stripping.
20
+ """
21
+ lines = [line.rstrip() for line in text.splitlines()]
22
+ return "\n".join(lines)
23
+
24
+
25
+ def compute_source_hash(
26
+ *,
27
+ git_diff: Optional[str] = None,
28
+ files: Optional[list[Path]] = None,
29
+ ) -> str:
30
+ """STATE-07 source_hash.
31
+
32
+ Git mode: caller passes git_diff (unified diff output).
33
+ Non-git mode: caller passes files. Files are sorted by posix path
34
+ string for cross-platform deterministic ordering (H3). Binary files
35
+ (UnicodeDecodeError on utf-8 read) are hashed as raw bytes with a
36
+ binary marker (H1) -- this preserves invalidation correctness for
37
+ binary edits and keeps source_hash stable.
38
+
39
+ Exactly one of git_diff / files must be provided. Returns lowercase
40
+ hex SHA256.
41
+ """
42
+ if (git_diff is None) == (files is None):
43
+ raise ValueError(
44
+ "compute_source_hash: pass exactly one of git_diff or files"
45
+ )
46
+
47
+ h = hashlib.sha256()
48
+ if git_diff is not None:
49
+ h.update(b"mode=git\n")
50
+ h.update(normalize_text(git_diff).encode("utf-8"))
51
+ return h.hexdigest()
52
+
53
+ h.update(b"mode=non-git\n")
54
+ for f in sorted(files, key=lambda p: p.as_posix()):
55
+ try:
56
+ content = f.read_text(encoding="utf-8")
57
+ h.update(("--- %s text\n" % f.as_posix()).encode("utf-8"))
58
+ h.update(normalize_text(content).encode("utf-8"))
59
+ except UnicodeDecodeError:
60
+ # H1: binary file -- hash raw bytes
61
+ h.update(("--- %s binary\n" % f.as_posix()).encode("utf-8"))
62
+ h.update(f.read_bytes())
63
+ h.update(b"\n")
64
+ return h.hexdigest()
code_forge/state.py ADDED
@@ -0,0 +1,246 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
3
+ """state.json schema + IO.
4
+
5
+ Schema owned by 02-01. Subsequent sub-plans add fields ADDITIVELY (no rename,
6
+ no remove). Bump SCHEMA_VERSION on breaking change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Literal, Optional
16
+
17
+ from .disposition import Disposition, DISPOSITION_PROTOCOL_VERSION
18
+ from .errors import CorruptedStateError, SchemaVersionMismatchError
19
+
20
+ SCHEMA_VERSION: int = 1
21
+
22
+
23
+ class Mode(str, Enum):
24
+ """Forge execution mode. Resolved by 02-05, consumed by 02-02."""
25
+ LOCAL = "LOCAL"
26
+ CI = "CI"
27
+
28
+
29
+ class Verdict(str, Enum):
30
+ """Process verdict (terminal). Set by state machine on exit."""
31
+ PASS = "PASS"
32
+ FAIL = "FAIL"
33
+ ESCALATED = "ESCALATED"
34
+ PENDING = "PENDING"
35
+
36
+
37
+ @dataclass
38
+ class StateFinding:
39
+ """A single finding entry in state.json findings[].
40
+
41
+ Named StateFinding (not Finding) to avoid conflict with Phase 1
42
+ forge.parsers.base.Finding (parser-emitted record, different shape).
43
+ Conversion: state machine in 02-02 maps parsers.base.Finding ->
44
+ StateFinding.
45
+ """
46
+ id: str
47
+ fingerprint: str
48
+ source: Literal["L0", "L1", "MUTANT", "E2E_CHECK"]
49
+ disposition: Disposition
50
+ file: str
51
+ line_range: list[int]
52
+ description: str
53
+ error: Optional[str] = None
54
+ anchor: Optional[dict] = None
55
+ evidence_files: Optional[list[str]] = None
56
+
57
+
58
+ @dataclass
59
+ class State:
60
+ """state.json schema. v1.
61
+
62
+ 02-02 additions (additive only, no schema_version bump per D2):
63
+ - baseline_spec_repr: from 02-03 serialize_baseline_spec; recorded so
64
+ HOLD resume can verify which baseline was used (OQ1 fix from 02-03)
65
+ - round_history: per-round snapshots for STATE-05 diagnosis
66
+ - infra_errors: error messages collected during L0/L1/falsify failures
67
+ (drives STATE-05 Category D classification)
68
+
69
+ 02-04 additions (additive per D2):
70
+ - hold_reason: Optional[str] -- set on HOLD entry; cleared on resume.
71
+ Disambiguates "interrupted mid-run" from "HOLD pending human input".
72
+ - promoted_fingerprints: set[str] -- fingerprints promoted CONFIRMED ->
73
+ UNCERTAIN via DISPO-05. Used by ESCALATED-frozen predicate.
74
+ Serialized as sorted list (JSON has no native set type).
75
+ """
76
+ schema_version: int = SCHEMA_VERSION
77
+ disposition_protocol_version: int = DISPOSITION_PROTOCOL_VERSION
78
+ round: int = 0
79
+ mode: Mode = Mode.LOCAL
80
+ source_hash: Optional[str] = None
81
+ findings: list[StateFinding] = field(default_factory=list)
82
+ # Derived lookup cache (NOT source of truth; SOT = StateFinding.disposition).
83
+ # save_state rebuilds from findings; load_state verifies cache matches.
84
+ dispositions: dict[str, Disposition] = field(default_factory=dict)
85
+ fix_attempts: dict[str, int] = field(default_factory=dict)
86
+ verdict: Verdict = Verdict.PENDING
87
+ converged: bool = False
88
+ # 02-02 additions:
89
+ baseline_spec_repr: Optional[str] = None
90
+ round_history: list[dict] = field(default_factory=list)
91
+ infra_errors: list[str] = field(default_factory=list)
92
+ # 02-04 additions:
93
+ hold_reason: Optional[str] = None
94
+ promoted_fingerprints: set[str] = field(default_factory=set)
95
+ # Mutation survivor round counter (LOCAL mode):
96
+ consecutive_survivor_rounds: int = 0 # LOCAL mode only
97
+
98
+
99
+ def _finding_from_dict(d: dict) -> StateFinding:
100
+ """Reconstruct StateFinding from JSON dict with enum conversion."""
101
+ return StateFinding(
102
+ id=d["id"],
103
+ fingerprint=d["fingerprint"],
104
+ source=d["source"],
105
+ disposition=Disposition(d["disposition"]),
106
+ file=d["file"],
107
+ line_range=list(d["line_range"]),
108
+ description=d["description"],
109
+ error=d.get("error"),
110
+ anchor=d.get("anchor"),
111
+ evidence_files=d.get("evidence_files"),
112
+ )
113
+
114
+
115
+ def load_state(path: Path) -> Optional[State]:
116
+ """Load state.json. Returns None if file does not exist.
117
+
118
+ Raises:
119
+ CorruptedStateError: JSON parse failure, missing/invalid fields,
120
+ invalid enum values, or cache mismatch.
121
+ SchemaVersionMismatchError: schema_version != SCHEMA_VERSION.
122
+ """
123
+ if not path.exists():
124
+ return None
125
+ try:
126
+ data = json.loads(path.read_text())
127
+ except json.JSONDecodeError as e:
128
+ raise CorruptedStateError(
129
+ "cannot parse %s: %s" % (path, e)
130
+ ) from e
131
+
132
+ sv = data.get("schema_version")
133
+ if sv != SCHEMA_VERSION:
134
+ raise SchemaVersionMismatchError(
135
+ "state.json schema_version=%s, forge expects %s; "
136
+ "remove .code-forge/state.json to start fresh" % (sv, SCHEMA_VERSION)
137
+ )
138
+
139
+ try:
140
+ findings = [
141
+ _finding_from_dict(f) for f in data.get("findings", [])
142
+ ]
143
+ dispositions = {
144
+ k: Disposition(v)
145
+ for k, v in data.get("dispositions", {}).items()
146
+ }
147
+ except (KeyError, ValueError) as e:
148
+ raise CorruptedStateError(
149
+ "invalid finding or disposition in %s: %s" % (path, e)
150
+ ) from e
151
+
152
+ expected = {f.id: f.disposition for f in findings}
153
+ if dispositions != expected:
154
+ raise CorruptedStateError(
155
+ "dispositions cache out of sync with findings (path=%s)" % path
156
+ )
157
+
158
+ try:
159
+ state = State(
160
+ schema_version=data["schema_version"],
161
+ disposition_protocol_version=data[
162
+ "disposition_protocol_version"
163
+ ],
164
+ round=data["round"],
165
+ mode=Mode(data["mode"]),
166
+ source_hash=data.get("source_hash"),
167
+ findings=findings,
168
+ dispositions=dispositions,
169
+ fix_attempts=dict(data.get("fix_attempts", {})),
170
+ verdict=Verdict(data["verdict"]),
171
+ converged=bool(data["converged"]),
172
+ )
173
+ except (KeyError, ValueError) as e:
174
+ raise CorruptedStateError(
175
+ "missing or invalid field in %s: %s" % (path, e)
176
+ ) from e
177
+
178
+ # 02-02 additions: backward-compat defaults for pre-02-02 state.json
179
+ # (R1 B1 silent-loss guard). Pre-02-02 files lack these keys; the
180
+ # loader returns a State with defaults rather than KeyError.
181
+ state.baseline_spec_repr = data.get("baseline_spec_repr")
182
+ state.round_history = data.get("round_history", [])
183
+ state.infra_errors = data.get("infra_errors", [])
184
+
185
+ # 02-04 additions: backward-compat defaults for pre-02-04 state.json.
186
+ state.hold_reason = data.get("hold_reason")
187
+ state.promoted_fingerprints = set(
188
+ data.get("promoted_fingerprints", [])
189
+ )
190
+
191
+ # 02-02 additions: backward-compat defaults for pre-02-02 state.json.
192
+ state.consecutive_survivor_rounds = data.get(
193
+ "consecutive_survivor_rounds", 0
194
+ )
195
+
196
+ return state
197
+
198
+
199
+ def _finding_to_dict(f: StateFinding) -> dict:
200
+ """Serialize StateFinding to JSON-safe dict."""
201
+ d = {
202
+ "id": f.id,
203
+ "fingerprint": f.fingerprint,
204
+ "source": f.source,
205
+ "disposition": f.disposition.value,
206
+ "file": f.file,
207
+ "line_range": list(f.line_range),
208
+ "description": f.description,
209
+ "error": f.error,
210
+ "anchor": f.anchor,
211
+ "evidence_files": f.evidence_files,
212
+ }
213
+ return d
214
+
215
+
216
+ def save_state(state: State, path: Path) -> None:
217
+ """Atomic write of state.json. Rebuilds dispositions cache first.
218
+
219
+ 02-04 rewrite: no asdict on State. asdict cannot handle the set-typed
220
+ promoted_fingerprints field. All fields serialized explicitly.
221
+ """
222
+ state.dispositions = {f.id: f.disposition for f in state.findings}
223
+ data = {
224
+ "schema_version": state.schema_version,
225
+ "disposition_protocol_version": state.disposition_protocol_version,
226
+ "round": state.round,
227
+ "mode": state.mode.value,
228
+ "source_hash": state.source_hash,
229
+ "findings": [_finding_to_dict(f) for f in state.findings],
230
+ "dispositions": {
231
+ k: v.value for k, v in state.dispositions.items()
232
+ },
233
+ "fix_attempts": dict(state.fix_attempts),
234
+ "verdict": state.verdict.value,
235
+ "converged": state.converged,
236
+ "baseline_spec_repr": state.baseline_spec_repr,
237
+ "round_history": list(state.round_history),
238
+ "infra_errors": list(state.infra_errors),
239
+ "hold_reason": state.hold_reason,
240
+ "promoted_fingerprints": sorted(state.promoted_fingerprints),
241
+ "consecutive_survivor_rounds": state.consecutive_survivor_rounds,
242
+ }
243
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
244
+ tmp = path.with_suffix(".tmp")
245
+ tmp.write_text(json.dumps(data, indent=2))
246
+ tmp.replace(path)
code_forge/verdict.py ADDED
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
3
+ """Verdict determination -- PASS/FAIL from delta findings.
4
+
5
+ Pure function. Phase 1 implements PASS/FAIL only (GATE-01).
6
+ HOLD state is Phase 2+.
7
+
8
+ Addresses:
9
+ - Consensus #4: ToolError in results -> FAIL (not false PASS)
10
+ - Consensus #6: uses EXIT_PASS/EXIT_FAIL from code_forge.__init__
11
+ """
12
+
13
+ from code_forge import EXIT_PASS, EXIT_FAIL
14
+ from code_forge.parsers.base import Finding, ToolError
15
+
16
+ # Lightweight type alias for readability.
17
+ # Phase 2 may replace with a proper enum or dataclass when HOLD is added.
18
+ Verdict = tuple[str, int] # (verdict_string, exit_code)
19
+
20
+
21
+ def determine_verdict(
22
+ delta_findings: list[Finding | ToolError],
23
+ ) -> Verdict:
24
+ """Determine verdict from delta findings.
25
+
26
+ Rules:
27
+ - Empty list: PASS (no new violations)
28
+ - Any ToolError: FAIL (tool crash = cannot guarantee no violations)
29
+ - Any Finding: FAIL (new violations found)
30
+
31
+ Per GATE-01, Phase 1 implements PASS/FAIL only.
32
+ Per GATE-04, all Layer 0 violations are gate-blocking.
33
+
34
+ Args:
35
+ delta_findings: filtered findings on changed lines
36
+
37
+ Returns:
38
+ (verdict_string, exit_code) tuple
39
+ """
40
+ if not delta_findings:
41
+ return ("PASS", EXIT_PASS)
42
+
43
+ return ("FAIL", EXIT_FAIL)