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/cli.py
ADDED
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Forge CLI entry point.
|
|
4
|
+
|
|
5
|
+
Subcommands: review (default), gate-check, mutation-check, e2e-check,
|
|
6
|
+
install-hooks, install-skill.
|
|
7
|
+
Bare invocation (no subcommand) routes to review for backward compatibility.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .runner import capture_tool_version
|
|
20
|
+
from .sarif import build_sarif_log, format_summary
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .registry import ToolConfig
|
|
24
|
+
from .baseline import (
|
|
25
|
+
EmptyBaseline,
|
|
26
|
+
GitRefBaseline,
|
|
27
|
+
SnapshotBaseline,
|
|
28
|
+
resolve_baseline,
|
|
29
|
+
serialize_baseline_spec,
|
|
30
|
+
)
|
|
31
|
+
from .env_resolver import (
|
|
32
|
+
resolve_falsification_engine,
|
|
33
|
+
resolve_max_fix_attempts,
|
|
34
|
+
resolve_max_total_rounds,
|
|
35
|
+
)
|
|
36
|
+
from .errors import BaselineResolutionError, CliError
|
|
37
|
+
from .exit_codes import (
|
|
38
|
+
EXIT_BUSY,
|
|
39
|
+
EXIT_CLI_ERROR,
|
|
40
|
+
EXIT_FAIL,
|
|
41
|
+
EXIT_PASS,
|
|
42
|
+
verdict_to_exit,
|
|
43
|
+
)
|
|
44
|
+
from .factories import build_autofixer, build_falsifier, build_revert_fn
|
|
45
|
+
from .git import is_git_repo
|
|
46
|
+
from .hold import HoldAborted, run_hold_ui
|
|
47
|
+
from .lock import ForgeLock, ForgeLockBusy
|
|
48
|
+
from .machine import StateMachine
|
|
49
|
+
from .mode_resolver import resolve_mode
|
|
50
|
+
from .registry import load_registry
|
|
51
|
+
from .source import compute_source_hash
|
|
52
|
+
from .state import Mode, Verdict, load_state as _load_state
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
MAX_HOLD_CYCLES = 10
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _emit_ci_output(
|
|
59
|
+
state_path: Path,
|
|
60
|
+
registry: dict[str, "ToolConfig"],
|
|
61
|
+
post_emit_hook: Optional[Callable[[], None]] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Emit SARIF to stdout and summary to stderr in CI mode.
|
|
64
|
+
|
|
65
|
+
Re-loads state from disk for canonical view (catches any save_state
|
|
66
|
+
divergence). Re-captures tool_versions to avoid constructor-time
|
|
67
|
+
snapshot staleness.
|
|
68
|
+
|
|
69
|
+
If load_state returns None -> silent return (no log warning).
|
|
70
|
+
SARIF is best-effort output, NOT canonical artifact; state.json is
|
|
71
|
+
canonical. Silent return matches "skip SARIF when state absent" semantics.
|
|
72
|
+
"""
|
|
73
|
+
final_state = _load_state(state_path)
|
|
74
|
+
if final_state is None:
|
|
75
|
+
return
|
|
76
|
+
tool_versions = {
|
|
77
|
+
name: capture_tool_version(tc.command)
|
|
78
|
+
for name, tc in registry.items()
|
|
79
|
+
}
|
|
80
|
+
log_dict = build_sarif_log(
|
|
81
|
+
final_state, tool_versions, forge_version=__version__
|
|
82
|
+
)
|
|
83
|
+
print(json.dumps(log_dict), file=sys.stdout)
|
|
84
|
+
print(format_summary(final_state), file=sys.stderr)
|
|
85
|
+
if post_emit_hook is not None:
|
|
86
|
+
post_emit_hook()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
90
|
+
"""Build the argparse parser with subcommands.
|
|
91
|
+
|
|
92
|
+
Subcommands:
|
|
93
|
+
- review: existing pipeline (all flags preserved)
|
|
94
|
+
- gate-check: test-based commit gate (R1)
|
|
95
|
+
- mutation-check: mutation testing gate (R2)
|
|
96
|
+
- e2e-check: cross-component coverage heuristic (R3)
|
|
97
|
+
- install-hooks: hook installer
|
|
98
|
+
|
|
99
|
+
Backward compat: bare `forge` (no subcommand) defaults to `review`
|
|
100
|
+
in main() for existing workflows.
|
|
101
|
+
|
|
102
|
+
--help includes an Exit Codes section in the epilog.
|
|
103
|
+
"""
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="code-forge",
|
|
106
|
+
description="3-state quality gate for code review",
|
|
107
|
+
epilog=(
|
|
108
|
+
"Exit codes:\n"
|
|
109
|
+
" 0 PASS\n"
|
|
110
|
+
" 1 FAIL\n"
|
|
111
|
+
" 2 CLI_ERROR (invalid args, missing config, "
|
|
112
|
+
"parse error)\n"
|
|
113
|
+
" 3 BUSY (another code-forge process holds the lock)\n"
|
|
114
|
+
" 4 ESCALATED (non-convergence or human-frozen)\n"
|
|
115
|
+
),
|
|
116
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
117
|
+
)
|
|
118
|
+
# --version on root parser so `forge --version` works
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--version", action="version",
|
|
121
|
+
version="code-forge %s" % __version__,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Subparsers: dest='subcommand' to capture which was invoked
|
|
125
|
+
# required=False (Python 3.7+ default) for backward compat
|
|
126
|
+
subparsers = parser.add_subparsers(
|
|
127
|
+
dest='subcommand',
|
|
128
|
+
help='subcommand to execute',
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# --- REVIEW subcommand: existing pipeline ---
|
|
132
|
+
review_parser = subparsers.add_parser(
|
|
133
|
+
'review',
|
|
134
|
+
help='run the full review pipeline (default)',
|
|
135
|
+
description='3-state quality gate for code review',
|
|
136
|
+
epilog=(
|
|
137
|
+
"Exit codes:\n"
|
|
138
|
+
" 0 PASS\n"
|
|
139
|
+
" 1 FAIL\n"
|
|
140
|
+
" 2 CLI_ERROR (invalid args, missing config, "
|
|
141
|
+
"parse error)\n"
|
|
142
|
+
" 3 BUSY (another code-forge process holds the lock)\n"
|
|
143
|
+
" 4 ESCALATED (non-convergence or human-frozen)\n"
|
|
144
|
+
),
|
|
145
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
146
|
+
)
|
|
147
|
+
review_parser.add_argument(
|
|
148
|
+
"--mode", choices=["local", "ci"], default=None,
|
|
149
|
+
help="execution mode (default: local if TTY, ci otherwise)",
|
|
150
|
+
)
|
|
151
|
+
review_parser.add_argument(
|
|
152
|
+
"--falsification-engine", choices=["auto", "stub", "real"],
|
|
153
|
+
default=None,
|
|
154
|
+
help="falsification engine (default: auto)",
|
|
155
|
+
)
|
|
156
|
+
review_parser.add_argument(
|
|
157
|
+
"--sandbox", action="store_true",
|
|
158
|
+
help="enable sandbox for autofixer "
|
|
159
|
+
"(Phase 4 hook; v2.0 no-op + warning)",
|
|
160
|
+
)
|
|
161
|
+
review_parser.add_argument(
|
|
162
|
+
"--baseline", default=None,
|
|
163
|
+
help="baseline ref "
|
|
164
|
+
"(git: HEAD/INDEX/<sha>; non-git: empty|<snapshot-path>)",
|
|
165
|
+
)
|
|
166
|
+
review_parser.add_argument(
|
|
167
|
+
"--head", default=None,
|
|
168
|
+
help="head ref (git only: WORKING/INDEX/<sha>; "
|
|
169
|
+
"ignored non-git)",
|
|
170
|
+
)
|
|
171
|
+
review_parser.add_argument(
|
|
172
|
+
"--registry", default=".code-forge/tools.yaml",
|
|
173
|
+
help="path to tools.yaml (default: .code-forge/tools.yaml)",
|
|
174
|
+
)
|
|
175
|
+
review_parser.add_argument(
|
|
176
|
+
"--state-dir", default=None,
|
|
177
|
+
help="DEPRECATED: state directory is hardcoded to "
|
|
178
|
+
"cwd/.code-forge; value is ignored.",
|
|
179
|
+
)
|
|
180
|
+
review_parser.add_argument(
|
|
181
|
+
"--max-total-rounds", type=int, default=None,
|
|
182
|
+
help="LOCAL mode round bound "
|
|
183
|
+
"(default 20 or FORGE_MAX_TOTAL_ROUNDS)",
|
|
184
|
+
)
|
|
185
|
+
review_parser.add_argument(
|
|
186
|
+
"--max-fix-attempts", type=int, default=None,
|
|
187
|
+
help="per-fingerprint fix budget "
|
|
188
|
+
"(default 3 or "
|
|
189
|
+
"FORGE_MAX_FIX_ATTEMPTS_PER_FINGERPRINT)",
|
|
190
|
+
)
|
|
191
|
+
review_parser.add_argument(
|
|
192
|
+
"--quiet", action="store_true",
|
|
193
|
+
help="suppress tool-skipped, version, and deprecation "
|
|
194
|
+
"messages",
|
|
195
|
+
)
|
|
196
|
+
review_parser.add_argument(
|
|
197
|
+
"--staged", action="store_true",
|
|
198
|
+
help="DEPRECATED v2.1: use --head INDEX "
|
|
199
|
+
"(mapped internally with warning)",
|
|
200
|
+
)
|
|
201
|
+
review_parser.add_argument(
|
|
202
|
+
"paths", nargs="*",
|
|
203
|
+
help="files/dirs to review; git mode filters diff, "
|
|
204
|
+
"non-git lists files",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# --- GATE-CHECK subcommand: test-based commit gate ---
|
|
208
|
+
gate_parser = subparsers.add_parser(
|
|
209
|
+
'gate-check',
|
|
210
|
+
help='run test gate for pre-commit hook',
|
|
211
|
+
description='Test-based commit gate (blocks on new failures)',
|
|
212
|
+
)
|
|
213
|
+
gate_parser.add_argument(
|
|
214
|
+
"--quiet", action="store_true",
|
|
215
|
+
help="suppress warning messages",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# --- MUTATION-CHECK subcommand: mutation testing gate (R2) ---
|
|
219
|
+
mutation_parser = subparsers.add_parser(
|
|
220
|
+
'mutation-check',
|
|
221
|
+
help='run mutation testing gate (R2)',
|
|
222
|
+
description=(
|
|
223
|
+
'Mutation testing gate: runs mutmut on diff-scoped files '
|
|
224
|
+
'and reports surviving mutants. '
|
|
225
|
+
'Exit codes: 0=PASS, 1=FAIL (survivors found), 2=CLI_ERROR.'
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
mutation_parser.add_argument(
|
|
229
|
+
"--diff", default=None,
|
|
230
|
+
help="path to unified diff file (default: uncommitted changes)",
|
|
231
|
+
)
|
|
232
|
+
mutation_parser.add_argument(
|
|
233
|
+
"--timeout", type=int, default=600,
|
|
234
|
+
help="mutmut run timeout in seconds (default: 600)",
|
|
235
|
+
)
|
|
236
|
+
mutation_parser.add_argument(
|
|
237
|
+
"--paths", default=None,
|
|
238
|
+
help="glob pattern to restrict mutation to matching files",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# --- E2E-CHECK subcommand: cross-component coverage heuristic (R3) ---
|
|
242
|
+
e2e_parser = subparsers.add_parser(
|
|
243
|
+
'e2e-check',
|
|
244
|
+
help='run cross-component e2e coverage heuristic (R3)',
|
|
245
|
+
description=(
|
|
246
|
+
'E2E coverage heuristic: detects cross-component signature '
|
|
247
|
+
'changes and checks for e2e artifacts. '
|
|
248
|
+
'Exit codes: 0=PASS (no findings or skip), 1=FAIL (P2 findings), '
|
|
249
|
+
'2=CLI_ERROR.'
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
e2e_parser.add_argument(
|
|
253
|
+
"--diff", default=None,
|
|
254
|
+
help="path to unified diff file (default: uncommitted changes)",
|
|
255
|
+
)
|
|
256
|
+
e2e_parser.add_argument(
|
|
257
|
+
"--repo-root", default=None,
|
|
258
|
+
help="repository root path (default: current directory)",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# --- INSTALL-HOOKS subcommand: hook installer ---
|
|
262
|
+
hooks_parser = subparsers.add_parser(
|
|
263
|
+
'install-hooks',
|
|
264
|
+
help='install code-forge pre-commit hook',
|
|
265
|
+
description='Write .git/hooks/pre-commit with forge gate-check',
|
|
266
|
+
)
|
|
267
|
+
hooks_parser.add_argument(
|
|
268
|
+
"--quiet", action="store_true",
|
|
269
|
+
help="suppress informational messages",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# --- INSTALL-SKILL subcommand: copy bundled skills into agent dir ---
|
|
273
|
+
skill_parser = subparsers.add_parser(
|
|
274
|
+
'install-skill',
|
|
275
|
+
help='copy bundled review skills into an agent skill directory',
|
|
276
|
+
description=(
|
|
277
|
+
'Copy bundled skills into a target agent skill directory. '
|
|
278
|
+
'Target conventions (subject to change): '
|
|
279
|
+
'claude=~/.claude/skills/, '
|
|
280
|
+
'vscode=<cwd>/.claude/skills/, '
|
|
281
|
+
'universal=<cwd>/.agents/skills/. '
|
|
282
|
+
'Use --dest to override. '
|
|
283
|
+
'Exit codes: 0=success, 2=CLI_ERROR.'
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
skill_parser.add_argument(
|
|
287
|
+
"--target",
|
|
288
|
+
choices=["claude", "vscode", "universal"],
|
|
289
|
+
default="claude",
|
|
290
|
+
help=(
|
|
291
|
+
"agent target: claude (~/.claude/skills/), "
|
|
292
|
+
"vscode (<cwd>/.claude/skills/), "
|
|
293
|
+
"universal (<cwd>/.agents/skills/) "
|
|
294
|
+
"(default: claude)"
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
skill_parser.add_argument(
|
|
298
|
+
"--dest",
|
|
299
|
+
default=None,
|
|
300
|
+
metavar="DIR",
|
|
301
|
+
help="override --target with an explicit destination directory",
|
|
302
|
+
)
|
|
303
|
+
skill_parser.add_argument(
|
|
304
|
+
"--skill",
|
|
305
|
+
default=None,
|
|
306
|
+
metavar="NAME",
|
|
307
|
+
help="install one named skill (default: all bundled skills)",
|
|
308
|
+
)
|
|
309
|
+
skill_parser.add_argument(
|
|
310
|
+
"--force",
|
|
311
|
+
action="store_true",
|
|
312
|
+
help="overwrite existing skill directories",
|
|
313
|
+
)
|
|
314
|
+
skill_parser.add_argument(
|
|
315
|
+
"--quiet",
|
|
316
|
+
action="store_true",
|
|
317
|
+
help="suppress informational messages",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return parser
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main() -> int:
|
|
324
|
+
"""Entry point. Returns exit code (int).
|
|
325
|
+
|
|
326
|
+
setuptools entry-point shim calls sys.exit(main()).
|
|
327
|
+
|
|
328
|
+
Subcommand routing:
|
|
329
|
+
- review: existing pipeline (_run)
|
|
330
|
+
- gate-check: gate_check.run_gate_check()
|
|
331
|
+
- mutation-check: _run_mutation_check()
|
|
332
|
+
- e2e-check: _run_e2e_check_cmd()
|
|
333
|
+
- install-hooks: install_hooks.run_install_hooks()
|
|
334
|
+
- None (bare forge): default to review for backward compat
|
|
335
|
+
|
|
336
|
+
Backward compat for `forge a.py b.py`:
|
|
337
|
+
If sys.argv doesn't start with a known subcommand, prepend 'review'
|
|
338
|
+
to route positional args to the review subparser.
|
|
339
|
+
"""
|
|
340
|
+
parser = _build_parser()
|
|
341
|
+
|
|
342
|
+
# Backward compat: detect if first arg is a known subcommand
|
|
343
|
+
# If not, prepend 'review' to sys.argv for argparse
|
|
344
|
+
known_subcommands = {
|
|
345
|
+
'review', 'gate-check', 'mutation-check', 'e2e-check',
|
|
346
|
+
'install-hooks', 'install-skill',
|
|
347
|
+
}
|
|
348
|
+
argv = sys.argv[1:] # skip program name
|
|
349
|
+
|
|
350
|
+
# Filter out --version and --help which are on root parser
|
|
351
|
+
non_flag_args = [a for a in argv if not a.startswith('-')]
|
|
352
|
+
|
|
353
|
+
if non_flag_args and non_flag_args[0] not in known_subcommands:
|
|
354
|
+
# First non-flag arg is not a subcommand, so prepend 'review'
|
|
355
|
+
argv = ['review'] + argv
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
args = parser.parse_args(argv)
|
|
359
|
+
except SystemExit as e:
|
|
360
|
+
return int(e.code) if e.code is not None else EXIT_CLI_ERROR
|
|
361
|
+
|
|
362
|
+
# Backward compat: bare `forge` (no subcommand) defaults to review
|
|
363
|
+
if args.subcommand is None:
|
|
364
|
+
args.subcommand = 'review'
|
|
365
|
+
|
|
366
|
+
# Route to subcommand handler
|
|
367
|
+
if args.subcommand == 'review':
|
|
368
|
+
try:
|
|
369
|
+
verdict = _run(args, env=os.environ, cwd=Path.cwd())
|
|
370
|
+
except CliError as exc:
|
|
371
|
+
print("code-forge: error: %s" % exc, file=sys.stderr)
|
|
372
|
+
return EXIT_CLI_ERROR
|
|
373
|
+
except ForgeLockBusy as exc:
|
|
374
|
+
print("code-forge: %s" % exc, file=sys.stderr)
|
|
375
|
+
return EXIT_BUSY
|
|
376
|
+
except Exception as exc: # noqa: BLE001
|
|
377
|
+
import traceback
|
|
378
|
+
print(
|
|
379
|
+
"code-forge: unexpected error: %s" % exc, file=sys.stderr
|
|
380
|
+
)
|
|
381
|
+
traceback.print_exc(file=sys.stderr)
|
|
382
|
+
return EXIT_FAIL
|
|
383
|
+
|
|
384
|
+
# B2: PENDING guard before verdict_to_exit.
|
|
385
|
+
if verdict == Verdict.PENDING:
|
|
386
|
+
return EXIT_PASS
|
|
387
|
+
return verdict_to_exit(verdict)
|
|
388
|
+
|
|
389
|
+
elif args.subcommand == 'gate-check':
|
|
390
|
+
from .gate_check import run_gate_check
|
|
391
|
+
return run_gate_check(
|
|
392
|
+
args=args, env=os.environ, cwd=Path.cwd(),
|
|
393
|
+
stdout=sys.stdout, stderr=sys.stderr
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
elif args.subcommand == 'mutation-check':
|
|
397
|
+
return _run_mutation_check(args, cwd=Path.cwd())
|
|
398
|
+
|
|
399
|
+
elif args.subcommand == 'e2e-check':
|
|
400
|
+
return _run_e2e_check_cmd(args, cwd=Path.cwd())
|
|
401
|
+
|
|
402
|
+
elif args.subcommand == 'install-hooks':
|
|
403
|
+
from .install_hooks import run_install_hooks
|
|
404
|
+
return run_install_hooks(
|
|
405
|
+
args=args, env=os.environ, cwd=Path.cwd(),
|
|
406
|
+
stdout=sys.stdout, stderr=sys.stderr
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
elif args.subcommand == 'install-skill':
|
|
410
|
+
return _run_install_skill(args, cwd=Path.cwd())
|
|
411
|
+
|
|
412
|
+
else:
|
|
413
|
+
print(
|
|
414
|
+
"code-forge: unknown subcommand: %s" % args.subcommand,
|
|
415
|
+
file=sys.stderr
|
|
416
|
+
)
|
|
417
|
+
return EXIT_CLI_ERROR
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _run(args, env, cwd: Path) -> Verdict:
|
|
421
|
+
"""Main pipeline body. Returns Verdict."""
|
|
422
|
+
warn = (lambda msg: None) if args.quiet else (
|
|
423
|
+
lambda msg: print("code-forge: %s" % msg, file=sys.stderr)
|
|
424
|
+
)
|
|
425
|
+
# R4-M2: --state-dir deprecated; hardcode to cwd/.forge.
|
|
426
|
+
if (args.state_dir is not None
|
|
427
|
+
and args.state_dir != ".code-forge"):
|
|
428
|
+
warn(
|
|
429
|
+
"warning: --state-dir is deprecated v2.1; v2.0 always "
|
|
430
|
+
"uses cwd/.code-forge (your value %r is ignored)"
|
|
431
|
+
% args.state_dir
|
|
432
|
+
)
|
|
433
|
+
state_dir = cwd / ".code-forge"
|
|
434
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
state_path = state_dir / "state.json"
|
|
436
|
+
lock_path = state_dir / "code-forge.lock"
|
|
437
|
+
|
|
438
|
+
# Step 1: mode
|
|
439
|
+
mode = resolve_mode(args.mode, env, sys.stdout.isatty())
|
|
440
|
+
|
|
441
|
+
# Step 2: registry
|
|
442
|
+
try:
|
|
443
|
+
registry = load_registry(args.registry)
|
|
444
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
445
|
+
raise CliError("registry load failed: %s" % exc)
|
|
446
|
+
|
|
447
|
+
# Step 3: env overrides
|
|
448
|
+
max_rounds = resolve_max_total_rounds(
|
|
449
|
+
args.max_total_rounds, env
|
|
450
|
+
)
|
|
451
|
+
max_fix = resolve_max_fix_attempts(
|
|
452
|
+
args.max_fix_attempts, env
|
|
453
|
+
)
|
|
454
|
+
engine_choice = resolve_falsification_engine(
|
|
455
|
+
args.falsification_engine, env
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Step 4: baseline / head (H4: two-phase paths resolution)
|
|
459
|
+
baseline_spec, head_spec = _build_baseline_specs(
|
|
460
|
+
args, cwd, warn=warn
|
|
461
|
+
)
|
|
462
|
+
initial_paths = _paths(args, cwd, resolved=None)
|
|
463
|
+
try:
|
|
464
|
+
resolved = resolve_baseline(
|
|
465
|
+
baseline_spec, head_spec, initial_paths, cwd
|
|
466
|
+
)
|
|
467
|
+
except BaselineResolutionError as exc:
|
|
468
|
+
raise CliError("baseline resolution failed: %s" % exc)
|
|
469
|
+
# Late-phase paths: extract from diff if user passed none.
|
|
470
|
+
if not initial_paths:
|
|
471
|
+
effective_paths = _paths(args, cwd, resolved=resolved)
|
|
472
|
+
if effective_paths:
|
|
473
|
+
resolved = resolve_baseline(
|
|
474
|
+
baseline_spec, head_spec, effective_paths, cwd
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Step 5: source identity (B3: keyword args on mode_hint)
|
|
478
|
+
if resolved.mode_hint == "git":
|
|
479
|
+
source_hash = compute_source_hash(
|
|
480
|
+
git_diff=resolved.git_diff or ""
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
source_hash = compute_source_hash(
|
|
484
|
+
files=resolved.source_files
|
|
485
|
+
)
|
|
486
|
+
baseline_repr = serialize_baseline_spec(baseline_spec)
|
|
487
|
+
|
|
488
|
+
# M6: non-git snapshot auto-detection.
|
|
489
|
+
if (resolved.mode_hint == "non-git"
|
|
490
|
+
and args.baseline is None
|
|
491
|
+
and isinstance(baseline_spec, EmptyBaseline)):
|
|
492
|
+
from .snapshot import find_existing_snapshot
|
|
493
|
+
snap_path = find_existing_snapshot(source_hash, cwd)
|
|
494
|
+
if snap_path is not None:
|
|
495
|
+
baseline_spec = SnapshotBaseline(path=snap_path)
|
|
496
|
+
try:
|
|
497
|
+
resolved = resolve_baseline(
|
|
498
|
+
baseline_spec, head_spec,
|
|
499
|
+
resolved.source_files, cwd,
|
|
500
|
+
)
|
|
501
|
+
except BaselineResolutionError as exc:
|
|
502
|
+
raise CliError(
|
|
503
|
+
"snapshot baseline resolution failed: %s"
|
|
504
|
+
% exc
|
|
505
|
+
)
|
|
506
|
+
baseline_repr = serialize_baseline_spec(
|
|
507
|
+
baseline_spec
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Step 6: factories
|
|
511
|
+
if args.sandbox:
|
|
512
|
+
warn(
|
|
513
|
+
"warning: --sandbox is a Phase 4 hook; "
|
|
514
|
+
"ignored in v2.0"
|
|
515
|
+
)
|
|
516
|
+
falsifier = build_falsifier(engine_choice)
|
|
517
|
+
autofixer = build_autofixer(resolved)
|
|
518
|
+
revert_fn = build_revert_fn(resolved, cwd)
|
|
519
|
+
|
|
520
|
+
# Step 7: lock + run
|
|
521
|
+
with ForgeLock(lock_path):
|
|
522
|
+
verdict = _run_hold_loop(
|
|
523
|
+
mode=mode,
|
|
524
|
+
falsifier=falsifier,
|
|
525
|
+
autofixer=autofixer,
|
|
526
|
+
revert_fn=revert_fn,
|
|
527
|
+
resolved=resolved,
|
|
528
|
+
source_hash=source_hash,
|
|
529
|
+
baseline_repr=baseline_repr,
|
|
530
|
+
cwd=cwd,
|
|
531
|
+
registry=registry,
|
|
532
|
+
max_rounds=max_rounds,
|
|
533
|
+
max_fix_attempts=max_fix,
|
|
534
|
+
state_path=state_path,
|
|
535
|
+
)
|
|
536
|
+
# SARIF emission in CI mode, inside lock scope.
|
|
537
|
+
if mode == Mode.CI:
|
|
538
|
+
_emit_ci_output(state_path, registry)
|
|
539
|
+
return verdict
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _run_hold_loop(
|
|
543
|
+
*, mode, falsifier, autofixer, revert_fn, resolved,
|
|
544
|
+
source_hash, baseline_repr, cwd, registry,
|
|
545
|
+
max_rounds, max_fix_attempts, state_path,
|
|
546
|
+
input_fn=input, output_fn=print,
|
|
547
|
+
) -> Verdict:
|
|
548
|
+
"""HOLD-resume loop. Bounded by MAX_HOLD_CYCLES."""
|
|
549
|
+
for cycle in range(MAX_HOLD_CYCLES):
|
|
550
|
+
sm = StateMachine(
|
|
551
|
+
mode=mode,
|
|
552
|
+
falsifier=falsifier,
|
|
553
|
+
autofixer=autofixer,
|
|
554
|
+
revert_fn=revert_fn,
|
|
555
|
+
resolved_review=resolved,
|
|
556
|
+
source_hash=source_hash,
|
|
557
|
+
baseline_spec_repr=baseline_repr,
|
|
558
|
+
cwd=cwd,
|
|
559
|
+
registry=registry,
|
|
560
|
+
max_total_rounds=max_rounds,
|
|
561
|
+
max_fix_attempts=max_fix_attempts,
|
|
562
|
+
)
|
|
563
|
+
verdict = sm.run()
|
|
564
|
+
if verdict != Verdict.PENDING:
|
|
565
|
+
return verdict
|
|
566
|
+
# M3: load state from disk (public API, not sm._state).
|
|
567
|
+
from .state import load_state
|
|
568
|
+
loaded = load_state(state_path)
|
|
569
|
+
if loaded is None:
|
|
570
|
+
return Verdict.ESCALATED
|
|
571
|
+
try:
|
|
572
|
+
run_hold_ui(
|
|
573
|
+
loaded, state_path,
|
|
574
|
+
input_fn=input_fn, output_fn=output_fn,
|
|
575
|
+
)
|
|
576
|
+
except HoldAborted as exc:
|
|
577
|
+
print(
|
|
578
|
+
"code-forge: %s; state preserved at %s"
|
|
579
|
+
% (exc, state_path),
|
|
580
|
+
file=sys.stderr,
|
|
581
|
+
)
|
|
582
|
+
return Verdict.PENDING
|
|
583
|
+
|
|
584
|
+
# MAX_HOLD_CYCLES exhausted.
|
|
585
|
+
from .state import State, load_state, save_state
|
|
586
|
+
final = load_state(state_path)
|
|
587
|
+
if final is None:
|
|
588
|
+
# R4-L3: fallback if state.json deleted mid-run.
|
|
589
|
+
final = State(
|
|
590
|
+
mode=mode,
|
|
591
|
+
source_hash=source_hash,
|
|
592
|
+
baseline_spec_repr=baseline_repr,
|
|
593
|
+
)
|
|
594
|
+
final.infra_errors.append(
|
|
595
|
+
"MAX_HOLD_CYCLES=%d exhausted; human re-entered HOLD "
|
|
596
|
+
"too many times" % MAX_HOLD_CYCLES
|
|
597
|
+
)
|
|
598
|
+
final.verdict = Verdict.ESCALATED
|
|
599
|
+
final.converged = False
|
|
600
|
+
save_state(final, state_path)
|
|
601
|
+
return Verdict.ESCALATED
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _run_mutation_check(args, cwd: Path) -> int:
|
|
605
|
+
"""Synchronous wrapper for mutation-check subcommand.
|
|
606
|
+
|
|
607
|
+
Reads diff-scoped files from git, calls run_mutation(), translates
|
|
608
|
+
findings to exit code.
|
|
609
|
+
|
|
610
|
+
Exit codes:
|
|
611
|
+
0 PASS (no survivors)
|
|
612
|
+
1 FAIL (survivors found)
|
|
613
|
+
2 CLI_ERROR (git or invocation error)
|
|
614
|
+
"""
|
|
615
|
+
from .mutation import run_mutation
|
|
616
|
+
|
|
617
|
+
# Resolve diff source.
|
|
618
|
+
if args.diff is not None:
|
|
619
|
+
diff_path = Path(args.diff)
|
|
620
|
+
if not diff_path.exists():
|
|
621
|
+
print(
|
|
622
|
+
"code-forge: mutation-check: diff file not found: %s"
|
|
623
|
+
% args.diff,
|
|
624
|
+
file=sys.stderr,
|
|
625
|
+
)
|
|
626
|
+
return EXIT_CLI_ERROR
|
|
627
|
+
try:
|
|
628
|
+
diff_text = diff_path.read_text(encoding="utf-8")
|
|
629
|
+
except OSError as exc:
|
|
630
|
+
print(
|
|
631
|
+
"code-forge: mutation-check: cannot read diff: %s" % exc,
|
|
632
|
+
file=sys.stderr,
|
|
633
|
+
)
|
|
634
|
+
return EXIT_CLI_ERROR
|
|
635
|
+
from .diff import get_changed_files
|
|
636
|
+
diff_files = get_changed_files(diff_text)
|
|
637
|
+
else:
|
|
638
|
+
# Uncommitted changes via git diff.
|
|
639
|
+
import subprocess
|
|
640
|
+
try:
|
|
641
|
+
result = subprocess.run(
|
|
642
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
643
|
+
capture_output=True,
|
|
644
|
+
text=True,
|
|
645
|
+
check=False,
|
|
646
|
+
cwd=str(cwd),
|
|
647
|
+
)
|
|
648
|
+
if result.returncode != 0:
|
|
649
|
+
print(
|
|
650
|
+
"code-forge: mutation-check: git diff failed: %s"
|
|
651
|
+
% result.stderr.strip(),
|
|
652
|
+
file=sys.stderr,
|
|
653
|
+
)
|
|
654
|
+
return EXIT_CLI_ERROR
|
|
655
|
+
diff_files = [
|
|
656
|
+
f for f in result.stdout.splitlines() if f.strip()
|
|
657
|
+
]
|
|
658
|
+
except FileNotFoundError:
|
|
659
|
+
print(
|
|
660
|
+
"code-forge: mutation-check: git not found",
|
|
661
|
+
file=sys.stderr,
|
|
662
|
+
)
|
|
663
|
+
return EXIT_CLI_ERROR
|
|
664
|
+
|
|
665
|
+
# Apply --paths glob filter if requested.
|
|
666
|
+
if getattr(args, "paths", None):
|
|
667
|
+
from fnmatch import fnmatch as _fnmatch
|
|
668
|
+
glob_pat = args.paths
|
|
669
|
+
diff_files = [f for f in diff_files if _fnmatch(f, glob_pat)]
|
|
670
|
+
|
|
671
|
+
# Default baseline command: pytest (same as gate_check convention).
|
|
672
|
+
baseline_cmd = ["pytest", "--tb=no", "-q"]
|
|
673
|
+
|
|
674
|
+
findings, infra_errors = run_mutation(
|
|
675
|
+
diff_files=diff_files,
|
|
676
|
+
baseline_cmd=baseline_cmd,
|
|
677
|
+
timeout=args.timeout,
|
|
678
|
+
cwd=cwd,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
# Report infra errors to stderr (informational).
|
|
682
|
+
for err in infra_errors:
|
|
683
|
+
print("code-forge: mutation-check: %s" % err, file=sys.stderr)
|
|
684
|
+
|
|
685
|
+
# Translate findings to exit code.
|
|
686
|
+
# CONFIRMED findings with source=MUTANT and id starting "mutant-" are
|
|
687
|
+
# survivors. DISMISSED findings (skips) are not failures.
|
|
688
|
+
from .disposition import Disposition
|
|
689
|
+
survivors = [
|
|
690
|
+
f for f in findings
|
|
691
|
+
if (f.disposition == Disposition.CONFIRMED
|
|
692
|
+
and f.source == "MUTANT"
|
|
693
|
+
and f.id.startswith("mutant-"))
|
|
694
|
+
]
|
|
695
|
+
if survivors:
|
|
696
|
+
print(
|
|
697
|
+
"code-forge: mutation-check: %d survivor(s) found"
|
|
698
|
+
% len(survivors),
|
|
699
|
+
file=sys.stderr,
|
|
700
|
+
)
|
|
701
|
+
for s in survivors:
|
|
702
|
+
print(
|
|
703
|
+
" %s" % s.description,
|
|
704
|
+
file=sys.stderr,
|
|
705
|
+
)
|
|
706
|
+
return EXIT_FAIL
|
|
707
|
+
|
|
708
|
+
print("code-forge: mutation-check: PASS", file=sys.stderr)
|
|
709
|
+
return EXIT_PASS
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _run_e2e_check_cmd(args, cwd: Path) -> int:
|
|
713
|
+
"""Synchronous wrapper for e2e-check subcommand.
|
|
714
|
+
|
|
715
|
+
Reads diff text, calls run_e2e_check(), translates findings to exit code.
|
|
716
|
+
|
|
717
|
+
Exit codes:
|
|
718
|
+
0 PASS (no UNCERTAIN findings or no diff)
|
|
719
|
+
1 FAIL (UNCERTAIN findings present -- P2 equivalent)
|
|
720
|
+
2 CLI_ERROR (diff read error)
|
|
721
|
+
"""
|
|
722
|
+
from .e2e_check import run_e2e_check
|
|
723
|
+
from .disposition import Disposition
|
|
724
|
+
|
|
725
|
+
repo_root = Path(args.repo_root) if args.repo_root else cwd
|
|
726
|
+
|
|
727
|
+
# Resolve diff source.
|
|
728
|
+
if args.diff is not None:
|
|
729
|
+
diff_path = Path(args.diff)
|
|
730
|
+
if not diff_path.exists():
|
|
731
|
+
print(
|
|
732
|
+
"code-forge: e2e-check: diff file not found: %s" % args.diff,
|
|
733
|
+
file=sys.stderr,
|
|
734
|
+
)
|
|
735
|
+
return EXIT_CLI_ERROR
|
|
736
|
+
try:
|
|
737
|
+
diff_text = diff_path.read_text(encoding="utf-8")
|
|
738
|
+
except OSError as exc:
|
|
739
|
+
print(
|
|
740
|
+
"code-forge: e2e-check: cannot read diff: %s" % exc,
|
|
741
|
+
file=sys.stderr,
|
|
742
|
+
)
|
|
743
|
+
return EXIT_CLI_ERROR
|
|
744
|
+
else:
|
|
745
|
+
import subprocess
|
|
746
|
+
try:
|
|
747
|
+
result = subprocess.run(
|
|
748
|
+
["git", "diff", "HEAD"],
|
|
749
|
+
capture_output=True,
|
|
750
|
+
text=True,
|
|
751
|
+
check=False,
|
|
752
|
+
cwd=str(cwd),
|
|
753
|
+
)
|
|
754
|
+
if result.returncode != 0:
|
|
755
|
+
print(
|
|
756
|
+
"code-forge: e2e-check: git diff failed: %s"
|
|
757
|
+
% result.stderr.strip(),
|
|
758
|
+
file=sys.stderr,
|
|
759
|
+
)
|
|
760
|
+
return EXIT_CLI_ERROR
|
|
761
|
+
diff_text = result.stdout
|
|
762
|
+
except FileNotFoundError:
|
|
763
|
+
print(
|
|
764
|
+
"code-forge: e2e-check: git not found",
|
|
765
|
+
file=sys.stderr,
|
|
766
|
+
)
|
|
767
|
+
return EXIT_CLI_ERROR
|
|
768
|
+
|
|
769
|
+
if not diff_text or not diff_text.strip():
|
|
770
|
+
print("code-forge: e2e-check: no diff -- SKIP", file=sys.stderr)
|
|
771
|
+
return EXIT_PASS
|
|
772
|
+
|
|
773
|
+
findings, infra_errors = run_e2e_check(
|
|
774
|
+
diff_text=diff_text,
|
|
775
|
+
repo_root=repo_root,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
for err in infra_errors:
|
|
779
|
+
print("code-forge: e2e-check: %s" % err, file=sys.stderr)
|
|
780
|
+
|
|
781
|
+
# UNCERTAIN findings are the P2-equivalent gate failures.
|
|
782
|
+
uncertain = [
|
|
783
|
+
f for f in findings
|
|
784
|
+
if f.disposition == Disposition.UNCERTAIN
|
|
785
|
+
]
|
|
786
|
+
if uncertain:
|
|
787
|
+
print(
|
|
788
|
+
"code-forge: e2e-check: %d finding(s)" % len(uncertain),
|
|
789
|
+
file=sys.stderr,
|
|
790
|
+
)
|
|
791
|
+
for f in uncertain:
|
|
792
|
+
print(" %s" % f.description, file=sys.stderr)
|
|
793
|
+
return EXIT_FAIL
|
|
794
|
+
|
|
795
|
+
print("code-forge: e2e-check: PASS", file=sys.stderr)
|
|
796
|
+
return EXIT_PASS
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _build_baseline_specs(
|
|
800
|
+
args, cwd: Path, warn=None,
|
|
801
|
+
) -> tuple:
|
|
802
|
+
"""Parse --baseline + --head into BaselineSpec union members."""
|
|
803
|
+
in_git = is_git_repo(cwd)
|
|
804
|
+
if args.baseline is None:
|
|
805
|
+
baseline = (
|
|
806
|
+
GitRefBaseline("HEAD") if in_git else EmptyBaseline()
|
|
807
|
+
)
|
|
808
|
+
elif args.baseline == "empty":
|
|
809
|
+
baseline = EmptyBaseline()
|
|
810
|
+
elif (args.baseline.startswith(".code-forge/snapshots/")
|
|
811
|
+
or (args.baseline.endswith(".json")
|
|
812
|
+
and "snapshots" in args.baseline)):
|
|
813
|
+
baseline = SnapshotBaseline(path=Path(args.baseline))
|
|
814
|
+
else:
|
|
815
|
+
baseline = GitRefBaseline(args.baseline)
|
|
816
|
+
|
|
817
|
+
# R2-M4: warn ANY time --staged is set.
|
|
818
|
+
if args.staged:
|
|
819
|
+
msg = (
|
|
820
|
+
"warning: --staged is deprecated; use --head INDEX "
|
|
821
|
+
"(will be removed in v2.1)"
|
|
822
|
+
)
|
|
823
|
+
if warn is not None:
|
|
824
|
+
warn(msg)
|
|
825
|
+
else:
|
|
826
|
+
print("code-forge: %s" % msg, file=sys.stderr)
|
|
827
|
+
|
|
828
|
+
if args.staged and args.head is None:
|
|
829
|
+
head = GitRefBaseline("INDEX")
|
|
830
|
+
elif args.head is None:
|
|
831
|
+
head = GitRefBaseline("WORKING") if in_git else None
|
|
832
|
+
else:
|
|
833
|
+
head = GitRefBaseline(args.head)
|
|
834
|
+
return baseline, head
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _paths(args, cwd: Path, resolved=None) -> list:
|
|
838
|
+
"""H4: derive paths from explicit args OR git_diff extraction."""
|
|
839
|
+
if args.paths:
|
|
840
|
+
return [Path(p) for p in args.paths]
|
|
841
|
+
if resolved is None:
|
|
842
|
+
return []
|
|
843
|
+
if resolved.mode_hint == "git" and resolved.git_diff:
|
|
844
|
+
from .diff import get_changed_files
|
|
845
|
+
return [Path(p) for p in get_changed_files(
|
|
846
|
+
resolved.git_diff
|
|
847
|
+
)]
|
|
848
|
+
if resolved.mode_hint == "non-git":
|
|
849
|
+
raise CliError(
|
|
850
|
+
"non-git mode requires explicit paths argument(s); "
|
|
851
|
+
"no files would be reviewed otherwise"
|
|
852
|
+
)
|
|
853
|
+
return []
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _run_install_skill(args, cwd: Path) -> int:
|
|
857
|
+
"""Install bundled review skills into an agent skill directory.
|
|
858
|
+
|
|
859
|
+
Target directory conventions (subject to change as agent ecosystems evolve):
|
|
860
|
+
claude -> ~/.claude/skills/
|
|
861
|
+
vscode -> <cwd>/.claude/skills/
|
|
862
|
+
universal -> <cwd>/.agents/skills/
|
|
863
|
+
--dest D -> D/
|
|
864
|
+
|
|
865
|
+
Returns 0 on success, 2 on CLI_ERROR.
|
|
866
|
+
"""
|
|
867
|
+
import shutil
|
|
868
|
+
from importlib.resources import files as _pkg_files
|
|
869
|
+
|
|
870
|
+
quiet = args.quiet
|
|
871
|
+
|
|
872
|
+
def _info(msg: str) -> None:
|
|
873
|
+
if not quiet:
|
|
874
|
+
print("code-forge: install-skill: %s" % msg)
|
|
875
|
+
|
|
876
|
+
def _warn(msg: str) -> None:
|
|
877
|
+
print("code-forge: install-skill: %s" % msg, file=sys.stderr)
|
|
878
|
+
|
|
879
|
+
# Resolve destination directory
|
|
880
|
+
if args.dest is not None:
|
|
881
|
+
dest_root = Path(args.dest)
|
|
882
|
+
elif args.target == "claude":
|
|
883
|
+
dest_root = Path.home() / ".claude" / "skills"
|
|
884
|
+
elif args.target == "vscode":
|
|
885
|
+
dest_root = cwd / ".claude" / "skills"
|
|
886
|
+
elif args.target == "universal":
|
|
887
|
+
dest_root = cwd / ".agents" / "skills"
|
|
888
|
+
else:
|
|
889
|
+
_warn("unknown target: %s" % args.target)
|
|
890
|
+
return EXIT_CLI_ERROR
|
|
891
|
+
|
|
892
|
+
# Locate bundled skills via importlib.resources
|
|
893
|
+
try:
|
|
894
|
+
src_root = _pkg_files("code_forge") / "skills"
|
|
895
|
+
except Exception as exc:
|
|
896
|
+
_warn("cannot locate bundled skills: %s" % exc)
|
|
897
|
+
return EXIT_CLI_ERROR
|
|
898
|
+
|
|
899
|
+
# Build list of skill names to install
|
|
900
|
+
if args.skill is not None:
|
|
901
|
+
# Reject names that contain path separators (path traversal guard)
|
|
902
|
+
if "/" in args.skill or "\\" in args.skill or args.skill in (".", ".."):
|
|
903
|
+
_warn("invalid skill name: %s" % args.skill)
|
|
904
|
+
return EXIT_CLI_ERROR
|
|
905
|
+
skill_src = src_root / args.skill
|
|
906
|
+
# Validate the named skill exists in the bundle
|
|
907
|
+
try:
|
|
908
|
+
# Access __iter__ or check the traversable exists
|
|
909
|
+
skill_files = list(skill_src.iterdir())
|
|
910
|
+
if not skill_files:
|
|
911
|
+
_warn("skill not found in bundle: %s" % args.skill)
|
|
912
|
+
return EXIT_CLI_ERROR
|
|
913
|
+
except (FileNotFoundError, NotADirectoryError, TypeError):
|
|
914
|
+
_warn("skill not found in bundle: %s" % args.skill)
|
|
915
|
+
return EXIT_CLI_ERROR
|
|
916
|
+
skill_names = [args.skill]
|
|
917
|
+
else:
|
|
918
|
+
try:
|
|
919
|
+
skill_names = sorted(
|
|
920
|
+
entry.name for entry in src_root.iterdir()
|
|
921
|
+
if entry.is_dir()
|
|
922
|
+
)
|
|
923
|
+
except Exception as exc:
|
|
924
|
+
_warn("cannot list bundled skills: %s" % exc)
|
|
925
|
+
return EXIT_CLI_ERROR
|
|
926
|
+
if not skill_names:
|
|
927
|
+
_warn("no bundled skills found")
|
|
928
|
+
return EXIT_CLI_ERROR
|
|
929
|
+
|
|
930
|
+
# Create destination root if needed
|
|
931
|
+
try:
|
|
932
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
933
|
+
except OSError as exc:
|
|
934
|
+
_warn("cannot create destination directory %s: %s" % (dest_root, exc))
|
|
935
|
+
return EXIT_CLI_ERROR
|
|
936
|
+
|
|
937
|
+
# Copy each skill
|
|
938
|
+
for name in skill_names:
|
|
939
|
+
skill_src_dir = src_root / name
|
|
940
|
+
skill_dest_dir = dest_root / name
|
|
941
|
+
|
|
942
|
+
if skill_dest_dir.exists() and not args.force:
|
|
943
|
+
_warn(
|
|
944
|
+
"SKIP %s (exists; use --force to overwrite)" % name
|
|
945
|
+
)
|
|
946
|
+
continue
|
|
947
|
+
|
|
948
|
+
# If force and dest exists, remove it first
|
|
949
|
+
if skill_dest_dir.exists() and args.force:
|
|
950
|
+
try:
|
|
951
|
+
shutil.rmtree(str(skill_dest_dir))
|
|
952
|
+
except OSError as exc:
|
|
953
|
+
_warn(
|
|
954
|
+
"cannot remove existing %s: %s" % (skill_dest_dir, exc)
|
|
955
|
+
)
|
|
956
|
+
return EXIT_CLI_ERROR
|
|
957
|
+
|
|
958
|
+
# Copy from importlib.resources traversable to filesystem
|
|
959
|
+
# importlib.resources Traversable does not support shutil.copytree
|
|
960
|
+
# directly; walk the traversable tree manually.
|
|
961
|
+
try:
|
|
962
|
+
_copy_traversable_tree(skill_src_dir, skill_dest_dir)
|
|
963
|
+
except OSError as exc:
|
|
964
|
+
_warn("failed to copy %s: %s" % (name, exc))
|
|
965
|
+
return EXIT_CLI_ERROR
|
|
966
|
+
|
|
967
|
+
_info("INSTALLED %s -> %s" % (name, skill_dest_dir))
|
|
968
|
+
|
|
969
|
+
return EXIT_PASS
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _copy_traversable_tree(src, dest: Path) -> None:
|
|
973
|
+
"""Recursively copy an importlib.resources Traversable tree to dest.
|
|
974
|
+
|
|
975
|
+
dest is created by this function. Caller must ensure it does not exist.
|
|
976
|
+
"""
|
|
977
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
978
|
+
for entry in src.iterdir():
|
|
979
|
+
child_dest = dest / entry.name
|
|
980
|
+
if entry.is_dir():
|
|
981
|
+
_copy_traversable_tree(entry, child_dest)
|
|
982
|
+
else:
|
|
983
|
+
child_dest.write_bytes(entry.read_bytes())
|