project-checks 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .venv/
4
+
5
+ # Build output
6
+ dist/
7
+ build/
8
+ *.tsbuildinfo
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *.egg-info/
14
+ .pytest_cache/
15
+
16
+ # Environment
17
+ .env
18
+ .env.*
19
+
20
+ # OS
21
+ .DS_Store
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: project-checks
3
+ Version: 0.1.0
4
+ Summary: Run conservative project diagnostics.
5
+ Project-URL: Homepage, https://github.com/lewishowles/dev-tools/tree/main/packages/project-checks
6
+ Project-URL: Repository, https://github.com/lewishowles/dev-tools
7
+ Author: Lewis Howles
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Requires-Python: >=3.11
@@ -0,0 +1,52 @@
1
+ # project-checks
2
+
3
+ Runs conservative diagnostics against a project: safe package scripts, Git change-impact summaries, generated-file drift detection, Markdown claims verification, and a compact repo briefing for agent session startup. Built for agent workflows, where a script needs to know what's safe to run without guessing.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.11+
8
+ - [uv](https://docs.astral.sh/uv/)
9
+
10
+ ## Getting started
11
+
12
+ Install it globally with `uv`:
13
+
14
+ ```bash
15
+ uv tool install project-checks
16
+ ```
17
+
18
+ To install it into an existing Python environment with `pip`:
19
+
20
+ ```bash
21
+ pip install project-checks
22
+ ```
23
+
24
+ This installs five commands on your PATH:
25
+
26
+ - `project-checks`: list or run conservative project diagnostics (package scripts such as lint, typecheck, and test)
27
+ - `project-checks-change-impact`: summarise local change impact from Git status
28
+ - `project-checks-generated-file-guard`: detect generated-file edits and stale generated output
29
+ - `project-checks-markdown-claims`: check that paths and commands claimed in Markdown files actually exist
30
+ - `project-checks-repo-context`: print a compact repo briefing for agent session startup
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ # List available checks without running them
36
+ project-checks --list --project ./my-project
37
+
38
+ # Run one or more checks
39
+ project-checks --check lint --check test:unit --project ./my-project
40
+
41
+ # The other four commands take --project-dir instead of --project
42
+ project-checks-change-impact --project-dir ./my-project
43
+ project-checks-generated-file-guard --project-dir ./my-project
44
+ project-checks-markdown-claims --project-dir ./my-project --mode all
45
+ project-checks-repo-context --project-dir ./my-project
46
+ ```
47
+
48
+ Add `--json` to any command for machine-readable output.
49
+
50
+ ## Other packages in this workspace
51
+
52
+ - [dev-tools](../../README.md)
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "project-checks"
3
+ version = "0.1.0"
4
+ description = "Run conservative project diagnostics."
5
+ authors = [{ name = "Lewis Howles" }]
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.11"
8
+ dependencies = []
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Topic :: Software Development :: Quality Assurance",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/lewishowles/dev-tools/tree/main/packages/project-checks"
20
+ Repository = "https://github.com/lewishowles/dev-tools"
21
+
22
+ [project.scripts]
23
+ project-checks = "project_checks.diagnostics:main"
24
+ project-checks-change-impact = "project_checks.change_impact:main"
25
+ project-checks-generated-file-guard = "project_checks.generated_file_guard:main"
26
+ project-checks-markdown-claims = "project_checks.markdown_claims:main"
27
+ project-checks-repo-context = "project_checks.repo_context:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/project_checks"]
@@ -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()