@softspark/ai-toolkit 4.2.4 → 4.3.0
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.
- package/CHANGELOG.md +67 -0
- package/README.md +10 -9
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/_hook-io.sh +64 -0
- package/app/hooks/_locate-toolkit.sh +39 -0
- package/app/hooks/_search-capability.sh +46 -0
- package/app/hooks/commit-quality.sh +3 -1
- package/app/hooks/config-desync-guard.sh +95 -0
- package/app/hooks/governance-capture.sh +5 -3
- package/app/hooks/guard-config.sh +5 -3
- package/app/hooks/guard-destructive.sh +3 -1
- package/app/hooks/guard-path.sh +8 -1
- package/app/hooks/instructions-audit.sh +43 -0
- package/app/hooks/post-tool-use.sh +20 -8
- package/app/hooks/quality-gate.sh +47 -0
- package/app/hooks/revert-guard.sh +84 -0
- package/app/hooks/search-tracker.sh +18 -0
- package/app/hooks/session-start.sh +14 -2
- package/app/hooks/stop-search-check.sh +37 -0
- package/app/hooks/test-cohesion-map.json +77 -0
- package/app/hooks/test-cohesion.sh +93 -0
- package/app/hooks/track-usage.sh +3 -1
- package/app/hooks/user-prompt-submit.sh +33 -6
- package/app/hooks.json +65 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +8 -8
- package/bin/ai-toolkit.js +43 -0
- package/kb/procedures/release-verification-sop.md +2 -2
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/extension-api.md +77 -11
- package/kb/reference/hooks-catalog.md +149 -24
- package/kb/reference/mcp-templates.md +6 -4
- package/kb/reference/unique-features.md +11 -5
- package/llms-full.txt +163 -32
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.py +13 -0
- package/scripts/generate_augment_hooks.py +5 -1
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_cursor_hooks.py +6 -0
- package/scripts/generate_gemini_hooks.py +5 -1
- package/scripts/generate_windsurf_hooks.py +6 -0
- package/scripts/inject_mcp_cli.py +514 -0
- package/scripts/install.py +2 -1
- package/scripts/install_steps/hooks.py +5 -0
- package/scripts/install_steps/markers.py +40 -0
- package/scripts/mcp_sources.py +162 -0
- package/scripts/merge-hooks.py +10 -1
- package/scripts/paths.py +2 -0
- package/scripts/plugin_schema.py +4 -0
- package/scripts/session_state.py +150 -0
- package/scripts/test_cohesion.py +133 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""URL source registry for externally-injected MCP templates.
|
|
3
|
+
|
|
4
|
+
Tracks which MCP templates were registered from a URL or local file so that
|
|
5
|
+
`ai-toolkit update` can re-fetch the latest version and re-inject. Mirrors
|
|
6
|
+
the design of hook_sources.py for parity between inject-hook and inject-mcp.
|
|
7
|
+
|
|
8
|
+
Metadata stored in ~/.softspark/ai-toolkit/mcp-templates/external/sources.json.
|
|
9
|
+
|
|
10
|
+
Stdlib-only -- no external dependencies.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
from paths import EXTERNAL_MCP_DIR
|
|
26
|
+
|
|
27
|
+
_SOURCES_FILENAME = "sources.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sources_path(mcp_dir: Path | None = None) -> Path:
|
|
31
|
+
return (mcp_dir or EXTERNAL_MCP_DIR) / _SOURCES_FILENAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_sources(mcp_dir: Path | None = None) -> dict[str, dict[str, Any]]:
|
|
35
|
+
"""Load sources.json. Returns {} if missing or corrupt."""
|
|
36
|
+
path = _sources_path(mcp_dir)
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
with open(path, encoding="utf-8") as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
return data.get("templates", {})
|
|
44
|
+
return {}
|
|
45
|
+
except (json.JSONDecodeError, OSError):
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_sources(mcp_dir: Path | None = None,
|
|
50
|
+
sources: dict[str, dict[str, Any]] | None = None) -> None:
|
|
51
|
+
"""Write sources.json atomically."""
|
|
52
|
+
mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
|
|
53
|
+
path = _sources_path(mcp_dir)
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
payload = json.dumps(
|
|
57
|
+
{"schema_version": 1, "templates": sources or {}}, indent=2
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
61
|
+
dir=str(path.parent), prefix=".sources_", suffix=".tmp"
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
65
|
+
f.write(payload)
|
|
66
|
+
f.write("\n")
|
|
67
|
+
f.flush()
|
|
68
|
+
os.fsync(f.fileno())
|
|
69
|
+
os.rename(tmp_path, str(path))
|
|
70
|
+
except BaseException:
|
|
71
|
+
try:
|
|
72
|
+
os.unlink(tmp_path)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def register_url_source(
|
|
79
|
+
mcp_dir: Path | None,
|
|
80
|
+
template_name: str,
|
|
81
|
+
url: str,
|
|
82
|
+
content: bytes | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Add or update a URL source entry for an MCP template.
|
|
85
|
+
|
|
86
|
+
When ``content`` is supplied, its sha256 is persisted. If a previous
|
|
87
|
+
sha256 exists and differs from the new one, a warning is printed
|
|
88
|
+
(and the process fails with exit 2 when ``AI_TOOLKIT_STRICT_PIN=1``).
|
|
89
|
+
"""
|
|
90
|
+
if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
|
|
91
|
+
raise ValueError(f"Invalid MCP template name: {template_name!r}")
|
|
92
|
+
mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
|
|
93
|
+
sources = load_sources(mcp_dir)
|
|
94
|
+
entry: dict[str, Any] = {
|
|
95
|
+
"url": url,
|
|
96
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
97
|
+
}
|
|
98
|
+
if content is not None:
|
|
99
|
+
new_hash = hashlib.sha256(content).hexdigest()
|
|
100
|
+
prev = sources.get(template_name) or {}
|
|
101
|
+
prev_hash = prev.get("sha256")
|
|
102
|
+
if prev_hash and prev_hash != new_hash:
|
|
103
|
+
msg = (
|
|
104
|
+
f" CHECKSUM CHANGED: mcp '{template_name}' sha256 "
|
|
105
|
+
f"{prev_hash[:12]}... -> {new_hash[:12]}..."
|
|
106
|
+
)
|
|
107
|
+
print(msg)
|
|
108
|
+
if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
|
|
109
|
+
raise SystemExit(
|
|
110
|
+
f"Refusing to update '{template_name}' under AI_TOOLKIT_STRICT_PIN=1."
|
|
111
|
+
)
|
|
112
|
+
entry["sha256"] = new_hash
|
|
113
|
+
sources[template_name] = entry
|
|
114
|
+
save_sources(mcp_dir, sources)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register_path_source(
|
|
118
|
+
mcp_dir: Path | None,
|
|
119
|
+
template_name: str,
|
|
120
|
+
path: Path,
|
|
121
|
+
content: bytes | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Add or update a local-file source entry for an MCP template.
|
|
124
|
+
|
|
125
|
+
Stores the absolute origin path so subsequent ``ai-toolkit update`` runs
|
|
126
|
+
can detect drift, plus a sha256 of the injected content.
|
|
127
|
+
"""
|
|
128
|
+
if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
|
|
129
|
+
raise ValueError(f"Invalid MCP template name: {template_name!r}")
|
|
130
|
+
mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
|
|
131
|
+
sources = load_sources(mcp_dir)
|
|
132
|
+
existing = sources.get(template_name) or {}
|
|
133
|
+
# Never demote a URL-tracked entry to a local-path entry. update() flows
|
|
134
|
+
# call inject() with the cached file path after URL fetch, which would
|
|
135
|
+
# otherwise overwrite the URL.
|
|
136
|
+
if "url" in existing:
|
|
137
|
+
return
|
|
138
|
+
entry: dict[str, Any] = {
|
|
139
|
+
"path": str(Path(path).resolve()),
|
|
140
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
141
|
+
}
|
|
142
|
+
if content is not None:
|
|
143
|
+
entry["sha256"] = hashlib.sha256(content).hexdigest()
|
|
144
|
+
sources[template_name] = entry
|
|
145
|
+
save_sources(mcp_dir, sources)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def unregister_source(mcp_dir: Path | None, template_name: str) -> bool:
|
|
149
|
+
"""Remove a source entry. Returns True if found and removed."""
|
|
150
|
+
mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
|
|
151
|
+
sources = load_sources(mcp_dir)
|
|
152
|
+
if template_name in sources:
|
|
153
|
+
del sources[template_name]
|
|
154
|
+
save_sources(mcp_dir, sources)
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_url_templates(mcp_dir: Path | None = None) -> dict[str, str]:
|
|
160
|
+
"""Return {template_name: url} for all URL-sourced MCP templates."""
|
|
161
|
+
sources = load_sources(mcp_dir)
|
|
162
|
+
return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
|
package/scripts/merge-hooks.py
CHANGED
package/scripts/paths.py
CHANGED
|
@@ -26,6 +26,8 @@ LEGACY_DATA_DIR = Path.home() / ".ai-toolkit"
|
|
|
26
26
|
# Sub-directories under TOOLKIT_DATA_DIR
|
|
27
27
|
HOOKS_DIR = TOOLKIT_DATA_DIR / "hooks"
|
|
28
28
|
EXTERNAL_HOOKS_DIR = HOOKS_DIR / "external"
|
|
29
|
+
MCP_TEMPLATES_DIR = TOOLKIT_DATA_DIR / "mcp-templates"
|
|
30
|
+
EXTERNAL_MCP_DIR = MCP_TEMPLATES_DIR / "external"
|
|
29
31
|
RULES_DIR = TOOLKIT_DATA_DIR / "rules"
|
|
30
32
|
SESSIONS_DIR = TOOLKIT_DATA_DIR / "sessions"
|
|
31
33
|
COMPACTIONS_DIR = TOOLKIT_DATA_DIR / "compactions"
|
package/scripts/plugin_schema.py
CHANGED
|
@@ -32,6 +32,10 @@ VALID_HOOK_EVENTS = frozenset({
|
|
|
32
32
|
"SessionStart", "Notification", "PreToolUse", "PostToolUse", "Stop",
|
|
33
33
|
"PreCompact", "SubagentStop", "UserPromptSubmit", "TaskCompleted",
|
|
34
34
|
"TeammateIdle", "SubagentStart", "SessionEnd", "PermissionRequest", "Setup",
|
|
35
|
+
"InstructionsLoaded", "ConfigChange", "PostToolUseFailure", "PostToolBatch",
|
|
36
|
+
"UserPromptExpansion", "PostCompact", "StopFailure", "CwdChanged",
|
|
37
|
+
"FileChanged", "PermissionDenied", "Elicitation", "ElicitationResult",
|
|
38
|
+
"WorktreeCreate", "WorktreeRemove", "TaskCreated",
|
|
35
39
|
})
|
|
36
40
|
|
|
37
41
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Session edit state tracker for ai-toolkit hooks.
|
|
3
|
+
|
|
4
|
+
Stores per-session file edits so hooks can:
|
|
5
|
+
- Know which paths were touched (revert-guard, test-cohesion).
|
|
6
|
+
- Run only related tests (quality-gate ai-toolkit branch).
|
|
7
|
+
|
|
8
|
+
State file: ``~/.softspark/ai-toolkit/state/session-edits.json``
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
session_state.py reset [--session-id ID]
|
|
12
|
+
session_state.py append --tool Edit --path /abs/path [--session-id ID]
|
|
13
|
+
session_state.py was-edited /abs/path
|
|
14
|
+
session_state.py list
|
|
15
|
+
session_state.py session-id
|
|
16
|
+
|
|
17
|
+
Exit codes:
|
|
18
|
+
0 success / true
|
|
19
|
+
1 false / not found (for boolean queries)
|
|
20
|
+
2 usage error
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import uuid
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
STATE_DIR = Path(os.path.expanduser("~/.softspark/ai-toolkit/state"))
|
|
33
|
+
STATE_FILE = STATE_DIR / "session-edits.json"
|
|
34
|
+
MAX_EDITS = 5000 # cap to avoid unbounded growth
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _now() -> str:
|
|
38
|
+
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ensure_dir() -> None:
|
|
42
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load() -> dict:
|
|
46
|
+
if not STATE_FILE.is_file():
|
|
47
|
+
return {"session_id": "", "started_at": "", "edits": []}
|
|
48
|
+
try:
|
|
49
|
+
with STATE_FILE.open() as f:
|
|
50
|
+
data = json.load(f)
|
|
51
|
+
except (json.JSONDecodeError, OSError):
|
|
52
|
+
return {"session_id": "", "started_at": "", "edits": []}
|
|
53
|
+
data.setdefault("session_id", "")
|
|
54
|
+
data.setdefault("started_at", "")
|
|
55
|
+
data.setdefault("edits", [])
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _save(data: dict) -> None:
|
|
60
|
+
_ensure_dir()
|
|
61
|
+
tmp = STATE_FILE.with_suffix(".json.tmp")
|
|
62
|
+
with tmp.open("w") as f:
|
|
63
|
+
json.dump(data, f, indent=2)
|
|
64
|
+
f.write("\n")
|
|
65
|
+
os.replace(tmp, STATE_FILE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_reset(session_id: str | None) -> int:
|
|
69
|
+
sid = session_id or str(uuid.uuid4())
|
|
70
|
+
_save({"session_id": sid, "started_at": _now(), "edits": []})
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_append(tool: str, path: str, session_id: str | None) -> int:
|
|
75
|
+
if not path:
|
|
76
|
+
return 0 # silently ignore tools without file_path (e.g., bash)
|
|
77
|
+
data = _load()
|
|
78
|
+
if session_id and data["session_id"] != session_id:
|
|
79
|
+
# New session detected mid-stream — auto-reset.
|
|
80
|
+
cmd_reset(session_id)
|
|
81
|
+
data = _load()
|
|
82
|
+
abs_path = os.path.abspath(path)
|
|
83
|
+
data["edits"].append({"ts": _now(), "tool": tool, "path": abs_path})
|
|
84
|
+
if len(data["edits"]) > MAX_EDITS:
|
|
85
|
+
data["edits"] = data["edits"][-MAX_EDITS:]
|
|
86
|
+
_save(data)
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_was_edited(path: str) -> int:
|
|
91
|
+
abs_path = os.path.abspath(path)
|
|
92
|
+
data = _load()
|
|
93
|
+
for edit in data["edits"]:
|
|
94
|
+
if edit.get("path") == abs_path:
|
|
95
|
+
return 0
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_list() -> int:
|
|
100
|
+
data = _load()
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
for edit in data["edits"]:
|
|
103
|
+
p = edit.get("path", "")
|
|
104
|
+
if p and p not in seen:
|
|
105
|
+
seen.add(p)
|
|
106
|
+
print(p)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_session_id() -> int:
|
|
111
|
+
data = _load()
|
|
112
|
+
print(data.get("session_id", ""))
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
parser = argparse.ArgumentParser(description="Session edit state tracker")
|
|
118
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
119
|
+
|
|
120
|
+
reset = sub.add_parser("reset", help="Clear state, start fresh session")
|
|
121
|
+
reset.add_argument("--session-id", default=None)
|
|
122
|
+
|
|
123
|
+
append = sub.add_parser("append", help="Record an edit")
|
|
124
|
+
append.add_argument("--tool", required=True)
|
|
125
|
+
append.add_argument("--path", required=True)
|
|
126
|
+
append.add_argument("--session-id", default=None)
|
|
127
|
+
|
|
128
|
+
was = sub.add_parser("was-edited", help="Exit 0 if path was edited this session")
|
|
129
|
+
was.add_argument("path")
|
|
130
|
+
|
|
131
|
+
sub.add_parser("list", help="Print unique edited paths, one per line")
|
|
132
|
+
sub.add_parser("session-id", help="Print current session id")
|
|
133
|
+
|
|
134
|
+
args = parser.parse_args(argv)
|
|
135
|
+
|
|
136
|
+
if args.cmd == "reset":
|
|
137
|
+
return cmd_reset(args.session_id)
|
|
138
|
+
if args.cmd == "append":
|
|
139
|
+
return cmd_append(args.tool, args.path, args.session_id)
|
|
140
|
+
if args.cmd == "was-edited":
|
|
141
|
+
return cmd_was_edited(args.path)
|
|
142
|
+
if args.cmd == "list":
|
|
143
|
+
return cmd_list()
|
|
144
|
+
if args.cmd == "session-id":
|
|
145
|
+
return cmd_session_id()
|
|
146
|
+
return 2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
sys.exit(main())
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test cohesion resolver for ai-toolkit hooks.
|
|
3
|
+
|
|
4
|
+
Reads a project-local or toolkit-default test-cohesion-map and answers:
|
|
5
|
+
"given these changed paths, what tests should run?"
|
|
6
|
+
|
|
7
|
+
Map format (JSON array of rules):
|
|
8
|
+
[
|
|
9
|
+
{
|
|
10
|
+
"match": "app/hooks/*.sh", # glob, repo-rooted
|
|
11
|
+
"tests": ["tests/test_hooks.bats"],
|
|
12
|
+
"runner": "bats", # bats|pytest|vitest|jest|custom
|
|
13
|
+
"command": null # optional: full command override
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
Lookup order:
|
|
18
|
+
1. $PWD/.claude/test-cohesion-map.json (project override)
|
|
19
|
+
2. $TOOLKIT_DIR/app/hooks/test-cohesion-map.json (toolkit default)
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
test_cohesion.py resolve --changed-paths PATH [PATH ...] [--repo-root DIR]
|
|
23
|
+
Prints one shell command per line. Exit 0 if any test commands resolved,
|
|
24
|
+
1 if no rules matched (so hook can skip silently).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import fnmatch
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
DEFAULT_RUNNERS: dict[str, str] = {
|
|
36
|
+
"bats": "bats --no-parallelize-within-files",
|
|
37
|
+
"pytest": "pytest -x",
|
|
38
|
+
"vitest": "npx vitest run",
|
|
39
|
+
"jest": "npx jest --bail",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _toolkit_dir() -> Path | None:
|
|
44
|
+
env = os.environ.get("AI_TOOLKIT_DIR")
|
|
45
|
+
if env and Path(env).is_dir():
|
|
46
|
+
return Path(env)
|
|
47
|
+
# Walk up from this script: scripts/ -> repo root
|
|
48
|
+
here = Path(__file__).resolve()
|
|
49
|
+
candidate = here.parent.parent
|
|
50
|
+
if (candidate / "app" / "hooks.json").is_file():
|
|
51
|
+
return candidate
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_map(repo_root: Path) -> list[dict]:
|
|
56
|
+
candidates = [repo_root / ".claude" / "test-cohesion-map.json"]
|
|
57
|
+
tk = _toolkit_dir()
|
|
58
|
+
if tk:
|
|
59
|
+
candidates.append(tk / "hooks" / "test-cohesion-map.json")
|
|
60
|
+
candidates.append(tk / "app" / "hooks" / "test-cohesion-map.json")
|
|
61
|
+
for path in candidates:
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
with path.open() as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
except (json.JSONDecodeError, OSError):
|
|
68
|
+
continue
|
|
69
|
+
if isinstance(data, list):
|
|
70
|
+
return data
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _matches(path: str, pattern: str) -> bool:
|
|
75
|
+
return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(
|
|
76
|
+
os.path.basename(path), pattern
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _relpath(repo_root: Path, abs_path: str) -> str:
|
|
81
|
+
try:
|
|
82
|
+
return str(Path(abs_path).resolve().relative_to(repo_root.resolve()))
|
|
83
|
+
except (ValueError, OSError):
|
|
84
|
+
return abs_path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve(repo_root: Path, changed: list[str]) -> list[str]:
|
|
88
|
+
rules = _load_map(repo_root)
|
|
89
|
+
if not rules:
|
|
90
|
+
return []
|
|
91
|
+
# First-match-wins per changed path. List specific rules before broad
|
|
92
|
+
# globs in the map to control scope.
|
|
93
|
+
buckets: dict[tuple[str, str | None], set[str]] = {}
|
|
94
|
+
for raw in changed:
|
|
95
|
+
rel = _relpath(repo_root, raw)
|
|
96
|
+
for rule in rules:
|
|
97
|
+
pattern = rule.get("match")
|
|
98
|
+
if not pattern or not _matches(rel, pattern):
|
|
99
|
+
continue
|
|
100
|
+
runner = rule.get("runner", "bats")
|
|
101
|
+
command_override = rule.get("command")
|
|
102
|
+
key = (runner, command_override)
|
|
103
|
+
buckets.setdefault(key, set()).update(rule.get("tests", []))
|
|
104
|
+
break # first match wins for this path
|
|
105
|
+
if not buckets:
|
|
106
|
+
return []
|
|
107
|
+
commands: list[str] = []
|
|
108
|
+
for (runner, override), tests in buckets.items():
|
|
109
|
+
if override:
|
|
110
|
+
commands.append(override)
|
|
111
|
+
continue
|
|
112
|
+
base = DEFAULT_RUNNERS.get(runner, runner)
|
|
113
|
+
commands.append(f"{base} {' '.join(sorted(tests))}".strip())
|
|
114
|
+
return commands
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
parser = argparse.ArgumentParser(description="Resolve cohesion test commands")
|
|
119
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
120
|
+
r = sub.add_parser("resolve")
|
|
121
|
+
r.add_argument("--changed-paths", nargs="+", required=True)
|
|
122
|
+
r.add_argument("--repo-root", default=os.getcwd())
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
|
|
125
|
+
repo_root = Path(args.repo_root)
|
|
126
|
+
commands = resolve(repo_root, args.changed_paths)
|
|
127
|
+
for c in commands:
|
|
128
|
+
print(c)
|
|
129
|
+
return 0 if commands else 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
sys.exit(main())
|