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.
- package/.shared/review-gate/README.md +21 -6
- package/.shared/review-gate/scripts/engine/__init__.py +2 -1
- package/.shared/review-gate/scripts/engine/auto_fixer.py +445 -0
- package/.shared/review-gate/scripts/review.py +75 -15
- package/.shared/review-gate/scripts/signals/graph_builder.py +61 -49
- package/.shared/review-gate/scripts/signals/layer_classifier.py +2 -46
- package/package.json +1 -1
- package/skills/review-gate/references/review-gate.md +6 -6
|
@@ -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>-<
|
|
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.
|
|
80
|
-
9.
|
|
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
|
|
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
|
-
|
|
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/{
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
404
|
-
|
|
427
|
+
all_findings = scorer.prioritize(findings)
|
|
428
|
+
|
|
429
|
+
findings_for_fix = all_findings
|
|
405
430
|
if args.domain:
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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=
|
|
53
|
+
nodes=nodes,
|
|
56
54
|
cycles=cycles,
|
|
57
55
|
impacted_nodes=impacted,
|
|
58
56
|
)
|
|
59
57
|
|
|
60
|
-
def
|
|
61
|
-
"""Scan
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
"""
|
|
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
|
@@ -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/<
|
|
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
|
|