punt-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- punt_kit/__init__.py +3 -0
- punt_kit/__main__.py +50 -0
- punt_kit/audit.py +650 -0
- punt_kit/detect.py +183 -0
- punt_kit/init.py +584 -0
- punt_kit/py.typed +0 -0
- punt_kit/templates/claude-md.md.j2 +24 -0
- punt_kit/templates/markdownlint-cli2.jsonc +3 -0
- punt_kit/templates/markdownlint.jsonc +10 -0
- punt_kit/templates/workflows/docs.yml +18 -0
- punt_kit/templates/workflows/lint-node.yml +19 -0
- punt_kit/templates/workflows/lint-python.yml +23 -0
- punt_kit/templates/workflows/release-python.yml.j2 +93 -0
- punt_kit/templates/workflows/test-python.yml +20 -0
- punt_kit-0.1.0.dist-info/METADATA +15 -0
- punt_kit-0.1.0.dist-info/RECORD +18 -0
- punt_kit-0.1.0.dist-info/WHEEL +4 -0
- punt_kit-0.1.0.dist-info/entry_points.txt +2 -0
punt_kit/__init__.py
ADDED
punt_kit/__main__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from punt_kit import __version__
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="punt",
|
|
10
|
+
help="Standards, scaffolding, and compliance tooling for Punt Labs projects.",
|
|
11
|
+
no_args_is_help=True,
|
|
12
|
+
)
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def init(
|
|
18
|
+
path: str = typer.Argument(".", help="Path to the project root"),
|
|
19
|
+
language: str = typer.Option(
|
|
20
|
+
"",
|
|
21
|
+
"--language",
|
|
22
|
+
"-l",
|
|
23
|
+
help="Override detected language (python, node, swift)",
|
|
24
|
+
),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Detect project type, generate missing files, and report manual steps."""
|
|
27
|
+
from punt_kit.init import run_init
|
|
28
|
+
|
|
29
|
+
run_init(path, language=language or None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def audit(
|
|
34
|
+
path: str = typer.Argument(".", help="Path to the project root"),
|
|
35
|
+
fix: bool = typer.Option(False, "--fix", help="Create missing mechanical files"),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Check compliance against Punt Labs standards."""
|
|
38
|
+
from punt_kit.audit import run_audit
|
|
39
|
+
|
|
40
|
+
run_audit(path, fix=fix)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def version() -> None:
|
|
45
|
+
"""Print the punt-kit version."""
|
|
46
|
+
console.print(f"punt-kit {__version__}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
app()
|
punt_kit/audit.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""punt audit — compliance check against Punt Labs standards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from punt_kit.detect import ProjectInfo, detect
|
|
16
|
+
from punt_kit.init import build_standard_permissions
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
TEMPLATES = importlib.resources.files("punt_kit") / "templates"
|
|
21
|
+
|
|
22
|
+
PASS = "[green]✓[/green]"
|
|
23
|
+
FAIL = "[red]✗[/red]"
|
|
24
|
+
FIXED = "[yellow]⚡[/yellow]"
|
|
25
|
+
INFO = "[dim]○[/dim]"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_audit(path: str, *, fix: bool = False) -> None:
|
|
29
|
+
"""Check compliance against Punt Labs standards.
|
|
30
|
+
|
|
31
|
+
When *fix* is True, create missing mechanical files from templates.
|
|
32
|
+
"""
|
|
33
|
+
root = Path(path).resolve()
|
|
34
|
+
if not root.is_dir():
|
|
35
|
+
console.print(f"[red]Error:[/red] {root} is not a directory")
|
|
36
|
+
raise SystemExit(1)
|
|
37
|
+
|
|
38
|
+
info = detect(root)
|
|
39
|
+
|
|
40
|
+
mode = "[bold]punt audit --fix[/bold]" if fix else "[bold]punt audit[/bold]"
|
|
41
|
+
console.print(f"\n{mode} — {root.name}")
|
|
42
|
+
lang = info.language or "none"
|
|
43
|
+
ptype = info.project_type or "unknown"
|
|
44
|
+
console.print(f" Language: {lang} Type: {ptype}\n")
|
|
45
|
+
|
|
46
|
+
results: list[tuple[str, str, str]] = []
|
|
47
|
+
|
|
48
|
+
results.extend(_check_ci(info))
|
|
49
|
+
results.extend(_check_tool_config(info))
|
|
50
|
+
results.extend(_check_markdownlint(info, fix=fix))
|
|
51
|
+
results.extend(_check_markdownlint_content(info))
|
|
52
|
+
results.extend(_check_py_typed(info, fix=fix))
|
|
53
|
+
results.extend(_check_changelog(info, fix=fix))
|
|
54
|
+
results.extend(_check_beads(info))
|
|
55
|
+
results.extend(_check_claude_md(info))
|
|
56
|
+
results.extend(_check_permissions(info))
|
|
57
|
+
results.extend(_check_plugin_dev_isolation(info))
|
|
58
|
+
results.extend(_check_github_settings(info))
|
|
59
|
+
|
|
60
|
+
# Print results
|
|
61
|
+
passes = 0
|
|
62
|
+
failures = 0
|
|
63
|
+
fixes = 0
|
|
64
|
+
for status, label, detail in results:
|
|
65
|
+
console.print(f" {status} {label}")
|
|
66
|
+
if detail:
|
|
67
|
+
console.print(f" [dim]{detail}[/dim]")
|
|
68
|
+
if status == PASS:
|
|
69
|
+
passes += 1
|
|
70
|
+
elif status == FAIL:
|
|
71
|
+
failures += 1
|
|
72
|
+
elif status == FIXED:
|
|
73
|
+
fixes += 1
|
|
74
|
+
|
|
75
|
+
summary = f" [bold]{passes} passed[/bold], [bold]{failures} failed[/bold]"
|
|
76
|
+
if fixes:
|
|
77
|
+
summary += f", [bold yellow]{fixes} fixed[/bold yellow]"
|
|
78
|
+
console.print(f"\n{summary}\n")
|
|
79
|
+
|
|
80
|
+
if failures > 0:
|
|
81
|
+
raise SystemExit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _check_ci(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
85
|
+
"""Check CI workflow existence per project type."""
|
|
86
|
+
results: list[tuple[str, str, str]] = []
|
|
87
|
+
workflows_dir = info.root / ".github" / "workflows"
|
|
88
|
+
|
|
89
|
+
if info.language == "python":
|
|
90
|
+
lint_exists = (workflows_dir / "lint.yml").exists()
|
|
91
|
+
results.append(
|
|
92
|
+
(
|
|
93
|
+
PASS if lint_exists else FAIL,
|
|
94
|
+
"CI lint workflow exists",
|
|
95
|
+
".github/workflows/lint.yml" if lint_exists else "Missing lint.yml",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
test_exists = (workflows_dir / "test.yml").exists()
|
|
100
|
+
results.append(
|
|
101
|
+
(
|
|
102
|
+
PASS if test_exists else FAIL,
|
|
103
|
+
"CI test workflow exists",
|
|
104
|
+
".github/workflows/test.yml" if test_exists else "Missing test.yml",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
elif info.language == "node":
|
|
109
|
+
lint_exists = (workflows_dir / "lint.yml").exists()
|
|
110
|
+
results.append(
|
|
111
|
+
(
|
|
112
|
+
PASS if lint_exists else FAIL,
|
|
113
|
+
"CI lint workflow exists",
|
|
114
|
+
".github/workflows/lint.yml" if lint_exists else "Missing lint.yml",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
elif info.language == "swift":
|
|
119
|
+
build_exists = (workflows_dir / "build.yml").exists()
|
|
120
|
+
results.append(
|
|
121
|
+
(
|
|
122
|
+
PASS if build_exists else FAIL,
|
|
123
|
+
"CI build workflow exists",
|
|
124
|
+
".github/workflows/build.yml" if build_exists else "Missing build.yml",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# All repos should have docs.yml
|
|
129
|
+
docs_exists = (workflows_dir / "docs.yml").exists()
|
|
130
|
+
results.append(
|
|
131
|
+
(
|
|
132
|
+
PASS if docs_exists else FAIL,
|
|
133
|
+
"CI docs workflow exists",
|
|
134
|
+
".github/workflows/docs.yml" if docs_exists else "Missing docs.yml",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return results
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_tool_config(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
142
|
+
"""Check language-specific tool configuration."""
|
|
143
|
+
results: list[tuple[str, str, str]] = []
|
|
144
|
+
|
|
145
|
+
if info.language != "python":
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
pyproject_path = info.root / "pyproject.toml"
|
|
149
|
+
if not pyproject_path.exists():
|
|
150
|
+
results.append((FAIL, "pyproject.toml exists", "Missing"))
|
|
151
|
+
return results
|
|
152
|
+
|
|
153
|
+
with open(pyproject_path, "rb") as f:
|
|
154
|
+
data = tomllib.load(f)
|
|
155
|
+
|
|
156
|
+
tool = data.get("tool", {})
|
|
157
|
+
|
|
158
|
+
ruff = tool.get("ruff")
|
|
159
|
+
results.append(
|
|
160
|
+
(
|
|
161
|
+
PASS if ruff else FAIL,
|
|
162
|
+
r"Linting configured (\[tool.ruff])",
|
|
163
|
+
r"in pyproject.toml" if ruff else r"Missing \[tool.ruff] in pyproject.toml",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
mypy = tool.get("mypy")
|
|
168
|
+
results.append(
|
|
169
|
+
(
|
|
170
|
+
PASS if mypy else FAIL,
|
|
171
|
+
r"Type checking configured (\[tool.mypy])",
|
|
172
|
+
r"in pyproject.toml" if mypy else r"Missing \[tool.mypy] in pyproject.toml",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
pyright = tool.get("pyright")
|
|
177
|
+
results.append(
|
|
178
|
+
(
|
|
179
|
+
PASS if pyright else FAIL,
|
|
180
|
+
r"Type checking configured (\[tool.pyright])",
|
|
181
|
+
r"in pyproject.toml"
|
|
182
|
+
if pyright
|
|
183
|
+
else r"Missing \[tool.pyright] in pyproject.toml",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
pytest_cfg = tool.get("pytest", {}).get("ini_options")
|
|
188
|
+
results.append(
|
|
189
|
+
(
|
|
190
|
+
PASS if pytest_cfg else FAIL,
|
|
191
|
+
r"Test config exists (\[tool.pytest.ini_options])",
|
|
192
|
+
r"in pyproject.toml"
|
|
193
|
+
if pytest_cfg
|
|
194
|
+
else r"Missing \[tool.pytest.ini_options]",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return results
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _check_markdownlint(info: ProjectInfo, *, fix: bool) -> list[tuple[str, str, str]]:
|
|
202
|
+
"""Check markdownlint configuration files."""
|
|
203
|
+
results: list[tuple[str, str, str]] = []
|
|
204
|
+
|
|
205
|
+
# Template files stored without dot prefix to avoid setuptools dotfile issues
|
|
206
|
+
configs = {
|
|
207
|
+
".markdownlint.jsonc": "markdownlint.jsonc",
|
|
208
|
+
".markdownlint-cli2.jsonc": "markdownlint-cli2.jsonc",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for filename, template_name in configs.items():
|
|
212
|
+
target = info.root / filename
|
|
213
|
+
if target.exists():
|
|
214
|
+
results.append((PASS, f"{filename} exists", ""))
|
|
215
|
+
elif fix:
|
|
216
|
+
template_ref = TEMPLATES / template_name
|
|
217
|
+
content = template_ref.read_text(encoding="utf-8")
|
|
218
|
+
target.write_text(content, encoding="utf-8")
|
|
219
|
+
results.append((FIXED, f"{filename} created", ""))
|
|
220
|
+
else:
|
|
221
|
+
results.append(
|
|
222
|
+
(FAIL, f"{filename} exists", "Missing — run punt audit --fix")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return results
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _check_markdownlint_content(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
229
|
+
"""Run markdownlint-cli2 on tracked markdown files to catch content errors."""
|
|
230
|
+
npx = shutil.which("npx")
|
|
231
|
+
if npx is None:
|
|
232
|
+
return [(INFO, "Markdown lint (npx not available)", "Install Node.js to run")]
|
|
233
|
+
|
|
234
|
+
# Only lint git-tracked files to avoid scratch file noise
|
|
235
|
+
tracked_md = _get_tracked_markdown(info.root)
|
|
236
|
+
if not tracked_md:
|
|
237
|
+
return [(INFO, "Markdown lint (no tracked .md files)", "")]
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
[npx, "--yes", "markdownlint-cli2", *tracked_md],
|
|
242
|
+
cwd=str(info.root),
|
|
243
|
+
capture_output=True,
|
|
244
|
+
text=True,
|
|
245
|
+
timeout=60,
|
|
246
|
+
)
|
|
247
|
+
if result.returncode == 0:
|
|
248
|
+
return [
|
|
249
|
+
(PASS, "Markdown lint passes", f"{len(tracked_md)} file(s) checked")
|
|
250
|
+
]
|
|
251
|
+
# Count errors from output
|
|
252
|
+
error_lines = [
|
|
253
|
+
line
|
|
254
|
+
for line in result.stdout.splitlines()
|
|
255
|
+
if " error " in line or "MD0" in line
|
|
256
|
+
]
|
|
257
|
+
return [
|
|
258
|
+
(
|
|
259
|
+
FAIL,
|
|
260
|
+
"Markdown lint passes",
|
|
261
|
+
f"{len(error_lines)} error(s) — run: npx markdownlint-cli2 '**/*.md'",
|
|
262
|
+
)
|
|
263
|
+
]
|
|
264
|
+
except subprocess.TimeoutExpired:
|
|
265
|
+
return [(INFO, "Markdown lint (timed out)", "")]
|
|
266
|
+
except OSError:
|
|
267
|
+
return [(INFO, "Markdown lint (could not run)", "")]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _get_tracked_markdown(root: Path) -> list[str]:
|
|
271
|
+
"""Get list of git-tracked markdown files relative to root."""
|
|
272
|
+
try:
|
|
273
|
+
result = subprocess.run(
|
|
274
|
+
["git", "ls-files", "--cached", "*.md", "**/*.md"],
|
|
275
|
+
cwd=str(root),
|
|
276
|
+
capture_output=True,
|
|
277
|
+
text=True,
|
|
278
|
+
timeout=5,
|
|
279
|
+
)
|
|
280
|
+
if result.returncode != 0:
|
|
281
|
+
return []
|
|
282
|
+
return [f for f in result.stdout.strip().splitlines() if f.endswith(".md")]
|
|
283
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _check_py_typed(info: ProjectInfo, *, fix: bool) -> list[tuple[str, str, str]]:
|
|
288
|
+
"""Check py.typed marker file for Python packages."""
|
|
289
|
+
if info.language != "python":
|
|
290
|
+
return []
|
|
291
|
+
|
|
292
|
+
pkg_dir = _find_package_dir(info)
|
|
293
|
+
if pkg_dir is None:
|
|
294
|
+
return [(INFO, "py.typed marker", "Could not determine package directory")]
|
|
295
|
+
|
|
296
|
+
py_typed = pkg_dir / "py.typed"
|
|
297
|
+
if py_typed.exists():
|
|
298
|
+
return [(PASS, "py.typed marker exists", str(_relpath(py_typed, info.root)))]
|
|
299
|
+
|
|
300
|
+
if fix:
|
|
301
|
+
py_typed.write_text("")
|
|
302
|
+
return [(FIXED, "py.typed marker created", str(_relpath(py_typed, info.root)))]
|
|
303
|
+
|
|
304
|
+
return [
|
|
305
|
+
(
|
|
306
|
+
FAIL,
|
|
307
|
+
"py.typed marker exists",
|
|
308
|
+
f"Missing {_relpath(py_typed, info.root)} — run punt audit --fix",
|
|
309
|
+
)
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _check_changelog(info: ProjectInfo, *, fix: bool) -> list[tuple[str, str, str]]:
|
|
314
|
+
"""Check CHANGELOG.md exists."""
|
|
315
|
+
changelog = info.root / "CHANGELOG.md"
|
|
316
|
+
if changelog.exists():
|
|
317
|
+
return [(PASS, "CHANGELOG.md exists", "")]
|
|
318
|
+
|
|
319
|
+
if fix:
|
|
320
|
+
project_name = _get_project_name(info)
|
|
321
|
+
changelog.write_text(
|
|
322
|
+
f"# Changelog\n\nAll notable changes to {project_name} "
|
|
323
|
+
"will be documented in this file.\n\n"
|
|
324
|
+
"The format is based on "
|
|
325
|
+
"[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\n",
|
|
326
|
+
encoding="utf-8",
|
|
327
|
+
)
|
|
328
|
+
return [(FIXED, "CHANGELOG.md created", "")]
|
|
329
|
+
|
|
330
|
+
return [(FAIL, "CHANGELOG.md exists", "Missing — run punt audit --fix")]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _check_beads(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
334
|
+
"""Check if beads is initialized."""
|
|
335
|
+
return [
|
|
336
|
+
(
|
|
337
|
+
PASS if info.has_beads else FAIL,
|
|
338
|
+
"Beads initialized",
|
|
339
|
+
".beads/ directory" if info.has_beads else "Missing .beads/ — run bd init",
|
|
340
|
+
)
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _check_claude_md(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
345
|
+
"""Check if CLAUDE.md exists."""
|
|
346
|
+
return [
|
|
347
|
+
(
|
|
348
|
+
PASS if info.has_claude_md else FAIL,
|
|
349
|
+
"CLAUDE.md exists",
|
|
350
|
+
"" if info.has_claude_md else "Missing CLAUDE.md — run punt init",
|
|
351
|
+
)
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _check_github_settings(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
356
|
+
"""Check GitHub repo settings via gh API. Falls back gracefully."""
|
|
357
|
+
results: list[tuple[str, str, str]] = []
|
|
358
|
+
|
|
359
|
+
gh = shutil.which("gh")
|
|
360
|
+
if gh is None:
|
|
361
|
+
results.append(
|
|
362
|
+
(
|
|
363
|
+
INFO,
|
|
364
|
+
"GitHub settings (gh CLI not available)",
|
|
365
|
+
"Install gh to check remote settings",
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
return results
|
|
369
|
+
|
|
370
|
+
# Detect repo from git remote
|
|
371
|
+
repo = _get_github_repo(info.root)
|
|
372
|
+
if repo is None:
|
|
373
|
+
results.append((INFO, "GitHub settings (no remote detected)", ""))
|
|
374
|
+
return results
|
|
375
|
+
|
|
376
|
+
# Check branch protection
|
|
377
|
+
try:
|
|
378
|
+
result = subprocess.run(
|
|
379
|
+
[gh, "api", f"repos/{repo}/branches/main/protection"],
|
|
380
|
+
capture_output=True,
|
|
381
|
+
text=True,
|
|
382
|
+
timeout=10,
|
|
383
|
+
)
|
|
384
|
+
if result.returncode == 0:
|
|
385
|
+
protection = json.loads(result.stdout)
|
|
386
|
+
pr_required = protection.get("required_pull_request_reviews") is not None
|
|
387
|
+
results.append(
|
|
388
|
+
(
|
|
389
|
+
PASS if pr_required else FAIL,
|
|
390
|
+
"Branch protection: PR required",
|
|
391
|
+
"",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
status_checks = protection.get("required_status_checks") is not None
|
|
396
|
+
results.append(
|
|
397
|
+
(
|
|
398
|
+
PASS if status_checks else FAIL,
|
|
399
|
+
"Branch protection: status checks required",
|
|
400
|
+
"",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
else:
|
|
404
|
+
results.append(
|
|
405
|
+
(
|
|
406
|
+
FAIL,
|
|
407
|
+
"Branch protection on main",
|
|
408
|
+
"Not configured or no access",
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
412
|
+
results.append((INFO, "Branch protection (could not check)", ""))
|
|
413
|
+
|
|
414
|
+
# Check Dependabot / vulnerability alerts
|
|
415
|
+
try:
|
|
416
|
+
result = subprocess.run(
|
|
417
|
+
[gh, "api", f"repos/{repo}/vulnerability-alerts", "--include", "-X", "GET"],
|
|
418
|
+
capture_output=True,
|
|
419
|
+
text=True,
|
|
420
|
+
timeout=10,
|
|
421
|
+
)
|
|
422
|
+
# 204 means enabled, 404 means disabled
|
|
423
|
+
enabled = "204" in (result.stderr + result.stdout) or result.returncode == 0
|
|
424
|
+
results.append(
|
|
425
|
+
(
|
|
426
|
+
PASS if enabled else FAIL,
|
|
427
|
+
"Dependabot vulnerability alerts",
|
|
428
|
+
"Enabled" if enabled else "Not enabled",
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
432
|
+
results.append((INFO, "Dependabot alerts (could not check)", ""))
|
|
433
|
+
|
|
434
|
+
return results
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _check_permissions(info: ProjectInfo) -> list[tuple[str, str, str]]:
|
|
438
|
+
"""Check .claude/settings.json has standard permissions for this project type."""
|
|
439
|
+
results: list[tuple[str, str, str]] = []
|
|
440
|
+
settings_path = info.root / ".claude" / "settings.json"
|
|
441
|
+
|
|
442
|
+
if not settings_path.exists():
|
|
443
|
+
results.append(
|
|
444
|
+
(
|
|
445
|
+
FAIL,
|
|
446
|
+
".claude/settings.json exists",
|
|
447
|
+
"Missing — run punt init",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
results.append((PASS, ".claude/settings.json exists", ""))
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
456
|
+
except (json.JSONDecodeError, OSError):
|
|
457
|
+
results.append((FAIL, "settings.json is valid JSON", "Parse error"))
|
|
458
|
+
return results
|
|
459
|
+
|
|
460
|
+
if not isinstance(data, dict):
|
|
461
|
+
results.append((FAIL, "settings.json is valid JSON", "Expected object"))
|
|
462
|
+
return results
|
|
463
|
+
|
|
464
|
+
typed_data = cast("dict[str, object]", data)
|
|
465
|
+
|
|
466
|
+
perms_raw = typed_data.get("permissions")
|
|
467
|
+
if not isinstance(perms_raw, dict):
|
|
468
|
+
results.append((FAIL, "Standard permissions present", "No permissions key"))
|
|
469
|
+
return results
|
|
470
|
+
|
|
471
|
+
perms = cast("dict[str, object]", perms_raw)
|
|
472
|
+
allow_raw = perms.get("allow")
|
|
473
|
+
if not isinstance(allow_raw, list):
|
|
474
|
+
results.append((FAIL, "Standard permissions present", "Missing allow array"))
|
|
475
|
+
return results
|
|
476
|
+
|
|
477
|
+
allow_strs = [str(x) for x in cast("list[object]", allow_raw)]
|
|
478
|
+
|
|
479
|
+
standard = build_standard_permissions(info)
|
|
480
|
+
missing = [p for p in standard if p not in allow_strs]
|
|
481
|
+
|
|
482
|
+
if missing:
|
|
483
|
+
results.append(
|
|
484
|
+
(
|
|
485
|
+
FAIL,
|
|
486
|
+
"Standard permissions present",
|
|
487
|
+
f"Missing {len(missing)}: {', '.join(missing[:5])}"
|
|
488
|
+
+ ("..." if len(missing) > 5 else ""),
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
else:
|
|
492
|
+
results.append(
|
|
493
|
+
(PASS, "Standard permissions present", f"{len(standard)} standard entries")
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
return results
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _check_plugin_dev_isolation(
|
|
500
|
+
info: ProjectInfo,
|
|
501
|
+
) -> list[tuple[str, str, str]]:
|
|
502
|
+
"""Check plugin repos follow the dev/prod namespace isolation standard.
|
|
503
|
+
|
|
504
|
+
Checks:
|
|
505
|
+
1. plugin.json name ends in -dev (working tree is the dev namespace)
|
|
506
|
+
2. Every prod command has a -dev variant
|
|
507
|
+
3. Release/restore scripts exist
|
|
508
|
+
"""
|
|
509
|
+
if not info.is_plugin:
|
|
510
|
+
return []
|
|
511
|
+
|
|
512
|
+
results: list[tuple[str, str, str]] = []
|
|
513
|
+
|
|
514
|
+
# Check plugin name has -dev suffix
|
|
515
|
+
plugin_name = _read_plugin_name(info)
|
|
516
|
+
if plugin_name is not None:
|
|
517
|
+
if plugin_name.endswith("-dev"):
|
|
518
|
+
results.append((PASS, "Plugin name has -dev suffix", plugin_name))
|
|
519
|
+
else:
|
|
520
|
+
results.append(
|
|
521
|
+
(
|
|
522
|
+
FAIL,
|
|
523
|
+
"Plugin name has -dev suffix",
|
|
524
|
+
f"'{plugin_name}' — should be '{plugin_name}-dev'",
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
# Check release/restore scripts exist
|
|
529
|
+
release_script = info.root / "scripts" / "release-plugin.sh"
|
|
530
|
+
restore_script = info.root / "scripts" / "restore-dev-plugin.sh"
|
|
531
|
+
if release_script.exists() and restore_script.exists():
|
|
532
|
+
results.append((PASS, "Release/restore scripts exist", ""))
|
|
533
|
+
else:
|
|
534
|
+
missing_scripts: list[str] = []
|
|
535
|
+
if not release_script.exists():
|
|
536
|
+
missing_scripts.append("scripts/release-plugin.sh")
|
|
537
|
+
if not restore_script.exists():
|
|
538
|
+
missing_scripts.append("scripts/restore-dev-plugin.sh")
|
|
539
|
+
detail = f"Missing: {', '.join(missing_scripts)}"
|
|
540
|
+
results.append((FAIL, "Release/restore scripts exist", detail))
|
|
541
|
+
|
|
542
|
+
# Check -dev command variants
|
|
543
|
+
commands_dir = info.root / "commands"
|
|
544
|
+
if commands_dir.is_dir():
|
|
545
|
+
prod_commands = sorted(
|
|
546
|
+
f.stem for f in commands_dir.glob("*.md") if not f.stem.endswith("-dev")
|
|
547
|
+
)
|
|
548
|
+
dev_commands = sorted(
|
|
549
|
+
f.stem.removesuffix("-dev") for f in commands_dir.glob("*-dev.md")
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if prod_commands:
|
|
553
|
+
missing = [c for c in prod_commands if c not in dev_commands]
|
|
554
|
+
if missing:
|
|
555
|
+
results.append(
|
|
556
|
+
(
|
|
557
|
+
FAIL,
|
|
558
|
+
"Dev command variants exist",
|
|
559
|
+
f"Missing: {', '.join(f'{c}-dev.md' for c in missing)}",
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
else:
|
|
563
|
+
results.append(
|
|
564
|
+
(
|
|
565
|
+
PASS,
|
|
566
|
+
"Dev command variants exist",
|
|
567
|
+
f"{len(dev_commands)} dev commands",
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
return results
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _read_plugin_name(info: ProjectInfo) -> str | None:
|
|
575
|
+
"""Read plugin name from plugin.json."""
|
|
576
|
+
for pj_path in (
|
|
577
|
+
info.root / ".claude-plugin" / "plugin.json",
|
|
578
|
+
info.root / "plugin.json",
|
|
579
|
+
):
|
|
580
|
+
if pj_path.exists():
|
|
581
|
+
try:
|
|
582
|
+
data = json.loads(pj_path.read_text(encoding="utf-8"))
|
|
583
|
+
name = data.get("name")
|
|
584
|
+
if isinstance(name, str):
|
|
585
|
+
return name
|
|
586
|
+
except (json.JSONDecodeError, OSError):
|
|
587
|
+
pass
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _find_package_dir(info: ProjectInfo) -> Path | None:
|
|
592
|
+
"""Find the Python package directory (src layout)."""
|
|
593
|
+
src_dir = info.root / "src"
|
|
594
|
+
if not src_dir.is_dir():
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
# Look for directories with __init__.py under src/
|
|
598
|
+
for child in sorted(src_dir.iterdir()):
|
|
599
|
+
if child.is_dir() and (child / "__init__.py").exists():
|
|
600
|
+
return child
|
|
601
|
+
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _get_project_name(info: ProjectInfo) -> str:
|
|
606
|
+
"""Extract human-readable project name from metadata."""
|
|
607
|
+
if info.pyproject is not None:
|
|
608
|
+
project_raw = info.pyproject.get("project")
|
|
609
|
+
if isinstance(project_raw, dict):
|
|
610
|
+
project = cast("dict[str, object]", project_raw)
|
|
611
|
+
name = project.get("name")
|
|
612
|
+
if isinstance(name, str):
|
|
613
|
+
return name
|
|
614
|
+
|
|
615
|
+
return info.root.name
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _relpath(path: Path, root: Path) -> str:
|
|
619
|
+
"""Return path relative to root as a string."""
|
|
620
|
+
try:
|
|
621
|
+
return str(path.relative_to(root))
|
|
622
|
+
except ValueError:
|
|
623
|
+
return str(path)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _get_github_repo(root: Path) -> str | None:
|
|
627
|
+
"""Extract GitHub owner/repo from git remote."""
|
|
628
|
+
try:
|
|
629
|
+
result = subprocess.run(
|
|
630
|
+
["git", "remote", "get-url", "origin"],
|
|
631
|
+
cwd=str(root),
|
|
632
|
+
capture_output=True,
|
|
633
|
+
text=True,
|
|
634
|
+
timeout=5,
|
|
635
|
+
)
|
|
636
|
+
if result.returncode != 0:
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
url = result.stdout.strip()
|
|
640
|
+
# Handle SSH format: git@github.com:owner/repo.git
|
|
641
|
+
if url.startswith("git@github.com:"):
|
|
642
|
+
repo = url.removeprefix("git@github.com:").removesuffix(".git")
|
|
643
|
+
return repo
|
|
644
|
+
# Handle HTTPS format: https://github.com/owner/repo.git
|
|
645
|
+
if "github.com/" in url:
|
|
646
|
+
parts = url.split("github.com/")[1].removesuffix(".git")
|
|
647
|
+
return parts
|
|
648
|
+
return None
|
|
649
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
650
|
+
return None
|