neo-skill 0.1.28 → 0.1.29

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.
@@ -69,15 +69,15 @@ python3 .shared/review-gate/scripts/review.py --persist path --path src/domain/u
69
69
 
70
70
  ## Workflow
71
71
 
72
- 1. Creates branch: `review-gate/<YYYYMMDD>-<topic>-<ref>`
72
+ 1. Creates branch: `review-gate/<YYYYMMDD-HHMMSS>-<ref>`
73
73
  2. (Optional) Runs existing tests baseline
74
- 3. Extracts PR diff changeset
74
+ 3. Extracts PR diff changeset (only changed files, no full repo scan)
75
75
  4. Builds impacted dependency subgraph
76
- 5. Generates signals (layer/dep/api/pure/complex/etc.)
76
+ 5. Generates signals from diff (layer/dep/api/pure/complex/etc.)
77
77
  6. Router selects & prioritizes checks
78
78
  7. Composer generates findings + report
79
- 8. (Optional) Applies minimal fixes for blockers
80
- 9. (Optional) Reruns tests to ensure green
79
+ 8. **Applies auto-fixes for BLOCKER findings**
80
+ 9. **Commits fixes to the review branch**
81
81
  10. Outputs final report (Markdown + JSON)
82
82
 
83
83
  ## Domain Reference
@@ -228,10 +228,25 @@ python3 .shared/review-gate/scripts/review.py \
228
228
  - Scope: TypeScript/JavaScript imports
229
229
  ```
230
230
 
231
+ ## Auto-Fix Capabilities
232
+
233
+ The skill now automatically fixes BLOCKER findings when possible:
234
+
235
+ - **Layer violations**: Adds TODO comments for manual refactoring
236
+ - **Side effect violations**: Adds TODO comments for isolation
237
+ - **API issues**: Requires manual review
238
+ - **Circular dependencies**: Requires manual refactoring
239
+
240
+ All fixes are:
241
+ - Applied to the review branch
242
+ - Automatically committed with descriptive messages
243
+ - Traceable through git history
244
+
231
245
  ## Notes
232
246
 
233
247
  - **NOT a linter**: Focus is architecture, not style
234
248
  - **Evidence-required**: All findings must have concrete proof
235
249
  - **Minimal changes**: Code modifications are scoped and traceable
236
- - **No new tests**: Only fixes to make existing tests pass
250
+ - **No full repo scan**: Only analyzes files in git diff
251
+ - **Auto-commit**: Fixes are automatically committed to review branch
237
252
  - **Deterministic**: Same input always produces same output
@@ -4,5 +4,6 @@ from .router import Router
4
4
  from .scorer import Scorer
5
5
  from .composer import Composer
6
6
  from .persist import PersistManager
7
+ from .auto_fixer import AutoFixer
7
8
 
8
- __all__ = ["Router", "Scorer", "Composer", "PersistManager"]
9
+ __all__ = ["Router", "Scorer", "Composer", "PersistManager", "AutoFixer"]
@@ -0,0 +1,445 @@
1
+ """Auto-fix module for applying code fixes based on findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import List, Optional, Tuple
10
+
11
+ from .scorer import Finding
12
+
13
+
14
+ @dataclass
15
+ class FixResult:
16
+ """Result of applying a fix."""
17
+ finding_id: str
18
+ success: bool
19
+ files_modified: List[str]
20
+ error: Optional[str] = None
21
+
22
+
23
+ class AutoFixer:
24
+ """Applies automatic fixes for findings."""
25
+
26
+ def __init__(self, repo_path: Path):
27
+ self.repo_path = repo_path
28
+
29
+ def apply_fixes(self, findings: List[Finding]) -> List[FixResult]:
30
+ """Apply fixes for all findings.
31
+
32
+ Note: not every finding is safely auto-fixable. For those, this method will
33
+ still return a FixResult with success=False.
34
+ """
35
+ results = []
36
+
37
+ for finding in findings:
38
+ result = self._apply_fix(finding)
39
+ results.append(result)
40
+
41
+ return results
42
+
43
+ def _apply_fix(self, finding: Finding) -> FixResult:
44
+ """Apply fix for a single finding."""
45
+ try:
46
+ if finding.area == "LAYER":
47
+ return self._fix_layer_violation(finding)
48
+ elif finding.area == "DEP":
49
+ return self._fix_circular_dependency(finding)
50
+ elif finding.area == "PURE":
51
+ return self._fix_side_effect_violation(finding)
52
+ elif finding.area == "API":
53
+ return self._fix_api_issue(finding)
54
+ elif finding.area == "COMPLEX":
55
+ return self._fix_complexity_issue(finding)
56
+ else:
57
+ return FixResult(
58
+ finding_id=finding.id,
59
+ success=False,
60
+ files_modified=[],
61
+ error=f"No structural auto-fix available for area: {finding.area}",
62
+ )
63
+ except Exception as e:
64
+ return FixResult(
65
+ finding_id=finding.id,
66
+ success=False,
67
+ files_modified=[],
68
+ error=str(e),
69
+ )
70
+
71
+ def _is_ts_module(self, file_path: str) -> bool:
72
+ lower = file_path.lower()
73
+ return lower.endswith((".ts", ".tsx"))
74
+
75
+ def _normalize_path(self, path: str) -> str:
76
+ return path.replace("\\", "/")
77
+
78
+ def _resolve_import(self, from_file: str, import_path: str) -> Optional[str]:
79
+ if not import_path.startswith(".") and not import_path.startswith("@/"):
80
+ return None
81
+
82
+ from_full = self.repo_path / from_file
83
+ if not from_full.exists():
84
+ return None
85
+
86
+ if import_path.startswith("@/"):
87
+ base = self.repo_path / "src"
88
+ rel = import_path[2:]
89
+ else:
90
+ base = from_full.parent
91
+ rel = import_path
92
+
93
+ candidates: List[Path] = []
94
+ base_path = (base / rel)
95
+ # Try direct and extension variants
96
+ candidates.append(base_path)
97
+ candidates.append(base_path.with_suffix(".ts"))
98
+ candidates.append(base_path.with_suffix(".tsx"))
99
+ candidates.append(base_path.with_suffix(".js"))
100
+ candidates.append(base_path.with_suffix(".jsx"))
101
+ candidates.append(base_path / "index.ts")
102
+ candidates.append(base_path / "index.tsx")
103
+ candidates.append(base_path / "index.js")
104
+ candidates.append(base_path / "index.jsx")
105
+
106
+ for cand in candidates:
107
+ if cand.exists():
108
+ try:
109
+ rel_path = cand.relative_to(self.repo_path)
110
+ return self._normalize_path(str(rel_path))
111
+ except ValueError:
112
+ continue
113
+
114
+ return None
115
+
116
+ def _convert_import_to_type_only(self, importer_file: str, imported_file: str) -> FixResult:
117
+ importer_file = self._normalize_path(importer_file)
118
+ imported_file = self._normalize_path(imported_file)
119
+
120
+ if not self._is_ts_module(importer_file):
121
+ return FixResult(
122
+ finding_id="",
123
+ success=False,
124
+ files_modified=[],
125
+ error=f"Only TypeScript supports import type: {importer_file}",
126
+ )
127
+
128
+ full_path = self.repo_path / importer_file
129
+ if not full_path.exists():
130
+ return FixResult(
131
+ finding_id="",
132
+ success=False,
133
+ files_modified=[],
134
+ error=f"File not found: {importer_file}",
135
+ )
136
+
137
+ content = full_path.read_text(encoding="utf-8")
138
+ lines = content.split("\n")
139
+
140
+ import_re = re.compile(r"^(\s*)import\s+(?!type\b)(.+?)\s+from\s+(['\"])([^'\"]+)\3\s*;?\s*$")
141
+
142
+ modified = False
143
+ for idx, line in enumerate(lines):
144
+ m = import_re.match(line)
145
+ if not m:
146
+ continue
147
+
148
+ imp_path = m.group(4)
149
+ resolved = self._resolve_import(importer_file, imp_path)
150
+ if resolved != imported_file:
151
+ continue
152
+
153
+ indent = m.group(1)
154
+ clause = m.group(2)
155
+ quote = m.group(3)
156
+ new_line = f"{indent}import type {clause} from {quote}{imp_path}{quote}"
157
+ lines[idx] = new_line
158
+ modified = True
159
+ break
160
+
161
+ if not modified:
162
+ return FixResult(
163
+ finding_id="",
164
+ success=False,
165
+ files_modified=[],
166
+ error="Could not locate import statement to convert",
167
+ )
168
+
169
+ full_path.write_text("\n".join(lines), encoding="utf-8")
170
+ return FixResult(
171
+ finding_id="",
172
+ success=True,
173
+ files_modified=[importer_file],
174
+ )
175
+
176
+ def _line_comment_prefix(self, file_path: str) -> Optional[str]:
177
+ """Return a safe line comment prefix for the given file, or None if unsupported."""
178
+ lower = file_path.lower()
179
+ if lower.endswith((".ts", ".tsx", ".js", ".jsx", ".c", ".cc", ".cpp", ".h", ".hpp", ".java", ".cs", ".go", ".rs")):
180
+ return "//"
181
+ if lower.endswith((".py", ".sh", ".bash", ".yml", ".yaml", ".toml", ".ini")):
182
+ return "#"
183
+ # JSON does not allow comments; markdown/docs are intentionally skipped.
184
+ return None
185
+
186
+ def _safe_insert_leading_comment(self, lines: List[str], comment_line: str) -> List[str]:
187
+ """Insert a comment near the top without breaking shebang/encoding lines."""
188
+ if not lines:
189
+ return [comment_line]
190
+
191
+ idx = 0
192
+
193
+ # Preserve shebang as first line for scripts.
194
+ if lines[0].startswith("#!"):
195
+ idx = 1
196
+ # Preserve Python encoding declaration if present as second line.
197
+ if len(lines) > 1 and "coding" in lines[1] and lines[1].lstrip().startswith("#"):
198
+ idx = 2
199
+
200
+ lines.insert(idx, comment_line)
201
+ return lines
202
+
203
+ def _fix_generic_marker(self, finding: Finding) -> FixResult:
204
+ """Fallback: add a minimal marker to evidence files (when safe) so the issue is visible in code."""
205
+ files = finding.evidence.get("files", [])
206
+ if not files:
207
+ return FixResult(
208
+ finding_id=finding.id,
209
+ success=False,
210
+ files_modified=[],
211
+ error=f"No evidence files available for area: {finding.area}",
212
+ )
213
+
214
+ modified_files: List[str] = []
215
+ marker = f"REVIEW-GATE: {finding.id} {finding.title}"
216
+
217
+ for file_info in files:
218
+ file_path = file_info.get("path")
219
+ if not file_path:
220
+ continue
221
+ prefix = self._line_comment_prefix(file_path)
222
+ if prefix is None:
223
+ continue
224
+
225
+ full_path = self.repo_path / file_path
226
+ if not full_path.exists():
227
+ continue
228
+
229
+ try:
230
+ content = full_path.read_text(encoding="utf-8")
231
+ except Exception:
232
+ continue
233
+
234
+ lines = content.split("\n")
235
+ # Avoid duplicating marker if already present in the header.
236
+ header = "\n".join(lines[:5])
237
+ if marker in header:
238
+ continue
239
+
240
+ insertion = f"{prefix} {marker}"
241
+ lines = self._safe_insert_leading_comment(lines, insertion)
242
+ full_path.write_text("\n".join(lines), encoding="utf-8")
243
+ modified_files.append(file_path)
244
+
245
+ if modified_files:
246
+ return FixResult(
247
+ finding_id=finding.id,
248
+ success=True,
249
+ files_modified=modified_files,
250
+ )
251
+
252
+ return FixResult(
253
+ finding_id=finding.id,
254
+ success=False,
255
+ files_modified=[],
256
+ error="No safe file types found to apply marker",
257
+ )
258
+
259
+ def _fix_layer_violation(self, finding: Finding) -> FixResult:
260
+ """Fix layer dependency violations."""
261
+ files = finding.evidence.get("files", [])
262
+ if not files:
263
+ return FixResult(
264
+ finding_id=finding.id,
265
+ success=False,
266
+ files_modified=[],
267
+ error="No files in evidence",
268
+ )
269
+
270
+ file_path = files[0]["path"]
271
+
272
+ dep_trace = finding.evidence.get("dependency_trace", {})
273
+ chain = dep_trace.get("chain") or []
274
+ if len(chain) < 2:
275
+ return FixResult(
276
+ finding_id=finding.id,
277
+ success=False,
278
+ files_modified=[],
279
+ error="No dependency chain available",
280
+ )
281
+
282
+ imported_file = chain[1]
283
+ conv = self._convert_import_to_type_only(file_path, imported_file)
284
+ if conv.success:
285
+ return FixResult(
286
+ finding_id=finding.id,
287
+ success=True,
288
+ files_modified=conv.files_modified,
289
+ )
290
+
291
+ return FixResult(
292
+ finding_id=finding.id,
293
+ success=False,
294
+ files_modified=[],
295
+ error=conv.error,
296
+ )
297
+
298
+ def _fix_circular_dependency(self, finding: Finding) -> FixResult:
299
+ """Try to break a cycle by converting one edge to a type-only import."""
300
+ files = finding.evidence.get("files", [])
301
+ cycle = [f.get("path") for f in files if f.get("path")]
302
+ cycle = [self._normalize_path(p) for p in cycle]
303
+ if len(cycle) < 2:
304
+ return FixResult(
305
+ finding_id=finding.id,
306
+ success=False,
307
+ files_modified=[],
308
+ error="No cycle file list available",
309
+ )
310
+
311
+ # Attempt to convert each edge a -> b within the cycle
312
+ for i in range(len(cycle) - 1):
313
+ a = cycle[i]
314
+ b = cycle[i + 1]
315
+ conv = self._convert_import_to_type_only(a, b)
316
+ if conv.success:
317
+ return FixResult(
318
+ finding_id=finding.id,
319
+ success=True,
320
+ files_modified=conv.files_modified,
321
+ )
322
+
323
+ return FixResult(
324
+ finding_id=finding.id,
325
+ success=False,
326
+ files_modified=[],
327
+ error="Could not break cycle with type-only import conversion",
328
+ )
329
+
330
+ def _fix_side_effect_violation(self, finding: Finding) -> FixResult:
331
+ """Fix side effect violations."""
332
+ files = finding.evidence.get("files", [])
333
+ if not files:
334
+ return FixResult(
335
+ finding_id=finding.id,
336
+ success=False,
337
+ files_modified=[],
338
+ error="No files in evidence",
339
+ )
340
+
341
+ file_path = files[0]["path"]
342
+ full_path = self.repo_path / file_path
343
+
344
+ if not full_path.exists():
345
+ return FixResult(
346
+ finding_id=finding.id,
347
+ success=False,
348
+ files_modified=[],
349
+ error=f"File not found: {file_path}",
350
+ )
351
+
352
+ prefix = self._line_comment_prefix(file_path)
353
+ if prefix is None:
354
+ return FixResult(
355
+ finding_id=finding.id,
356
+ success=False,
357
+ files_modified=[],
358
+ error=f"Unsupported file type for auto-fix: {file_path}",
359
+ )
360
+
361
+ content = full_path.read_text(encoding="utf-8")
362
+ lines = content.split("\n")
363
+
364
+ # Add TODO at the top of the file
365
+ todo_line = f"{prefix} TODO: Isolate side effects - {finding.title}"
366
+ header = "\n".join(lines[:5])
367
+ if todo_line not in header:
368
+ lines = self._safe_insert_leading_comment(lines, todo_line)
369
+ full_path.write_text("\n".join(lines), encoding="utf-8")
370
+ return FixResult(
371
+ finding_id=finding.id,
372
+ success=True,
373
+ files_modified=[file_path],
374
+ )
375
+
376
+ return FixResult(
377
+ finding_id=finding.id,
378
+ success=False,
379
+ files_modified=[],
380
+ error="TODO already exists",
381
+ )
382
+
383
+ def _fix_api_issue(self, finding: Finding) -> FixResult:
384
+ """Fix API design issues."""
385
+ return FixResult(
386
+ finding_id=finding.id,
387
+ success=False,
388
+ files_modified=[],
389
+ error="No structural auto-fix available for API issues",
390
+ )
391
+
392
+ def _fix_complexity_issue(self, finding: Finding) -> FixResult:
393
+ """Complexity refactors are not safely automatable (yet)."""
394
+ return FixResult(
395
+ finding_id=finding.id,
396
+ success=False,
397
+ files_modified=[],
398
+ error="No structural auto-fix available for complexity issues",
399
+ )
400
+
401
+ def commit_fixes(self, results: List[FixResult], branch_name: str) -> bool:
402
+ """Commit all fixes to git."""
403
+ modified_files: List[str] = []
404
+ successful_results: List[FixResult] = []
405
+ for result in results:
406
+ if result.success:
407
+ successful_results.append(result)
408
+ modified_files.extend(result.files_modified)
409
+
410
+ modified_files = sorted(set(modified_files))
411
+
412
+ if not modified_files:
413
+ return False
414
+
415
+ try:
416
+ # Stage modified files
417
+ for file_path in modified_files:
418
+ subprocess.run(
419
+ ["git", "add", file_path],
420
+ cwd=self.repo_path,
421
+ check=True,
422
+ )
423
+
424
+ subject = "fix(review-gate): apply auto-fixes"
425
+ body_lines = [
426
+ f"Branch: {branch_name}",
427
+ "",
428
+ f"Fixed {len(successful_results)} finding(s):",
429
+ ]
430
+ for result in successful_results:
431
+ body_lines.append(f"- {result.finding_id}")
432
+ body = "\n".join(body_lines)
433
+
434
+ subprocess.run(
435
+ ["git", "commit", "-m", subject, "-m", body],
436
+ cwd=self.repo_path,
437
+ check=True,
438
+ )
439
+
440
+ print(f"✓ Committed {len(modified_files)} file(s)")
441
+ return True
442
+
443
+ except subprocess.CalledProcessError as e:
444
+ print(f"⚠ Failed to commit fixes: {e}")
445
+ return False
@@ -39,11 +39,12 @@ from signals import (
39
39
  from engine import Router, Scorer, Composer, PersistManager
40
40
  from engine.scorer import Finding
41
41
  from engine.router import Signal
42
+ from engine.auto_fixer import AutoFixer
42
43
 
43
44
 
44
45
  def create_review_branch(base_branch: str, repo_path: Path) -> str:
45
46
  """Create review-gate branch."""
46
- date_str = datetime.now().strftime("%Y%m%d")
47
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
47
48
 
48
49
  try:
49
50
  result = subprocess.run(
@@ -57,19 +58,32 @@ def create_review_branch(base_branch: str, repo_path: Path) -> str:
57
58
  except subprocess.CalledProcessError:
58
59
  ref = "unknown"
59
60
 
60
- branch_name = f"review-gate/{date_str}-review-{ref}"
61
+ branch_name = f"review-gate/{timestamp}-{ref}"
61
62
 
62
63
  try:
63
64
  subprocess.run(
64
65
  ["git", "checkout", "-b", branch_name],
65
66
  cwd=repo_path,
66
67
  check=True,
68
+ capture_output=True,
67
69
  )
68
70
  print(f"✓ Created branch: {branch_name}")
69
- except subprocess.CalledProcessError:
70
- print(f"⚠ Could not create branch, continuing on current branch", file=sys.stderr)
71
-
72
- return branch_name
71
+ return branch_name
72
+ except subprocess.CalledProcessError as e:
73
+ print(f"⚠ Could not create branch: {e}", file=sys.stderr)
74
+ print("⚠ Continuing on current branch", file=sys.stderr)
75
+ # Get current branch name
76
+ try:
77
+ result = subprocess.run(
78
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
79
+ cwd=repo_path,
80
+ capture_output=True,
81
+ text=True,
82
+ check=True,
83
+ )
84
+ return result.stdout.strip()
85
+ except subprocess.CalledProcessError:
86
+ return "unknown"
73
87
 
74
88
 
75
89
  def collect_signals(
@@ -234,6 +248,14 @@ def generate_findings(
234
248
 
235
249
  elif signal.type == "circular_dependency":
236
250
  finding_counter["DEP"] += 1
251
+ cycle_files = []
252
+ if getattr(dep_graph, "cycles", None):
253
+ try:
254
+ first_cycle = dep_graph.cycles[0]
255
+ if isinstance(first_cycle, list):
256
+ cycle_files = [{"path": p, "diff_hunks": []} for p in first_cycle]
257
+ except Exception:
258
+ cycle_files = []
237
259
  findings.append(Finding(
238
260
  id=f"RG-DEP-{finding_counter['DEP']:03d}",
239
261
  severity="BLOCKER",
@@ -243,7 +265,7 @@ def generate_findings(
243
265
  status="OPEN",
244
266
  confidence="HIGH",
245
267
  evidence={
246
- "files": [],
268
+ "files": cycle_files,
247
269
  "dependency_trace": {
248
270
  "cycles": signal.value["cycles"],
249
271
  },
@@ -383,6 +405,8 @@ def main() -> None:
383
405
  print("║ REVIEW GATE - Architecture Review ║")
384
406
  print("╚════════════════════════════════════════════════════════════════╝")
385
407
 
408
+ print(f"\n📋 Base branch: {args.base_branch}")
409
+ print("\n🌿 Creating review branch...")
386
410
  branch_name = create_review_branch(args.base_branch, repo_path)
387
411
 
388
412
  signals, changeset, dep_graph, layer_info, api_surface, test_result = collect_signals(
@@ -400,12 +424,13 @@ def main() -> None:
400
424
  findings = generate_findings(signals, changeset, dep_graph, layer_info, api_surface)
401
425
 
402
426
  scorer = Scorer()
403
- findings = scorer.prioritize(findings)
404
-
427
+ all_findings = scorer.prioritize(findings)
428
+
429
+ findings_for_fix = all_findings
405
430
  if args.domain:
406
- findings = [f for f in findings if f.area.lower() == args.domain.lower()]
407
-
408
- findings = findings[:args.max_results]
431
+ findings_for_fix = [f for f in findings_for_fix if f.area.lower() == args.domain.lower()]
432
+
433
+ findings_for_report = findings_for_fix[:args.max_results]
409
434
 
410
435
  composer = Composer(DATA_DIR / "templates")
411
436
 
@@ -416,7 +441,7 @@ def main() -> None:
416
441
  }
417
442
 
418
443
  report = composer.compose_report(
419
- findings,
444
+ findings_for_report,
420
445
  changeset_summary,
421
446
  branch_name,
422
447
  args.base_branch,
@@ -433,7 +458,7 @@ def main() -> None:
433
458
  report_file = composer.save_report(report, output_dir, format=args.format)
434
459
  print(f"\n✓ Report saved to: {report_file}")
435
460
 
436
- json_report = composer.compose_report(findings, changeset_summary, branch_name, args.base_branch, format="json")
461
+ json_report = composer.compose_report(findings_for_report, changeset_summary, branch_name, args.base_branch, format="json")
437
462
  json_file = composer.save_report(json_report, output_dir, format="json")
438
463
  print(f"✓ JSON report saved to: {json_file}")
439
464
 
@@ -441,7 +466,42 @@ def main() -> None:
441
466
  persist_mgr = PersistManager(REVIEW_SYSTEM_DIR)
442
467
  print(f"\n📝 Persisting {args.persist} overrides...")
443
468
 
444
- blockers = [f for f in findings if f.severity == "BLOCKER"]
469
+ blockers = [f for f in findings_for_fix if f.severity == "BLOCKER"]
470
+
471
+ print(f"\n🔧 Applying auto-fixes for {len(findings_for_fix)} finding(s)...")
472
+ auto_fixer = AutoFixer(repo_path)
473
+ fix_results = auto_fixer.apply_fixes(findings_for_fix)
474
+
475
+ successful_fixes = [r for r in fix_results if r.success]
476
+ failed_fixes = [r for r in fix_results if not r.success]
477
+
478
+ if successful_fixes:
479
+ print(f" ✓ Applied {len(successful_fixes)} fix(es)")
480
+ for result in successful_fixes[:20]:
481
+ files_str = ", ".join(result.files_modified)
482
+ print(f" - {result.finding_id}: {files_str}")
483
+ if len(successful_fixes) > 20:
484
+ print(f" ... and {len(successful_fixes) - 20} more")
485
+
486
+ print("\n💾 Committing fixes...")
487
+ if auto_fixer.commit_fixes(fix_results, branch_name):
488
+ print(f" ✓ Fixes committed to branch: {branch_name}")
489
+ else:
490
+ print(" ⚠ No changes to commit")
491
+ else:
492
+ print(" ℹ No auto-fixable issues found")
493
+
494
+ if failed_fixes:
495
+ print(f"\n ⚠ {len(failed_fixes)} issue(s) were not auto-fixable")
496
+ for result in failed_fixes[:10]:
497
+ print(f" - {result.finding_id}: {result.error}")
498
+ if len(failed_fixes) > 10:
499
+ print(f" ... and {len(failed_fixes) - 10} more")
500
+
501
+ print(f"\n📌 Review branch: {branch_name}")
502
+ print(" Run 'git diff' to see applied fixes")
503
+ print(f" Merge with: git checkout {args.base_branch} && git merge {branch_name}")
504
+
445
505
  if blockers:
446
506
  print(f"\n⚠️ {len(blockers)} BLOCKER(S) found - review required before merge")
447
507
  sys.exit(1)
@@ -37,50 +37,64 @@ class GraphBuilder:
37
37
 
38
38
  def build_impacted_subgraph(self, changed_files: List[str]) -> DependencyGraph:
39
39
  """Build subgraph of modules impacted by changed files."""
40
- all_nodes = self._scan_all_modules()
41
-
40
+ nodes = self._scan_module_closure(changed_files)
41
+
42
42
  # Mark changed files
43
43
  for file_path in changed_files:
44
44
  normalized = self._normalize_path(file_path)
45
- if normalized in all_nodes:
46
- all_nodes[normalized].is_changed = True
47
-
48
- # Find impacted nodes (transitive closure)
49
- impacted = self._find_impacted_nodes(all_nodes, changed_files)
50
-
51
- # Detect cycles
52
- cycles = self._detect_cycles(all_nodes)
53
-
45
+ if normalized in nodes:
46
+ nodes[normalized].is_changed = True
47
+
48
+ # Detect cycles within the scanned subgraph
49
+ cycles = self._detect_cycles(nodes)
50
+
51
+ impacted = set(nodes.keys())
54
52
  return DependencyGraph(
55
- nodes=all_nodes,
53
+ nodes=nodes,
56
54
  cycles=cycles,
57
55
  impacted_nodes=impacted,
58
56
  )
59
57
 
60
- def _scan_all_modules(self) -> Dict[str, DependencyNode]:
61
- """Scan all TypeScript/JavaScript modules."""
62
- nodes = {}
63
-
64
- if not self.src_path.exists():
65
- return nodes
66
-
67
- for ext in ["*.ts", "*.tsx", "*.js", "*.jsx"]:
68
- for file_path in self.src_path.rglob(ext):
69
- if "node_modules" in file_path.parts or ".test." in file_path.name:
70
- continue
71
-
72
- normalized = self._normalize_path(str(file_path.relative_to(self.repo_path)))
73
- imports = self._extract_imports(file_path)
74
-
75
- node = DependencyNode(path=normalized, imports=imports)
76
- nodes[normalized] = node
77
-
78
- # Build reverse edges
58
+ def _scan_module_closure(self, entry_files: List[str]) -> Dict[str, DependencyNode]:
59
+ """Scan only the changed files and their transitive local imports.
60
+
61
+ This intentionally avoids scanning the whole repository.
62
+ """
63
+ nodes: Dict[str, DependencyNode] = {}
64
+ queue: List[str] = []
65
+ visited: Set[str] = set()
66
+
67
+ for file_path in entry_files:
68
+ normalized = self._normalize_path(file_path)
69
+ queue.append(normalized)
70
+
71
+ while queue:
72
+ current = queue.pop(0)
73
+ if current in visited:
74
+ continue
75
+ visited.add(current)
76
+
77
+ full_path = self.repo_path / current
78
+ if not full_path.exists():
79
+ continue
80
+ if "node_modules" in full_path.parts or ".test." in full_path.name:
81
+ continue
82
+
83
+ imports = self._extract_imports(full_path)
84
+ node = nodes.get(current) or DependencyNode(path=current)
85
+ node.imports = imports
86
+ nodes[current] = node
87
+
88
+ for imp in imports:
89
+ if imp not in visited:
90
+ queue.append(imp)
91
+
92
+ # Build reverse edges for scanned nodes only
79
93
  for node in nodes.values():
80
94
  for imp in node.imports:
81
95
  if imp in nodes:
82
96
  nodes[imp].imported_by.append(node.path)
83
-
97
+
84
98
  return nodes
85
99
 
86
100
  def _extract_imports(self, file_path: Path) -> List[str]:
@@ -94,6 +108,19 @@ class GraphBuilder:
94
108
 
95
109
  # Match ES6 imports: import ... from "..."
96
110
  for match in re.finditer(r'import\s+.*?\s+from\s+["\']([^"\']+)["\']', content):
111
+ stmt = match.group(0)
112
+
113
+ # Ignore type-only imports (TypeScript): they don't create runtime deps.
114
+ if re.match(r"^\s*import\s+type\b", stmt):
115
+ continue
116
+
117
+ # Ignore `import { type A, type B } from ...` when *all* specifiers are type-only.
118
+ if "{" in stmt and "}" in stmt:
119
+ inner = stmt.split("{", 1)[1].split("}", 1)[0]
120
+ parts = [p.strip() for p in inner.split(",") if p.strip()]
121
+ if parts and all(p.startswith("type ") or p.startswith("type\t") for p in parts):
122
+ continue
123
+
97
124
  import_path = match.group(1)
98
125
  resolved = self._resolve_import(file_path, import_path)
99
126
  if resolved:
@@ -143,27 +170,12 @@ class GraphBuilder:
143
170
  return path.replace("\\", "/")
144
171
 
145
172
  def _find_impacted_nodes(self, nodes: Dict[str, DependencyNode], changed_files: List[str]) -> Set[str]:
146
- """Find all nodes impacted by changed files (BFS)."""
147
- impacted = set()
148
- queue = []
149
-
173
+ """Deprecated: kept for backward compatibility."""
174
+ impacted: Set[str] = set()
150
175
  for file_path in changed_files:
151
176
  normalized = self._normalize_path(file_path)
152
177
  if normalized in nodes:
153
- queue.append(normalized)
154
178
  impacted.add(normalized)
155
-
156
- while queue:
157
- current = queue.pop(0)
158
- node = nodes.get(current)
159
- if not node:
160
- continue
161
-
162
- for dependent in node.imported_by:
163
- if dependent not in impacted:
164
- impacted.add(dependent)
165
- queue.append(dependent)
166
-
167
179
  return impacted
168
180
 
169
181
  def _detect_cycles(self, nodes: Dict[str, DependencyNode]) -> List[List[str]]:
@@ -83,7 +83,7 @@ class LayerClassifier:
83
83
  self.repo_path = repo_path
84
84
 
85
85
  def classify(self, file_path: str) -> LayerInfo:
86
- """Classify a file into a layer."""
86
+ """Classify a file into a layer based on path only (no content scanning)."""
87
87
  normalized = file_path.replace("\\", "/").lower()
88
88
 
89
89
  # Check explicit layer patterns
@@ -96,61 +96,17 @@ class LayerClassifier:
96
96
  reason=f"Path contains '{pattern}'",
97
97
  )
98
98
 
99
- # Heuristic: check file content for clues
100
- content_layer = self._classify_by_content(file_path)
101
- if content_layer:
102
- return content_layer
103
-
104
99
  # Default to shared if uncertain
105
100
  return LayerInfo(
106
101
  layer="shared",
107
102
  confidence="LOW",
108
- reason="No clear layer indicators",
103
+ reason="No clear layer indicators in path",
109
104
  )
110
105
 
111
106
  def classify_batch(self, file_paths: List[str]) -> Dict[str, LayerInfo]:
112
107
  """Classify multiple files."""
113
108
  return {path: self.classify(path) for path in file_paths}
114
109
 
115
- def _classify_by_content(self, file_path: str) -> Optional[LayerInfo]:
116
- """Classify by analyzing file content."""
117
- full_path = self.repo_path / file_path
118
- if not full_path.exists():
119
- return None
120
-
121
- try:
122
- content = full_path.read_text(encoding="utf-8")
123
- except Exception:
124
- return None
125
-
126
- # React component indicators
127
- if any(pattern in content for pattern in ["export default function", "export const", "React.FC", "JSX.Element"]):
128
- if "useState" in content or "useEffect" in content or "onClick" in content:
129
- return LayerInfo(
130
- layer="presentation",
131
- confidence="MEDIUM",
132
- reason="Contains React component patterns",
133
- )
134
-
135
- # Domain model indicators
136
- if any(pattern in content for pattern in ["class ", "interface ", "type "]):
137
- if not any(pattern in content for pattern in ["fetch(", "axios", "useState", "useEffect"]):
138
- return LayerInfo(
139
- layer="domain",
140
- confidence="MEDIUM",
141
- reason="Contains type definitions without side effects",
142
- )
143
-
144
- # Infrastructure indicators
145
- if any(pattern in content for pattern in ["fetch(", "axios", "prisma", "db.", "api."]):
146
- return LayerInfo(
147
- layer="infra",
148
- confidence="MEDIUM",
149
- reason="Contains external I/O operations",
150
- )
151
-
152
- return None
153
-
154
110
  def get_layer_hierarchy(self) -> Dict[str, int]:
155
111
  """Get layer hierarchy (lower number = lower in stack)."""
156
112
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neo-skill",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "A multi-assistant skill generator (Claude/Windsurf/Cursor/GitHub Skills) driven by a canonical SkillSpec.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -104,12 +104,12 @@ python .shared/review-gate/scripts/review.py --persist path --domain layer
104
104
 
105
105
  ### 工作流
106
106
 
107
- 1. **创建审查分支** → `review-gate/<timestamp>`
107
+ 1. **创建审查分支** → `review-gate/<YYYYMMDD-HHMMSS>-<ref>`
108
108
  2. **可选:运行测试** → 确保基线通过
109
- 3. **收集信号**:
110
- - Diff 变更集
109
+ 3. **收集信号**(仅从 git diff):
110
+ - Diff 变更集(仅变更文件,不扫描整个仓库)
111
111
  - 依赖子图
112
- - 层级分类
112
+ - 层级分类(基于路径,不读取文件内容)
113
113
  - API 表面变更
114
114
  - 副作用扫描
115
115
  - 复杂度扫描
@@ -120,8 +120,8 @@ python .shared/review-gate/scripts/review.py --persist path --domain layer
120
120
  5. **生成报告**:
121
121
  - Markdown 报告(按领域分组)
122
122
  - JSON 结构化输出
123
- 6. **可选:最小化修复**自动应用简单修复
124
- 7. **可选:重新测试**验证修复未破坏功能
123
+ 6. **自动修复 BLOCKER** 应用可自动化的修复
124
+ 7. **提交修复**自动提交到审查分支
125
125
 
126
126
  详细工作流见:[`.shared/review-gate/README.md#workflow`](../../../.shared/review-gate/README.md#workflow)
127
127