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