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,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