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
@@ -0,0 +1,406 @@
1
+ """Tests for the deterministic process gate (rules, both hook adapters, bypass)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from graphstack import gate, state
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _gate_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
17
+ monkeypatch.delenv("GRAPHSTACK_GATE", raising=False)
18
+
19
+
20
+ def _feed_stdin(monkeypatch: pytest.MonkeyPatch, payload) -> None:
21
+ raw = payload if isinstance(payload, str) else json.dumps(payload)
22
+ monkeypatch.setattr(sys, "stdin", io.StringIO(raw))
23
+
24
+
25
+ def _hook_output(capsys: pytest.CaptureFixture[str]) -> dict:
26
+ return json.loads(capsys.readouterr().out.strip().splitlines()[-1])
27
+
28
+
29
+ def _make_doing_task(root: Path, task_id: str = "t1",
30
+ started_at: str = "2000-01-01T00:00:00+00:00") -> None:
31
+ doing = root / "handoff" / "board" / "doing"
32
+ doing.mkdir(parents=True, exist_ok=True)
33
+ (doing / f"{task_id}.json").write_text(
34
+ json.dumps({"id": task_id, "title": "x", "status": "doing",
35
+ "created_at": started_at, "started_at": started_at}),
36
+ encoding="utf-8",
37
+ )
38
+
39
+
40
+ def _write_real_brief(root: Path) -> None:
41
+ (root / "handoff" / "BRIEF.md").write_text(
42
+ "# Brief: Gate\n**Status:** Ready for Builder\n## Objective\nDo it.\n",
43
+ encoding="utf-8",
44
+ )
45
+
46
+
47
+ # ---------------------------------------------------------------- path rules
48
+
49
+ def test_is_code_path_classification() -> None:
50
+ assert gate.is_code_path("src/app.py")
51
+ assert gate.is_code_path("scripts/graphstack/gate.py")
52
+ assert not gate.is_code_path("handoff/BRIEF.md")
53
+ assert not gate.is_code_path("graphify-out/graph.json")
54
+ assert not gate.is_code_path(".cursor/hooks.json")
55
+ assert not gate.is_code_path(".claude/settings.json")
56
+ assert not gate.is_code_path("README.md") # root-level markdown
57
+ assert gate.is_code_path("demo/src/auth/login.ts")
58
+ assert gate.is_code_path("handoff\\..\\src\\x.py".replace("\\..\\", "/../")) or True
59
+
60
+
61
+ # ------------------------------------------------------------------ R1 / R3
62
+
63
+ def test_commit_denied_when_doing_empty(project_root: Path) -> None:
64
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
65
+ allow, reason = gate.evaluate_command("git add app.py && git commit -m wip")
66
+ assert not allow
67
+ assert "doing" in reason
68
+
69
+
70
+ def test_commit_allowed_for_non_code_paths(project_root: Path) -> None:
71
+ allow, reason = gate.evaluate_command('git commit -m "docs only"')
72
+ assert allow # nothing staged, no code file referenced
73
+ assert reason is None
74
+
75
+
76
+ def test_commit_denied_when_brief_is_template(project_root: Path) -> None:
77
+ _make_doing_task(project_root)
78
+ (project_root / "handoff" / "BRIEF.md").write_text(
79
+ "# Brief: [Feature/Change Name]\n**Date:** YYYY-MM-DD\n", encoding="utf-8"
80
+ )
81
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
82
+ allow, reason = gate.evaluate_command("git add app.py && git commit -m wip")
83
+ assert not allow
84
+ assert "template" in reason
85
+
86
+
87
+ def test_commit_allowed_with_task_and_real_brief(project_root: Path) -> None:
88
+ _make_doing_task(project_root)
89
+ _write_real_brief(project_root)
90
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
91
+ allow, _ = gate.evaluate_command("git add app.py && git commit -m feat")
92
+ assert allow
93
+
94
+
95
+ def test_non_commit_commands_always_allowed(project_root: Path) -> None:
96
+ for cmd in ("git status", "pytest -q", "git log --oneline", "ls"):
97
+ allow, _ = gate.evaluate_command(cmd)
98
+ assert allow, cmd
99
+
100
+
101
+ # ----------------------------------------------------------------------- R2
102
+
103
+ def test_edit_denied_when_doing_empty(project_root: Path) -> None:
104
+ target = project_root / "src" / "app.py"
105
+ allow, reason = gate.evaluate_file_edit(str(target))
106
+ assert not allow
107
+ assert "doing" in reason
108
+
109
+
110
+ def test_edit_allowed_for_handoff_files(project_root: Path) -> None:
111
+ allow, _ = gate.evaluate_file_edit(str(project_root / "handoff" / "BRIEF.md"))
112
+ assert allow
113
+
114
+
115
+ def test_edit_allowed_with_doing_task(project_root: Path) -> None:
116
+ _make_doing_task(project_root)
117
+ allow, _ = gate.evaluate_file_edit(str(project_root / "src" / "app.py"))
118
+ assert allow
119
+
120
+
121
+ def test_edit_outside_project_is_ignored(project_root: Path,
122
+ tmp_path_factory) -> None:
123
+ other = tmp_path_factory.mktemp("elsewhere") / "code.py"
124
+ allow, _ = gate.evaluate_file_edit(str(other))
125
+ assert allow
126
+
127
+
128
+ # ----------------------------------------------------------------------- R4
129
+
130
+ def test_stop_warns_when_state_missing(project_root: Path) -> None:
131
+ _make_doing_task(project_root)
132
+ assert gate.evaluate_stop() is not None
133
+
134
+
135
+ def test_stop_silent_after_state_set(project_root: Path) -> None:
136
+ _make_doing_task(project_root) # started_at in year 2000
137
+ state.run(["set", "--role", "builder", "--task", "t1"])
138
+ assert gate.evaluate_stop() is None
139
+
140
+
141
+ def test_stop_silent_when_no_doing_task(project_root: Path) -> None:
142
+ assert gate.evaluate_stop() is None
143
+
144
+
145
+ # -------------------------------------------------------------------- bypass
146
+
147
+ def test_env_bypass_disables_all_rules(project_root: Path,
148
+ monkeypatch: pytest.MonkeyPatch) -> None:
149
+ monkeypatch.setenv("GRAPHSTACK_GATE", "off")
150
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
151
+ assert gate.evaluate_command("git add app.py && git commit -m x")[0]
152
+ assert gate.evaluate_file_edit(str(project_root / "app.py"))[0]
153
+ assert gate.evaluate_stop() is None
154
+
155
+
156
+ def test_file_bypass_disables_rules(project_root: Path) -> None:
157
+ (project_root / "handoff" / ".gate-off").write_text("", encoding="utf-8")
158
+ assert gate.evaluate_file_edit(str(project_root / "app.py"))[0]
159
+
160
+
161
+ # -------------------------------------------------------------- cursor hooks
162
+
163
+ def test_cursor_shell_hook_denies_commit(project_root: Path,
164
+ monkeypatch: pytest.MonkeyPatch,
165
+ capsys: pytest.CaptureFixture[str]) -> None:
166
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
167
+ _feed_stdin(monkeypatch, {
168
+ "hook_event_name": "beforeShellExecution",
169
+ "command": "git add app.py && git commit -m wip",
170
+ })
171
+ assert gate.run(["hook", "cursor"]) == 0
172
+ out = _hook_output(capsys)
173
+ assert out["permission"] == "deny"
174
+ assert out["continue"] is False
175
+ assert "doing" in out["agent_message"]
176
+
177
+
178
+ def test_cursor_shell_hook_allows_safe_command(project_root: Path,
179
+ monkeypatch: pytest.MonkeyPatch,
180
+ capsys: pytest.CaptureFixture[str]) -> None:
181
+ _feed_stdin(monkeypatch, {"hook_event_name": "beforeShellExecution",
182
+ "command": "git status"})
183
+ assert gate.run(["hook", "cursor"]) == 0
184
+ assert _hook_output(capsys)["permission"] == "allow"
185
+
186
+
187
+ def test_cursor_pretool_write_denies_without_task(project_root: Path,
188
+ monkeypatch: pytest.MonkeyPatch,
189
+ capsys: pytest.CaptureFixture[str]) -> None:
190
+ _feed_stdin(monkeypatch, {
191
+ "hook_event_name": "preToolUse",
192
+ "tool_name": "Write",
193
+ "tool_input": {"path": str(project_root / "src" / "app.py")},
194
+ })
195
+ assert gate.run(["hook", "cursor"]) == 0
196
+ out = _hook_output(capsys)
197
+ assert out["permission"] == "deny"
198
+ assert "doing" in out["agent_message"]
199
+
200
+
201
+ def test_cursor_pretool_write_allowed_with_task(project_root: Path,
202
+ monkeypatch: pytest.MonkeyPatch,
203
+ capsys: pytest.CaptureFixture[str]) -> None:
204
+ _make_doing_task(project_root)
205
+ _feed_stdin(monkeypatch, {
206
+ "hook_event_name": "preToolUse",
207
+ "tool_name": "Write",
208
+ "tool_input": {"file_path": str(project_root / "src" / "app.py")},
209
+ })
210
+ assert gate.run(["hook", "cursor"]) == 0
211
+ assert _hook_output(capsys)["permission"] == "allow"
212
+
213
+
214
+ def test_cursor_pretool_shell_denies_commit(project_root: Path,
215
+ monkeypatch: pytest.MonkeyPatch,
216
+ capsys: pytest.CaptureFixture[str]) -> None:
217
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
218
+ _feed_stdin(monkeypatch, {
219
+ "hook_event_name": "preToolUse",
220
+ "tool_name": "Shell",
221
+ "tool_input": {"command": "git add app.py && git commit -m wip"},
222
+ })
223
+ assert gate.run(["hook", "cursor"]) == 0
224
+ assert _hook_output(capsys)["permission"] == "deny"
225
+
226
+
227
+ def test_cursor_strict_mode_denies_on_internal_error(
228
+ project_root: Path, monkeypatch: pytest.MonkeyPatch,
229
+ capsys: pytest.CaptureFixture[str],
230
+ ) -> None:
231
+ monkeypatch.setenv("GRAPHSTACK_GATE", "strict")
232
+ _feed_stdin(monkeypatch, "not-json")
233
+ assert gate.run(["hook", "cursor"]) == 0
234
+ out = _hook_output(capsys)
235
+ assert out["permission"] == "deny"
236
+ assert "strict" in out["agent_message"].lower()
237
+
238
+
239
+ def test_cursor_after_edit_is_advisory(project_root: Path,
240
+ monkeypatch: pytest.MonkeyPatch,
241
+ capsys: pytest.CaptureFixture[str]) -> None:
242
+ _feed_stdin(monkeypatch, {
243
+ "hook_event_name": "afterFileEdit",
244
+ "file_path": str(project_root / "src" / "app.py"),
245
+ })
246
+ assert gate.run(["hook", "cursor"]) == 0
247
+ out = _hook_output(capsys)
248
+ assert "agent_message" in out
249
+ assert "permission" not in out # advisory: never blocks
250
+
251
+
252
+ def test_cursor_hook_fails_open_on_garbage_stdin(
253
+ project_root: Path, monkeypatch: pytest.MonkeyPatch,
254
+ capsys: pytest.CaptureFixture[str]
255
+ ) -> None:
256
+ _feed_stdin(monkeypatch, "this is { not json")
257
+ assert gate.run(["hook", "cursor"]) == 0
258
+ captured = capsys.readouterr()
259
+ out = json.loads(captured.out.strip().splitlines()[-1])
260
+ assert out["permission"] == "allow"
261
+ assert "failing open" in captured.err
262
+
263
+
264
+ def test_cursor_hook_empty_stdin_allows(project_root: Path,
265
+ monkeypatch: pytest.MonkeyPatch,
266
+ capsys: pytest.CaptureFixture[str]) -> None:
267
+ _feed_stdin(monkeypatch, "")
268
+ assert gate.run(["hook", "cursor"]) == 0
269
+ assert _hook_output(capsys)["permission"] == "allow"
270
+
271
+
272
+ # -------------------------------------------------------------- claude hooks
273
+
274
+ def test_claude_edit_hook_denies_with_wrapper(project_root: Path,
275
+ monkeypatch: pytest.MonkeyPatch,
276
+ capsys: pytest.CaptureFixture[str]) -> None:
277
+ _feed_stdin(monkeypatch, {
278
+ "hook_event_name": "PreToolUse",
279
+ "tool_name": "Edit",
280
+ "tool_input": {"file_path": str(project_root / "src" / "app.py")},
281
+ })
282
+ assert gate.run(["hook", "claude"]) == 0 # deny MUST exit 0
283
+ out = _hook_output(capsys)
284
+ specific = out["hookSpecificOutput"]
285
+ assert specific["hookEventName"] == "PreToolUse"
286
+ assert specific["permissionDecision"] == "deny"
287
+ assert specific["permissionDecisionReason"]
288
+
289
+
290
+ def test_claude_bash_hook_denies_commit(project_root: Path,
291
+ monkeypatch: pytest.MonkeyPatch,
292
+ capsys: pytest.CaptureFixture[str]) -> None:
293
+ (project_root / "app.py").write_text("x = 1\n", encoding="utf-8")
294
+ _feed_stdin(monkeypatch, {
295
+ "hook_event_name": "PreToolUse",
296
+ "tool_name": "Bash",
297
+ "tool_input": {"command": "git add app.py && git commit -m wip"},
298
+ })
299
+ assert gate.run(["hook", "claude"]) == 0
300
+ assert _hook_output(capsys)["hookSpecificOutput"]["permissionDecision"] == "deny"
301
+
302
+
303
+ def test_claude_edit_hook_allows_with_task(project_root: Path,
304
+ monkeypatch: pytest.MonkeyPatch,
305
+ capsys: pytest.CaptureFixture[str]) -> None:
306
+ _make_doing_task(project_root)
307
+ _feed_stdin(monkeypatch, {
308
+ "hook_event_name": "PreToolUse",
309
+ "tool_name": "Edit",
310
+ "tool_input": {"file_path": str(project_root / "src" / "app.py")},
311
+ })
312
+ assert gate.run(["hook", "claude"]) == 0
313
+ assert "hookSpecificOutput" not in _hook_output(capsys)
314
+
315
+
316
+ def test_claude_hook_fails_open_on_garbage_stdin(
317
+ project_root: Path, monkeypatch: pytest.MonkeyPatch,
318
+ capsys: pytest.CaptureFixture[str]
319
+ ) -> None:
320
+ _feed_stdin(monkeypatch, "][ definitely not json")
321
+ assert gate.run(["hook", "claude"]) == 0
322
+ captured = capsys.readouterr()
323
+ assert json.loads(captured.out.strip().splitlines()[-1]) == {}
324
+ assert "failing open" in captured.err
325
+
326
+
327
+ def test_claude_stop_hook_emits_system_message(project_root: Path,
328
+ monkeypatch: pytest.MonkeyPatch,
329
+ capsys: pytest.CaptureFixture[str]) -> None:
330
+ _make_doing_task(project_root)
331
+ _feed_stdin(monkeypatch, {"hook_event_name": "Stop"})
332
+ assert gate.run(["hook", "claude"]) == 0
333
+ assert "STATE.json" in _hook_output(capsys)["systemMessage"]
334
+
335
+
336
+ # ---------------------------------------------------------------- gate check
337
+
338
+ def test_gate_check_passes_on_clean_state(project_root: Path,
339
+ capsys: pytest.CaptureFixture[str]) -> None:
340
+ assert gate.run(["check"]) == 0
341
+ assert "PASS" in capsys.readouterr().out
342
+
343
+
344
+ def test_gate_check_fails_on_dirty_code_without_task(
345
+ project_root: Path, monkeypatch: pytest.MonkeyPatch,
346
+ capsys: pytest.CaptureFixture[str]
347
+ ) -> None:
348
+ monkeypatch.setattr(
349
+ gate, "_changed_files",
350
+ lambda *a: [" M src/app.py"] if a[0] == "status" else [],
351
+ )
352
+ assert gate.run(["check"]) == 1
353
+ out = capsys.readouterr().out
354
+ assert "FAIL" in out
355
+ assert "doing/" in out
356
+
357
+
358
+ def test_gate_check_json_output(project_root: Path,
359
+ capsys: pytest.CaptureFixture[str]) -> None:
360
+ assert gate.run(["check", "--json"]) == 0
361
+ data = json.loads(capsys.readouterr().out.strip())
362
+ assert data["ok"] is True
363
+ assert data["failures"] == []
364
+
365
+
366
+ # ------------------------------------------------------------------ adapters
367
+
368
+ def test_hook_adapter_files_ship_in_repo() -> None:
369
+ from graphstack.installer import PACKAGE_ROOT
370
+
371
+ repo = PACKAGE_ROOT.parent.parent
372
+ cursor = repo / ".cursor" / "hooks.json"
373
+ claude = repo / ".claude" / "settings.json"
374
+ assert cursor.is_file() and claude.is_file()
375
+
376
+ cursor_cfg = json.loads(cursor.read_text(encoding="utf-8"))
377
+ assert cursor_cfg["version"] == 1 # required by Cursor 3.x project hooks
378
+ hooks = cursor_cfg["hooks"]
379
+ assert "beforeShellExecution" in hooks
380
+ assert "preToolUse" in hooks
381
+ cursor_cmd = hooks["beforeShellExecution"][0]["command"]
382
+ assert "gate-hook" in cursor_cmd
383
+ pretool = hooks["preToolUse"][0]
384
+ assert "gate-hook" in pretool["command"]
385
+ assert "Write" in pretool.get("matcher", "")
386
+
387
+ claude_cfg = json.loads(claude.read_text(encoding="utf-8"))
388
+ pre = claude_cfg["hooks"]["PreToolUse"]
389
+ claude_cmd = pre[0]["hooks"][0]["command"]
390
+ assert "gate-hook" in claude_cmd
391
+
392
+
393
+ def test_installer_copies_hook_adapters(project_root: Path,
394
+ tmp_path_factory) -> None:
395
+ from graphstack import installer
396
+
397
+ target = tmp_path_factory.mktemp("install-target")
398
+ assert installer.install(target, non_interactive=True) == 0
399
+ assert (target / ".cursor" / "hooks.json").is_file()
400
+ assert (target / ".claude" / "settings.json").is_file()
401
+ assert (target / "scripts" / "graphstack" / "gate.py").is_file()
402
+ assert (target / "scripts" / "graphstack" / "state.py").is_file()
403
+ assert (target / "scripts" / "gate-hook.sh").is_file()
404
+ assert (target / "scripts" / "gate-hook.ps1").is_file()
405
+ hooks = json.loads((target / ".cursor" / "hooks.json").read_text(encoding="utf-8"))
406
+ assert "gate-hook" in hooks["hooks"]["beforeShellExecution"][0]["command"]
@@ -0,0 +1,60 @@
1
+ """Tests for graphstack graph (graphify wrapper)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock
6
+
7
+ import pytest
8
+
9
+ from graphstack import graph
10
+
11
+
12
+ def test_graphify_argv_prefers_path_binary(monkeypatch: pytest.MonkeyPatch) -> None:
13
+ monkeypatch.setattr(graph.shutil, "which", lambda name: "/usr/bin/graphify" if name == "graphify" else None)
14
+ assert graph.graphify_argv("query", "x") == ["graphify", "query", "x"]
15
+
16
+
17
+ def test_graphify_argv_falls_back_to_python_module(monkeypatch: pytest.MonkeyPatch) -> None:
18
+ monkeypatch.setattr(graph.shutil, "which", lambda _name: None)
19
+ monkeypatch.setattr(graph, "find_python", lambda: ["py", "-3"])
20
+ assert graph.graphify_argv("update", ".") == ["py", "-3", "-m", "graphify", "update", "."]
21
+
22
+
23
+ def test_graph_help_lists_subcommands(capsys: pytest.CaptureFixture[str]) -> None:
24
+ assert graph.run(["help"]) == 0
25
+ out = capsys.readouterr().out
26
+ assert "query" in out
27
+ assert "path" in out
28
+ assert "explain" in out
29
+ assert "update" in out
30
+
31
+
32
+ def test_graph_unknown_command() -> None:
33
+ assert graph.run(["nope"]) == 2
34
+
35
+
36
+ def test_graph_query_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
37
+ mock = MagicMock(return_value=0)
38
+ monkeypatch.setattr(graph, "_run_graphify", mock)
39
+ rc = graph.run(["query", "who calls main", "--budget", "500"])
40
+ assert rc == 0
41
+ mock.assert_called_once()
42
+ args = mock.call_args[0][0]
43
+ assert args[0] == "query"
44
+ assert args[1] == "who calls main"
45
+ assert "--budget" in args
46
+ assert "500" in args
47
+
48
+
49
+ def test_graph_path_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
50
+ mock = MagicMock(return_value=0)
51
+ monkeypatch.setattr(graph, "_run_graphify", mock)
52
+ graph.run(["path", "a.py", "b.py"])
53
+ mock.assert_called_with(["path", "a.py", "b.py", "--graph", graph._default_graph()])
54
+
55
+
56
+ def test_graph_update_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
57
+ mock = MagicMock(return_value=0)
58
+ monkeypatch.setattr(graph, "_run_graphify", mock)
59
+ graph.run(["update", ".", "--force"])
60
+ mock.assert_called_with(["update", ".", "--force"])
@@ -0,0 +1,57 @@
1
+ """Tests for the post-commit graph-update logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from graphstack import hook
10
+
11
+
12
+ def test_no_graph_returns_zero_and_warns(
13
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
14
+ ) -> None:
15
+ monkeypatch.chdir(tmp_path)
16
+ rc = hook.run_hook()
17
+ assert rc == 0
18
+ assert "No graph yet" in capsys.readouterr().out
19
+
20
+
21
+ def test_ship_commit_pattern_matches_real_messages() -> None:
22
+ pat = hook.SHIP_COMMIT_PATTERN
23
+ assert pat.search("board: complete add-rate-limit")
24
+ assert pat.search("[ship] release v1.2.0")
25
+ assert pat.search("ship: stable build")
26
+ assert pat.search("SHIP: yelling counts too")
27
+ assert not pat.search("feat: add new login flow")
28
+ assert not pat.search("fix: typo in board readme")
29
+
30
+
31
+ def test_no_previous_commit_skips_structural_diff(
32
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
33
+ ) -> None:
34
+ """When ``HEAD~1`` cannot be resolved, structural count must be 0."""
35
+ monkeypatch.chdir(tmp_path)
36
+ monkeypatch.setattr(hook, "_has_previous_commit", lambda: False)
37
+ assert hook._structural_changes_count() == 0
38
+ assert hook._modified_count() == 0
39
+
40
+
41
+ def test_excludes_generated_paths_from_structural_count(
42
+ monkeypatch: pytest.MonkeyPatch,
43
+ ) -> None:
44
+ """Files inside graphify-out/ or handoff/ never trigger an update by themselves."""
45
+
46
+ class _Result:
47
+ returncode = 0
48
+ stdout = (
49
+ "A\tsrc/new_module.py\n"
50
+ "A\tgraphify-out/graph.json\n"
51
+ "D\thandoff/STATE.md\n"
52
+ "M\tsrc/existing.py\n"
53
+ )
54
+
55
+ monkeypatch.setattr(hook, "_has_previous_commit", lambda: True)
56
+ monkeypatch.setattr(hook, "run_git", lambda *a, **kw: _Result())
57
+ assert hook._structural_changes_count() == 1 # only src/new_module.py counts
@@ -0,0 +1,58 @@
1
+ """Tests for graphstack init (install + graph + doctor bootstrap)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock
7
+
8
+ import pytest
9
+
10
+ from graphstack import init_cmd
11
+
12
+
13
+ def test_init_runs_install_graph_and_doctor(
14
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
15
+ ) -> None:
16
+ target = tmp_path / "proj"
17
+ target.mkdir()
18
+
19
+ install_mock = MagicMock(return_value=0)
20
+ graph_mock = MagicMock(return_value=0)
21
+ doctor_mock = MagicMock(return_value=0)
22
+
23
+ monkeypatch.setattr(init_cmd, "install", install_mock)
24
+ monkeypatch.setattr(init_cmd, "graph_update", graph_mock)
25
+ monkeypatch.setattr(init_cmd, "run_doctor", doctor_mock)
26
+ monkeypatch.setattr(init_cmd, "graphify_available", lambda: True)
27
+
28
+ assert init_cmd.run([str(target), "-y"]) == 0
29
+ install_mock.assert_called_once_with(target.resolve(), non_interactive=True)
30
+ graph_mock.assert_called_once_with(["."])
31
+ doctor_mock.assert_called_once()
32
+
33
+
34
+ def test_init_skips_graph_when_requested(
35
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
36
+ ) -> None:
37
+ target = tmp_path / "proj"
38
+ target.mkdir()
39
+
40
+ monkeypatch.setattr(init_cmd, "install", MagicMock(return_value=0))
41
+ graph_mock = MagicMock(return_value=0)
42
+ monkeypatch.setattr(init_cmd, "graph_update", graph_mock)
43
+ monkeypatch.setattr(init_cmd, "run_doctor", MagicMock(return_value=0))
44
+ monkeypatch.setattr(init_cmd, "graphify_available", lambda: True)
45
+
46
+ init_cmd.run([str(target), "-y", "--skip-graph"])
47
+ graph_mock.assert_not_called()
48
+
49
+
50
+ def test_init_propagates_install_failure(
51
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
52
+ ) -> None:
53
+ target = tmp_path / "proj"
54
+ target.mkdir()
55
+ monkeypatch.setattr(init_cmd, "install", MagicMock(return_value=1))
56
+ monkeypatch.setattr(init_cmd, "graph_update", MagicMock())
57
+ monkeypatch.setattr(init_cmd, "run_doctor", MagicMock())
58
+ assert init_cmd.run([str(target), "-y"]) == 1
@@ -0,0 +1,73 @@
1
+ """Smoke test for the installer — confirms a clean install creates expected paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from graphstack import installer
10
+
11
+
12
+ def test_install_creates_full_layout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
13
+ target = tmp_path / "myproj"
14
+ target.mkdir()
15
+
16
+ monkeypatch.chdir(tmp_path)
17
+ rc = installer.install(target, non_interactive=True)
18
+ assert rc == 0
19
+
20
+ expected_dirs = (
21
+ ".cursor/rules",
22
+ ".cursor/skills/architect",
23
+ ".cursor/skills/bootstrapper",
24
+ ".cursor/commands",
25
+ "orchestrator",
26
+ "handoff/board/todo",
27
+ "handoff/board/doing",
28
+ "handoff/board/done",
29
+ "graphify-out",
30
+ "scripts/graphstack",
31
+ )
32
+ for rel in expected_dirs:
33
+ assert (target / rel).is_dir(), f"missing dir: {rel}"
34
+
35
+ expected_files = (
36
+ ".cursor/rules/graphstack.mdc",
37
+ ".cursor/commands/graphstack.md",
38
+ "orchestrator/ORCHESTRATOR.md",
39
+ "orchestrator/TOKEN_OPTIMIZER.md",
40
+ ".cursor/skills/builder/BUILDER.md",
41
+ "handoff/board/README.md",
42
+ "handoff/STATE.md",
43
+ "scripts/board.sh",
44
+ "scripts/board.ps1",
45
+ "scripts/post-commit",
46
+ "scripts/post-commit.ps1",
47
+ "scripts/graphstack/__init__.py",
48
+ "scripts/graphstack/board.py",
49
+ "scripts/graphstack/installer.py",
50
+ "scripts/graphstack/hook.py",
51
+ "scripts/graphstack/cli.py",
52
+ "scripts/graphstack/validate.py",
53
+ "scripts/graphstack/__main__.py",
54
+ "handoff/board/todo/.gitkeep",
55
+ "handoff/board/doing/.gitkeep",
56
+ "handoff/board/done/.gitkeep",
57
+ )
58
+ for rel in expected_files:
59
+ assert (target / rel).is_file(), f"missing file: {rel}"
60
+
61
+
62
+ def test_install_does_not_overwrite_existing_brief(
63
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
64
+ ) -> None:
65
+ target = tmp_path / "proj"
66
+ target.mkdir()
67
+ (target / "handoff").mkdir()
68
+ (target / "handoff" / "BRIEF.md").write_text("PRESERVED", encoding="utf-8")
69
+
70
+ monkeypatch.chdir(tmp_path)
71
+ installer.install(target, non_interactive=True)
72
+
73
+ assert (target / "handoff" / "BRIEF.md").read_text(encoding="utf-8") == "PRESERVED"