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
|
@@ -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"
|