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.
- constraintloop/__init__.py +7 -0
- constraintloop/__main__.py +4 -0
- constraintloop/cli.py +485 -0
- constraintloop/config.py +53 -0
- constraintloop/digest.py +233 -0
- constraintloop/engine.py +466 -0
- constraintloop/environment.py +50 -0
- constraintloop/eval_corpus.py +46 -0
- constraintloop/evaluators.py +334 -0
- constraintloop/hooks.py +335 -0
- constraintloop/loops.py +334 -0
- constraintloop/models.py +397 -0
- constraintloop/native_cli_evaluator.py +464 -0
- constraintloop/py.typed +1 -0
- constraintloop/runners.py +290 -0
- constraintloop/scaffold.py +181 -0
- constraintloop/setup_hooks.py +191 -0
- constraintloop/state.py +225 -0
- constraintloop-0.1.0.dist-info/METADATA +371 -0
- constraintloop-0.1.0.dist-info/RECORD +23 -0
- constraintloop-0.1.0.dist-info/WHEEL +4 -0
- constraintloop-0.1.0.dist-info/entry_points.txt +5 -0
- constraintloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|