codeforge-dev 1.8.0 → 1.9.0
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/.devcontainer/CHANGELOG.md +51 -0
- package/.devcontainer/CLAUDE.md +1 -1
- package/.devcontainer/config/defaults/rules/spec-workflow.md +67 -0
- package/.devcontainer/config/defaults/rules/workspace-scope.md +7 -0
- package/.devcontainer/config/defaults/settings.json +63 -66
- package/.devcontainer/config/file-manifest.json +30 -18
- package/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json +104 -97
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/.claude-plugin/plugin.json +7 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md +158 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/hooks/hooks.json +39 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/collect-edited-files.py +47 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/format-on-stop.py +297 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/lint-file.py +536 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/syntax-validator.py +146 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/architect.md +77 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/debug-logs.md +18 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/dependency-analyst.md +18 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/doc-writer.md +86 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/explorer.md +18 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/generalist.md +142 -8
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/git-archaeologist.md +18 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/migrator.md +108 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/perf-profiler.md +24 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/refactorer.md +97 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/researcher.md +33 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/security-auditor.md +24 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/spec-writer.md +29 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/test-writer.md +96 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/hooks/hooks.json +100 -95
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/spec-reminder.py +121 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-check/SKILL.md +86 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-init/SKILL.md +97 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-init/references/backlog-template.md +7 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-init/references/roadmap-template.md +13 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-new/SKILL.md +101 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-new/references/template.md +110 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/spec-update/SKILL.md +124 -0
- package/.devcontainer/scripts/setup-config.sh +86 -83
- package/package.json +42 -42
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Batch linter — runs as a Stop hook.
|
|
4
|
+
|
|
5
|
+
Reads file paths collected by collect-edited-files.py during the
|
|
6
|
+
conversation turn, deduplicates them, and lints each based on
|
|
7
|
+
extension:
|
|
8
|
+
.py / .pyi → Pyright (type checking) + Ruff check (style/correctness)
|
|
9
|
+
.js/.jsx/.ts/… → Biome lint
|
|
10
|
+
.css/.graphql/… → Biome lint
|
|
11
|
+
.sh/.bash/.zsh/… → ShellCheck
|
|
12
|
+
.go → go vet
|
|
13
|
+
Dockerfile → hadolint
|
|
14
|
+
.rs → clippy (conditional)
|
|
15
|
+
|
|
16
|
+
Outputs JSON with additionalContext containing lint warnings.
|
|
17
|
+
Always cleans up the temp file. Always exits 0.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# ── Extension sets ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
PYTHON_EXTS = {".py", ".pyi"}
|
|
29
|
+
BIOME_EXTS = {
|
|
30
|
+
".js",
|
|
31
|
+
".jsx",
|
|
32
|
+
".ts",
|
|
33
|
+
".tsx",
|
|
34
|
+
".mjs",
|
|
35
|
+
".cjs",
|
|
36
|
+
".mts",
|
|
37
|
+
".cts",
|
|
38
|
+
".css",
|
|
39
|
+
".graphql",
|
|
40
|
+
".gql",
|
|
41
|
+
}
|
|
42
|
+
SHELL_EXTS = {".sh", ".bash", ".zsh", ".mksh", ".bats"}
|
|
43
|
+
GO_EXTS = {".go"}
|
|
44
|
+
RUST_EXTS = {".rs"}
|
|
45
|
+
|
|
46
|
+
SUBPROCESS_TIMEOUT = 10
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Tool resolution ─────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _which(name: str) -> str | None:
|
|
53
|
+
"""Check if a tool is available in PATH."""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(["which", name], capture_output=True, text=True)
|
|
56
|
+
if result.returncode == 0:
|
|
57
|
+
return result.stdout.strip()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_tool_upward(file_path: str, tool_name: str) -> str | None:
|
|
64
|
+
"""Walk up from file directory looking for node_modules/.bin/<tool>."""
|
|
65
|
+
current = Path(file_path).parent
|
|
66
|
+
for _ in range(20):
|
|
67
|
+
candidate = current / "node_modules" / ".bin" / tool_name
|
|
68
|
+
if candidate.is_file():
|
|
69
|
+
return str(candidate)
|
|
70
|
+
parent = current.parent
|
|
71
|
+
if parent == current:
|
|
72
|
+
break
|
|
73
|
+
current = parent
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _find_biome(file_path: str) -> str | None:
|
|
78
|
+
"""Find biome binary: project-local first, then global."""
|
|
79
|
+
local = _find_tool_upward(file_path, "biome")
|
|
80
|
+
if local:
|
|
81
|
+
return local
|
|
82
|
+
return _which("biome")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Diagnostic formatting ──────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _format_issues(filename: str, diagnostics: list[dict]) -> str:
|
|
89
|
+
"""Format a list of {severity, line, message} dicts into display text."""
|
|
90
|
+
if not diagnostics:
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
issues = []
|
|
94
|
+
for diag in diagnostics[:5]:
|
|
95
|
+
severity = diag.get("severity", "info")
|
|
96
|
+
message = diag.get("message", "")
|
|
97
|
+
line = diag.get("line", 0)
|
|
98
|
+
|
|
99
|
+
if severity == "error":
|
|
100
|
+
icon = "\u2717"
|
|
101
|
+
elif severity == "warning":
|
|
102
|
+
icon = "!"
|
|
103
|
+
else:
|
|
104
|
+
icon = "\u2022"
|
|
105
|
+
|
|
106
|
+
issues.append(f" {icon} Line {line}: {message}")
|
|
107
|
+
|
|
108
|
+
total = len(diagnostics)
|
|
109
|
+
shown = min(5, total)
|
|
110
|
+
header = f" {filename}: {total} issue(s)"
|
|
111
|
+
if total > shown:
|
|
112
|
+
header += f" (showing first {shown})"
|
|
113
|
+
|
|
114
|
+
return header + "\n" + "\n".join(issues)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Linters ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def lint_python_pyright(file_path: str) -> str:
|
|
121
|
+
"""Run Pyright type checker on a Python file."""
|
|
122
|
+
pyright = _which("pyright")
|
|
123
|
+
if not pyright:
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
result = subprocess.run(
|
|
128
|
+
[pyright, "--outputjson", file_path],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
output = json.loads(result.stdout)
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
diagnostics = output.get("generalDiagnostics", [])
|
|
140
|
+
if not diagnostics:
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
parsed = [
|
|
144
|
+
{
|
|
145
|
+
"severity": d.get("severity", "info"),
|
|
146
|
+
"line": d.get("range", {}).get("start", {}).get("line", 0) + 1,
|
|
147
|
+
"message": d.get("message", ""),
|
|
148
|
+
}
|
|
149
|
+
for d in diagnostics
|
|
150
|
+
]
|
|
151
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
152
|
+
|
|
153
|
+
except subprocess.TimeoutExpired:
|
|
154
|
+
return f" {Path(file_path).name}: pyright timed out"
|
|
155
|
+
except Exception:
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def lint_python_ruff(file_path: str) -> str:
|
|
160
|
+
"""Run Ruff linter on a Python file."""
|
|
161
|
+
ruff = _which("ruff")
|
|
162
|
+
if not ruff:
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
[ruff, "check", "--output-format=json", "--no-fix", file_path],
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
issues = json.loads(result.stdout)
|
|
175
|
+
except json.JSONDecodeError:
|
|
176
|
+
return ""
|
|
177
|
+
|
|
178
|
+
if not issues:
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
parsed = [
|
|
182
|
+
{
|
|
183
|
+
"severity": "warning",
|
|
184
|
+
"line": issue.get("location", {}).get("row", 0),
|
|
185
|
+
"message": f"[{issue.get('code', '?')}] {issue.get('message', '')}",
|
|
186
|
+
}
|
|
187
|
+
for issue in issues
|
|
188
|
+
]
|
|
189
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
190
|
+
|
|
191
|
+
except subprocess.TimeoutExpired:
|
|
192
|
+
return f" {Path(file_path).name}: ruff timed out"
|
|
193
|
+
except Exception:
|
|
194
|
+
return ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def lint_biome(file_path: str) -> str:
|
|
198
|
+
"""Run Biome linter for JS/TS/CSS/GraphQL files."""
|
|
199
|
+
biome = _find_biome(file_path)
|
|
200
|
+
if not biome:
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
result = subprocess.run(
|
|
205
|
+
[biome, "lint", "--reporter=json", file_path],
|
|
206
|
+
capture_output=True,
|
|
207
|
+
text=True,
|
|
208
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
output = json.loads(result.stdout)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
return ""
|
|
215
|
+
|
|
216
|
+
diagnostics = output.get("diagnostics", [])
|
|
217
|
+
if not diagnostics:
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
parsed = [
|
|
221
|
+
{
|
|
222
|
+
"severity": d.get("severity", "warning"),
|
|
223
|
+
"line": (
|
|
224
|
+
d.get("location", {}).get("span", {}).get("start", {})
|
|
225
|
+
if isinstance(
|
|
226
|
+
d.get("location", {}).get("span", {}).get("start"), int
|
|
227
|
+
)
|
|
228
|
+
else 0
|
|
229
|
+
),
|
|
230
|
+
"message": d.get("description", d.get("message", "")),
|
|
231
|
+
}
|
|
232
|
+
for d in diagnostics
|
|
233
|
+
]
|
|
234
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
235
|
+
|
|
236
|
+
except subprocess.TimeoutExpired:
|
|
237
|
+
return f" {Path(file_path).name}: biome lint timed out"
|
|
238
|
+
except Exception:
|
|
239
|
+
return ""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def lint_shellcheck(file_path: str) -> str:
|
|
243
|
+
"""Run ShellCheck on a shell script."""
|
|
244
|
+
shellcheck = _which("shellcheck")
|
|
245
|
+
if not shellcheck:
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
result = subprocess.run(
|
|
250
|
+
[shellcheck, "--format=json", file_path],
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
issues = json.loads(result.stdout)
|
|
258
|
+
except json.JSONDecodeError:
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
if not issues:
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
severity_map = {
|
|
265
|
+
"error": "error",
|
|
266
|
+
"warning": "warning",
|
|
267
|
+
"info": "info",
|
|
268
|
+
"style": "info",
|
|
269
|
+
}
|
|
270
|
+
parsed = [
|
|
271
|
+
{
|
|
272
|
+
"severity": severity_map.get(issue.get("level", "info"), "info"),
|
|
273
|
+
"line": issue.get("line", 0),
|
|
274
|
+
"message": f"[SC{issue.get('code', '?')}] {issue.get('message', '')}",
|
|
275
|
+
}
|
|
276
|
+
for issue in issues
|
|
277
|
+
]
|
|
278
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
279
|
+
|
|
280
|
+
except subprocess.TimeoutExpired:
|
|
281
|
+
return f" {Path(file_path).name}: shellcheck timed out"
|
|
282
|
+
except Exception:
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def lint_go_vet(file_path: str) -> str:
|
|
287
|
+
"""Run go vet on a Go file."""
|
|
288
|
+
go = _which("go")
|
|
289
|
+
if not go:
|
|
290
|
+
return ""
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
result = subprocess.run(
|
|
294
|
+
[go, "vet", file_path],
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# go vet outputs to stderr
|
|
301
|
+
output = result.stderr.strip()
|
|
302
|
+
if not output:
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
lines = output.splitlines()
|
|
306
|
+
parsed = []
|
|
307
|
+
for line in lines:
|
|
308
|
+
# Format: file.go:LINE:COL: message
|
|
309
|
+
parts = line.split(":", 3)
|
|
310
|
+
if len(parts) >= 4:
|
|
311
|
+
try:
|
|
312
|
+
line_num = int(parts[1])
|
|
313
|
+
except ValueError:
|
|
314
|
+
line_num = 0
|
|
315
|
+
parsed.append(
|
|
316
|
+
{
|
|
317
|
+
"severity": "warning",
|
|
318
|
+
"line": line_num,
|
|
319
|
+
"message": parts[3].strip(),
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
elif line.strip():
|
|
323
|
+
parsed.append(
|
|
324
|
+
{
|
|
325
|
+
"severity": "warning",
|
|
326
|
+
"line": 0,
|
|
327
|
+
"message": line.strip(),
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
332
|
+
|
|
333
|
+
except subprocess.TimeoutExpired:
|
|
334
|
+
return f" {Path(file_path).name}: go vet timed out"
|
|
335
|
+
except Exception:
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def lint_hadolint(file_path: str) -> str:
|
|
340
|
+
"""Run hadolint on a Dockerfile."""
|
|
341
|
+
hadolint = _which("hadolint")
|
|
342
|
+
if not hadolint:
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
result = subprocess.run(
|
|
347
|
+
[hadolint, "--format", "json", file_path],
|
|
348
|
+
capture_output=True,
|
|
349
|
+
text=True,
|
|
350
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
issues = json.loads(result.stdout)
|
|
355
|
+
except json.JSONDecodeError:
|
|
356
|
+
return ""
|
|
357
|
+
|
|
358
|
+
if not issues:
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
severity_map = {
|
|
362
|
+
"error": "error",
|
|
363
|
+
"warning": "warning",
|
|
364
|
+
"info": "info",
|
|
365
|
+
"style": "info",
|
|
366
|
+
}
|
|
367
|
+
parsed = [
|
|
368
|
+
{
|
|
369
|
+
"severity": severity_map.get(issue.get("level", "info"), "info"),
|
|
370
|
+
"line": issue.get("line", 0),
|
|
371
|
+
"message": f"[{issue.get('code', '?')}] {issue.get('message', '')}",
|
|
372
|
+
}
|
|
373
|
+
for issue in issues
|
|
374
|
+
]
|
|
375
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
376
|
+
|
|
377
|
+
except subprocess.TimeoutExpired:
|
|
378
|
+
return f" {Path(file_path).name}: hadolint timed out"
|
|
379
|
+
except Exception:
|
|
380
|
+
return ""
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def lint_clippy(file_path: str) -> str:
|
|
384
|
+
"""Run clippy on a Rust file (conditional — only if cargo is in PATH)."""
|
|
385
|
+
cargo = _which("cargo")
|
|
386
|
+
if not cargo:
|
|
387
|
+
return ""
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
result = subprocess.run(
|
|
391
|
+
[cargo, "clippy", "--message-format=json", "--", "-W", "clippy::all"],
|
|
392
|
+
capture_output=True,
|
|
393
|
+
text=True,
|
|
394
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
395
|
+
cwd=str(Path(file_path).parent),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
lines = result.stdout.strip().splitlines()
|
|
399
|
+
parsed = []
|
|
400
|
+
target_name = Path(file_path).name
|
|
401
|
+
|
|
402
|
+
for line in lines:
|
|
403
|
+
try:
|
|
404
|
+
msg = json.loads(line)
|
|
405
|
+
except json.JSONDecodeError:
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
if msg.get("reason") != "compiler-message":
|
|
409
|
+
continue
|
|
410
|
+
inner = msg.get("message", {})
|
|
411
|
+
level = inner.get("level", "")
|
|
412
|
+
if level not in ("warning", "error"):
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
# Match diagnostics to the target file
|
|
416
|
+
spans = inner.get("spans", [])
|
|
417
|
+
line_num = 0
|
|
418
|
+
for span in spans:
|
|
419
|
+
if span.get("is_primary") and target_name in span.get("file_name", ""):
|
|
420
|
+
line_num = span.get("line_start", 0)
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
if line_num or not spans:
|
|
424
|
+
parsed.append(
|
|
425
|
+
{
|
|
426
|
+
"severity": level,
|
|
427
|
+
"line": line_num,
|
|
428
|
+
"message": inner.get("message", ""),
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return _format_issues(Path(file_path).name, parsed)
|
|
433
|
+
|
|
434
|
+
except subprocess.TimeoutExpired:
|
|
435
|
+
return f" {Path(file_path).name}: clippy timed out"
|
|
436
|
+
except Exception:
|
|
437
|
+
return ""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ── Main ────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def main():
|
|
444
|
+
try:
|
|
445
|
+
input_data = json.load(sys.stdin)
|
|
446
|
+
except (json.JSONDecodeError, ValueError):
|
|
447
|
+
sys.exit(0)
|
|
448
|
+
|
|
449
|
+
if input_data.get("stop_hook_active"):
|
|
450
|
+
sys.exit(0)
|
|
451
|
+
|
|
452
|
+
session_id = input_data.get("session_id", "")
|
|
453
|
+
if not session_id:
|
|
454
|
+
sys.exit(0)
|
|
455
|
+
|
|
456
|
+
tmp_path = f"/tmp/claude-cq-lint-{session_id}"
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
with open(tmp_path) as f:
|
|
460
|
+
raw_paths = f.read().splitlines()
|
|
461
|
+
except FileNotFoundError:
|
|
462
|
+
sys.exit(0)
|
|
463
|
+
except OSError:
|
|
464
|
+
sys.exit(0)
|
|
465
|
+
finally:
|
|
466
|
+
try:
|
|
467
|
+
os.unlink(tmp_path)
|
|
468
|
+
except OSError:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
# Deduplicate, filter to existing files
|
|
472
|
+
seen: set[str] = set()
|
|
473
|
+
paths: list[str] = []
|
|
474
|
+
for p in raw_paths:
|
|
475
|
+
p = p.strip()
|
|
476
|
+
if p and p not in seen and os.path.isfile(p):
|
|
477
|
+
seen.add(p)
|
|
478
|
+
paths.append(p)
|
|
479
|
+
|
|
480
|
+
if not paths:
|
|
481
|
+
sys.exit(0)
|
|
482
|
+
|
|
483
|
+
# Collect results grouped by linter
|
|
484
|
+
all_results: dict[str, list[str]] = {}
|
|
485
|
+
|
|
486
|
+
for path in paths:
|
|
487
|
+
ext = Path(path).suffix.lower()
|
|
488
|
+
name = Path(path).name
|
|
489
|
+
|
|
490
|
+
if ext in PYTHON_EXTS:
|
|
491
|
+
msg = lint_python_pyright(path)
|
|
492
|
+
if msg:
|
|
493
|
+
all_results.setdefault("Pyright", []).append(msg)
|
|
494
|
+
msg = lint_python_ruff(path)
|
|
495
|
+
if msg:
|
|
496
|
+
all_results.setdefault("Ruff", []).append(msg)
|
|
497
|
+
|
|
498
|
+
elif ext in BIOME_EXTS:
|
|
499
|
+
msg = lint_biome(path)
|
|
500
|
+
if msg:
|
|
501
|
+
all_results.setdefault("Biome", []).append(msg)
|
|
502
|
+
|
|
503
|
+
elif ext in SHELL_EXTS:
|
|
504
|
+
msg = lint_shellcheck(path)
|
|
505
|
+
if msg:
|
|
506
|
+
all_results.setdefault("ShellCheck", []).append(msg)
|
|
507
|
+
|
|
508
|
+
elif ext in GO_EXTS:
|
|
509
|
+
msg = lint_go_vet(path)
|
|
510
|
+
if msg:
|
|
511
|
+
all_results.setdefault("go vet", []).append(msg)
|
|
512
|
+
|
|
513
|
+
elif name == "Dockerfile" or ext == ".dockerfile":
|
|
514
|
+
msg = lint_hadolint(path)
|
|
515
|
+
if msg:
|
|
516
|
+
all_results.setdefault("hadolint", []).append(msg)
|
|
517
|
+
|
|
518
|
+
elif ext in RUST_EXTS:
|
|
519
|
+
msg = lint_clippy(path)
|
|
520
|
+
if msg:
|
|
521
|
+
all_results.setdefault("clippy", []).append(msg)
|
|
522
|
+
|
|
523
|
+
if all_results:
|
|
524
|
+
sections = []
|
|
525
|
+
for linter_name, results in all_results.items():
|
|
526
|
+
sections.append(
|
|
527
|
+
f"[Auto-linter] {linter_name} results:\n" + "\n".join(results)
|
|
528
|
+
)
|
|
529
|
+
output = "\n\n".join(sections)
|
|
530
|
+
print(json.dumps({"additionalContext": output}))
|
|
531
|
+
|
|
532
|
+
sys.exit(0)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
main()
|
package/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/syntax-validator.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Data file syntax validator.
|
|
4
|
+
|
|
5
|
+
Validates JSON, JSONC, YAML, and TOML files after editing.
|
|
6
|
+
Uses Python stdlib only (plus PyYAML if available).
|
|
7
|
+
|
|
8
|
+
Reads tool input from stdin, validates syntax, reports errors.
|
|
9
|
+
Non-blocking: always exits 0.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
EXTENSIONS = {".json", ".jsonc", ".yaml", ".yml", ".toml"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def strip_jsonc_comments(text: str) -> str:
|
|
22
|
+
"""Remove // and /* */ comments from JSONC text.
|
|
23
|
+
|
|
24
|
+
Handles URLs (https://...) by only stripping // comments that are
|
|
25
|
+
preceded by whitespace or appear at line start, not those inside strings.
|
|
26
|
+
"""
|
|
27
|
+
# Remove multi-line comments first
|
|
28
|
+
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
|
|
29
|
+
# Remove single-line comments: // preceded by start-of-line or whitespace
|
|
30
|
+
# This avoids stripping :// in URLs
|
|
31
|
+
text = re.sub(r"(^|[\s,\[\{])//.*$", r"\1", text, flags=re.MULTILINE)
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_json(file_path: str, is_jsonc: bool) -> str:
|
|
36
|
+
"""Validate JSON/JSONC syntax.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Error message string, or empty string if valid.
|
|
40
|
+
"""
|
|
41
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
42
|
+
content = f.read()
|
|
43
|
+
|
|
44
|
+
if is_jsonc:
|
|
45
|
+
content = strip_jsonc_comments(content)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
json.loads(content)
|
|
49
|
+
return ""
|
|
50
|
+
except json.JSONDecodeError as e:
|
|
51
|
+
return f"[Syntax] JSON error at line {e.lineno}, col {e.colno}: {e.msg}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_yaml(file_path: str) -> str:
|
|
55
|
+
"""Validate YAML syntax.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Error message string, or empty string if valid.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
import yaml
|
|
62
|
+
except ImportError:
|
|
63
|
+
return "" # PyYAML not available, skip
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
67
|
+
yaml.safe_load(f)
|
|
68
|
+
return ""
|
|
69
|
+
except yaml.YAMLError as e:
|
|
70
|
+
if hasattr(e, "problem_mark"):
|
|
71
|
+
mark = e.problem_mark
|
|
72
|
+
return f"[Syntax] YAML error at line {mark.line + 1}, col {mark.column + 1}: {e.problem}"
|
|
73
|
+
return f"[Syntax] YAML error: {e}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_toml(file_path: str) -> str:
|
|
77
|
+
"""Validate TOML syntax.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Error message string, or empty string if valid.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
import tomllib
|
|
84
|
+
except ImportError:
|
|
85
|
+
return "" # Python < 3.11, skip
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
with open(file_path, "rb") as f:
|
|
89
|
+
tomllib.load(f)
|
|
90
|
+
return ""
|
|
91
|
+
except tomllib.TOMLDecodeError as e:
|
|
92
|
+
return f"[Syntax] TOML error: {e}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate(file_path: str) -> str:
|
|
96
|
+
"""Validate file syntax based on extension.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Error message string, or empty string if valid.
|
|
100
|
+
"""
|
|
101
|
+
ext = Path(file_path).suffix.lower()
|
|
102
|
+
|
|
103
|
+
if ext == ".jsonc":
|
|
104
|
+
return validate_json(file_path, is_jsonc=True)
|
|
105
|
+
elif ext == ".json":
|
|
106
|
+
return validate_json(file_path, is_jsonc=False)
|
|
107
|
+
elif ext in {".yaml", ".yml"}:
|
|
108
|
+
return validate_yaml(file_path)
|
|
109
|
+
elif ext == ".toml":
|
|
110
|
+
return validate_toml(file_path)
|
|
111
|
+
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
try:
|
|
117
|
+
input_data = json.load(sys.stdin)
|
|
118
|
+
tool_input = input_data.get("tool_input", {})
|
|
119
|
+
file_path = tool_input.get("file_path", "")
|
|
120
|
+
|
|
121
|
+
if not file_path:
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
|
|
124
|
+
ext = Path(file_path).suffix.lower()
|
|
125
|
+
if ext not in EXTENSIONS:
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
if not os.path.isfile(file_path):
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
message = validate(file_path)
|
|
132
|
+
|
|
133
|
+
if message:
|
|
134
|
+
print(json.dumps({"additionalContext": message}))
|
|
135
|
+
|
|
136
|
+
sys.exit(0)
|
|
137
|
+
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
sys.exit(0)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
print(f"Hook error: {e}", file=sys.stderr)
|
|
142
|
+
sys.exit(0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|