constraintloop 0.1.0__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.
@@ -0,0 +1,290 @@
1
+ """Deterministic constraint runners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import operator
7
+ import re
8
+ import subprocess
9
+ import time
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ from constraintloop.models import (
15
+ ArtifactConstraint,
16
+ CommandConstraint,
17
+ ConstraintResult,
18
+ Enforcement,
19
+ MetricConstraint,
20
+ Verdict,
21
+ )
22
+
23
+ _OPS: dict[str, Callable[[float, float], bool]] = {
24
+ "gt": operator.gt,
25
+ "gte": operator.ge,
26
+ "lt": operator.lt,
27
+ "lte": operator.le,
28
+ "eq": operator.eq,
29
+ }
30
+
31
+
32
+ def run_command_constraint(
33
+ project_root: Path,
34
+ constraint_id: str,
35
+ spec: CommandConstraint,
36
+ input_digest: str,
37
+ output_limit: int,
38
+ ) -> ConstraintResult:
39
+ started = time.monotonic()
40
+ execution = _run_command(project_root, spec.command, spec.shell, spec.cwd, spec.timeout_seconds)
41
+ duration = (time.monotonic() - started) * 1000
42
+ if isinstance(execution, str):
43
+ return _error_result(
44
+ constraint_id, spec.kind, spec.enforcement, input_digest, execution, duration
45
+ )
46
+ returncode, stdout, stderr = execution
47
+ if returncode in spec.pending_codes:
48
+ return ConstraintResult(
49
+ constraint_id=constraint_id,
50
+ kind=spec.kind,
51
+ verdict=Verdict.PENDING,
52
+ enforcement=spec.enforcement,
53
+ input_digest=input_digest,
54
+ message=f"Command is pending (exit code {returncode})",
55
+ duration_ms=duration,
56
+ exit_code=returncode,
57
+ output_tail=_output_tail(stdout, stderr, output_limit) or None,
58
+ )
59
+ passed = returncode in spec.success_codes
60
+ output = _output_tail(stdout, stderr, output_limit)
61
+ return ConstraintResult(
62
+ constraint_id=constraint_id,
63
+ kind=spec.kind,
64
+ verdict=Verdict.PASS if passed else Verdict.FAIL,
65
+ enforcement=spec.enforcement,
66
+ input_digest=input_digest,
67
+ message="Command passed" if passed else f"Command exited with code {returncode}",
68
+ duration_ms=duration,
69
+ exit_code=returncode,
70
+ output_tail=output or None,
71
+ )
72
+
73
+
74
+ def run_metric_constraint(
75
+ project_root: Path,
76
+ constraint_id: str,
77
+ spec: MetricConstraint,
78
+ input_digest: str,
79
+ output_limit: int,
80
+ ) -> ConstraintResult:
81
+ started = time.monotonic()
82
+ execution = _run_command(project_root, spec.command, spec.shell, spec.cwd, spec.timeout_seconds)
83
+ duration = (time.monotonic() - started) * 1000
84
+ if isinstance(execution, str):
85
+ return _error_result(
86
+ constraint_id, spec.kind, spec.enforcement, input_digest, execution, duration
87
+ )
88
+ returncode, stdout, stderr = execution
89
+ output = _output_tail(stdout, stderr, output_limit)
90
+ if returncode in spec.pending_codes:
91
+ return ConstraintResult(
92
+ constraint_id=constraint_id,
93
+ kind=spec.kind,
94
+ verdict=Verdict.PENDING,
95
+ enforcement=spec.enforcement,
96
+ input_digest=input_digest,
97
+ message=f"Metric is pending (exit code {returncode})",
98
+ duration_ms=duration,
99
+ exit_code=returncode,
100
+ output_tail=output or None,
101
+ )
102
+ if returncode not in spec.success_codes:
103
+ return ConstraintResult(
104
+ constraint_id=constraint_id,
105
+ kind=spec.kind,
106
+ verdict=Verdict.FAIL,
107
+ enforcement=spec.enforcement,
108
+ input_digest=input_digest,
109
+ message=f"Metric command exited with code {returncode}",
110
+ duration_ms=duration,
111
+ exit_code=returncode,
112
+ output_tail=output or None,
113
+ )
114
+ try:
115
+ value = _parse_metric(project_root / spec.cwd, spec, stdout, stderr)
116
+ except (ValueError, OSError, json.JSONDecodeError, re.error) as exc:
117
+ return _error_result(
118
+ constraint_id,
119
+ spec.kind,
120
+ spec.enforcement,
121
+ input_digest,
122
+ f"Could not parse metric: {exc}",
123
+ duration,
124
+ output,
125
+ )
126
+ passed = _OPS[spec.threshold.operator](value, spec.threshold.value)
127
+ return ConstraintResult(
128
+ constraint_id=constraint_id,
129
+ kind=spec.kind,
130
+ verdict=Verdict.PASS if passed else Verdict.FAIL,
131
+ enforcement=spec.enforcement,
132
+ input_digest=input_digest,
133
+ message=(
134
+ f"Metric {value:g} satisfies {spec.threshold.operator} {spec.threshold.value:g}"
135
+ if passed
136
+ else (
137
+ f"Metric {value:g} does not satisfy "
138
+ f"{spec.threshold.operator} {spec.threshold.value:g}"
139
+ )
140
+ ),
141
+ duration_ms=duration,
142
+ exit_code=returncode,
143
+ value=value,
144
+ output_tail=output or None,
145
+ )
146
+
147
+
148
+ def run_artifact_constraint(
149
+ project_root: Path,
150
+ constraint_id: str,
151
+ spec: ArtifactConstraint,
152
+ input_digest: str,
153
+ ) -> ConstraintResult:
154
+ started = time.monotonic()
155
+ path = (project_root / spec.path).resolve()
156
+ try:
157
+ path.relative_to(project_root.resolve())
158
+ except ValueError:
159
+ return _error_result(
160
+ constraint_id,
161
+ spec.kind,
162
+ spec.enforcement,
163
+ input_digest,
164
+ "Artifact path escapes the project root",
165
+ 0,
166
+ )
167
+ if not path.is_file():
168
+ verdict, message = Verdict.FAIL, f"Required artifact does not exist: {spec.path}"
169
+ elif spec.non_empty and path.stat().st_size == 0:
170
+ verdict, message = Verdict.FAIL, f"Required artifact is empty: {spec.path}"
171
+ else:
172
+ try:
173
+ if spec.format == "json":
174
+ json.loads(path.read_text(encoding="utf-8"))
175
+ elif spec.format == "junit":
176
+ import xml.etree.ElementTree as ET
177
+
178
+ ET.parse(path)
179
+ verdict, message = Verdict.PASS, f"Artifact is present: {spec.path}"
180
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
181
+ verdict, message = Verdict.FAIL, f"Artifact is invalid: {exc}"
182
+ return ConstraintResult(
183
+ constraint_id=constraint_id,
184
+ kind=spec.kind,
185
+ verdict=verdict,
186
+ enforcement=spec.enforcement,
187
+ input_digest=input_digest,
188
+ message=message,
189
+ duration_ms=(time.monotonic() - started) * 1000,
190
+ )
191
+
192
+
193
+ def _run_command(
194
+ project_root: Path,
195
+ command: list[str] | str,
196
+ shell: bool,
197
+ cwd: str,
198
+ timeout: float,
199
+ ) -> tuple[int, str, str] | str:
200
+ working_dir = (project_root / cwd).resolve()
201
+ try:
202
+ working_dir.relative_to(project_root.resolve())
203
+ except ValueError:
204
+ return f"Command cwd escapes the project root: {cwd}"
205
+ if not working_dir.is_dir():
206
+ return f"Command cwd does not exist: {cwd}"
207
+ try:
208
+ result = subprocess.run(
209
+ command,
210
+ shell=shell,
211
+ cwd=working_dir,
212
+ capture_output=True,
213
+ encoding="utf-8",
214
+ errors="replace",
215
+ timeout=timeout,
216
+ check=False,
217
+ )
218
+ except subprocess.TimeoutExpired:
219
+ return f"Command timed out after {timeout:g}s"
220
+ except (FileNotFoundError, OSError) as exc:
221
+ return f"Command could not start: {exc}"
222
+ return result.returncode, result.stdout or "", result.stderr or ""
223
+
224
+
225
+ def _parse_metric(
226
+ cwd: Path,
227
+ spec: MetricConstraint,
228
+ stdout: str,
229
+ stderr: str,
230
+ ) -> float:
231
+ parser = spec.parser
232
+ if parser.source == "stdout":
233
+ raw = stdout
234
+ elif parser.source == "stderr":
235
+ raw = stderr
236
+ else:
237
+ assert parser.file is not None
238
+ metric_path = (cwd / parser.file).resolve()
239
+ try:
240
+ metric_path.relative_to(cwd.resolve())
241
+ except ValueError as exc:
242
+ raise ValueError("metric file escapes the constraint cwd") from exc
243
+ raw = metric_path.read_text(encoding="utf-8")
244
+
245
+ if parser.type == "regex":
246
+ assert parser.pattern is not None
247
+ match = re.search(parser.pattern, raw, re.MULTILINE)
248
+ if not match:
249
+ raise ValueError("regex did not match")
250
+ return float(match.group(parser.group))
251
+
252
+ assert parser.path is not None
253
+ value: object = json.loads(raw)
254
+ for part in parser.path.split("."):
255
+ if isinstance(value, list):
256
+ value = value[int(part)]
257
+ elif isinstance(value, dict):
258
+ value = value[part]
259
+ else:
260
+ raise ValueError(f"path stopped before {part!r}")
261
+ return float(cast(Any, value))
262
+
263
+
264
+ def _output_tail(stdout: str, stderr: str, limit: int) -> str:
265
+ combined = "\n".join(part for part in (stdout.strip(), stderr.strip()) if part)
266
+ encoded = combined.encode()
267
+ if len(encoded) <= limit:
268
+ return combined
269
+ return "[output truncated]\n" + encoded[-limit:].decode("utf-8", errors="replace")
270
+
271
+
272
+ def _error_result(
273
+ constraint_id: str,
274
+ kind: str,
275
+ enforcement: Enforcement,
276
+ input_digest: str,
277
+ message: str,
278
+ duration: float,
279
+ output: str | None = None,
280
+ ) -> ConstraintResult:
281
+ return ConstraintResult(
282
+ constraint_id=constraint_id,
283
+ kind=kind,
284
+ verdict=Verdict.ERROR,
285
+ enforcement=enforcement,
286
+ input_digest=input_digest,
287
+ message=message,
288
+ duration_ms=duration,
289
+ output_tail=output or None,
290
+ )
@@ -0,0 +1,181 @@
1
+ """Project detection and explicit contract/proposal generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ def initial_contract(project_root: Path) -> dict[str, Any]:
13
+ constraints: dict[str, Any] = {
14
+ "diff_hygiene": {
15
+ "kind": "command",
16
+ "description": "Reject whitespace errors in the patch",
17
+ "command": ["git", "diff", "--check"],
18
+ "phases": ["change", "stop", "ci"],
19
+ "watch": ["**/*"],
20
+ }
21
+ }
22
+ pyproject = project_root / "pyproject.toml"
23
+ if pyproject.is_file():
24
+ uses_uv = (project_root / "uv.lock").is_file()
25
+ venv_python = project_root / ".venv" / "bin" / "python"
26
+ venv_pytest = project_root / ".venv" / "bin" / "pytest"
27
+ uv = ["uv", "--cache-dir", ".constraintloop/uv-cache", "run"]
28
+ if venv_python.is_file() and venv_pytest.is_file():
29
+ python = [".venv/bin/python"]
30
+ pytest = [".venv/bin/pytest"]
31
+ else:
32
+ python = [*uv, "python"] if uses_uv else ["python"]
33
+ pytest = [*uv, "pytest"] if uses_uv else ["python", "-m", "pytest"]
34
+ constraints["python_syntax"] = {
35
+ "kind": "command",
36
+ "command": [*python, "-m", "compileall", "-q", "."],
37
+ "phases": ["change", "stop", "ci"],
38
+ "watch": ["**/*.py"],
39
+ }
40
+ if (project_root / "tests").is_dir() or "pytest" in pyproject.read_text(
41
+ encoding="utf-8", errors="ignore"
42
+ ):
43
+ constraints["tests"] = {
44
+ "kind": "command",
45
+ "command": [*pytest, "-q"],
46
+ "phases": ["stop", "ci"],
47
+ "watch": ["**/*.py", "pyproject.toml"],
48
+ "needs": ["python_syntax"],
49
+ }
50
+
51
+ package_json = project_root / "package.json"
52
+ if package_json.is_file():
53
+ try:
54
+ scripts = json.loads(package_json.read_text(encoding="utf-8")).get("scripts", {})
55
+ except (OSError, json.JSONDecodeError):
56
+ scripts = {}
57
+ for name in ("lint", "typecheck", "test"):
58
+ if name in scripts:
59
+ constraint_id = f"npm_{name}"
60
+ constraints[constraint_id] = {
61
+ "kind": "command",
62
+ "command": ["npm", "run", name],
63
+ "phases": ["stop", "ci"],
64
+ "watch": ["src/**/*", "test/**/*", "tests/**/*", "package.json"],
65
+ }
66
+
67
+ return {
68
+ "version": 1,
69
+ "settings": {
70
+ "max_auto_retries": 2,
71
+ "concurrency": 4,
72
+ "evidence_output_limit": 65536,
73
+ "evaluation_bundle_limit": 102400,
74
+ },
75
+ "constraints": constraints,
76
+ "evaluators": {},
77
+ }
78
+
79
+
80
+ def write_initial_contract(project_root: Path) -> Path:
81
+ path = project_root / "constraintloop.yml"
82
+ path.write_text(
83
+ "# Generated by ConstraintLoop. Every gate is explicit and reviewable.\n"
84
+ + yaml.safe_dump(initial_contract(project_root), sort_keys=False),
85
+ encoding="utf-8",
86
+ )
87
+ return path
88
+
89
+
90
+ def enhancement_proposal(project_root: Path) -> dict[str, Any]:
91
+ suggestions: list[dict[str, Any]] = []
92
+ if (project_root / "pyproject.toml").is_file():
93
+ suggestions.extend(
94
+ [
95
+ {
96
+ "id": "ruff",
97
+ "reason": "Fast deterministic linting for Python",
98
+ "dependency": "ruff",
99
+ "constraint": {
100
+ "kind": "command",
101
+ "command": ["python", "-m", "ruff", "check", "."],
102
+ "phases": ["change", "stop", "ci"],
103
+ "watch": ["**/*.py", "pyproject.toml"],
104
+ },
105
+ },
106
+ {
107
+ "id": "coverage",
108
+ "reason": "Measure exercised lines; choose the threshold deliberately",
109
+ "dependency": "pytest-cov",
110
+ "constraint": {
111
+ "kind": "command",
112
+ "command": ["python", "-m", "pytest", "--cov", "--cov-fail-under=80"],
113
+ "phases": ["stop", "ci"],
114
+ "watch": ["**/*.py", "pyproject.toml"],
115
+ },
116
+ },
117
+ {
118
+ "id": "mutation",
119
+ "reason": "Check whether tests detect behavioral mutations",
120
+ "dependency": "mutmut",
121
+ "constraint": {
122
+ "kind": "command",
123
+ "command": ["python", "-m", "mutmut", "run"],
124
+ "phases": ["ci"],
125
+ "watch": ["**/*.py", "pyproject.toml"],
126
+ "timeout_seconds": 1800,
127
+ },
128
+ },
129
+ ]
130
+ )
131
+ if (project_root / "package.json").is_file():
132
+ suggestions.append(
133
+ {
134
+ "id": "mutation",
135
+ "reason": "Check whether tests detect behavioral mutations",
136
+ "dependency": "@stryker-mutator/core",
137
+ "constraint": {
138
+ "kind": "command",
139
+ "command": ["npx", "stryker", "run"],
140
+ "phases": ["ci"],
141
+ "watch": ["src/**/*", "test/**/*", "tests/**/*", "package.json"],
142
+ "timeout_seconds": 1800,
143
+ },
144
+ }
145
+ )
146
+ return {"schema_version": 1, "mode": "proposal_only", "suggestions": suggestions}
147
+
148
+
149
+ def authoring_proposal(project_root: Path) -> dict[str, Any]:
150
+ return {
151
+ "schema_version": 1,
152
+ "mode": "proposal_only",
153
+ "rules": [
154
+ "Generated tests must be reviewed before adoption.",
155
+ "A generated test may not weaken or replace an existing required gate.",
156
+ "Prefer behavioral assertions over implementation snapshots.",
157
+ ],
158
+ "candidates": [
159
+ {
160
+ "type": "acceptance_test",
161
+ "status": "needs_human_specification",
162
+ "prompt": "Describe the user-visible behavior and failure cases to encode.",
163
+ },
164
+ {
165
+ "type": "mutation_survivor_test",
166
+ "status": "needs_mutation_report",
167
+ "prompt": (
168
+ "Add focused tests for surviving mutations without changing "
169
+ "production behavior."
170
+ ),
171
+ },
172
+ ],
173
+ }
174
+
175
+
176
+ def write_proposal(project_root: Path, name: str, payload: dict[str, Any]) -> Path:
177
+ directory = project_root / ".constraintloop" / "proposals"
178
+ directory.mkdir(parents=True, exist_ok=True)
179
+ path = directory / f"{name}.yml"
180
+ path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
181
+ return path
@@ -0,0 +1,191 @@
1
+ """Idempotent hook installation for supported coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shlex
8
+ import sys
9
+ import tempfile
10
+ from contextlib import suppress
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ ADAPTERS: dict[str, tuple[Path, dict[str, str]]] = {
15
+ "claude": (
16
+ Path(".claude/settings.json"),
17
+ {
18
+ "SessionStart": "session-start",
19
+ "UserPromptSubmit": "user-prompt",
20
+ "PreToolUse": "pre-tool",
21
+ "PostToolUse": "post-tool",
22
+ "PreCompact": "pre-compact",
23
+ "Stop": "stop",
24
+ },
25
+ ),
26
+ "codex": (
27
+ Path(".codex/hooks.json"),
28
+ {
29
+ "SessionStart": "session-start",
30
+ "UserPromptSubmit": "user-prompt",
31
+ "PreToolUse": "pre-tool",
32
+ "PostToolUse": "post-tool",
33
+ "PreCompact": "pre-compact",
34
+ "Stop": "stop",
35
+ },
36
+ ),
37
+ "gemini": (
38
+ Path(".gemini/settings.json"),
39
+ {
40
+ "SessionStart": "session-start",
41
+ "BeforeAgent": "user-prompt",
42
+ "BeforeTool": "pre-tool",
43
+ "AfterTool": "post-tool",
44
+ "PreCompress": "pre-compact",
45
+ "AfterAgent": "stop",
46
+ },
47
+ ),
48
+ }
49
+
50
+
51
+ def install_hooks(project_root: Path, adapter: str) -> Path:
52
+ relative, events = ADAPTERS[adapter]
53
+ path = project_root / relative
54
+ try:
55
+ data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
56
+ except FileNotFoundError:
57
+ data = {}
58
+ except json.JSONDecodeError as exc:
59
+ raise ValueError(f"Refusing to overwrite invalid hook settings {path}: {exc}") from exc
60
+ if not isinstance(data, dict):
61
+ raise ValueError(f"Refusing to overwrite non-object hook settings {path}")
62
+ hooks = data.setdefault("hooks", {})
63
+ if not isinstance(hooks, dict):
64
+ raise ValueError(f"Existing hooks value is not an object in {path}")
65
+ executable = _portable_executable(project_root)
66
+ project_argument = '"$(git rev-parse --show-toplevel)"'
67
+ for native_event, event in events.items():
68
+ command = (
69
+ f"{executable} hook --adapter {adapter} --event {event} --project {project_argument}"
70
+ )
71
+ groups = hooks.setdefault(native_event, [])
72
+ _validate_event_groups(path, native_event, groups)
73
+ if _has_or_update_command(groups, command, adapter, event):
74
+ continue
75
+ hook: dict[str, Any] = {
76
+ "type": "command",
77
+ "command": command,
78
+ "statusMessage": f"ConstraintLoop: {event}",
79
+ }
80
+ if adapter == "gemini":
81
+ hook["name"] = f"constraintloop-{event}"
82
+ group: dict[str, Any] = {"hooks": [hook]}
83
+ if native_event in {"PreToolUse", "PostToolUse", "BeforeTool", "AfterTool"}:
84
+ group["matcher"] = (
85
+ "Bash|Edit|Write"
86
+ if adapter in {"claude", "codex"}
87
+ else "run_shell_command|write_file|replace"
88
+ )
89
+ groups.append(group)
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ _write_settings(path, data)
92
+ return path
93
+
94
+
95
+ def _portable_executable(project_root: Path) -> str:
96
+ """Return a hook executable without embedding machine-specific paths."""
97
+ executable_path = Path(sys.argv[0]).resolve()
98
+ if executable_path.name == "__main__.py":
99
+ return "python -m constraintloop"
100
+ try:
101
+ relative = executable_path.relative_to(project_root.resolve())
102
+ except ValueError:
103
+ return shlex.quote(executable_path.name)
104
+ return f'"$(git rev-parse --show-toplevel)/{relative.as_posix()}"'
105
+
106
+
107
+ def uninstall_hooks(project_root: Path, adapter: str) -> tuple[Path, int]:
108
+ """Remove only ConstraintLoop hook entries and preserve all user settings."""
109
+ relative, _ = ADAPTERS[adapter]
110
+ path = project_root / relative
111
+ try:
112
+ data: Any = json.loads(path.read_text(encoding="utf-8"))
113
+ except FileNotFoundError:
114
+ return path, 0
115
+ except json.JSONDecodeError as exc:
116
+ raise ValueError(f"Refusing to modify invalid hook settings {path}: {exc}") from exc
117
+ if not isinstance(data, dict):
118
+ raise ValueError(f"Refusing to modify non-object hook settings {path}")
119
+ hooks = data.get("hooks")
120
+ if not isinstance(hooks, dict):
121
+ return path, 0
122
+
123
+ marker = f" hook --adapter {adapter} "
124
+ removed = 0
125
+ for event, groups in list(hooks.items()):
126
+ if not isinstance(groups, list):
127
+ continue
128
+ retained_groups: list[Any] = []
129
+ for group in groups:
130
+ if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
131
+ retained_groups.append(group)
132
+ continue
133
+ retained_entries = []
134
+ for entry in group["hooks"]:
135
+ command = entry.get("command") if isinstance(entry, dict) else None
136
+ if isinstance(command, str) and "constraintloop" in command and marker in command:
137
+ removed += 1
138
+ else:
139
+ retained_entries.append(entry)
140
+ if retained_entries:
141
+ retained_group = dict(group)
142
+ retained_group["hooks"] = retained_entries
143
+ retained_groups.append(retained_group)
144
+ if retained_groups:
145
+ hooks[event] = retained_groups
146
+ else:
147
+ hooks.pop(event)
148
+ if removed:
149
+ _write_settings(path, data)
150
+ return path, removed
151
+
152
+
153
+ def _write_settings(path: Path, data: dict[str, Any]) -> None:
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
156
+ try:
157
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
158
+ handle.write(json.dumps(data, indent=2) + "\n")
159
+ os.replace(temporary, path)
160
+ finally:
161
+ with suppress(FileNotFoundError):
162
+ os.unlink(temporary)
163
+
164
+
165
+ def _validate_event_groups(path: Path, event: str, groups: object) -> None:
166
+ if not isinstance(groups, list):
167
+ raise ValueError(f"Existing {event} hooks value is not a list in {path}")
168
+ for group in groups:
169
+ if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
170
+ raise ValueError(f"Existing {event} hook group is invalid in {path}")
171
+ if any(not isinstance(entry, dict) for entry in group["hooks"]):
172
+ raise ValueError(f"Existing {event} hook entry is invalid in {path}")
173
+
174
+
175
+ def _has_or_update_command(groups: object, command: str, adapter: str, event: str) -> bool:
176
+ if not isinstance(groups, list):
177
+ return False
178
+ marker = f" hook --adapter {adapter} --event {event}"
179
+ for group in groups:
180
+ if not isinstance(group, dict):
181
+ continue
182
+ for hook in group.get("hooks", []):
183
+ if not isinstance(hook, dict):
184
+ continue
185
+ existing = hook.get("command")
186
+ if existing == command:
187
+ return True
188
+ if isinstance(existing, str) and "constraintloop" in existing and marker in existing:
189
+ hook["command"] = command
190
+ return True
191
+ return False