project-checks 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.
- project_checks/__init__.py +1 -0
- project_checks/change_impact.py +443 -0
- project_checks/diagnostics.py +1080 -0
- project_checks/generated_file_guard.py +352 -0
- project_checks/markdown_claims.py +209 -0
- project_checks/repo_context.py +411 -0
- project_checks-0.1.0.dist-info/METADATA +15 -0
- project_checks-0.1.0.dist-info/RECORD +10 -0
- project_checks-0.1.0.dist-info/WHEEL +4 -0
- project_checks-0.1.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Project-level diagnostic tools."""
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Summarise Git change categories, risk signals, and focused verification hints.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_PROJECT_DIR = Path.cwd()
|
|
16
|
+
|
|
17
|
+
CATEGORY_LABELS = {
|
|
18
|
+
"config": "Config",
|
|
19
|
+
"docs": "Docs",
|
|
20
|
+
"generated": "Generated",
|
|
21
|
+
"scripts": "Scripts",
|
|
22
|
+
"skills": "Skills",
|
|
23
|
+
"source": "Source",
|
|
24
|
+
"templates": "Templates",
|
|
25
|
+
"tests": "Tests",
|
|
26
|
+
"other": "Other",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
CATEGORY_ORDER = [
|
|
30
|
+
"source",
|
|
31
|
+
"tests",
|
|
32
|
+
"skills",
|
|
33
|
+
"scripts",
|
|
34
|
+
"config",
|
|
35
|
+
"templates",
|
|
36
|
+
"docs",
|
|
37
|
+
"generated",
|
|
38
|
+
"other",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
GENERATED_PATHS = [
|
|
42
|
+
"dist/",
|
|
43
|
+
"dist-docs/",
|
|
44
|
+
"build/",
|
|
45
|
+
"coverage/",
|
|
46
|
+
"test-results/",
|
|
47
|
+
"playwright-report/",
|
|
48
|
+
"docs/agents.md",
|
|
49
|
+
"docs/commands.md",
|
|
50
|
+
"docs/hooks.md",
|
|
51
|
+
"docs/plugins.md",
|
|
52
|
+
"docs/skills.md",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
CONFIG_FILENAMES = {
|
|
56
|
+
".editorconfig",
|
|
57
|
+
".eslintrc",
|
|
58
|
+
".gitignore",
|
|
59
|
+
".prettierrc",
|
|
60
|
+
"AGENTS.md",
|
|
61
|
+
"AGENT_CAPABILITIES.md",
|
|
62
|
+
"WORKSPACE.md",
|
|
63
|
+
"package.json",
|
|
64
|
+
"pyproject.toml",
|
|
65
|
+
"tsconfig.json",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
DOC_EXTENSIONS = {".md", ".mdx", ".rst", ".txt"}
|
|
69
|
+
SOURCE_EXTENSIONS = {
|
|
70
|
+
".css",
|
|
71
|
+
".go",
|
|
72
|
+
".html",
|
|
73
|
+
".js",
|
|
74
|
+
".jsx",
|
|
75
|
+
".py",
|
|
76
|
+
".rb",
|
|
77
|
+
".swift",
|
|
78
|
+
".ts",
|
|
79
|
+
".tsx",
|
|
80
|
+
".vue",
|
|
81
|
+
}
|
|
82
|
+
TEST_MARKERS = ["/test/", "/tests/", "/__tests__/", ".test.", ".spec.", "_test."]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ChangedPath:
|
|
87
|
+
status: str
|
|
88
|
+
path: str
|
|
89
|
+
category: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class RiskSignal:
|
|
94
|
+
code: str
|
|
95
|
+
message: str
|
|
96
|
+
path: str
|
|
97
|
+
severity: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def command_output(command: list[str], project_dir: Path) -> tuple[int, str]:
|
|
101
|
+
completed = subprocess.run(
|
|
102
|
+
command,
|
|
103
|
+
cwd=project_dir,
|
|
104
|
+
text=True,
|
|
105
|
+
stdout=subprocess.PIPE,
|
|
106
|
+
stderr=subprocess.DEVNULL,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return completed.returncode, completed.stdout
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def git_status(project_dir: Path) -> list[str]:
|
|
114
|
+
exit_code, output = command_output(["git", "status", "--porcelain=v1"], project_dir)
|
|
115
|
+
if exit_code != 0:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
return output.splitlines()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def changed_paths(project_dir: Path) -> list[ChangedPath]:
|
|
122
|
+
changed = []
|
|
123
|
+
|
|
124
|
+
for line in git_status(project_dir):
|
|
125
|
+
status = line[:2]
|
|
126
|
+
path = line[3:]
|
|
127
|
+
if " -> " in path:
|
|
128
|
+
_, path = path.split(" -> ", 1)
|
|
129
|
+
changed.append(
|
|
130
|
+
ChangedPath(status=status, path=path, category=classify_path(path))
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return sorted(changed, key=lambda item: item.path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_path_match(path: str, pattern: str) -> bool:
|
|
137
|
+
if pattern.endswith("/"):
|
|
138
|
+
return path.startswith(pattern)
|
|
139
|
+
|
|
140
|
+
return path == pattern or path.startswith(f"{pattern}/")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def classify_path(path: str) -> str:
|
|
144
|
+
path_object = Path(path)
|
|
145
|
+
name = path_object.name
|
|
146
|
+
suffix = path_object.suffix
|
|
147
|
+
|
|
148
|
+
if any(is_path_match(path, pattern) for pattern in GENERATED_PATHS):
|
|
149
|
+
return "generated"
|
|
150
|
+
if path.startswith("tests/") or any(
|
|
151
|
+
marker in f"/{path}" for marker in TEST_MARKERS
|
|
152
|
+
):
|
|
153
|
+
return "tests"
|
|
154
|
+
if path.startswith("skills/"):
|
|
155
|
+
return "skills"
|
|
156
|
+
if path.startswith("scripts/") or path.startswith("hooks/") or suffix in {".sh"}:
|
|
157
|
+
return "scripts"
|
|
158
|
+
if path.startswith("templates/"):
|
|
159
|
+
return "templates"
|
|
160
|
+
if (
|
|
161
|
+
name in CONFIG_FILENAMES
|
|
162
|
+
or path.startswith("adapters/")
|
|
163
|
+
or path.startswith("rules/")
|
|
164
|
+
):
|
|
165
|
+
return "config"
|
|
166
|
+
if path.startswith("docs/") or suffix in DOC_EXTENSIONS:
|
|
167
|
+
return "docs"
|
|
168
|
+
if (
|
|
169
|
+
path.startswith(("src/", "app/", "lib/", "packages/"))
|
|
170
|
+
or suffix in SOURCE_EXTENSIONS
|
|
171
|
+
):
|
|
172
|
+
return "source"
|
|
173
|
+
|
|
174
|
+
return "other"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def group_changed(changed: list[ChangedPath]) -> dict[str, list[str]]:
|
|
178
|
+
grouped = {category: [] for category in CATEGORY_ORDER}
|
|
179
|
+
|
|
180
|
+
for item in changed:
|
|
181
|
+
grouped.setdefault(item.category, []).append(item.path)
|
|
182
|
+
|
|
183
|
+
return {category: paths for category, paths in grouped.items() if paths}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def script_candidates(project_dir: Path, script_name: str) -> list[Path]:
|
|
187
|
+
return [
|
|
188
|
+
Path(__file__).resolve().with_name(script_name),
|
|
189
|
+
project_dir / ".agent" / "scripts" / script_name,
|
|
190
|
+
project_dir / "scripts" / script_name,
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def existing_script(project_dir: Path, script_name: str) -> Path | None:
|
|
195
|
+
for path in script_candidates(project_dir, script_name):
|
|
196
|
+
if path.exists():
|
|
197
|
+
return path
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def generated_guard(project_dir: Path) -> dict[str, Any]:
|
|
203
|
+
try:
|
|
204
|
+
from project_checks.generated_file_guard import guard
|
|
205
|
+
except ModuleNotFoundError as error:
|
|
206
|
+
if error.name != "project_checks":
|
|
207
|
+
return {"available": False, "findings": [], "ok": False}
|
|
208
|
+
|
|
209
|
+
source_root = str(Path(__file__).resolve().parents[1])
|
|
210
|
+
if source_root not in sys.path:
|
|
211
|
+
sys.path.insert(0, source_root)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
from project_checks.generated_file_guard import guard
|
|
215
|
+
except (ImportError, OSError):
|
|
216
|
+
return {"available": False, "findings": [], "ok": False}
|
|
217
|
+
except (ImportError, OSError):
|
|
218
|
+
return {"available": False, "findings": [], "ok": False}
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
result = guard(project_dir)
|
|
222
|
+
except OSError:
|
|
223
|
+
return {"available": False, "findings": [], "ok": False}
|
|
224
|
+
|
|
225
|
+
result["available"] = True
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def diagnostics(project_dir: Path) -> dict[str, Any]:
|
|
230
|
+
script = existing_script(project_dir, "project-diagnostics.py")
|
|
231
|
+
if not script:
|
|
232
|
+
return {"available": False, "checks": []}
|
|
233
|
+
|
|
234
|
+
exit_code, output = command_output(
|
|
235
|
+
[str(script), "--project", str(project_dir), "--json", "--list"], project_dir
|
|
236
|
+
)
|
|
237
|
+
if exit_code != 0:
|
|
238
|
+
return {"available": False, "checks": []}
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
result = json.loads(output)
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
return {"available": False, "checks": []}
|
|
244
|
+
|
|
245
|
+
result["available"] = True
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def risk_signals(
|
|
250
|
+
grouped: dict[str, list[str]], guard_result: dict[str, Any]
|
|
251
|
+
) -> list[RiskSignal]:
|
|
252
|
+
signals = []
|
|
253
|
+
|
|
254
|
+
for finding in guard_result.get("findings", []):
|
|
255
|
+
signals.append(
|
|
256
|
+
RiskSignal(
|
|
257
|
+
code=finding.get("code", "generated-risk"),
|
|
258
|
+
message=finding.get("message", "Generated output needs review."),
|
|
259
|
+
path=finding.get("path", ""),
|
|
260
|
+
severity="high",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if "scripts" in grouped:
|
|
265
|
+
signals.append(
|
|
266
|
+
RiskSignal(
|
|
267
|
+
code="scripts-changed",
|
|
268
|
+
message="Script changes can affect setup, validation, or generated output.",
|
|
269
|
+
path=", ".join(grouped["scripts"]),
|
|
270
|
+
severity="medium",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if "config" in grouped:
|
|
275
|
+
signals.append(
|
|
276
|
+
RiskSignal(
|
|
277
|
+
code="config-changed",
|
|
278
|
+
message="Configuration or rule changes can affect agent behaviour.",
|
|
279
|
+
path=", ".join(grouped["config"]),
|
|
280
|
+
severity="medium",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if "generated" in grouped and not guard_result.get("findings"):
|
|
285
|
+
signals.append(
|
|
286
|
+
RiskSignal(
|
|
287
|
+
code="generated-changed",
|
|
288
|
+
message="Generated output changed; verify it came from the source generator.",
|
|
289
|
+
path=", ".join(grouped["generated"]),
|
|
290
|
+
severity="low",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return signals
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def verification_gaps(
|
|
298
|
+
grouped: dict[str, list[str]], guard_result: dict[str, Any]
|
|
299
|
+
) -> list[str]:
|
|
300
|
+
gaps = []
|
|
301
|
+
substantive_categories = {"config", "scripts", "skills", "source", "templates"}
|
|
302
|
+
|
|
303
|
+
if substantive_categories.intersection(grouped) and "tests" not in grouped:
|
|
304
|
+
gaps.append(
|
|
305
|
+
"No test files changed alongside source, skill, script, template, or config changes."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if {"config", "scripts", "source"}.intersection(grouped) and "docs" not in grouped:
|
|
309
|
+
gaps.append("No docs changed alongside source, script, or config changes.")
|
|
310
|
+
|
|
311
|
+
if guard_result.get("findings"):
|
|
312
|
+
gaps.append("Generated/source mismatch detected by generated-file guard.")
|
|
313
|
+
elif "generated" in grouped:
|
|
314
|
+
gaps.append("Generated output changed; confirm the generator was run.")
|
|
315
|
+
|
|
316
|
+
return gaps
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def suggested_checks(
|
|
320
|
+
project_dir: Path, diagnostics_result: dict[str, Any], guard_result: dict[str, Any]
|
|
321
|
+
) -> list[str]:
|
|
322
|
+
checks = []
|
|
323
|
+
|
|
324
|
+
if guard_result.get("available"):
|
|
325
|
+
checks.append("scripts/validate/generated-file-guard.py")
|
|
326
|
+
|
|
327
|
+
if (project_dir / "scripts" / "validate.sh").exists():
|
|
328
|
+
checks.append("bash scripts/validate.sh")
|
|
329
|
+
|
|
330
|
+
for check in diagnostics_result.get("checks", []):
|
|
331
|
+
command = check.get("command", [])
|
|
332
|
+
if command:
|
|
333
|
+
checks.append(" ".join(command))
|
|
334
|
+
|
|
335
|
+
return checks
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def build_report(project_dir: Path) -> dict[str, Any]:
|
|
339
|
+
changed = changed_paths(project_dir)
|
|
340
|
+
grouped = group_changed(changed)
|
|
341
|
+
guard_result = generated_guard(project_dir)
|
|
342
|
+
diagnostics_result = diagnostics(project_dir)
|
|
343
|
+
risks = risk_signals(grouped, guard_result)
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"changed": [item.__dict__ for item in changed],
|
|
347
|
+
"changed_count": len(changed),
|
|
348
|
+
"groups": grouped,
|
|
349
|
+
"guard": guard_result,
|
|
350
|
+
"ok": not any(risk.severity == "high" for risk in risks),
|
|
351
|
+
"project_dir": str(project_dir),
|
|
352
|
+
"risks": [risk.__dict__ for risk in risks],
|
|
353
|
+
"suggested_checks": suggested_checks(
|
|
354
|
+
project_dir, diagnostics_result, guard_result
|
|
355
|
+
),
|
|
356
|
+
"verification_gaps": verification_gaps(grouped, guard_result),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def render_paths(paths: list[str], limit: int = 4) -> str:
|
|
361
|
+
if len(paths) <= limit:
|
|
362
|
+
return ", ".join(f"`{path}`" for path in paths)
|
|
363
|
+
|
|
364
|
+
visible = ", ".join(f"`{path}`" for path in paths[:limit])
|
|
365
|
+
return f"{visible}, +{len(paths) - limit} more"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def render_markdown(report: dict[str, Any]) -> str:
|
|
369
|
+
lines = [
|
|
370
|
+
"# Change impact",
|
|
371
|
+
"",
|
|
372
|
+
f"Project: `{report['project_dir']}`",
|
|
373
|
+
f"Git: {report['changed_count']} changed file(s)",
|
|
374
|
+
"",
|
|
375
|
+
"## Changed files",
|
|
376
|
+
"",
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
if report["groups"]:
|
|
380
|
+
lines.extend(["| Category | Count | Paths |", "| --- | ---: | --- |"])
|
|
381
|
+
for category in CATEGORY_ORDER:
|
|
382
|
+
paths = report["groups"].get(category)
|
|
383
|
+
if paths:
|
|
384
|
+
lines.append(
|
|
385
|
+
f"| {CATEGORY_LABELS[category]} | {len(paths)} | {render_paths(paths)} |"
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
lines.append("No changed files detected.")
|
|
389
|
+
|
|
390
|
+
lines.extend(["", "## Risk signals", ""])
|
|
391
|
+
if report["risks"]:
|
|
392
|
+
for risk in report["risks"]:
|
|
393
|
+
path = f" `{risk['path']}`:" if risk["path"] else ""
|
|
394
|
+
lines.append(
|
|
395
|
+
f"- {risk['severity']}: {path} {risk['message']}".replace(": ", ": ")
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
lines.append("- None detected.")
|
|
399
|
+
|
|
400
|
+
lines.extend(["", "## Verification gaps", ""])
|
|
401
|
+
if report["verification_gaps"]:
|
|
402
|
+
lines.extend(f"- {gap}" for gap in report["verification_gaps"])
|
|
403
|
+
else:
|
|
404
|
+
lines.append("- None detected.")
|
|
405
|
+
|
|
406
|
+
lines.extend(["", "## Suggested checks", ""])
|
|
407
|
+
if report["suggested_checks"]:
|
|
408
|
+
lines.extend(f"- `{check}`" for check in report["suggested_checks"])
|
|
409
|
+
else:
|
|
410
|
+
lines.append("- No local checks discovered.")
|
|
411
|
+
|
|
412
|
+
return "\n".join(lines)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main() -> None:
|
|
416
|
+
parser = argparse.ArgumentParser(
|
|
417
|
+
description="Summarise local change impact from Git status."
|
|
418
|
+
)
|
|
419
|
+
parser.add_argument(
|
|
420
|
+
"--project-dir",
|
|
421
|
+
type=Path,
|
|
422
|
+
default=DEFAULT_PROJECT_DIR,
|
|
423
|
+
help="Project directory to inspect.",
|
|
424
|
+
)
|
|
425
|
+
parser.add_argument(
|
|
426
|
+
"--json", action="store_true", help="Print machine-readable JSON."
|
|
427
|
+
)
|
|
428
|
+
args = parser.parse_args()
|
|
429
|
+
|
|
430
|
+
project_dir = args.project_dir.resolve()
|
|
431
|
+
report = build_report(project_dir)
|
|
432
|
+
|
|
433
|
+
if args.json:
|
|
434
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
435
|
+
else:
|
|
436
|
+
print(render_markdown(report))
|
|
437
|
+
|
|
438
|
+
if not report["ok"]:
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
if __name__ == "__main__":
|
|
443
|
+
main()
|