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.
Files changed (57) hide show
  1. graphstack/__init__.py +12 -0
  2. graphstack/__main__.py +10 -0
  3. graphstack/assets/docs/CURSOR_PROMPTS.md +215 -0
  4. graphstack/assets/handoff/BOOTSTRAP.md +73 -0
  5. graphstack/assets/handoff/BRIEF.md +66 -0
  6. graphstack/assets/handoff/REVIEW.md +7 -0
  7. graphstack/assets/handoff/board/README.md +60 -0
  8. graphstack/assets/orchestrator/ORCHESTRATOR.md +416 -0
  9. graphstack/assets/orchestrator/TOKEN_OPTIMIZER.md +319 -0
  10. graphstack/assets/scripts/board.ps1 +37 -0
  11. graphstack/assets/scripts/board.sh +22 -0
  12. graphstack/assets/scripts/gate-hook.ps1 +41 -0
  13. graphstack/assets/scripts/gate-hook.sh +26 -0
  14. graphstack/assets/scripts/post-commit +20 -0
  15. graphstack/assets/scripts/post-commit.ps1 +44 -0
  16. graphstack/board.py +361 -0
  17. graphstack/bootstrap.py +50 -0
  18. graphstack/cli.py +99 -0
  19. graphstack/compact/__init__.py +9 -0
  20. graphstack/compact/__pycache__/__init__.cpython-311.pyc +0 -0
  21. graphstack/compact/__pycache__/base.cpython-311.pyc +0 -0
  22. graphstack/compact/__pycache__/generic.cpython-311.pyc +0 -0
  23. graphstack/compact/__pycache__/git.cpython-311.pyc +0 -0
  24. graphstack/compact/__pycache__/registry.cpython-311.pyc +0 -0
  25. graphstack/compact/base.py +115 -0
  26. graphstack/compact/generic.py +90 -0
  27. graphstack/compact/git.py +167 -0
  28. graphstack/compact/registry.py +47 -0
  29. graphstack/constants.py +38 -0
  30. graphstack/gate.py +429 -0
  31. graphstack/graph.py +143 -0
  32. graphstack/hook.py +144 -0
  33. graphstack/init_cmd.py +113 -0
  34. graphstack/installer.py +366 -0
  35. graphstack/platform_utils.py +127 -0
  36. graphstack/run.py +103 -0
  37. graphstack/state.py +117 -0
  38. graphstack/tests/__init__.py +0 -0
  39. graphstack/tests/conftest.py +30 -0
  40. graphstack/tests/test_assets.py +35 -0
  41. graphstack/tests/test_board.py +166 -0
  42. graphstack/tests/test_compact.py +93 -0
  43. graphstack/tests/test_gate.py +406 -0
  44. graphstack/tests/test_graph.py +60 -0
  45. graphstack/tests/test_hook.py +57 -0
  46. graphstack/tests/test_init.py +58 -0
  47. graphstack/tests/test_installer.py +73 -0
  48. graphstack/tests/test_platform_utils.py +69 -0
  49. graphstack/tests/test_state.py +56 -0
  50. graphstack/tests/test_validate.py +204 -0
  51. graphstack/validate.py +469 -0
  52. mertcapkin_graphstack-4.5.1.dist-info/METADATA +720 -0
  53. mertcapkin_graphstack-4.5.1.dist-info/RECORD +57 -0
  54. mertcapkin_graphstack-4.5.1.dist-info/WHEEL +5 -0
  55. mertcapkin_graphstack-4.5.1.dist-info/entry_points.txt +2 -0
  56. mertcapkin_graphstack-4.5.1.dist-info/licenses/LICENSE +21 -0
  57. 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)