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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parse checkpatch.pl emacs-format output into Finding objects."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
8
|
+
|
|
9
|
+
# checkpatch --emacs --show-types output format:
|
|
10
|
+
# file.c:42: WARNING:LONG_LINE: line length 82 exceeds 80 columns
|
|
11
|
+
_CHECKPATCH_RE = re.compile(
|
|
12
|
+
r"^(.+):(\d+):\s+(WARNING|ERROR|CHECK):(\S+):\s+(.+)$"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_SUMMARY_RE = re.compile(r"^total:\s+", re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_checkpatch(
|
|
19
|
+
output: str,
|
|
20
|
+
tool_name: str = "checkpatch",
|
|
21
|
+
exit_code: int = 0,
|
|
22
|
+
) -> list[Finding | ToolError]:
|
|
23
|
+
"""Parse checkpatch.pl --emacs output.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
[] on empty string (clean run).
|
|
27
|
+
[Finding, ...] on valid output with violations.
|
|
28
|
+
[ToolError] if non-empty input has no regex matches AND no
|
|
29
|
+
summary line (indicates corrupt output).
|
|
30
|
+
"""
|
|
31
|
+
if not output.strip():
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
findings = []
|
|
35
|
+
has_summary = False
|
|
36
|
+
|
|
37
|
+
for line in output.splitlines():
|
|
38
|
+
stripped = line.strip()
|
|
39
|
+
if not stripped:
|
|
40
|
+
continue
|
|
41
|
+
if _SUMMARY_RE.match(stripped):
|
|
42
|
+
has_summary = True
|
|
43
|
+
continue
|
|
44
|
+
m = _CHECKPATCH_RE.match(stripped)
|
|
45
|
+
if m:
|
|
46
|
+
findings.append(Finding(
|
|
47
|
+
file=m.group(1),
|
|
48
|
+
line=int(m.group(2)),
|
|
49
|
+
end_line=int(m.group(2)),
|
|
50
|
+
column=0,
|
|
51
|
+
rule_id=m.group(4),
|
|
52
|
+
level=m.group(3).lower(),
|
|
53
|
+
message=m.group(5),
|
|
54
|
+
tool_name=tool_name,
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
# Non-empty input, zero matches, no summary -> corrupt
|
|
58
|
+
if not findings and not has_summary:
|
|
59
|
+
return [ToolError(
|
|
60
|
+
tool_name=tool_name,
|
|
61
|
+
exit_code=exit_code,
|
|
62
|
+
stderr="",
|
|
63
|
+
message=f"Failed to parse {tool_name} output: no matches",
|
|
64
|
+
)]
|
|
65
|
+
|
|
66
|
+
return findings
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parse cargo clippy JSON diagnostic output into Finding objects."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_clippy(
|
|
11
|
+
output: str,
|
|
12
|
+
tool_name: str = "clippy",
|
|
13
|
+
exit_code: int = 0,
|
|
14
|
+
) -> list[Finding | ToolError]:
|
|
15
|
+
"""Parse cargo clippy --message-format=json output.
|
|
16
|
+
|
|
17
|
+
Cargo emits one JSON object per line. Only lines with
|
|
18
|
+
reason="compiler-message" and level in (warning, error) are
|
|
19
|
+
relevant. Non-diagnostic lines (build-script-executed, etc.)
|
|
20
|
+
are silently skipped.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
[] on empty string (clean run).
|
|
24
|
+
[Finding, ...] on valid output with diagnostics.
|
|
25
|
+
[ToolError] if ALL lines fail JSON parse and input is non-empty.
|
|
26
|
+
"""
|
|
27
|
+
if not output.strip():
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
findings = []
|
|
31
|
+
parse_failures = 0
|
|
32
|
+
total_lines = 0
|
|
33
|
+
|
|
34
|
+
for raw_line in output.splitlines():
|
|
35
|
+
stripped = raw_line.strip()
|
|
36
|
+
if not stripped:
|
|
37
|
+
continue
|
|
38
|
+
total_lines += 1
|
|
39
|
+
try:
|
|
40
|
+
obj = json.loads(stripped)
|
|
41
|
+
except (json.JSONDecodeError, ValueError):
|
|
42
|
+
parse_failures += 1
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if obj.get("reason") != "compiler-message":
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
msg = obj.get("message", {})
|
|
49
|
+
level = msg.get("level", "")
|
|
50
|
+
if level not in ("warning", "error"):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
spans = msg.get("spans")
|
|
54
|
+
if not spans:
|
|
55
|
+
continue # empty spans -- no file location
|
|
56
|
+
|
|
57
|
+
span = spans[0]
|
|
58
|
+
code_obj = msg.get("code")
|
|
59
|
+
if code_obj is not None:
|
|
60
|
+
rule_id = code_obj.get("code", "unknown")
|
|
61
|
+
else:
|
|
62
|
+
rule_id = "unknown"
|
|
63
|
+
|
|
64
|
+
line_start = span.get("line_start", 0)
|
|
65
|
+
findings.append(Finding(
|
|
66
|
+
file=span.get("file_name", ""),
|
|
67
|
+
line=line_start,
|
|
68
|
+
end_line=(span.get("line_end") or line_start),
|
|
69
|
+
column=(span.get("column_start") or 0),
|
|
70
|
+
rule_id=rule_id,
|
|
71
|
+
level=level,
|
|
72
|
+
message=msg.get("message", ""),
|
|
73
|
+
tool_name=tool_name,
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
# If all lines failed JSON parse, output is corrupt
|
|
77
|
+
if total_lines > 0 and parse_failures == total_lines:
|
|
78
|
+
return [ToolError(
|
|
79
|
+
tool_name=tool_name,
|
|
80
|
+
exit_code=exit_code,
|
|
81
|
+
stderr="",
|
|
82
|
+
message=f"Failed to parse {tool_name} output: no valid JSON",
|
|
83
|
+
)]
|
|
84
|
+
|
|
85
|
+
return findings
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parse grep -Pn non-ASCII output into Finding objects."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
8
|
+
|
|
9
|
+
# grep -Pn output: filename:lineno:content
|
|
10
|
+
_GREP_LINE_RE = re.compile(r"^(.+?):(\d+):(.+)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_non_ascii(
|
|
14
|
+
output: str,
|
|
15
|
+
tool_name: str = "non_ascii",
|
|
16
|
+
exit_code: int = 0,
|
|
17
|
+
) -> list[Finding | ToolError]:
|
|
18
|
+
"""Parse grep -Pn '[^\\x00-\\x7F]' output.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
[] on empty string (clean run).
|
|
22
|
+
[Finding, ...] on valid grep output with matches.
|
|
23
|
+
[] on non-matching lines (grep found nothing parseable).
|
|
24
|
+
"""
|
|
25
|
+
if not output.strip():
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
findings = []
|
|
29
|
+
for line in output.splitlines():
|
|
30
|
+
stripped = line.strip()
|
|
31
|
+
if not stripped:
|
|
32
|
+
continue
|
|
33
|
+
m = _GREP_LINE_RE.match(stripped)
|
|
34
|
+
if m:
|
|
35
|
+
content = m.group(3).strip()
|
|
36
|
+
findings.append(Finding(
|
|
37
|
+
file=m.group(1),
|
|
38
|
+
line=int(m.group(2)),
|
|
39
|
+
end_line=int(m.group(2)),
|
|
40
|
+
column=0,
|
|
41
|
+
rule_id="NON_ASCII",
|
|
42
|
+
level="error",
|
|
43
|
+
message=f"non-ASCII character found: {content}",
|
|
44
|
+
tool_name=tool_name,
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
return findings
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parse ruff SARIF output into Finding objects."""
|
|
4
|
+
|
|
5
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
6
|
+
from code_forge.parsers._sarif import _parse_sarif
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_ruff(
|
|
10
|
+
output: str,
|
|
11
|
+
tool_name: str = "ruff",
|
|
12
|
+
exit_code: int = 0,
|
|
13
|
+
) -> list[Finding | ToolError]:
|
|
14
|
+
"""Parse ruff --output-format sarif output.
|
|
15
|
+
|
|
16
|
+
Thin wrapper around shared SARIF parser with tool_name="ruff".
|
|
17
|
+
"""
|
|
18
|
+
return _parse_sarif(output, tool_name=tool_name, exit_code=exit_code)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parse semgrep SARIF output into Finding objects."""
|
|
4
|
+
|
|
5
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
6
|
+
from code_forge.parsers._sarif import _parse_sarif
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_semgrep(
|
|
10
|
+
output: str,
|
|
11
|
+
tool_name: str = "semgrep",
|
|
12
|
+
exit_code: int = 0,
|
|
13
|
+
) -> list[Finding | ToolError]:
|
|
14
|
+
"""Parse semgrep --sarif output.
|
|
15
|
+
|
|
16
|
+
Thin wrapper around shared SARIF parser with tool_name="semgrep".
|
|
17
|
+
"""
|
|
18
|
+
return _parse_sarif(output, tool_name=tool_name, exit_code=exit_code)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Parse shellcheck JSON output into Finding objects."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_shellcheck(
|
|
11
|
+
output: str,
|
|
12
|
+
tool_name: str = "shellcheck",
|
|
13
|
+
exit_code: int = 0,
|
|
14
|
+
) -> list[Finding | ToolError]:
|
|
15
|
+
"""Parse shellcheck -f json output.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
[] on empty string (clean run).
|
|
19
|
+
[Finding, ...] on valid output with findings.
|
|
20
|
+
[ToolError] on malformed/unparseable output.
|
|
21
|
+
"""
|
|
22
|
+
if not output.strip():
|
|
23
|
+
return []
|
|
24
|
+
try:
|
|
25
|
+
raw = json.loads(output)
|
|
26
|
+
except (json.JSONDecodeError, ValueError):
|
|
27
|
+
return [ToolError(
|
|
28
|
+
tool_name=tool_name,
|
|
29
|
+
exit_code=exit_code,
|
|
30
|
+
stderr="",
|
|
31
|
+
message=f"Failed to parse {tool_name} JSON output",
|
|
32
|
+
)]
|
|
33
|
+
|
|
34
|
+
findings = []
|
|
35
|
+
try:
|
|
36
|
+
for item in raw:
|
|
37
|
+
findings.append(Finding(
|
|
38
|
+
file=item["file"],
|
|
39
|
+
line=item["line"],
|
|
40
|
+
# Round 3 H-1: use `or` to handle JSON null values
|
|
41
|
+
end_line=(item.get("endLine") or item["line"]),
|
|
42
|
+
column=(item.get("column") or 0),
|
|
43
|
+
rule_id=f"SC{item['code']}",
|
|
44
|
+
level=item.get("level", "warning"),
|
|
45
|
+
message=item["message"],
|
|
46
|
+
tool_name=tool_name,
|
|
47
|
+
fix=None,
|
|
48
|
+
))
|
|
49
|
+
except (KeyError, TypeError, AttributeError):
|
|
50
|
+
return [ToolError(
|
|
51
|
+
tool_name=tool_name,
|
|
52
|
+
exit_code=exit_code,
|
|
53
|
+
stderr="",
|
|
54
|
+
message=f"Failed to parse {tool_name} output: missing fields",
|
|
55
|
+
)]
|
|
56
|
+
return findings
|
code_forge/registry.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""YAML tool registry loader and file matcher.
|
|
4
|
+
|
|
5
|
+
Reads .code-forge/tools.yaml and returns structured ToolConfig objects.
|
|
6
|
+
Validates required fields and filters disabled entries (Round 3 C-4).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from fnmatch import fnmatch
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Known parser keys -- warn on unknown but do not reject
|
|
19
|
+
_KNOWN_FORMATS = frozenset({
|
|
20
|
+
"shellcheck_json",
|
|
21
|
+
"ruff_json",
|
|
22
|
+
"semgrep_json",
|
|
23
|
+
"checkpatch",
|
|
24
|
+
"pylint_json",
|
|
25
|
+
"clippy_json",
|
|
26
|
+
"golangci_json",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
# Fields that must be present in each tool entry
|
|
30
|
+
_REQUIRED_FIELDS = ("command", "output_format", "file_patterns")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ToolConfig:
|
|
35
|
+
"""Configuration for a single tool in the registry."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
command: str
|
|
39
|
+
args: list[str]
|
|
40
|
+
output_format: str # parser dispatch key
|
|
41
|
+
file_patterns: list[str] # glob patterns (e.g. ["*.sh", "*.bash"])
|
|
42
|
+
required: bool = False
|
|
43
|
+
timeout: int = 30
|
|
44
|
+
exclude_patterns: list[str] = field(default_factory=list)
|
|
45
|
+
working_dir: Optional[str] = None # e.g. "cargo_root"
|
|
46
|
+
enabled: bool = True # Round 3 C-4: allows disabling tools
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_registry(yaml_path: str) -> dict[str, ToolConfig]:
|
|
50
|
+
"""Load .code-forge/tools.yaml, validate, return {name: ToolConfig}.
|
|
51
|
+
|
|
52
|
+
Filters out entries where enabled=False (Round 3 C-4).
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
FileNotFoundError: if yaml_path does not exist
|
|
56
|
+
ValueError: if a tool entry is missing required fields
|
|
57
|
+
"""
|
|
58
|
+
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
59
|
+
try:
|
|
60
|
+
data = yaml.safe_load(f)
|
|
61
|
+
except yaml.YAMLError as e:
|
|
62
|
+
raise ValueError(f"Invalid YAML in {yaml_path}: {e}") from e
|
|
63
|
+
|
|
64
|
+
if data is None or "tools" not in data:
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
tools = data["tools"]
|
|
68
|
+
if not tools:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
if not isinstance(tools, dict):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"{yaml_path}: 'tools' must be a mapping, got {type(tools).__name__}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
registry = {}
|
|
77
|
+
for name, entry in tools.items():
|
|
78
|
+
if not isinstance(entry, dict):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"Tool '%s': entry must be a mapping, got %s"
|
|
81
|
+
% (name, type(entry).__name__)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Validate required fields
|
|
85
|
+
for req in _REQUIRED_FIELDS:
|
|
86
|
+
if req not in entry:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"Tool '%s': missing required field '%s'" % (name, req)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for list_field in ("args", "file_patterns", "exclude_patterns"):
|
|
92
|
+
val = entry.get(list_field)
|
|
93
|
+
if val is None and list_field in _REQUIRED_FIELDS:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"Tool '%s': required field '%s' cannot be null"
|
|
96
|
+
% (name, list_field)
|
|
97
|
+
)
|
|
98
|
+
if val is not None and not isinstance(val, list):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"Tool '%s': '%s' must be a list, got %s"
|
|
101
|
+
% (name, list_field, type(val).__name__)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
fmt = entry["output_format"]
|
|
105
|
+
if fmt not in _KNOWN_FORMATS:
|
|
106
|
+
logger.warning(
|
|
107
|
+
"Tool '%s': unknown output_format '%s'", name, fmt
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
tc = ToolConfig(
|
|
111
|
+
name=name,
|
|
112
|
+
command=entry["command"],
|
|
113
|
+
args=entry.get("args", []),
|
|
114
|
+
output_format=fmt,
|
|
115
|
+
file_patterns=entry["file_patterns"],
|
|
116
|
+
required=entry.get("required", False),
|
|
117
|
+
timeout=entry.get("timeout", 30),
|
|
118
|
+
exclude_patterns=entry.get("exclude_patterns", []),
|
|
119
|
+
working_dir=entry.get("working_dir"),
|
|
120
|
+
enabled=entry.get("enabled", True),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Filter disabled entries (Round 3 C-4)
|
|
124
|
+
if not tc.enabled:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
registry[name] = tc
|
|
128
|
+
|
|
129
|
+
return registry
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def match_tools(
|
|
133
|
+
registry: dict[str, ToolConfig],
|
|
134
|
+
files: list[str],
|
|
135
|
+
) -> dict[str, list[str]]:
|
|
136
|
+
"""Return {tool_name: [matching_files]} for given file list.
|
|
137
|
+
|
|
138
|
+
Only considers enabled tools (registry already filtered by
|
|
139
|
+
load_registry). Files matching exclude_patterns are removed.
|
|
140
|
+
"""
|
|
141
|
+
result = {}
|
|
142
|
+
for name, tc in registry.items():
|
|
143
|
+
matched = []
|
|
144
|
+
for filepath in files:
|
|
145
|
+
# Check if file matches any include pattern
|
|
146
|
+
if not any(fnmatch(filepath, p) for p in tc.file_patterns):
|
|
147
|
+
continue
|
|
148
|
+
# Check if file matches any exclude pattern
|
|
149
|
+
if any(fnmatch(filepath, p) for p in tc.exclude_patterns):
|
|
150
|
+
continue
|
|
151
|
+
matched.append(filepath)
|
|
152
|
+
result[name] = matched
|
|
153
|
+
return result
|
code_forge/reporter.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Terminal output formatting, cargo-check style.
|
|
4
|
+
|
|
5
|
+
Plain text output per D-05. Shows delta findings prominently,
|
|
6
|
+
pre-existing violation count for context, tool versions for
|
|
7
|
+
reproducibility.
|
|
8
|
+
|
|
9
|
+
Addresses:
|
|
10
|
+
- Mimo: all_findings preserved for pre-existing count
|
|
11
|
+
- Round 3 C-1: tools_failed parameter for silent-miss visibility
|
|
12
|
+
- DeepSeek F-4: optional tool failure warning
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from code_forge.parsers.base import Finding, ToolError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_report(
|
|
19
|
+
delta_findings: list[Finding | ToolError],
|
|
20
|
+
all_findings: list[Finding | ToolError],
|
|
21
|
+
tool_versions: dict[str, str],
|
|
22
|
+
tools_skipped: list[str],
|
|
23
|
+
tools_failed: list[str],
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Format findings into terminal-friendly report.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
delta_findings: findings on changed lines (drive verdict)
|
|
29
|
+
all_findings: all findings including pre-existing
|
|
30
|
+
tool_versions: {tool_name: version_string} for reproducibility
|
|
31
|
+
tools_skipped: tools not installed or no matching files
|
|
32
|
+
tools_failed: tools that ran but returned ToolError (separate
|
|
33
|
+
from tools_skipped -- different messaging)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Formatted report string
|
|
37
|
+
"""
|
|
38
|
+
lines: list[str] = []
|
|
39
|
+
|
|
40
|
+
# Separate ToolErrors from Findings in delta
|
|
41
|
+
delta_errors = [f for f in delta_findings if isinstance(f, ToolError)]
|
|
42
|
+
delta_violations = [
|
|
43
|
+
f for f in delta_findings if isinstance(f, Finding)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
has_errors = len(delta_errors) > 0
|
|
47
|
+
has_violations = len(delta_violations) > 0
|
|
48
|
+
|
|
49
|
+
if has_violations or has_errors:
|
|
50
|
+
# FAIL output
|
|
51
|
+
if has_violations and has_errors:
|
|
52
|
+
lines.append(
|
|
53
|
+
"forge: FAIL -- %d new violation(s) and tool error(s)"
|
|
54
|
+
% len(delta_violations)
|
|
55
|
+
)
|
|
56
|
+
elif has_violations:
|
|
57
|
+
lines.append(
|
|
58
|
+
"forge: FAIL -- %d new violation(s)"
|
|
59
|
+
% len(delta_violations)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
lines.append("forge: FAIL -- tool error(s)")
|
|
63
|
+
|
|
64
|
+
lines.append("")
|
|
65
|
+
|
|
66
|
+
# Show violations
|
|
67
|
+
for f in delta_violations:
|
|
68
|
+
lines.append(
|
|
69
|
+
" %s:%d: [%s/%s] %s: %s"
|
|
70
|
+
% (f.file, f.line, f.tool_name, f.rule_id, f.level, f.message)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Show tool errors
|
|
74
|
+
for e in delta_errors:
|
|
75
|
+
lines.append(
|
|
76
|
+
" [%s] ERROR: %s" % (e.tool_name, e.message)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
lines.append("")
|
|
80
|
+
|
|
81
|
+
if has_violations and has_errors:
|
|
82
|
+
lines.append(
|
|
83
|
+
"forge: fix %d violation(s) and resolve tool errors before commit"
|
|
84
|
+
% len(delta_violations)
|
|
85
|
+
)
|
|
86
|
+
elif has_violations:
|
|
87
|
+
lines.append(
|
|
88
|
+
"forge: fix %d violation(s) before commit"
|
|
89
|
+
% len(delta_violations)
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
lines.append("forge: resolve tool errors before commit")
|
|
93
|
+
else:
|
|
94
|
+
# PASS output
|
|
95
|
+
# Count pre-existing violations (all minus delta, Findings only)
|
|
96
|
+
all_finding_count = sum(
|
|
97
|
+
1 for f in all_findings if isinstance(f, Finding)
|
|
98
|
+
)
|
|
99
|
+
delta_finding_count = sum(
|
|
100
|
+
1 for f in delta_findings if isinstance(f, Finding)
|
|
101
|
+
)
|
|
102
|
+
pre_existing = all_finding_count - delta_finding_count
|
|
103
|
+
|
|
104
|
+
lines.append("forge: PASS -- no new violations")
|
|
105
|
+
if pre_existing > 0:
|
|
106
|
+
lines.append(
|
|
107
|
+
" (%d pre-existing violation(s) in unchanged code,"
|
|
108
|
+
" not blocking)"
|
|
109
|
+
% pre_existing
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Tools failed warning (always shown, even on PASS -- Round 3 C-1)
|
|
113
|
+
if tools_failed:
|
|
114
|
+
lines.append(
|
|
115
|
+
" WARNING: %d optional tool(s) failed: %s"
|
|
116
|
+
" -- results may be incomplete"
|
|
117
|
+
% (len(tools_failed), ", ".join(tools_failed))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Tools skipped
|
|
121
|
+
if tools_skipped:
|
|
122
|
+
lines.append(
|
|
123
|
+
" (tools skipped: %s)" % ", ".join(tools_skipped)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Tool versions for reproducibility
|
|
127
|
+
if tool_versions:
|
|
128
|
+
lines.append("")
|
|
129
|
+
lines.append("tool versions:")
|
|
130
|
+
for name in sorted(tool_versions.keys()):
|
|
131
|
+
lines.append(" %s: %s" % (name, tool_versions[name]))
|
|
132
|
+
|
|
133
|
+
return "\n".join(lines)
|