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