@softspark/ai-toolkit 4.2.4 → 4.2.5
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 +33 -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/kb/procedures/release-verification-sop.md +2 -2
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/hooks-catalog.md +149 -24
- 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/install_steps/hooks.py +5 -0
- package/scripts/merge-hooks.py +10 -1
- package/scripts/plugin_schema.py +4 -0
- package/scripts/session_state.py +150 -0
- package/scripts/test_cohesion.py +133 -0
|
@@ -43,6 +43,7 @@ CURSOR_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
43
43
|
"beforeShellExecution": [
|
|
44
44
|
("", "guard-destructive.sh"),
|
|
45
45
|
("", "commit-quality.sh"),
|
|
46
|
+
("", "revert-guard.sh"),
|
|
46
47
|
],
|
|
47
48
|
"beforeReadFile": [
|
|
48
49
|
("", "guard-path.sh"),
|
|
@@ -50,6 +51,7 @@ CURSOR_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
50
51
|
"afterFileEdit": [
|
|
51
52
|
("", "post-tool-use.sh"),
|
|
52
53
|
("", "governance-capture.sh"),
|
|
54
|
+
("", "test-cohesion.sh"),
|
|
53
55
|
],
|
|
54
56
|
"beforeSubmitPrompt": [
|
|
55
57
|
("", "user-prompt-submit.sh"),
|
|
@@ -58,6 +60,9 @@ CURSOR_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
58
60
|
"beforeMCPExecution": [
|
|
59
61
|
("", "guard-config.sh"),
|
|
60
62
|
],
|
|
63
|
+
"afterMCPExecution": [
|
|
64
|
+
("", "search-tracker.sh"),
|
|
65
|
+
],
|
|
61
66
|
"preCompact": [
|
|
62
67
|
("", "pre-compact.sh"),
|
|
63
68
|
("", "pre-compact-save.sh"),
|
|
@@ -71,6 +76,7 @@ CURSOR_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
71
76
|
"stop": [
|
|
72
77
|
("", "quality-check.sh"),
|
|
73
78
|
("", "save-session.sh"),
|
|
79
|
+
("", "stop-search-check.sh"),
|
|
74
80
|
],
|
|
75
81
|
"sessionEnd": [
|
|
76
82
|
("", "session-end.sh"),
|
|
@@ -29,7 +29,7 @@ import json
|
|
|
29
29
|
import sys
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
-
HOOKS_PREFIX = '"$HOME/.softspark/ai-toolkit/hooks/'
|
|
32
|
+
HOOKS_PREFIX = 'AI_TOOLKIT_HOOK_FORMAT=json "$HOME/.softspark/ai-toolkit/hooks/'
|
|
33
33
|
SOURCE_TAG = "ai-toolkit"
|
|
34
34
|
|
|
35
35
|
# Event -> list of (matcher, script) pairs. Matcher semantics:
|
|
@@ -46,13 +46,16 @@ GEMINI_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
46
46
|
# run_shell_command covers Bash-equivalent destructive patterns
|
|
47
47
|
("run_shell_command", "guard-destructive.sh"),
|
|
48
48
|
("run_shell_command", "commit-quality.sh"),
|
|
49
|
+
("run_shell_command", "revert-guard.sh"),
|
|
49
50
|
# File-touching tools — guard paths + config
|
|
50
51
|
("write_file|edit|replace|read_file", "guard-path.sh"),
|
|
51
52
|
("write_file|edit|replace", "guard-config.sh"),
|
|
52
53
|
],
|
|
53
54
|
"AfterTool": [
|
|
54
55
|
("write_file|edit|replace", "post-tool-use.sh"),
|
|
56
|
+
("write_file|edit|replace", "test-cohesion.sh"),
|
|
55
57
|
("run_shell_command|write_file|edit|replace", "governance-capture.sh"),
|
|
58
|
+
("google_web_search|web_fetch", "search-tracker.sh"),
|
|
56
59
|
],
|
|
57
60
|
"BeforeAgent": [
|
|
58
61
|
("", "user-prompt-submit.sh"),
|
|
@@ -62,6 +65,7 @@ GEMINI_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
62
65
|
# Closest equivalent to Claude's Stop event. Gemini has no native Stop.
|
|
63
66
|
("", "quality-check.sh"),
|
|
64
67
|
("", "save-session.sh"),
|
|
68
|
+
("", "stop-search-check.sh"),
|
|
65
69
|
],
|
|
66
70
|
"BeforeModel": [
|
|
67
71
|
# Light-weight observability slot. Reuses the session-context probe.
|
|
@@ -41,10 +41,12 @@ WINDSURF_HOOKS: dict[str, list[str]] = {
|
|
|
41
41
|
"post_write_code": [
|
|
42
42
|
"post-tool-use.sh",
|
|
43
43
|
"governance-capture.sh",
|
|
44
|
+
"test-cohesion.sh",
|
|
44
45
|
],
|
|
45
46
|
"pre_run_command": [
|
|
46
47
|
"guard-destructive.sh",
|
|
47
48
|
"commit-quality.sh",
|
|
49
|
+
"revert-guard.sh",
|
|
48
50
|
],
|
|
49
51
|
"post_run_command": [
|
|
50
52
|
"governance-capture.sh",
|
|
@@ -52,6 +54,9 @@ WINDSURF_HOOKS: dict[str, list[str]] = {
|
|
|
52
54
|
"pre_mcp_tool_use": [
|
|
53
55
|
"guard-config.sh",
|
|
54
56
|
],
|
|
57
|
+
"post_mcp_tool_use": [
|
|
58
|
+
"search-tracker.sh",
|
|
59
|
+
],
|
|
55
60
|
"pre_user_prompt": [
|
|
56
61
|
"user-prompt-submit.sh",
|
|
57
62
|
"track-usage.sh",
|
|
@@ -59,6 +64,7 @@ WINDSURF_HOOKS: dict[str, list[str]] = {
|
|
|
59
64
|
"post_cascade_response": [
|
|
60
65
|
"quality-check.sh",
|
|
61
66
|
"save-session.sh",
|
|
67
|
+
"stop-search-check.sh",
|
|
62
68
|
],
|
|
63
69
|
"post_setup_worktree": [
|
|
64
70
|
"session-context.sh",
|
|
@@ -58,6 +58,9 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
58
58
|
shutil.copy2(hook_file, dst)
|
|
59
59
|
dst.chmod(dst.stat().st_mode | 0o111)
|
|
60
60
|
copied += 1
|
|
61
|
+
for runtime_file in sorted(hooks_src.glob("*.json")):
|
|
62
|
+
shutil.copy2(runtime_file, hooks_scripts_dir / runtime_file.name)
|
|
63
|
+
copied += 1
|
|
61
64
|
print(f" Copied: {copied} hook scripts to ~/.softspark/ai-toolkit/hooks/")
|
|
62
65
|
legacy_hooks = claude_dir / "hooks"
|
|
63
66
|
if legacy_hooks.is_symlink():
|
|
@@ -68,7 +71,9 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
68
71
|
# Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
|
|
69
72
|
# scripts that a deployed hook actually executes belong here.
|
|
70
73
|
HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
|
|
74
|
+
"session_state.py",
|
|
71
75
|
"session_token_stats.py",
|
|
76
|
+
"test_cohesion.py",
|
|
72
77
|
"version_check.py",
|
|
73
78
|
)
|
|
74
79
|
|
package/scripts/merge-hooks.py
CHANGED
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())
|