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