MertCapkin-GraphStack 4.5.1__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.
- graphstack/__init__.py +12 -0
- graphstack/__main__.py +10 -0
- graphstack/assets/docs/CURSOR_PROMPTS.md +215 -0
- graphstack/assets/handoff/BOOTSTRAP.md +73 -0
- graphstack/assets/handoff/BRIEF.md +66 -0
- graphstack/assets/handoff/REVIEW.md +7 -0
- graphstack/assets/handoff/board/README.md +60 -0
- graphstack/assets/orchestrator/ORCHESTRATOR.md +416 -0
- graphstack/assets/orchestrator/TOKEN_OPTIMIZER.md +319 -0
- graphstack/assets/scripts/board.ps1 +37 -0
- graphstack/assets/scripts/board.sh +22 -0
- graphstack/assets/scripts/gate-hook.ps1 +41 -0
- graphstack/assets/scripts/gate-hook.sh +26 -0
- graphstack/assets/scripts/post-commit +20 -0
- graphstack/assets/scripts/post-commit.ps1 +44 -0
- graphstack/board.py +361 -0
- graphstack/bootstrap.py +50 -0
- graphstack/cli.py +99 -0
- graphstack/compact/__init__.py +9 -0
- graphstack/compact/__pycache__/__init__.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/base.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/generic.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/git.cpython-311.pyc +0 -0
- graphstack/compact/__pycache__/registry.cpython-311.pyc +0 -0
- graphstack/compact/base.py +115 -0
- graphstack/compact/generic.py +90 -0
- graphstack/compact/git.py +167 -0
- graphstack/compact/registry.py +47 -0
- graphstack/constants.py +38 -0
- graphstack/gate.py +429 -0
- graphstack/graph.py +143 -0
- graphstack/hook.py +144 -0
- graphstack/init_cmd.py +113 -0
- graphstack/installer.py +366 -0
- graphstack/platform_utils.py +127 -0
- graphstack/run.py +103 -0
- graphstack/state.py +117 -0
- graphstack/tests/__init__.py +0 -0
- graphstack/tests/conftest.py +30 -0
- graphstack/tests/test_assets.py +35 -0
- graphstack/tests/test_board.py +166 -0
- graphstack/tests/test_compact.py +93 -0
- graphstack/tests/test_gate.py +406 -0
- graphstack/tests/test_graph.py +60 -0
- graphstack/tests/test_hook.py +57 -0
- graphstack/tests/test_init.py +58 -0
- graphstack/tests/test_installer.py +73 -0
- graphstack/tests/test_platform_utils.py +69 -0
- graphstack/tests/test_state.py +56 -0
- graphstack/tests/test_validate.py +204 -0
- graphstack/validate.py +469 -0
- mertcapkin_graphstack-4.5.1.dist-info/METADATA +720 -0
- mertcapkin_graphstack-4.5.1.dist-info/RECORD +57 -0
- mertcapkin_graphstack-4.5.1.dist-info/WHEEL +5 -0
- mertcapkin_graphstack-4.5.1.dist-info/entry_points.txt +2 -0
- mertcapkin_graphstack-4.5.1.dist-info/licenses/LICENSE +21 -0
- mertcapkin_graphstack-4.5.1.dist-info/top_level.txt +1 -0
graphstack/validate.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""GraphStack project health checks (LLM-free).
|
|
2
|
+
|
|
3
|
+
Validates handoff layout, board task JSON, brief readiness, and graph freshness.
|
|
4
|
+
Use ``graphstack doctor`` for a human-friendly report of the same checks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .constants import (
|
|
17
|
+
BOARD_DIR,
|
|
18
|
+
DOING_DIR,
|
|
19
|
+
DONE_DIR,
|
|
20
|
+
GRAPH_REPORT,
|
|
21
|
+
HANDOFF_DIR,
|
|
22
|
+
TASK_REQUIRED_KEYS,
|
|
23
|
+
TODO_DIR,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
FRAMEWORK_MARKER = Path(".graphstack-framework")
|
|
27
|
+
from .platform_utils import echo, git_available, graphify_available, run_git
|
|
28
|
+
|
|
29
|
+
BRIEF_TEMPLATE_MARKERS = (
|
|
30
|
+
"[Feature/Change Name]",
|
|
31
|
+
"YYYY-MM-DD",
|
|
32
|
+
"> One sentence. What outcome does the user want?",
|
|
33
|
+
)
|
|
34
|
+
BRIEF_READY_STATUSES = ("Ready for Builder", "In Review", "Complete")
|
|
35
|
+
GRAPH_COMMIT_RE = re.compile(r"Built from commit:\s*`([0-9a-f]+)`", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
REQUIRED_PATHS = (
|
|
38
|
+
".cursor/rules/graphstack.mdc",
|
|
39
|
+
"orchestrator/ORCHESTRATOR.md",
|
|
40
|
+
"orchestrator/TOKEN_OPTIMIZER.md",
|
|
41
|
+
".cursor/skills/architect/ARCHITECT.md",
|
|
42
|
+
".cursor/skills/builder/BUILDER.md",
|
|
43
|
+
"handoff/BRIEF.md",
|
|
44
|
+
"handoff/STATE.md",
|
|
45
|
+
"handoff/board/README.md",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Finding:
|
|
51
|
+
level: str # error | warn | ok
|
|
52
|
+
code: str
|
|
53
|
+
message: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Report:
|
|
58
|
+
findings: list[Finding] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def add(self, level: str, code: str, message: str) -> None:
|
|
61
|
+
self.findings.append(Finding(level, code, message))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def errors(self) -> list[Finding]:
|
|
65
|
+
return [f for f in self.findings if f.level == "error"]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def warnings(self) -> list[Finding]:
|
|
69
|
+
return [f for f in self.findings if f.level == "warn"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _root() -> Path:
|
|
73
|
+
return Path.cwd()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _iter_board_tasks() -> list[Path]:
|
|
77
|
+
paths: list[Path] = []
|
|
78
|
+
for directory in (TODO_DIR, DOING_DIR, DONE_DIR):
|
|
79
|
+
if not directory.is_dir():
|
|
80
|
+
continue
|
|
81
|
+
paths.extend(sorted(directory.glob("*.json")))
|
|
82
|
+
return paths
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _brief_is_template(text: str) -> bool:
|
|
86
|
+
return any(marker in text for marker in BRIEF_TEMPLATE_MARKERS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _brief_status(text: str) -> str | None:
|
|
90
|
+
match = re.search(r"\*\*Status:\*\*\s*(.+)", text)
|
|
91
|
+
if not match:
|
|
92
|
+
return None
|
|
93
|
+
return match.group(1).strip()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_layout(report: Report, root: Path) -> None:
|
|
97
|
+
for rel in REQUIRED_PATHS:
|
|
98
|
+
path = root / rel
|
|
99
|
+
if path.is_file():
|
|
100
|
+
report.add("ok", "layout_ok", f"Found {rel}")
|
|
101
|
+
else:
|
|
102
|
+
report.add("error", "layout_missing", f"Missing required file: {rel}")
|
|
103
|
+
|
|
104
|
+
for rel in ("handoff/board/todo", "handoff/board/doing", "handoff/board/done"):
|
|
105
|
+
path = root / rel
|
|
106
|
+
if path.is_dir():
|
|
107
|
+
report.add("ok", "board_dir_ok", f"Found {rel}/")
|
|
108
|
+
else:
|
|
109
|
+
report.add("error", "board_dir_missing", f"Missing directory: {rel}/")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_brief(report: Report, root: Path, *, strict: bool) -> None:
|
|
113
|
+
brief_path = root / "handoff" / "BRIEF.md"
|
|
114
|
+
if not brief_path.is_file():
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
text = brief_path.read_text(encoding="utf-8")
|
|
118
|
+
if _brief_is_template(text):
|
|
119
|
+
level = "error" if strict else "warn"
|
|
120
|
+
report.add(
|
|
121
|
+
level,
|
|
122
|
+
"brief_template",
|
|
123
|
+
"handoff/BRIEF.md still contains template placeholders",
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
status = _brief_status(text)
|
|
128
|
+
if status and status.startswith("Draft"):
|
|
129
|
+
report.add("warn", "brief_draft", f"BRIEF.md status is '{status}' (not ready for Builder)")
|
|
130
|
+
elif status and any(s in status for s in BRIEF_READY_STATUSES):
|
|
131
|
+
report.add("ok", "brief_ready", f"BRIEF.md status: {status}")
|
|
132
|
+
elif status:
|
|
133
|
+
report.add("ok", "brief_status", f"BRIEF.md status: {status}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check_board_tasks(report: Report) -> None:
|
|
137
|
+
for path in _iter_board_tasks():
|
|
138
|
+
try:
|
|
139
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
140
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
141
|
+
report.add("error", "task_invalid_json", f"{path}: {exc}")
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
missing = [k for k in TASK_REQUIRED_KEYS if k not in data]
|
|
145
|
+
if missing:
|
|
146
|
+
report.add(
|
|
147
|
+
"error",
|
|
148
|
+
"task_missing_keys",
|
|
149
|
+
f"{path.name}: missing keys {missing}",
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
task_id = data.get("id")
|
|
154
|
+
if task_id and path.stem != str(task_id):
|
|
155
|
+
report.add(
|
|
156
|
+
"warn",
|
|
157
|
+
"task_id_mismatch",
|
|
158
|
+
f"{path.name}: filename does not match id '{task_id}'",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
folder = path.parent.name
|
|
162
|
+
status = str(data.get("status", ""))
|
|
163
|
+
if folder == "todo" and status != "todo":
|
|
164
|
+
report.add(
|
|
165
|
+
"warn",
|
|
166
|
+
"task_status_folder",
|
|
167
|
+
f"{path.name}: in todo/ but status is '{status}'",
|
|
168
|
+
)
|
|
169
|
+
elif folder == "doing" and status != "doing":
|
|
170
|
+
report.add(
|
|
171
|
+
"warn",
|
|
172
|
+
"task_status_folder",
|
|
173
|
+
f"{path.name}: in doing/ but status is '{status}'",
|
|
174
|
+
)
|
|
175
|
+
elif folder == "done" and status != "done":
|
|
176
|
+
report.add(
|
|
177
|
+
"warn",
|
|
178
|
+
"task_status_folder",
|
|
179
|
+
f"{path.name}: in done/ but status is '{status}'",
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
report.add("ok", "task_ok", f"{path.name} ({folder}, {status})")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _commit_matches_graph(graph_commit: str, ref: str) -> bool:
|
|
186
|
+
ref = ref.strip().lower()
|
|
187
|
+
graph_commit = graph_commit.lower()
|
|
188
|
+
return ref.startswith(graph_commit) or graph_commit.startswith(ref[: len(graph_commit)])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _refs_for_staleness_check() -> list[str]:
|
|
192
|
+
"""HEAD, HEAD~1, and last commit that touched the graph report.
|
|
193
|
+
|
|
194
|
+
Graph is often built on HEAD~1 then committed on HEAD (release workflow).
|
|
195
|
+
GitHub Actions uses fetch-depth: 1 by default — without HEAD~1, fall back to
|
|
196
|
+
the commit that last modified GRAPH_REPORT.md (needs at least that commit).
|
|
197
|
+
"""
|
|
198
|
+
refs: list[str] = []
|
|
199
|
+
seen: set[str] = set()
|
|
200
|
+
for arg in ("HEAD", "HEAD~1"):
|
|
201
|
+
proc = run_git("rev-parse", arg)
|
|
202
|
+
if proc.returncode == 0 and proc.stdout:
|
|
203
|
+
ref = proc.stdout.strip().lower()
|
|
204
|
+
if ref not in seen:
|
|
205
|
+
seen.add(ref)
|
|
206
|
+
refs.append(ref)
|
|
207
|
+
proc = run_git("log", "-1", "--format=%H", "--", str(GRAPH_REPORT))
|
|
208
|
+
if proc.returncode == 0 and proc.stdout:
|
|
209
|
+
ref = proc.stdout.strip().lower()
|
|
210
|
+
if ref not in seen:
|
|
211
|
+
seen.add(ref)
|
|
212
|
+
refs.append(ref)
|
|
213
|
+
return refs
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _state_has_active_sessions(text: str) -> bool:
|
|
217
|
+
"""True when STATE.md contains real session entries (not only the template comment)."""
|
|
218
|
+
without_comments = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
219
|
+
return bool(re.search(r"^## \[\d{4}-", without_comments, re.MULTILINE))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_framework_handoff(report: Report, root: Path) -> None:
|
|
223
|
+
"""Warn when the framework source repo ships consumer session state in handoff/."""
|
|
224
|
+
if not (root / FRAMEWORK_MARKER).is_file():
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
brief_path = root / "handoff" / "BRIEF.md"
|
|
228
|
+
if brief_path.is_file():
|
|
229
|
+
try:
|
|
230
|
+
if not _brief_is_template(brief_path.read_text(encoding="utf-8")):
|
|
231
|
+
report.add(
|
|
232
|
+
"warn",
|
|
233
|
+
"framework_brief_dirty",
|
|
234
|
+
"Framework repo: handoff/BRIEF.md is not the template — "
|
|
235
|
+
"reset before release (see CONTRIBUTING.md)",
|
|
236
|
+
)
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
done_tasks = list(DONE_DIR.glob("*.json")) if DONE_DIR.is_dir() else []
|
|
241
|
+
if done_tasks:
|
|
242
|
+
report.add(
|
|
243
|
+
"warn",
|
|
244
|
+
"framework_board_dirty",
|
|
245
|
+
f"Framework repo: handoff/board/done/ has {len(done_tasks)} task(s) — "
|
|
246
|
+
"reset before release",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
state_path = root / "handoff" / "STATE.md"
|
|
250
|
+
if state_path.is_file():
|
|
251
|
+
try:
|
|
252
|
+
if _state_has_active_sessions(state_path.read_text(encoding="utf-8")):
|
|
253
|
+
report.add(
|
|
254
|
+
"warn",
|
|
255
|
+
"framework_state_dirty",
|
|
256
|
+
"Framework repo: handoff/STATE.md has active session entries — "
|
|
257
|
+
"reset before release",
|
|
258
|
+
)
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def check_state(report: Report, root: Path) -> None:
|
|
264
|
+
state_path = root / "handoff" / "STATE.md"
|
|
265
|
+
if not state_path.is_file():
|
|
266
|
+
report.add("error", "state_missing", "handoff/STATE.md is missing")
|
|
267
|
+
return
|
|
268
|
+
if state_path.stat().st_size == 0:
|
|
269
|
+
report.add("warn", "state_empty", "handoff/STATE.md is empty")
|
|
270
|
+
else:
|
|
271
|
+
report.add("ok", "state_ok", "handoff/STATE.md present")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def check_graph(report: Report, root: Path, *, fail_stale: bool) -> None:
|
|
275
|
+
report_path = root / GRAPH_REPORT
|
|
276
|
+
if not report_path.is_file():
|
|
277
|
+
report.add("warn", "graph_missing", "graphify-out/GRAPH_REPORT.md not found — run /graphify .")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
text = report_path.read_text(encoding="utf-8", errors="replace")
|
|
281
|
+
match = GRAPH_COMMIT_RE.search(text)
|
|
282
|
+
if not match:
|
|
283
|
+
report.add("warn", "graph_no_commit", "GRAPH_REPORT.md has no 'Built from commit' line")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
graph_commit = match.group(1)
|
|
287
|
+
if not git_available():
|
|
288
|
+
report.add("ok", "graph_commit", f"Graph built from {graph_commit[:12]} (git not checked)")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
refs = _refs_for_staleness_check()
|
|
292
|
+
if not refs:
|
|
293
|
+
report.add("warn", "graph_git_head", "Could not read git HEAD for staleness check")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
head = refs[0]
|
|
297
|
+
for ref in refs:
|
|
298
|
+
if _commit_matches_graph(graph_commit, ref):
|
|
299
|
+
label = "HEAD" if ref == head else "git ref"
|
|
300
|
+
report.add("ok", "graph_fresh", f"Graph matches {label} ({ref[:12]})")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# Graph built on an older commit that is still in history (full or deep clone).
|
|
304
|
+
ancestor = run_git("merge-base", "--is-ancestor", graph_commit, "HEAD")
|
|
305
|
+
if ancestor.returncode == 0:
|
|
306
|
+
report.add(
|
|
307
|
+
"ok",
|
|
308
|
+
"graph_fresh",
|
|
309
|
+
f"Graph commit {graph_commit[:12]} is an ancestor of HEAD ({head[:12]})",
|
|
310
|
+
)
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# Shallow clone: match any fetched commit on the current branch.
|
|
314
|
+
listed = run_git("rev-list", "--max-count", "100", "HEAD")
|
|
315
|
+
if listed.returncode == 0 and listed.stdout:
|
|
316
|
+
for ref in listed.stdout.splitlines():
|
|
317
|
+
if _commit_matches_graph(graph_commit, ref):
|
|
318
|
+
report.add(
|
|
319
|
+
"ok",
|
|
320
|
+
"graph_fresh",
|
|
321
|
+
f"Graph matches fetched commit {ref[:12]} (shallow history)",
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
level = "error" if fail_stale else "warn"
|
|
326
|
+
report.add(
|
|
327
|
+
level,
|
|
328
|
+
"graph_stale",
|
|
329
|
+
f"Graph built from {graph_commit[:12]} but HEAD is {head[:12]} — run graphify update .",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def check_compact_module(report: Report, root: Path) -> None:
|
|
334
|
+
run_py = root / "scripts" / "graphstack" / "run.py"
|
|
335
|
+
registry = root / "scripts" / "graphstack" / "compact" / "registry.py"
|
|
336
|
+
if run_py.is_file() and registry.is_file():
|
|
337
|
+
report.add(
|
|
338
|
+
"ok",
|
|
339
|
+
"compact_ok",
|
|
340
|
+
"Output compact module present (use: python -m graphstack run -- <cmd>)",
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
report.add(
|
|
344
|
+
"warn",
|
|
345
|
+
"compact_missing",
|
|
346
|
+
"Output compact module missing — reinstall GraphStack for shell token savings",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def check_graph_module(report: Report, root: Path) -> None:
|
|
351
|
+
graph_py = root / "scripts" / "graphstack" / "graph.py"
|
|
352
|
+
if graph_py.is_file():
|
|
353
|
+
report.add(
|
|
354
|
+
"ok",
|
|
355
|
+
"graph_ok",
|
|
356
|
+
"Graph query module present (use: python -m graphstack graph query \"…\")",
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
report.add(
|
|
360
|
+
"warn",
|
|
361
|
+
"graph_missing",
|
|
362
|
+
"Graph query module missing — reinstall GraphStack for graph-first queries",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def check_tooling(report: Report, *, doctor: bool) -> None:
|
|
367
|
+
if graphify_available():
|
|
368
|
+
report.add("ok", "graphify_ok", "graphify CLI found on PATH")
|
|
369
|
+
else:
|
|
370
|
+
report.add(
|
|
371
|
+
"warn",
|
|
372
|
+
"graphify_missing",
|
|
373
|
+
"graphify not on PATH — install with: pip install -r requirements.txt",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if git_available():
|
|
377
|
+
report.add("ok", "git_ok", "git found on PATH")
|
|
378
|
+
else:
|
|
379
|
+
msg = "git not on PATH (board commits and staleness checks need git)"
|
|
380
|
+
if doctor:
|
|
381
|
+
report.add("warn", "git_missing", msg)
|
|
382
|
+
else:
|
|
383
|
+
report.add("warn", "git_missing", msg)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def run_checks(
|
|
387
|
+
*,
|
|
388
|
+
strict: bool = False,
|
|
389
|
+
fail_stale: bool = False,
|
|
390
|
+
doctor: bool = False,
|
|
391
|
+
) -> Report:
|
|
392
|
+
root = _root()
|
|
393
|
+
report = Report()
|
|
394
|
+
check_layout(report, root)
|
|
395
|
+
check_brief(report, root, strict=strict)
|
|
396
|
+
check_board_tasks(report)
|
|
397
|
+
check_framework_handoff(report, root)
|
|
398
|
+
check_state(report, root)
|
|
399
|
+
check_graph(report, root, fail_stale=fail_stale)
|
|
400
|
+
check_compact_module(report, root)
|
|
401
|
+
check_graph_module(report, root)
|
|
402
|
+
check_tooling(report, doctor=doctor)
|
|
403
|
+
return report
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _print_report(report: Report, *, doctor: bool) -> None:
|
|
407
|
+
if doctor:
|
|
408
|
+
echo("")
|
|
409
|
+
echo("GraphStack doctor")
|
|
410
|
+
echo("=" * 40)
|
|
411
|
+
|
|
412
|
+
errors = report.errors
|
|
413
|
+
warnings = report.warnings
|
|
414
|
+
oks = [f for f in report.findings if f.level == "ok"]
|
|
415
|
+
|
|
416
|
+
for finding in report.findings:
|
|
417
|
+
if finding.level == "error":
|
|
418
|
+
prefix = "ERROR"
|
|
419
|
+
elif finding.level == "warn":
|
|
420
|
+
prefix = "WARN "
|
|
421
|
+
else:
|
|
422
|
+
if not doctor:
|
|
423
|
+
continue
|
|
424
|
+
prefix = "OK "
|
|
425
|
+
echo(f" [{prefix}] {finding.message}")
|
|
426
|
+
|
|
427
|
+
echo("")
|
|
428
|
+
echo(
|
|
429
|
+
f" Summary: {len(errors)} error(s), {len(warnings)} warning(s), "
|
|
430
|
+
f"{len(oks)} check(s) passed"
|
|
431
|
+
)
|
|
432
|
+
echo("")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _build_parser(prog: str) -> argparse.ArgumentParser:
|
|
436
|
+
parser = argparse.ArgumentParser(prog=prog, description="Validate GraphStack project layout.")
|
|
437
|
+
parser.add_argument(
|
|
438
|
+
"--strict",
|
|
439
|
+
action="store_true",
|
|
440
|
+
help="Treat template BRIEF.md as an error (not only a warning)",
|
|
441
|
+
)
|
|
442
|
+
parser.add_argument(
|
|
443
|
+
"--fail-stale-graph",
|
|
444
|
+
action="store_true",
|
|
445
|
+
help="Exit 1 when GRAPH_REPORT commit does not match git HEAD",
|
|
446
|
+
)
|
|
447
|
+
return parser
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def run_validate(argv: list[str]) -> int:
|
|
451
|
+
parser = _build_parser("graphstack validate")
|
|
452
|
+
args = parser.parse_args(argv)
|
|
453
|
+
report = run_checks(strict=args.strict, fail_stale=args.fail_stale_graph)
|
|
454
|
+
_print_report(report, doctor=False)
|
|
455
|
+
return 1 if report.errors else 0
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def run_doctor(argv: list[str]) -> int:
|
|
459
|
+
parser = _build_parser("graphstack doctor")
|
|
460
|
+
args = parser.parse_args(argv)
|
|
461
|
+
report = run_checks(strict=False, fail_stale=False, doctor=True)
|
|
462
|
+
_print_report(report, doctor=True)
|
|
463
|
+
return 1 if report.errors else 0
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def run(argv: list[str] | None = None) -> int:
|
|
467
|
+
"""Default entry when invoked as ``validate`` sub-command."""
|
|
468
|
+
args = sys.argv[2:] if argv is None else argv
|
|
469
|
+
return run_validate(args)
|