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/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)
|