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,127 @@
|
|
|
1
|
+
"""OS-agnostic helpers: python detection, git wrappers, console output.
|
|
2
|
+
|
|
3
|
+
The package intentionally avoids any third-party dependencies — only the
|
|
4
|
+
Python standard library is used so that a fresh ``pip install graphifyy``
|
|
5
|
+
already covers all runtime requirements.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _reconfigure_stdout() -> None:
|
|
20
|
+
"""Make stdout/stderr tolerant of Unicode on Windows code pages.
|
|
21
|
+
|
|
22
|
+
Many Windows shells default to cp1252/cp1254 which cannot represent box
|
|
23
|
+
drawing characters or emoji. Python 3.7+ exposes ``reconfigure`` so we
|
|
24
|
+
can switch to UTF-8 with replacement mode and never crash on output.
|
|
25
|
+
"""
|
|
26
|
+
for stream in (sys.stdout, sys.stderr):
|
|
27
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
28
|
+
if reconfigure is None:
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
32
|
+
except (OSError, ValueError):
|
|
33
|
+
try:
|
|
34
|
+
reconfigure(errors="replace")
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_reconfigure_stdout()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_python() -> list[str]:
|
|
43
|
+
"""Return the argv prefix to launch a Python interpreter.
|
|
44
|
+
|
|
45
|
+
Order: ``python3`` → ``python`` → ``py -3``. Returns the first one that
|
|
46
|
+
actually exists on PATH. Falls back to ``sys.executable`` if nothing is
|
|
47
|
+
discoverable (we are obviously running under one already).
|
|
48
|
+
"""
|
|
49
|
+
for name in ("python3", "python"):
|
|
50
|
+
if shutil.which(name):
|
|
51
|
+
return [name]
|
|
52
|
+
if IS_WINDOWS and shutil.which("py"):
|
|
53
|
+
return ["py", "-3"]
|
|
54
|
+
return [sys.executable]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_git(*args: str, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
|
58
|
+
"""Run a git subcommand without ever raising on non-zero exit.
|
|
59
|
+
|
|
60
|
+
Returns the CompletedProcess so callers can decide what to do with
|
|
61
|
+
stdout/stderr. Mirrors the silent-fail behaviour of the original bash
|
|
62
|
+
scripts (``2>/dev/null || true``).
|
|
63
|
+
"""
|
|
64
|
+
return subprocess.run(
|
|
65
|
+
["git", *args],
|
|
66
|
+
capture_output=capture,
|
|
67
|
+
text=True,
|
|
68
|
+
check=False,
|
|
69
|
+
encoding="utf-8",
|
|
70
|
+
errors="replace",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def git_available() -> bool:
|
|
75
|
+
return shutil.which("git") is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def graphify_available() -> bool:
|
|
79
|
+
return shutil.which("graphify") is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def utc_now_iso() -> str:
|
|
83
|
+
"""ISO 8601 timestamp in UTC, second precision (matches old shell output)."""
|
|
84
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def file_mtime_seconds(path: Path) -> int | None:
|
|
88
|
+
try:
|
|
89
|
+
return int(path.stat().st_mtime)
|
|
90
|
+
except OSError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def emoji_safe(text: str) -> str:
|
|
95
|
+
"""Return ``text`` unchanged on UTF-8 capable stdouts, ASCII-flatten otherwise.
|
|
96
|
+
|
|
97
|
+
Windows ``cmd.exe`` defaults to cp1252 and chokes on emoji. We detect
|
|
98
|
+
the encoding once and downgrade if needed instead of crashing the user.
|
|
99
|
+
"""
|
|
100
|
+
encoding = (sys.stdout.encoding or "").lower()
|
|
101
|
+
if "utf" in encoding:
|
|
102
|
+
return text
|
|
103
|
+
replacements = {
|
|
104
|
+
"🧠": "[*]", "📋": "[#]", "📁": "[+]", "🤖": "[~]", "🎭": "[~]",
|
|
105
|
+
"🚀": "[>]", "📝": "[w]", "📚": "[b]", "🔗": "[L]", "🎉": "[!]",
|
|
106
|
+
"✅": "[ok]", "❌": "[x]", "⚠️": "[!]", "⏭️": "[-]", "🟢": "[+]",
|
|
107
|
+
"🔄": "[~]", "📜": "[H]", "✓": "[ok]", "✗": "[x]",
|
|
108
|
+
}
|
|
109
|
+
for k, v in replacements.items():
|
|
110
|
+
text = text.replace(k, v)
|
|
111
|
+
return text
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def echo(message: str = "") -> None:
|
|
115
|
+
"""``print`` with emoji-safe fallback. Always flushes for shim transparency.
|
|
116
|
+
|
|
117
|
+
Defensive against encoding errors: if the runtime locale still cannot
|
|
118
|
+
encode the message (older Pythons or odd ``cmd.exe`` configurations),
|
|
119
|
+
we fall back to an ASCII transliteration via ``encode(errors='replace')``.
|
|
120
|
+
"""
|
|
121
|
+
safe = emoji_safe(message)
|
|
122
|
+
try:
|
|
123
|
+
print(safe, flush=True)
|
|
124
|
+
except UnicodeEncodeError:
|
|
125
|
+
encoding = sys.stdout.encoding or "ascii"
|
|
126
|
+
ascii_only = safe.encode(encoding, errors="replace").decode(encoding, errors="replace")
|
|
127
|
+
print(ascii_only, flush=True)
|
graphstack/run.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Run shell commands with token-safe output compaction.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
graphstack run -- git status
|
|
5
|
+
graphstack run --raw -- git diff
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .compact.registry import compact_command_output
|
|
16
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="graphstack run",
|
|
19
|
+
description="Run a command and print token-safe output (stderr preserved).",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--raw",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="Disable compaction; print stdout verbatim (quality/debug).",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"command",
|
|
28
|
+
nargs=argparse.REMAINDER,
|
|
29
|
+
help="Command after -- (e.g. git status)",
|
|
30
|
+
)
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_leading_dashes(argv: list[str]) -> list[str]:
|
|
35
|
+
if argv and argv[0] == "--":
|
|
36
|
+
return argv[1:]
|
|
37
|
+
return argv
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quality_git_argv(cmd: list[str], *, raw: bool) -> list[str]:
|
|
41
|
+
"""Use porcelain status for reliable path preservation when compacting."""
|
|
42
|
+
if raw or len(cmd) < 2:
|
|
43
|
+
return cmd
|
|
44
|
+
exe = cmd[0].lower().removesuffix(".exe")
|
|
45
|
+
if exe != "git":
|
|
46
|
+
return cmd
|
|
47
|
+
sub = cmd[1].lower()
|
|
48
|
+
joined = " ".join(cmd[2:]).lower()
|
|
49
|
+
if sub == "status" and "--porcelain" not in joined and " -s" not in f" {joined} ":
|
|
50
|
+
return cmd[:2] + ["--porcelain=v1", "-b"] + cmd[2:]
|
|
51
|
+
if sub == "log" and "--oneline" not in joined:
|
|
52
|
+
return cmd[:2] + ["--oneline"] + cmd[2:]
|
|
53
|
+
return cmd
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def execute(argv: list[str], *, raw: bool = False) -> int:
|
|
57
|
+
cmd = _quality_git_argv(_strip_leading_dashes(argv), raw=raw)
|
|
58
|
+
if not cmd:
|
|
59
|
+
print(
|
|
60
|
+
"graphstack run: missing command (use: graphstack run -- git status)",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return 2
|
|
64
|
+
|
|
65
|
+
executable = cmd[0]
|
|
66
|
+
if shutil.which(executable) is None and not executable.startswith("."):
|
|
67
|
+
print(f"graphstack run: command not found: {executable}", file=sys.stderr)
|
|
68
|
+
return 127
|
|
69
|
+
|
|
70
|
+
proc = subprocess.run(
|
|
71
|
+
cmd,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
errors="replace",
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
stdout = proc.stdout or ""
|
|
80
|
+
stderr = proc.stderr or ""
|
|
81
|
+
|
|
82
|
+
if raw:
|
|
83
|
+
if stdout:
|
|
84
|
+
print(stdout, end="" if stdout.endswith("\n") else "\n")
|
|
85
|
+
if stderr:
|
|
86
|
+
print(stderr, end="" if stderr.endswith("\n") else "", file=sys.stderr)
|
|
87
|
+
return proc.returncode
|
|
88
|
+
|
|
89
|
+
result = compact_command_output(cmd, stdout, stderr)
|
|
90
|
+
if result.text:
|
|
91
|
+
print(result.text)
|
|
92
|
+
elif proc.returncode == 0:
|
|
93
|
+
print(f"(ok, {result.used_compactor}, no stdout)")
|
|
94
|
+
|
|
95
|
+
return proc.returncode
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run(argv: list[str] | None = None) -> int:
|
|
99
|
+
args_list = sys.argv[2:] if argv is None else argv
|
|
100
|
+
parser = _build_parser()
|
|
101
|
+
args = parser.parse_args(args_list)
|
|
102
|
+
cmd = _strip_leading_dashes(args.command)
|
|
103
|
+
return execute(cmd, raw=args.raw)
|
graphstack/state.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Machine-readable session state — ``handoff/STATE.json``.
|
|
2
|
+
|
|
3
|
+
Complements the human-readable ``handoff/STATE.md`` log (which stays
|
|
4
|
+
append-only and unchanged). The JSON file holds only the *current* state so
|
|
5
|
+
hooks and the process gate can verify it deterministically.
|
|
6
|
+
|
|
7
|
+
Schema: ``{"role": str, "task_id": str | None, "updated_at": str, "note": str}``
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from .constants import STATE_JSON
|
|
17
|
+
from .platform_utils import echo, utc_now_iso
|
|
18
|
+
|
|
19
|
+
VALID_ROLES = (
|
|
20
|
+
"idle", "architect", "builder", "reviewer", "qa", "ship", "bootstrapper",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_state() -> dict | None:
|
|
25
|
+
"""Return the current state dict, or None if missing/unreadable."""
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(STATE_JSON.read_text(encoding="utf-8"))
|
|
28
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_state(role: str, task_id: str | None = None, note: str = "") -> dict:
|
|
33
|
+
state = {
|
|
34
|
+
"role": role,
|
|
35
|
+
"task_id": task_id,
|
|
36
|
+
"updated_at": utc_now_iso(),
|
|
37
|
+
"note": note,
|
|
38
|
+
}
|
|
39
|
+
STATE_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
STATE_JSON.write_text(
|
|
41
|
+
json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
42
|
+
)
|
|
43
|
+
return state
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_set(args: argparse.Namespace) -> int:
|
|
47
|
+
role = args.role.lower()
|
|
48
|
+
if role not in VALID_ROLES:
|
|
49
|
+
echo(f"⚠️ Unknown role '{role}' (expected one of: {', '.join(VALID_ROLES)})")
|
|
50
|
+
state = save_state(role, args.task, args.note or "")
|
|
51
|
+
echo(f"✅ STATE.json: role={state['role']} task={state['task_id'] or '-'}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_get(args: argparse.Namespace) -> int:
|
|
56
|
+
state = load_state()
|
|
57
|
+
if state is None:
|
|
58
|
+
if args.json:
|
|
59
|
+
echo("null")
|
|
60
|
+
else:
|
|
61
|
+
echo("(no STATE.json — run: python -m graphstack state set --role <role>)")
|
|
62
|
+
return 1
|
|
63
|
+
if args.json:
|
|
64
|
+
echo(json.dumps(state, ensure_ascii=False))
|
|
65
|
+
else:
|
|
66
|
+
echo(f"role={state.get('role', '-')} task={state.get('task_id') or '-'} "
|
|
67
|
+
f"updated={state.get('updated_at', '-')}")
|
|
68
|
+
if state.get("note"):
|
|
69
|
+
echo(f"note: {state['note']}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_clear(_args: argparse.Namespace) -> int:
|
|
74
|
+
try:
|
|
75
|
+
STATE_JSON.unlink()
|
|
76
|
+
echo("✅ STATE.json cleared")
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
echo("(STATE.json already absent)")
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
echo(f"❌ Could not remove STATE.json: {exc}")
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
86
|
+
p = argparse.ArgumentParser(
|
|
87
|
+
prog="graphstack state",
|
|
88
|
+
description="Manage the machine-readable session state (handoff/STATE.json).",
|
|
89
|
+
)
|
|
90
|
+
sub = p.add_subparsers(dest="action", required=True)
|
|
91
|
+
|
|
92
|
+
p_set = sub.add_parser("set", help="write current role/task state")
|
|
93
|
+
p_set.add_argument("--role", required=True)
|
|
94
|
+
p_set.add_argument("--task", default=None, help="board task id")
|
|
95
|
+
p_set.add_argument("--note", default="", help="free-text note")
|
|
96
|
+
|
|
97
|
+
p_get = sub.add_parser("get", help="print current state")
|
|
98
|
+
p_get.add_argument("--json", action="store_true")
|
|
99
|
+
|
|
100
|
+
sub.add_parser("clear", help="remove STATE.json")
|
|
101
|
+
return p
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_DISPATCH = {"set": cmd_set, "get": cmd_get, "clear": cmd_clear}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run(argv: list[str]) -> int:
|
|
108
|
+
parser = _build_parser()
|
|
109
|
+
try:
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
except SystemExit as e:
|
|
112
|
+
return int(e.code) if isinstance(e.code, int) else 2
|
|
113
|
+
return _DISPATCH[args.action](args)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
sys.exit(run(sys.argv[1:]))
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared pytest fixtures for the graphstack package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
13
|
+
"""Provide an isolated, writable directory and chdir into it.
|
|
14
|
+
|
|
15
|
+
All board operations are resolved relative to ``cwd`` so each test gets a
|
|
16
|
+
pristine handoff/board layout without touching the real repository.
|
|
17
|
+
"""
|
|
18
|
+
monkeypatch.chdir(tmp_path)
|
|
19
|
+
(tmp_path / "handoff" / "board" / "todo").mkdir(parents=True)
|
|
20
|
+
(tmp_path / "handoff" / "board" / "doing").mkdir(parents=True)
|
|
21
|
+
(tmp_path / "handoff" / "board" / "done").mkdir(parents=True)
|
|
22
|
+
return tmp_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def _disable_git_in_tests(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
27
|
+
"""Prevent any board command from creating real git commits during tests."""
|
|
28
|
+
from graphstack import board
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(board, "_git_commit_board", lambda _msg: None)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tests for bundled assets and install source resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from graphstack.installer import PACKAGE_ROOT, install_source_root
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_install_source_root_from_dev_repo() -> None:
|
|
11
|
+
root = install_source_root()
|
|
12
|
+
assert (root / "orchestrator" / "ORCHESTRATOR.md").is_file()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_bundled_assets_exist_in_package() -> None:
|
|
16
|
+
assets = PACKAGE_ROOT / "assets"
|
|
17
|
+
assert (assets / "orchestrator" / "ORCHESTRATOR.md").is_file()
|
|
18
|
+
assert (assets / ".cursor" / "rules" / "graphstack.mdc").is_file()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_install_from_bundled_assets_only(tmp_path: Path, monkeypatch) -> None:
|
|
22
|
+
from graphstack import installer
|
|
23
|
+
|
|
24
|
+
assets = PACKAGE_ROOT / "assets"
|
|
25
|
+
monkeypatch.setattr(
|
|
26
|
+
installer,
|
|
27
|
+
"_source_root",
|
|
28
|
+
lambda: assets,
|
|
29
|
+
)
|
|
30
|
+
target = tmp_path / "consumer"
|
|
31
|
+
target.mkdir()
|
|
32
|
+
assert installer.install(target, non_interactive=True) == 0
|
|
33
|
+
assert (target / "orchestrator" / "ORCHESTRATOR.md").is_file()
|
|
34
|
+
assert (target / ".cursor" / "rules" / "graphstack.mdc").is_file()
|
|
35
|
+
assert (target / "handoff" / "BRIEF.md").is_file()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Round-trip tests for the GNAP board lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from graphstack import board
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read(path: Path) -> dict:
|
|
14
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_status_empty_board(project_root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
18
|
+
rc = board.run(["status"])
|
|
19
|
+
captured = capsys.readouterr()
|
|
20
|
+
assert rc == 0
|
|
21
|
+
assert "Todo: 0" in captured.out
|
|
22
|
+
assert "(no tasks yet)" in captured.out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_new_task_creates_file(project_root: Path) -> None:
|
|
26
|
+
rc = board.run(["new", "add-oauth", "Add", "OAuth", "login"])
|
|
27
|
+
assert rc == 0
|
|
28
|
+
task_path = project_root / "handoff" / "board" / "todo" / "add-oauth.json"
|
|
29
|
+
assert task_path.is_file()
|
|
30
|
+
data = _read(task_path)
|
|
31
|
+
assert data["id"] == "add-oauth"
|
|
32
|
+
assert data["title"] == "Add OAuth login"
|
|
33
|
+
assert data["status"] == "todo"
|
|
34
|
+
assert data["assigned_to"] is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_new_task_rejects_duplicates(project_root: Path) -> None:
|
|
38
|
+
assert board.run(["new", "dup", "First"]) == 0
|
|
39
|
+
assert board.run(["new", "dup", "Second"]) == 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_full_lifecycle_todo_to_done(project_root: Path) -> None:
|
|
43
|
+
assert board.run(["new", "rate-limit", "Add", "rate", "limiting"]) == 0
|
|
44
|
+
|
|
45
|
+
todo = project_root / "handoff" / "board" / "todo" / "rate-limit.json"
|
|
46
|
+
doing = project_root / "handoff" / "board" / "doing" / "rate-limit.json"
|
|
47
|
+
done = project_root / "handoff" / "board" / "done" / "rate-limit.json"
|
|
48
|
+
|
|
49
|
+
assert todo.is_file()
|
|
50
|
+
|
|
51
|
+
assert board.run(["claim", "rate-limit", "builder"]) == 0
|
|
52
|
+
assert not todo.exists()
|
|
53
|
+
assert doing.is_file()
|
|
54
|
+
claimed = _read(doing)
|
|
55
|
+
assert claimed["status"] == "doing"
|
|
56
|
+
assert claimed["assigned_to"] == "builder"
|
|
57
|
+
assert claimed["started_at"] is not None
|
|
58
|
+
|
|
59
|
+
assert board.run(["complete", "rate-limit"]) == 0
|
|
60
|
+
assert not doing.exists()
|
|
61
|
+
assert done.is_file()
|
|
62
|
+
completed = _read(done)
|
|
63
|
+
assert completed["status"] == "done"
|
|
64
|
+
assert completed["completed_at"] is not None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_claim_missing_task_returns_error(project_root: Path) -> None:
|
|
68
|
+
assert board.run(["claim", "ghost", "builder"]) == 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_claim_already_doing_is_idempotent(project_root: Path) -> None:
|
|
72
|
+
board.run(["new", "twice", "Do", "it", "once"])
|
|
73
|
+
board.run(["claim", "twice", "builder"])
|
|
74
|
+
rc = board.run(["claim", "twice", "builder"])
|
|
75
|
+
assert rc == 0 # already-claimed path returns 0 with a warning
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_complete_already_done_is_idempotent(project_root: Path) -> None:
|
|
79
|
+
board.run(["new", "loop", "Loop", "task"])
|
|
80
|
+
board.run(["claim", "loop", "qa"])
|
|
81
|
+
board.run(["complete", "loop"])
|
|
82
|
+
rc = board.run(["complete", "loop"])
|
|
83
|
+
assert rc == 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_status_lists_tasks_in_each_column(
|
|
87
|
+
project_root: Path, capsys: pytest.CaptureFixture[str]
|
|
88
|
+
) -> None:
|
|
89
|
+
board.run(["new", "a", "Task", "A"])
|
|
90
|
+
board.run(["new", "b", "Task", "B"])
|
|
91
|
+
board.run(["new", "c", "Task", "C"])
|
|
92
|
+
board.run(["claim", "b", "builder"])
|
|
93
|
+
board.run(["claim", "c", "reviewer"])
|
|
94
|
+
board.run(["complete", "c"])
|
|
95
|
+
|
|
96
|
+
rc = board.run(["status"])
|
|
97
|
+
out = capsys.readouterr().out
|
|
98
|
+
assert rc == 0
|
|
99
|
+
assert "Todo: 1" in out
|
|
100
|
+
assert "In Progress: 1" in out
|
|
101
|
+
assert "Done: 1" in out
|
|
102
|
+
assert "a" in out and "b" in out and "c" in out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_help_prints_usage(capsys: pytest.CaptureFixture[str]) -> None:
|
|
106
|
+
rc = board.run([])
|
|
107
|
+
assert rc == 0
|
|
108
|
+
out = capsys.readouterr().out
|
|
109
|
+
assert "GraphStack Board" in out
|
|
110
|
+
assert "status" in out
|
|
111
|
+
assert "claim" in out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_reopen_done_to_todo(project_root: Path) -> None:
|
|
115
|
+
board.run(["new", "fix-bug", "Fix", "production", "bug"])
|
|
116
|
+
board.run(["claim", "fix-bug", "builder"])
|
|
117
|
+
board.run(["complete", "fix-bug"])
|
|
118
|
+
|
|
119
|
+
done = project_root / "handoff" / "board" / "done" / "fix-bug.json"
|
|
120
|
+
todo = project_root / "handoff" / "board" / "todo" / "fix-bug.json"
|
|
121
|
+
assert done.is_file()
|
|
122
|
+
|
|
123
|
+
assert board.run(["reopen", "fix-bug", "--to", "todo"]) == 0
|
|
124
|
+
assert not done.exists()
|
|
125
|
+
assert todo.is_file()
|
|
126
|
+
data = _read(todo)
|
|
127
|
+
assert data["status"] == "todo"
|
|
128
|
+
assert data["assigned_to"] is None
|
|
129
|
+
assert data["completed_at"] is None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_reopen_done_to_doing(project_root: Path) -> None:
|
|
133
|
+
board.run(["new", "hotfix", "Hotfix"])
|
|
134
|
+
board.run(["claim", "hotfix", "builder"])
|
|
135
|
+
board.run(["complete", "hotfix"])
|
|
136
|
+
|
|
137
|
+
doing = project_root / "handoff" / "board" / "doing" / "hotfix.json"
|
|
138
|
+
assert board.run(["reopen", "hotfix", "--to", "doing"]) == 0
|
|
139
|
+
assert doing.is_file()
|
|
140
|
+
assert _read(doing)["status"] == "doing"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_list_done_empty(project_root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
144
|
+
rc = board.run(["list-done"])
|
|
145
|
+
assert rc == 0
|
|
146
|
+
assert "(none)" in capsys.readouterr().out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_list_done_shows_completed(project_root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
150
|
+
board.run(["new", "a", "Task", "A"])
|
|
151
|
+
board.run(["claim", "a", "builder"])
|
|
152
|
+
board.run(["complete", "a"])
|
|
153
|
+
board.run(["new", "b", "Task", "B"])
|
|
154
|
+
board.run(["claim", "b", "qa"])
|
|
155
|
+
board.run(["complete", "b"])
|
|
156
|
+
|
|
157
|
+
board.run(["list-done", "--limit", "1"])
|
|
158
|
+
out = capsys.readouterr().out
|
|
159
|
+
assert "b" in out
|
|
160
|
+
assert "Showing 1 task" in out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_unicode_title_is_preserved(project_root: Path) -> None:
|
|
164
|
+
board.run(["new", "tr", "Türkçe", "başlık", "ışık"])
|
|
165
|
+
task = _read(project_root / "handoff" / "board" / "todo" / "tr.json")
|
|
166
|
+
assert task["title"] == "Türkçe başlık ışık"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Quality tests for output compaction — paths and errors must survive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from graphstack.compact.git import compact_git_diff, compact_git_log, compact_git_status
|
|
6
|
+
from graphstack.compact.generic import compact_generic, compact_pytest
|
|
7
|
+
from graphstack.compact.registry import compact_command_output
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SAMPLE_STATUS = """\
|
|
11
|
+
On branch feature/login
|
|
12
|
+
Your branch is ahead of 'origin/main' by 2 commits.
|
|
13
|
+
Changes to be committed:
|
|
14
|
+
(use "git restore --staged <file>..." to unstage)
|
|
15
|
+
\tmodified: src/auth/login.ts
|
|
16
|
+
\tnew file: src/auth/session.ts
|
|
17
|
+
|
|
18
|
+
Changes not staged for commit:
|
|
19
|
+
\tmodified: src/api/types.ts
|
|
20
|
+
|
|
21
|
+
Untracked files:
|
|
22
|
+
\tREADME.local.md
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
SAMPLE_DIFF = """\
|
|
26
|
+
diff --git a/src/a.py b/src/a.py
|
|
27
|
+
--- a/src/a.py
|
|
28
|
+
+++ b/src/a.py
|
|
29
|
+
@@ -1,3 +1,4 @@
|
|
30
|
+
line1
|
|
31
|
+
-line2
|
|
32
|
+
+line2changed
|
|
33
|
+
line3
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SAMPLE_PYTEST_FAIL = """\
|
|
37
|
+
============================= test session starts ==============================
|
|
38
|
+
collected 3 items
|
|
39
|
+
|
|
40
|
+
tests/test_auth.py::test_login FAILED
|
|
41
|
+
|
|
42
|
+
=================================== FAILURES ===================================
|
|
43
|
+
_______________________________ test_login ___________________________________
|
|
44
|
+
E AssertionError: expected 200
|
|
45
|
+
|
|
46
|
+
========================== 1 failed, 2 passed in 0.12s ==========================
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_git_status_keeps_branch_and_paths() -> None:
|
|
51
|
+
out = compact_git_status(SAMPLE_STATUS)
|
|
52
|
+
assert "feature/login" in out or "On branch" in out
|
|
53
|
+
assert "login.ts" in out
|
|
54
|
+
assert "types.ts" in out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_git_diff_keeps_hunk_headers() -> None:
|
|
58
|
+
out = compact_git_diff(SAMPLE_DIFF)
|
|
59
|
+
assert "diff --git" in out
|
|
60
|
+
assert "@@" in out
|
|
61
|
+
assert "-line2" in out or "+line2changed" in out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_git_log_truncates_but_keeps_recent() -> None:
|
|
65
|
+
lines = [f"{i:07x} commit message {i}" for i in range(50)]
|
|
66
|
+
raw = "\n".join(lines)
|
|
67
|
+
out = compact_git_log(raw, max_entries=10)
|
|
68
|
+
assert "commit message 0" in out
|
|
69
|
+
assert "omitted" in out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_pytest_keeps_failure_and_summary() -> None:
|
|
73
|
+
out = compact_pytest(SAMPLE_PYTEST_FAIL)
|
|
74
|
+
assert "FAILED" in out or "AssertionError" in out
|
|
75
|
+
assert "failed" in out.lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_registry_falls_back_when_compaction_empty() -> None:
|
|
79
|
+
raw = "important error on line 1\n" + ("noise\n" * 500)
|
|
80
|
+
result = compact_command_output(["unknown-tool"], raw)
|
|
81
|
+
assert "error" in result.text.lower() or len(result.text) > 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_critical_lines_survive_generic_truncate() -> None:
|
|
85
|
+
lines = ["ok"] * 200 + ["Fatal: disk full"] + ["ok"] * 200
|
|
86
|
+
out = compact_generic("\n".join(lines))
|
|
87
|
+
assert "Fatal" in out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_registry_git_status_route() -> None:
|
|
91
|
+
result = compact_command_output(["git", "status"], SAMPLE_STATUS)
|
|
92
|
+
assert result.used_compactor == "git-status"
|
|
93
|
+
assert "login.ts" in result.text
|