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/git.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Git subprocess wrapper with diff-spec validation.
|
|
4
|
+
|
|
5
|
+
This module is THE single owner of git diff subprocess calls in the
|
|
6
|
+
codebase. All other modules call these functions -- they do not call
|
|
7
|
+
git directly. Addresses Consensus #1 (git diff execution unowned).
|
|
8
|
+
|
|
9
|
+
02-03 additions: repo detection, ref validation, pseudo-ref resolution.
|
|
10
|
+
Existing Phase 1 API unchanged. New surface (B1 + H2 fixes):
|
|
11
|
+
- is_git_repo(cwd) -> bool (B1)
|
|
12
|
+
- resolve_git_ref(ref, cwd) -> str (resolved sha) (B1)
|
|
13
|
+
- is_pseudo_ref(name) -> bool
|
|
14
|
+
- working_tree_diff(...) -> str (H2)
|
|
15
|
+
- cached_diff(...) -> str
|
|
16
|
+
- git_diff(baseline, head, ...) -> str
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import warnings
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Safe known flags that are allowed despite starting with --
|
|
26
|
+
_SAFE_FLAGS = frozenset({"--staged", "--cached"})
|
|
27
|
+
|
|
28
|
+
# Allowlist regex for diff-spec values.
|
|
29
|
+
# Permits: branch names (feature/foo), tags (v1.2.3), commit hashes
|
|
30
|
+
# (abc123), HEAD references (HEAD, HEAD~1, HEAD^), remote refs
|
|
31
|
+
# (origin/main), commit ranges (abc..def, HEAD~3..HEAD), and the @
|
|
32
|
+
# character (for refs like HEAD@).
|
|
33
|
+
#
|
|
34
|
+
# Round 7 R7-L5 (DeepSeek): ^ and - placement inside the character
|
|
35
|
+
# class is fragile. Both are now explicitly escaped (\^, \-) so
|
|
36
|
+
# reordering the class will not silently change semantics.
|
|
37
|
+
#
|
|
38
|
+
# Curly braces ({}) are NOT permitted -- users should use explicit
|
|
39
|
+
# ref names instead of @{u} / @{upstream} syntax.
|
|
40
|
+
_DIFF_SPEC_RE = re.compile(
|
|
41
|
+
r"^[A-Za-z0-9_./~@\^\-]+(?:\.\.[A-Za-z0-9_./~@\^\-]+)?$"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_diff_spec(diff_spec: str) -> str:
|
|
46
|
+
"""Validate diff_spec against flag injection.
|
|
47
|
+
|
|
48
|
+
Returns sanitized spec unchanged.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: on empty string, unsafe flags, or characters
|
|
52
|
+
outside the allowlist.
|
|
53
|
+
"""
|
|
54
|
+
if not diff_spec:
|
|
55
|
+
raise ValueError("diff_spec must not be empty")
|
|
56
|
+
|
|
57
|
+
# Allow safe known flags
|
|
58
|
+
if diff_spec in _SAFE_FLAGS:
|
|
59
|
+
return diff_spec
|
|
60
|
+
|
|
61
|
+
# Reject other leading dashes (flag injection)
|
|
62
|
+
if diff_spec.startswith("-"):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"Invalid diff_spec: '%s' looks like a flag" % diff_spec
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Allowlist check -- reject everything not matching
|
|
68
|
+
if not _DIFF_SPEC_RE.match(diff_spec):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Invalid diff_spec: '%s' contains disallowed characters"
|
|
71
|
+
% diff_spec
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return diff_spec
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_git_diff(
|
|
78
|
+
diff_spec: str = "HEAD",
|
|
79
|
+
extra_args: list[str] | None = None,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""Execute git diff and return raw diff text.
|
|
82
|
+
|
|
83
|
+
Validates diff_spec before use. Default: git diff -U0 HEAD
|
|
84
|
+
(working tree vs HEAD).
|
|
85
|
+
|
|
86
|
+
Supports: HEAD, --staged, commit..commit, commit ranges.
|
|
87
|
+
|
|
88
|
+
Note (Round 3 item 12): extra_args exists for future extensibility
|
|
89
|
+
(e.g. --name-only). Currently unused by any caller in Phase 1.
|
|
90
|
+
|
|
91
|
+
Git diff exit code semantics (CRITICAL):
|
|
92
|
+
Exit 0: no differences (return stdout, typically empty)
|
|
93
|
+
Exit 1: differences found (NORMAL -- return stdout with diff)
|
|
94
|
+
Exit 128+: fatal git error (raise RuntimeError with stderr)
|
|
95
|
+
|
|
96
|
+
This addresses Mimo F-03: git diff returns 1 when differences
|
|
97
|
+
exist, NOT an error condition.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
RuntimeError: if git is not found or git returns exit 128+
|
|
101
|
+
ValueError: if diff_spec is invalid (from validate_diff_spec)
|
|
102
|
+
"""
|
|
103
|
+
diff_spec = validate_diff_spec(diff_spec)
|
|
104
|
+
|
|
105
|
+
if shutil.which("git") is None:
|
|
106
|
+
raise RuntimeError("git not found")
|
|
107
|
+
|
|
108
|
+
cmd = ["git", "diff", "-U0", diff_spec] + (extra_args or [])
|
|
109
|
+
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
cmd,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
check=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if result.returncode not in (0, 1):
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
result.stderr or f"git diff failed (exit {result.returncode})"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return result.stdout
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- 02-03 additions: pseudo-refs, repo detection, ref validation ---
|
|
126
|
+
|
|
127
|
+
WORKING = "WORKING"
|
|
128
|
+
INDEX = "INDEX"
|
|
129
|
+
PSEUDO_REFS = {WORKING, INDEX}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_pseudo_ref(name: str) -> bool:
|
|
133
|
+
"""Check whether name is a forge pseudo-ref (WORKING or INDEX)."""
|
|
134
|
+
return name in PSEUDO_REFS
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_git_repo(cwd: Path) -> bool:
|
|
138
|
+
"""B1 fix: check whether cwd is inside a git repo.
|
|
139
|
+
|
|
140
|
+
Uses `git rev-parse --git-dir` (non-zero outside a repo).
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["git", "rev-parse", "--git-dir"],
|
|
145
|
+
cwd=cwd,
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
148
|
+
check=False,
|
|
149
|
+
)
|
|
150
|
+
return result.returncode == 0
|
|
151
|
+
except (FileNotFoundError, OSError):
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_git_ref(ref: str, cwd: Path) -> str:
|
|
156
|
+
"""B1 fix: validate that a git ref exists; return its resolved sha.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
BaselineResolutionError: ref does not exist.
|
|
160
|
+
"""
|
|
161
|
+
from .errors import BaselineResolutionError
|
|
162
|
+
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
["git", "rev-parse", "--verify", ref + "^{commit}"],
|
|
165
|
+
cwd=cwd,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True,
|
|
168
|
+
check=False,
|
|
169
|
+
)
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
raise BaselineResolutionError(
|
|
172
|
+
"git ref %r does not resolve in %s: %s"
|
|
173
|
+
% (ref, cwd, result.stderr.strip())
|
|
174
|
+
)
|
|
175
|
+
return result.stdout.strip()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_likely_binary(path: Path) -> bool:
|
|
179
|
+
"""H2 fix: heuristic binary detection via null-byte in first 8KB.
|
|
180
|
+
|
|
181
|
+
Matches git's own diff-detection behavior (loosely). Used to skip
|
|
182
|
+
binary untracked files in working_tree_diff.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
with open(path, "rb") as f:
|
|
186
|
+
chunk = f.read(8192)
|
|
187
|
+
return b"\0" in chunk
|
|
188
|
+
except OSError:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def git_diff(
|
|
193
|
+
baseline_ref: str,
|
|
194
|
+
head_ref: str,
|
|
195
|
+
paths: list[Path],
|
|
196
|
+
repo_root: Path,
|
|
197
|
+
) -> str:
|
|
198
|
+
"""Standard `git diff <baseline_ref> <head_ref> -- <paths>`.
|
|
199
|
+
|
|
200
|
+
Exit code semantics (R3-1 fix; matches Phase 1 run_git_diff per
|
|
201
|
+
Mimo F-03 in src/forge/git.py:80-86):
|
|
202
|
+
0 = no differences (return empty stdout)
|
|
203
|
+
1 = differences found (NORMAL -- return stdout with diff)
|
|
204
|
+
2+ = real git error (raise BaselineResolutionError)
|
|
205
|
+
"""
|
|
206
|
+
from .errors import BaselineResolutionError
|
|
207
|
+
|
|
208
|
+
cmd = (
|
|
209
|
+
["git", "diff", baseline_ref, head_ref, "--"]
|
|
210
|
+
+ [str(p) for p in paths]
|
|
211
|
+
)
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
cmd, cwd=repo_root, capture_output=True, text=True, check=False
|
|
214
|
+
)
|
|
215
|
+
if result.returncode not in (0, 1):
|
|
216
|
+
raise BaselineResolutionError(
|
|
217
|
+
"git diff %s..%s failed (exit %d): %s"
|
|
218
|
+
% (
|
|
219
|
+
baseline_ref,
|
|
220
|
+
head_ref,
|
|
221
|
+
result.returncode,
|
|
222
|
+
result.stderr.strip(),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return result.stdout
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cached_diff(
|
|
229
|
+
baseline_ref: str,
|
|
230
|
+
paths: list[Path],
|
|
231
|
+
repo_root: Path,
|
|
232
|
+
) -> str:
|
|
233
|
+
"""`git diff --cached <baseline_ref> -- <paths>` (staged vs baseline).
|
|
234
|
+
|
|
235
|
+
Exit code semantics (R3-1 fix): accept 0/1, raise on 2+.
|
|
236
|
+
"""
|
|
237
|
+
from .errors import BaselineResolutionError
|
|
238
|
+
|
|
239
|
+
cmd = (
|
|
240
|
+
["git", "diff", "--cached", baseline_ref, "--"]
|
|
241
|
+
+ [str(p) for p in paths]
|
|
242
|
+
)
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
cmd, cwd=repo_root, capture_output=True, text=True, check=False
|
|
245
|
+
)
|
|
246
|
+
if result.returncode not in (0, 1):
|
|
247
|
+
raise BaselineResolutionError(
|
|
248
|
+
"git diff --cached %s failed (exit %d): %s"
|
|
249
|
+
% (baseline_ref, result.returncode, result.stderr.strip())
|
|
250
|
+
)
|
|
251
|
+
return result.stdout
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def working_tree_diff(
|
|
255
|
+
baseline_ref: str,
|
|
256
|
+
paths: list[Path],
|
|
257
|
+
repo_root: Path,
|
|
258
|
+
) -> str:
|
|
259
|
+
"""Diff baseline..working_tree, including non-binary untracked files.
|
|
260
|
+
|
|
261
|
+
Tracked diff: `git diff <baseline_ref> -- <paths>`.
|
|
262
|
+
Untracked: enumerate via `git ls-files --others --exclude-standard`,
|
|
263
|
+
synthesize as full-add via `git diff --no-index /dev/null <file>`.
|
|
264
|
+
H2 fix: binary untracked files are SKIPPED with warnings.warn.
|
|
265
|
+
|
|
266
|
+
Exit code handling (R2-1 + R3-1): all git diff calls accept exit 0/1,
|
|
267
|
+
raise BaselineResolutionError on exit 2+. Follows Phase 1 run_git_diff
|
|
268
|
+
convention (Mimo F-03).
|
|
269
|
+
"""
|
|
270
|
+
from .errors import BaselineResolutionError
|
|
271
|
+
|
|
272
|
+
# Tracked diff (R3-1: must NOT use check=True)
|
|
273
|
+
tracked_cmd = (
|
|
274
|
+
["git", "diff", baseline_ref, "--"]
|
|
275
|
+
+ [str(p) for p in paths]
|
|
276
|
+
)
|
|
277
|
+
tracked_result = subprocess.run(
|
|
278
|
+
tracked_cmd,
|
|
279
|
+
cwd=repo_root,
|
|
280
|
+
capture_output=True,
|
|
281
|
+
text=True,
|
|
282
|
+
check=False,
|
|
283
|
+
)
|
|
284
|
+
if tracked_result.returncode not in (0, 1):
|
|
285
|
+
raise BaselineResolutionError(
|
|
286
|
+
"git diff %s (tracked, working_tree_diff) failed (exit %d): %s"
|
|
287
|
+
% (
|
|
288
|
+
baseline_ref,
|
|
289
|
+
tracked_result.returncode,
|
|
290
|
+
tracked_result.stderr.strip(),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
tracked = tracked_result.stdout
|
|
294
|
+
|
|
295
|
+
# Untracked files (ls-files has no exit-1-normal semantics)
|
|
296
|
+
ls_cmd = (
|
|
297
|
+
["git", "ls-files", "--others", "--exclude-standard", "--"]
|
|
298
|
+
+ [str(p) for p in paths]
|
|
299
|
+
)
|
|
300
|
+
untracked_paths = [
|
|
301
|
+
line
|
|
302
|
+
for line in subprocess.run(
|
|
303
|
+
ls_cmd,
|
|
304
|
+
cwd=repo_root,
|
|
305
|
+
capture_output=True,
|
|
306
|
+
text=True,
|
|
307
|
+
check=True,
|
|
308
|
+
).stdout.splitlines()
|
|
309
|
+
if line.strip()
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
untracked_diffs: list[str] = []
|
|
313
|
+
skipped_binary: list[str] = []
|
|
314
|
+
for rel_path in sorted(untracked_paths):
|
|
315
|
+
full = repo_root / rel_path
|
|
316
|
+
if _is_likely_binary(full):
|
|
317
|
+
skipped_binary.append(rel_path)
|
|
318
|
+
continue
|
|
319
|
+
# R2-1: git diff --no-index exit codes:
|
|
320
|
+
# 0 = files identical (impossible vs /dev/null with content)
|
|
321
|
+
# 1 = files differ (THE expected case)
|
|
322
|
+
# 2+ = real error
|
|
323
|
+
cmd = ["git", "diff", "--no-index", "/dev/null", str(full)]
|
|
324
|
+
result = subprocess.run(
|
|
325
|
+
cmd,
|
|
326
|
+
cwd=repo_root,
|
|
327
|
+
capture_output=True,
|
|
328
|
+
text=True,
|
|
329
|
+
check=False,
|
|
330
|
+
)
|
|
331
|
+
if result.returncode not in (0, 1):
|
|
332
|
+
raise BaselineResolutionError(
|
|
333
|
+
"git diff --no-index failed for untracked file %s "
|
|
334
|
+
"(exit %d): %s"
|
|
335
|
+
% (rel_path, result.returncode, result.stderr.strip())
|
|
336
|
+
)
|
|
337
|
+
untracked_diffs.append(result.stdout)
|
|
338
|
+
|
|
339
|
+
if skipped_binary:
|
|
340
|
+
warnings.warn(
|
|
341
|
+
"forge: skipped %d binary untracked file(s) from "
|
|
342
|
+
"working-tree diff: %s%s"
|
|
343
|
+
% (
|
|
344
|
+
len(skipped_binary),
|
|
345
|
+
skipped_binary[:3],
|
|
346
|
+
"..." if len(skipped_binary) > 3 else "",
|
|
347
|
+
),
|
|
348
|
+
stacklevel=2,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return tracked + "\n".join(untracked_diffs)
|
code_forge/hold.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""HOLD UX + ESCALATED-frozen predicate.
|
|
4
|
+
|
|
5
|
+
run_hold_ui prompts human for UNCERTAIN dispositions. check_escalated_frozen
|
|
6
|
+
implements DISPO-05(c) deferred from 02-02.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from .disposition import Disposition, MAX_FIX_ATTEMPTS_PER_FINGERPRINT
|
|
14
|
+
from .state import State, StateFinding, save_state
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
VALID_INPUTS = {"c": Disposition.CONFIRMED, "d": Disposition.DISMISSED}
|
|
18
|
+
QUIT_INPUTS = {"q"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HoldAborted(Exception):
|
|
22
|
+
"""Raised when human aborts HOLD UX (Ctrl+D / EOF / "q" input).
|
|
23
|
+
|
|
24
|
+
Message is generic ("HOLD UX aborted by user"), not stdin-specific.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_hold_ui(
|
|
29
|
+
state: State,
|
|
30
|
+
state_path: Path,
|
|
31
|
+
input_fn: Callable[[str], str] = input,
|
|
32
|
+
output_fn: Callable[[str], None] = print,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Prompt human for each UNCERTAIN finding.
|
|
35
|
+
|
|
36
|
+
For each finding with disposition == UNCERTAIN:
|
|
37
|
+
- Print summary (id, file:line, description).
|
|
38
|
+
- Prompt: "[c]onfirm / [d]ismiss / [s]kip / [q]uit: "
|
|
39
|
+
- "c" -> set disposition CONFIRMED
|
|
40
|
+
- "d" -> set disposition DISMISSED
|
|
41
|
+
- "s" -> leave UNCERTAIN, move on
|
|
42
|
+
- "q" -> raise HoldAborted
|
|
43
|
+
- invalid -> reprompt
|
|
44
|
+
- EOF -> raise HoldAborted
|
|
45
|
+
|
|
46
|
+
After loop: clear hold_reason, rebuild dispositions cache, persist.
|
|
47
|
+
|
|
48
|
+
Idempotent: if zero UNCERTAIN findings, returns immediately with
|
|
49
|
+
no I/O (caller may invoke unconditionally after PENDING return).
|
|
50
|
+
"""
|
|
51
|
+
uncertain = [
|
|
52
|
+
f for f in state.findings if f.disposition == Disposition.UNCERTAIN
|
|
53
|
+
]
|
|
54
|
+
if not uncertain:
|
|
55
|
+
state.hold_reason = None
|
|
56
|
+
save_state(state, state_path)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
output_fn(
|
|
60
|
+
"HOLD: %d UNCERTAIN finding(s) need human disposition."
|
|
61
|
+
% len(uncertain)
|
|
62
|
+
)
|
|
63
|
+
for finding in uncertain:
|
|
64
|
+
_prompt_one(finding, input_fn, output_fn)
|
|
65
|
+
|
|
66
|
+
state.hold_reason = None
|
|
67
|
+
state.dispositions = {f.id: f.disposition for f in state.findings}
|
|
68
|
+
save_state(state, state_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prompt_one(
|
|
72
|
+
finding: StateFinding,
|
|
73
|
+
input_fn: Callable[[str], str],
|
|
74
|
+
output_fn: Callable[[str], None],
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Inner per-finding prompt loop (reprompts on invalid input)."""
|
|
77
|
+
output_fn(
|
|
78
|
+
" [%s] %s:%d-%d %s"
|
|
79
|
+
% (
|
|
80
|
+
finding.id,
|
|
81
|
+
finding.file,
|
|
82
|
+
finding.line_range[0],
|
|
83
|
+
finding.line_range[1],
|
|
84
|
+
finding.description,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
while True:
|
|
88
|
+
try:
|
|
89
|
+
choice = input_fn(
|
|
90
|
+
" [c]onfirm / [d]ismiss / [s]kip / [q]uit: "
|
|
91
|
+
).strip().lower()
|
|
92
|
+
except EOFError:
|
|
93
|
+
raise HoldAborted("HOLD UX aborted by user")
|
|
94
|
+
if choice in QUIT_INPUTS:
|
|
95
|
+
raise HoldAborted("HOLD UX aborted by user")
|
|
96
|
+
if choice == "s":
|
|
97
|
+
return
|
|
98
|
+
if choice in VALID_INPUTS:
|
|
99
|
+
finding.disposition = VALID_INPUTS[choice]
|
|
100
|
+
return
|
|
101
|
+
output_fn(" (invalid input %r; expected c/d/s/q)" % choice)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_escalated_frozen(state: State) -> bool:
|
|
105
|
+
"""DISPO-05(c) predicate: re-CONFIRM of promoted finding -> ESCALATED.
|
|
106
|
+
|
|
107
|
+
Returns True iff ALL of:
|
|
108
|
+
- state.hold_reason is None (not currently in HOLD entry)
|
|
109
|
+
- state.promoted_fingerprints is non-empty
|
|
110
|
+
- at least one finding has: disposition == CONFIRMED AND
|
|
111
|
+
fingerprint in promoted_fingerprints AND
|
|
112
|
+
fix_attempts[fp] >= MAX_FIX_ATTEMPTS_PER_FINGERPRINT
|
|
113
|
+
"""
|
|
114
|
+
if state.hold_reason is not None:
|
|
115
|
+
return False
|
|
116
|
+
if not state.promoted_fingerprints:
|
|
117
|
+
return False
|
|
118
|
+
for finding in state.findings:
|
|
119
|
+
if (
|
|
120
|
+
finding.disposition == Disposition.CONFIRMED
|
|
121
|
+
and finding.fingerprint in state.promoted_fingerprints
|
|
122
|
+
and state.fix_attempts.get(finding.fingerprint, 0)
|
|
123
|
+
>= MAX_FIX_ATTEMPTS_PER_FINGERPRINT
|
|
124
|
+
):
|
|
125
|
+
return True
|
|
126
|
+
return False
|