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/runner.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Tool execution engine with subprocess orchestration.
|
|
4
|
+
|
|
5
|
+
Resolves tool binaries (PATH and relative paths), captures tool
|
|
6
|
+
versions for GATE-02 reproducibility, runs tools with timeout, and
|
|
7
|
+
handles missing/failed tools gracefully.
|
|
8
|
+
|
|
9
|
+
Security: subprocess.run is ALWAYS called with a list argument,
|
|
10
|
+
never a string. shell=True is never used. See T-01-07.
|
|
11
|
+
|
|
12
|
+
Phase 1 scope note (Kimi H2): checkpatch.pl requires stdin input,
|
|
13
|
+
not file arguments. The current runner only supports file-argument
|
|
14
|
+
tools. stdin-input mode is deferred to Phase 2.
|
|
15
|
+
|
|
16
|
+
Phase 1 scope note (DeepSeek H-2): cargo_root detection (walking
|
|
17
|
+
parent directories to find Cargo.toml) is deferred to Phase 2.
|
|
18
|
+
When working_dir="cargo_root", the runner skips appending files to
|
|
19
|
+
the command but does NOT change the working directory.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
|
|
27
|
+
from code_forge.registry import ToolConfig, match_tools
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_command(command: str) -> str | None:
|
|
33
|
+
"""Resolve a tool command to an executable path.
|
|
34
|
+
|
|
35
|
+
First tries shutil.which (PATH-based resolution). If that fails
|
|
36
|
+
and the command contains os.sep (e.g. "scripts/checkpatch.pl"),
|
|
37
|
+
checks whether the path exists and is executable.
|
|
38
|
+
|
|
39
|
+
This addresses DeepSeek's finding: checkpatch.pl is a relative
|
|
40
|
+
path, not on PATH. shutil.which alone misses it.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
command: tool command string from ToolConfig.command
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Resolved path string, or None if not found.
|
|
47
|
+
"""
|
|
48
|
+
resolved = shutil.which(command)
|
|
49
|
+
if resolved is not None:
|
|
50
|
+
return resolved
|
|
51
|
+
|
|
52
|
+
# Try relative path resolution (e.g. scripts/checkpatch.pl)
|
|
53
|
+
if os.sep in command:
|
|
54
|
+
if os.path.isfile(command) and os.access(command, os.X_OK):
|
|
55
|
+
return command
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def capture_tool_version(command: str) -> str:
|
|
61
|
+
"""Capture a tool's version string for GATE-02 reproducibility.
|
|
62
|
+
|
|
63
|
+
Runs "<resolved_cmd> --version" and returns the first line of
|
|
64
|
+
stdout. Called once per tool at pipeline startup, NOT per file.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
command: tool command string (will be resolved via PATH)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Version string (first line of stdout), "not_installed" if
|
|
71
|
+
the command cannot be found, or "unknown" on any error.
|
|
72
|
+
"""
|
|
73
|
+
resolved = _resolve_command(command)
|
|
74
|
+
if resolved is None:
|
|
75
|
+
return "not_installed"
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
[resolved, "--version"],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
timeout=5,
|
|
83
|
+
check=False,
|
|
84
|
+
)
|
|
85
|
+
first_line = result.stdout.strip().split("\n")[0]
|
|
86
|
+
return first_line if first_line else "unknown"
|
|
87
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
88
|
+
return "unknown"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_tool(
|
|
92
|
+
tool_config: ToolConfig,
|
|
93
|
+
files: list[str],
|
|
94
|
+
) -> tuple[str, int, str] | None:
|
|
95
|
+
"""Execute a single tool via subprocess.
|
|
96
|
+
|
|
97
|
+
Returns (stdout, returncode, stderr) 3-tuple on success, or
|
|
98
|
+
None if the tool is missing (optional), timed out, or hit an
|
|
99
|
+
OS error.
|
|
100
|
+
|
|
101
|
+
The stderr field is captured and propagated so that downstream
|
|
102
|
+
code can populate ToolError.stderr with the tool's actual error
|
|
103
|
+
output (Round 5 Kimi R5-M3).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
tool_config: tool configuration from registry
|
|
107
|
+
files: list of file paths to lint
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
(stdout, returncode, stderr) or None
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
RuntimeError: if tool is required but not found
|
|
114
|
+
"""
|
|
115
|
+
resolved = _resolve_command(tool_config.command)
|
|
116
|
+
|
|
117
|
+
if resolved is None:
|
|
118
|
+
if tool_config.required:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
"Required tool not found: %s" % tool_config.command
|
|
121
|
+
)
|
|
122
|
+
logger.info(
|
|
123
|
+
"Optional tool '%s' not found, skipping", tool_config.name
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Build command: [resolved_cmd] + args + files
|
|
128
|
+
# Exception: cargo_root mode skips file args (clippy operates on crate)
|
|
129
|
+
cmd = [resolved] + tool_config.args
|
|
130
|
+
if tool_config.working_dir != "cargo_root":
|
|
131
|
+
cmd = cmd + files
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
result = subprocess.run(
|
|
135
|
+
cmd,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
timeout=tool_config.timeout,
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
return (result.stdout, result.returncode, result.stderr)
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
logger.warning(
|
|
144
|
+
"Tool '%s' timed out after %ds",
|
|
145
|
+
tool_config.name,
|
|
146
|
+
tool_config.timeout,
|
|
147
|
+
)
|
|
148
|
+
return None
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Tool '%s' failed with OS error: %s",
|
|
152
|
+
tool_config.name,
|
|
153
|
+
exc,
|
|
154
|
+
)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run_tools(
|
|
159
|
+
registry: dict[str, ToolConfig],
|
|
160
|
+
files: list[str],
|
|
161
|
+
) -> tuple[dict[str, tuple[str, int, str]], dict[str, str], list[str]]:
|
|
162
|
+
"""Execute all matching tools from the registry.
|
|
163
|
+
|
|
164
|
+
Returns a 3-tuple:
|
|
165
|
+
tool_results: {tool_name: (stdout, returncode, stderr)}
|
|
166
|
+
tool_versions: {tool_name: version_string}
|
|
167
|
+
tools_skipped: [tool_name, ...]
|
|
168
|
+
|
|
169
|
+
Iterates sorted(registry.keys()) for GATE-02 determinism
|
|
170
|
+
(Round 3 item 11). Calls match_tools once before the per-tool
|
|
171
|
+
loop (Mimo F-04).
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
registry: {name: ToolConfig} from load_registry
|
|
175
|
+
files: list of changed file paths
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
(tool_results, tool_versions, tools_skipped)
|
|
179
|
+
"""
|
|
180
|
+
tool_results: dict[str, tuple[str, int, str]] = {}
|
|
181
|
+
tool_versions: dict[str, str] = {}
|
|
182
|
+
tools_skipped: list[str] = []
|
|
183
|
+
|
|
184
|
+
# Call match_tools once (Mimo F-04)
|
|
185
|
+
matched = match_tools(registry, files)
|
|
186
|
+
|
|
187
|
+
for tool_name in sorted(registry.keys()):
|
|
188
|
+
tool_config = registry[tool_name]
|
|
189
|
+
|
|
190
|
+
# Capture version (Consensus #3)
|
|
191
|
+
tool_versions[tool_name] = capture_tool_version(tool_config.command)
|
|
192
|
+
|
|
193
|
+
# Check for matching files
|
|
194
|
+
matching_files = matched.get(tool_name, [])
|
|
195
|
+
if not matching_files:
|
|
196
|
+
tools_skipped.append(tool_name)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
result = run_tool(tool_config, matching_files)
|
|
200
|
+
if result is None:
|
|
201
|
+
tools_skipped.append(tool_name)
|
|
202
|
+
else:
|
|
203
|
+
tool_results[tool_name] = result
|
|
204
|
+
|
|
205
|
+
return (tool_results, tool_versions, tools_skipped)
|
code_forge/sarif.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""LAYER0-07: SARIF 2.1.0 emission for CI mode.
|
|
4
|
+
|
|
5
|
+
Pure data transformation: State + tool_versions -> SARIF log dict.
|
|
6
|
+
Caller (cli.py CI path) handles I/O.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .disposition import Disposition
|
|
13
|
+
from .state import State, StateFinding, Verdict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SARIF_VERSION = "2.1.0"
|
|
17
|
+
SARIF_SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Disposition -> SARIF level. DISMISSED + FIXED use "note" (lowest severity)
|
|
21
|
+
# because they are emitted-but-suppressed per LAYER0-07; the suppressions
|
|
22
|
+
# array does the actual non-blocking signaling.
|
|
23
|
+
DISPOSITION_TO_LEVEL: dict[Disposition, str] = {
|
|
24
|
+
Disposition.CONFIRMED: "error",
|
|
25
|
+
Disposition.UNCERTAIN: "warning",
|
|
26
|
+
Disposition.DISMISSED: "note",
|
|
27
|
+
Disposition.FIXED: "note",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _suppressions_for(disposition: Disposition) -> Optional[list[dict[str, Any]]]:
|
|
32
|
+
"""Return suppressions array or None to omit.
|
|
33
|
+
|
|
34
|
+
Disposition -> suppressions array. CONFIRMED + UNCERTAIN have no
|
|
35
|
+
suppressions (raw, blocking-relevant signal). DISMISSED + FIXED carry
|
|
36
|
+
kind=external + kind=inSource respectively per LAYER0-07.
|
|
37
|
+
Explicit dispatch on all 4 known states + ValueError default.
|
|
38
|
+
Silent None on unknown disposition (e.g., enum gained 5th state) would
|
|
39
|
+
emit wrong SARIF; loud raise surfaces the issue at deploy time.
|
|
40
|
+
"""
|
|
41
|
+
if disposition in (Disposition.CONFIRMED, Disposition.UNCERTAIN):
|
|
42
|
+
return None
|
|
43
|
+
if disposition == Disposition.DISMISSED:
|
|
44
|
+
return [{"kind": "external"}]
|
|
45
|
+
if disposition == Disposition.FIXED:
|
|
46
|
+
return [{
|
|
47
|
+
"kind": "inSource",
|
|
48
|
+
"properties": {"fix_commit": None},
|
|
49
|
+
}]
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"unknown Disposition %r; sarif.py mapping table needs update"
|
|
52
|
+
% disposition
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_sarif_log(
|
|
57
|
+
state: State,
|
|
58
|
+
tool_versions: dict[str, str],
|
|
59
|
+
forge_version: str,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Build SARIF 2.1.0 log dict.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: state.verdict is PENDING. CI never PENDINGs (no HOLD
|
|
65
|
+
in CI per GATE-01b); reaching this is a caller bug.
|
|
66
|
+
"""
|
|
67
|
+
if state.verdict == Verdict.PENDING:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"build_sarif_log called with PENDING verdict; CI mode does "
|
|
70
|
+
"not enter HOLD (GATE-01b). Caller bug."
|
|
71
|
+
)
|
|
72
|
+
return {
|
|
73
|
+
"$schema": SARIF_SCHEMA_URI,
|
|
74
|
+
"version": SARIF_VERSION,
|
|
75
|
+
"runs": [_build_run(state, tool_versions, forge_version)],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_run(
|
|
80
|
+
state: State,
|
|
81
|
+
tool_versions: dict[str, str],
|
|
82
|
+
forge_version: str,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Build SARIF run dict.
|
|
85
|
+
|
|
86
|
+
tool.driver.rules is intentionally NOT populated in v2.0.
|
|
87
|
+
Rationale: v2.0 fingerprints are sha256(tool:file:line:rule_id)[:16]
|
|
88
|
+
placeholders (Phase 3 replaces with semantic_hash). Generating rule
|
|
89
|
+
definitions from opaque hashes adds JSON noise without integrator
|
|
90
|
+
value. Integrators that need rule[] can construct from results;
|
|
91
|
+
v2.x adds rules[] when fingerprint generation evolves (Phase 3).
|
|
92
|
+
Documented as known v2.0 limitation in Out of Scope.
|
|
93
|
+
"""
|
|
94
|
+
return {
|
|
95
|
+
"tool": {
|
|
96
|
+
"driver": {
|
|
97
|
+
"name": "code-forge",
|
|
98
|
+
"semanticVersion": _build_semantic_version(
|
|
99
|
+
forge_version, tool_versions
|
|
100
|
+
),
|
|
101
|
+
"informationUri": "https://github.com/HouMinXi/code-forge",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"results": [_finding_to_result(f) for f in state.findings],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_semantic_version(
|
|
109
|
+
forge_version: str,
|
|
110
|
+
tool_versions: dict[str, str],
|
|
111
|
+
) -> str:
|
|
112
|
+
"""LAYER0-07: 'code-forge <version> [<tool>=<ver> ...]' for reproducibility.
|
|
113
|
+
|
|
114
|
+
Sorted tool list -> deterministic output for byte-equality testing.
|
|
115
|
+
|
|
116
|
+
Tool names MUST be alphanumeric + dash/underscore (matches Phase 1
|
|
117
|
+
registry.py validation regex). Names containing `=` or `]` would
|
|
118
|
+
corrupt the format string -- pre-validated upstream by registry
|
|
119
|
+
loader; this builder trusts the input.
|
|
120
|
+
"""
|
|
121
|
+
tools_str = " ".join(
|
|
122
|
+
"%s=%s" % (t, v) for t, v in sorted(tool_versions.items())
|
|
123
|
+
)
|
|
124
|
+
if tools_str:
|
|
125
|
+
return "code-forge %s [%s]" % (forge_version, tools_str)
|
|
126
|
+
return "code-forge %s []" % forge_version
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _finding_to_result(finding: StateFinding) -> dict[str, Any]:
|
|
130
|
+
"""Convert StateFinding -> SARIF result dict."""
|
|
131
|
+
result: dict[str, Any] = {
|
|
132
|
+
"ruleId": finding.fingerprint,
|
|
133
|
+
"level": DISPOSITION_TO_LEVEL[finding.disposition],
|
|
134
|
+
"message": {"text": finding.description},
|
|
135
|
+
"locations": [_build_location(finding)],
|
|
136
|
+
}
|
|
137
|
+
suppressions = _suppressions_for(finding.disposition)
|
|
138
|
+
if suppressions is not None:
|
|
139
|
+
result["suppressions"] = suppressions
|
|
140
|
+
result["properties"] = _build_properties(finding)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_location(finding: StateFinding) -> dict[str, Any]:
|
|
145
|
+
"""Build SARIF physicalLocation.
|
|
146
|
+
|
|
147
|
+
Bounds-check line_range. Production line_range is always a 2-element
|
|
148
|
+
list (02-02 _default_l0_runner sets [f.line, f.end_line]; snapshot
|
|
149
|
+
reload preserves list[int]). line_range values are 1-based (per
|
|
150
|
+
parsers/base.py Finding.line definition). SARIF spec uses 1-based
|
|
151
|
+
(region.startLine >= 1), so values pass through directly.
|
|
152
|
+
|
|
153
|
+
Defensive guard handles malformed state.json (corrupted file, partial
|
|
154
|
+
write, future schema change): empty -> startLine=1 endLine=1;
|
|
155
|
+
single-element -> endLine mirrors startLine; >2 elements -> first
|
|
156
|
+
two used, extras silently ignored (upstream schema drift case).
|
|
157
|
+
"""
|
|
158
|
+
line_range = finding.line_range
|
|
159
|
+
if not line_range:
|
|
160
|
+
start = end = 1
|
|
161
|
+
elif len(line_range) == 1:
|
|
162
|
+
start = end = line_range[0]
|
|
163
|
+
else:
|
|
164
|
+
start = line_range[0]
|
|
165
|
+
end = line_range[1]
|
|
166
|
+
return {
|
|
167
|
+
"physicalLocation": {
|
|
168
|
+
"artifactLocation": {"uri": finding.file},
|
|
169
|
+
"region": {
|
|
170
|
+
"startLine": start,
|
|
171
|
+
"endLine": end,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_properties(finding: StateFinding) -> dict[str, Any]:
|
|
178
|
+
"""Optional fields (anchor, evidence_files, error) -> properties dict.
|
|
179
|
+
|
|
180
|
+
Absent fields are OMITTED, not emitted as null. Keeps SARIF compact
|
|
181
|
+
for integrators that pretty-print.
|
|
182
|
+
"""
|
|
183
|
+
props: dict[str, Any] = {}
|
|
184
|
+
if finding.anchor is not None:
|
|
185
|
+
props["anchor"] = finding.anchor
|
|
186
|
+
if finding.evidence_files is not None:
|
|
187
|
+
props["evidence_files"] = finding.evidence_files
|
|
188
|
+
if finding.error is not None:
|
|
189
|
+
props["error"] = finding.error
|
|
190
|
+
props["source"] = finding.source
|
|
191
|
+
return props
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_summary(state: State) -> str:
|
|
195
|
+
"""One-line stderr summary per LAYER0-07.
|
|
196
|
+
|
|
197
|
+
Format matches regex:
|
|
198
|
+
^code-forge: (PASS|FAIL|ESCALATED) findings=\\d+ confirmed=\\d+
|
|
199
|
+
uncertain=\\d+ dismissed=\\d+ fixed=\\d+$
|
|
200
|
+
|
|
201
|
+
Verdict.PENDING is rejected (CI never PENDINGs; caller guards).
|
|
202
|
+
"""
|
|
203
|
+
if state.verdict == Verdict.PENDING:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
"format_summary called with PENDING verdict; CI mode does "
|
|
206
|
+
"not enter HOLD (GATE-01b). Caller bug."
|
|
207
|
+
)
|
|
208
|
+
counts = {
|
|
209
|
+
Disposition.CONFIRMED: 0,
|
|
210
|
+
Disposition.UNCERTAIN: 0,
|
|
211
|
+
Disposition.DISMISSED: 0,
|
|
212
|
+
Disposition.FIXED: 0,
|
|
213
|
+
}
|
|
214
|
+
for f in state.findings:
|
|
215
|
+
counts[f.disposition] += 1
|
|
216
|
+
total = len(state.findings)
|
|
217
|
+
return (
|
|
218
|
+
"code-forge: %s findings=%d confirmed=%d uncertain=%d "
|
|
219
|
+
"dismissed=%d fixed=%d" % (
|
|
220
|
+
state.verdict.value, total,
|
|
221
|
+
counts[Disposition.CONFIRMED],
|
|
222
|
+
counts[Disposition.UNCERTAIN],
|
|
223
|
+
counts[Disposition.DISMISSED],
|
|
224
|
+
counts[Disposition.FIXED],
|
|
225
|
+
)
|
|
226
|
+
)
|