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
constraintloop/digest.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Content-addressed project snapshots and git context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
from constraintloop.models import ConstraintSpec
|
|
14
|
+
|
|
15
|
+
_IGNORED_PARTS = {
|
|
16
|
+
".git",
|
|
17
|
+
".constraintloop",
|
|
18
|
+
".mypy_cache",
|
|
19
|
+
".pytest_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
".tox",
|
|
22
|
+
".venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"build",
|
|
25
|
+
"dist",
|
|
26
|
+
"node_modules",
|
|
27
|
+
}
|
|
28
|
+
_SECRET_NAMES = {
|
|
29
|
+
".env",
|
|
30
|
+
".env.local",
|
|
31
|
+
".env.production",
|
|
32
|
+
"credentials.json",
|
|
33
|
+
"secrets.env",
|
|
34
|
+
}
|
|
35
|
+
_SECRET_SUFFIXES = {".key", ".pem", ".p12", ".pfx"}
|
|
36
|
+
_CREDENTIAL_ASSIGNMENT = re.compile(
|
|
37
|
+
r"(?i)\b(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)"
|
|
38
|
+
r"(\s*[:=]\s*[\"']?)([^\s,\"']{8,})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def matching_files(project_root: Path, patterns: Iterable[str]) -> list[Path]:
|
|
43
|
+
"""Return stable, unique files matching glob patterns under the project."""
|
|
44
|
+
found: dict[str, Path] = {}
|
|
45
|
+
for pattern in patterns:
|
|
46
|
+
for path in project_root.glob(pattern):
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
relative = path.relative_to(project_root)
|
|
51
|
+
path.resolve().relative_to(project_root.resolve())
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
if any(part in _IGNORED_PARTS for part in relative.parts):
|
|
55
|
+
continue
|
|
56
|
+
found[relative.as_posix()] = path
|
|
57
|
+
return [found[key] for key in sorted(found)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def constraint_input_digest(
|
|
61
|
+
project_root: Path,
|
|
62
|
+
constraint_id: str,
|
|
63
|
+
spec: ConstraintSpec,
|
|
64
|
+
*,
|
|
65
|
+
contract_digest: str | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
digest = hashlib.sha256()
|
|
68
|
+
digest.update(constraint_id.encode())
|
|
69
|
+
if contract_digest is not None:
|
|
70
|
+
digest.update(contract_digest.encode())
|
|
71
|
+
digest.update(
|
|
72
|
+
json.dumps(spec.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
|
|
73
|
+
)
|
|
74
|
+
for path in matching_files(project_root, spec.watch):
|
|
75
|
+
relative = path.relative_to(project_root).as_posix()
|
|
76
|
+
digest.update(relative.encode())
|
|
77
|
+
try:
|
|
78
|
+
digest.update(path.read_bytes())
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
digest.update(f"<unreadable:{exc}>".encode())
|
|
81
|
+
return digest.hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def project_key(project_root: Path) -> str:
|
|
85
|
+
return hashlib.sha256(str(project_root.resolve()).encode()).hexdigest()[:20]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def git_diff(
|
|
89
|
+
project_root: Path,
|
|
90
|
+
*,
|
|
91
|
+
patterns: Iterable[str] = ("**/*",),
|
|
92
|
+
limit: int | None = None,
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Return staged, unstaged, and untracked changes as a bounded text bundle."""
|
|
95
|
+
pattern_list = list(patterns)
|
|
96
|
+
eligible = sorted(
|
|
97
|
+
{
|
|
98
|
+
relative
|
|
99
|
+
for group in _changed_path_groups(project_root, include_untracked=True)
|
|
100
|
+
if all(_allowed_disclosure_path(path, pattern_list) for path in group)
|
|
101
|
+
for relative in group
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
chunks: list[str] = []
|
|
105
|
+
for args in (["git", "diff", "--no-ext-diff"], ["git", "diff", "--cached", "--no-ext-diff"]):
|
|
106
|
+
if not eligible:
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
[*args, "--", *eligible],
|
|
111
|
+
cwd=project_root,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
encoding="utf-8",
|
|
114
|
+
errors="replace",
|
|
115
|
+
timeout=15,
|
|
116
|
+
check=False,
|
|
117
|
+
)
|
|
118
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
119
|
+
continue
|
|
120
|
+
if result.stdout:
|
|
121
|
+
chunks.append(result.stdout)
|
|
122
|
+
|
|
123
|
+
for relative in eligible:
|
|
124
|
+
path = project_root / relative
|
|
125
|
+
if not path.is_file() or _is_git_tracked(project_root, relative):
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
129
|
+
except OSError:
|
|
130
|
+
continue
|
|
131
|
+
chunks.append(
|
|
132
|
+
f"diff --git a/{relative} b/{relative}\nnew file\n+++ b/{relative}\n{content}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
output = redact_text("\n".join(chunks))
|
|
136
|
+
if limit is not None and len(output.encode()) > limit:
|
|
137
|
+
return output.encode()[:limit].decode("utf-8", errors="ignore") + "\n[diff truncated]"
|
|
138
|
+
return output
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def changed_files(project_root: Path, *, include_untracked: bool = True) -> list[str]:
|
|
142
|
+
return sorted(
|
|
143
|
+
{
|
|
144
|
+
relative
|
|
145
|
+
for group in _changed_path_groups(project_root, include_untracked=include_untracked)
|
|
146
|
+
for relative in group
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _changed_path_groups(project_root: Path, *, include_untracked: bool) -> list[tuple[str, ...]]:
|
|
152
|
+
args = ["git", "status", "--porcelain=v1", "--untracked-files=all", "-z"]
|
|
153
|
+
try:
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
args,
|
|
156
|
+
cwd=project_root,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
encoding="utf-8",
|
|
159
|
+
errors="replace",
|
|
160
|
+
timeout=10,
|
|
161
|
+
check=False,
|
|
162
|
+
)
|
|
163
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
164
|
+
return []
|
|
165
|
+
changed: list[tuple[str, ...]] = []
|
|
166
|
+
entries = result.stdout.split("\0")
|
|
167
|
+
index = 0
|
|
168
|
+
while index < len(entries):
|
|
169
|
+
line = entries[index]
|
|
170
|
+
index += 1
|
|
171
|
+
if len(line) < 4:
|
|
172
|
+
continue
|
|
173
|
+
status = line[:2]
|
|
174
|
+
if status == "??" and not include_untracked:
|
|
175
|
+
continue
|
|
176
|
+
name = line[3:]
|
|
177
|
+
if any(marker in {"R", "C"} for marker in status) and index < len(entries):
|
|
178
|
+
previous_name = entries[index]
|
|
179
|
+
index += 1
|
|
180
|
+
changed.append((name, previous_name))
|
|
181
|
+
else:
|
|
182
|
+
changed.append((name,))
|
|
183
|
+
return changed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _allowed_disclosure_path(relative: str, patterns: list[str]) -> bool:
|
|
187
|
+
if not is_disclosable_path(relative):
|
|
188
|
+
return False
|
|
189
|
+
path = PurePosixPath(relative)
|
|
190
|
+
return any(
|
|
191
|
+
path.match(pattern)
|
|
192
|
+
or (pattern.startswith("**/") and path.match(pattern.removeprefix("**/")))
|
|
193
|
+
for pattern in patterns
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_disclosable_path(relative: str) -> bool:
|
|
198
|
+
path = PurePosixPath(relative)
|
|
199
|
+
name = path.name.lower()
|
|
200
|
+
return not (
|
|
201
|
+
name in _SECRET_NAMES
|
|
202
|
+
or (name.startswith(".env.") and name != ".env.example")
|
|
203
|
+
or path.suffix.lower() in _SECRET_SUFFIXES
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def redact_text(value: str) -> str:
|
|
208
|
+
redacted = value
|
|
209
|
+
for name, secret in os.environ.items():
|
|
210
|
+
if (
|
|
211
|
+
secret
|
|
212
|
+
and len(secret) >= 8
|
|
213
|
+
and any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
|
|
214
|
+
):
|
|
215
|
+
redacted = redacted.replace(secret, "[REDACTED]")
|
|
216
|
+
return _CREDENTIAL_ASSIGNMENT.sub(
|
|
217
|
+
lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]",
|
|
218
|
+
redacted,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_git_tracked(project_root: Path, relative: str) -> bool:
|
|
223
|
+
try:
|
|
224
|
+
result = subprocess.run(
|
|
225
|
+
["git", "ls-files", "--error-unmatch", "--", relative],
|
|
226
|
+
cwd=project_root,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
timeout=5,
|
|
229
|
+
check=False,
|
|
230
|
+
)
|
|
231
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
232
|
+
return False
|
|
233
|
+
return result.returncode == 0
|
constraintloop/engine.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Constraint execution, evidence freshness, quorum, and policy decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from constraintloop.config import contract_digest
|
|
15
|
+
from constraintloop.digest import (
|
|
16
|
+
changed_files,
|
|
17
|
+
constraint_input_digest,
|
|
18
|
+
git_diff,
|
|
19
|
+
is_disclosable_path,
|
|
20
|
+
matching_files,
|
|
21
|
+
redact_text,
|
|
22
|
+
)
|
|
23
|
+
from constraintloop.environment import load_project_environment
|
|
24
|
+
from constraintloop.evaluators import EvaluatorError, build_evaluator
|
|
25
|
+
from constraintloop.models import (
|
|
26
|
+
ArtifactConstraint,
|
|
27
|
+
CommandConstraint,
|
|
28
|
+
ConstraintResult,
|
|
29
|
+
Contract,
|
|
30
|
+
EvaluationBundle,
|
|
31
|
+
EvaluatorCallMetadata,
|
|
32
|
+
EvidenceRecord,
|
|
33
|
+
MetricConstraint,
|
|
34
|
+
Phase,
|
|
35
|
+
RubricConstraint,
|
|
36
|
+
Verdict,
|
|
37
|
+
)
|
|
38
|
+
from constraintloop.runners import (
|
|
39
|
+
run_artifact_constraint,
|
|
40
|
+
run_command_constraint,
|
|
41
|
+
run_metric_constraint,
|
|
42
|
+
)
|
|
43
|
+
from constraintloop.state import load_cached_result, save_cached_result, waiver_reason
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConstraintEngine:
|
|
47
|
+
"""Run a strict contract against one project snapshot."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
project_root: Path,
|
|
52
|
+
contract: Contract,
|
|
53
|
+
*,
|
|
54
|
+
use_cache: bool = True,
|
|
55
|
+
allow_waivers: bool = True,
|
|
56
|
+
goal: str | None = None,
|
|
57
|
+
agent_adapter: str | None = None,
|
|
58
|
+
refresh_pending: bool = False,
|
|
59
|
+
):
|
|
60
|
+
self.project_root = project_root.resolve()
|
|
61
|
+
self.contract = contract
|
|
62
|
+
self.use_cache = use_cache
|
|
63
|
+
self.allow_waivers = allow_waivers
|
|
64
|
+
self.goal = goal
|
|
65
|
+
self.agent_adapter = agent_adapter
|
|
66
|
+
self.refresh_pending = refresh_pending
|
|
67
|
+
self.contract_digest = contract_digest(contract)
|
|
68
|
+
|
|
69
|
+
def run(self, phase: Phase) -> EvidenceRecord:
|
|
70
|
+
"""Run applicable constraints in dependency order."""
|
|
71
|
+
started_at = time.time()
|
|
72
|
+
results: dict[str, ConstraintResult] = {}
|
|
73
|
+
pending = {
|
|
74
|
+
constraint_id
|
|
75
|
+
for constraint_id, spec in self.contract.constraints.items()
|
|
76
|
+
if spec.enabled and phase in spec.phases
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
while pending:
|
|
80
|
+
ready = sorted(
|
|
81
|
+
constraint_id
|
|
82
|
+
for constraint_id in pending
|
|
83
|
+
if all(
|
|
84
|
+
dependency not in pending
|
|
85
|
+
for dependency in self.contract.constraints[constraint_id].needs
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
if not ready: # Contract validation should make this unreachable.
|
|
89
|
+
raise RuntimeError("No runnable constraints remain")
|
|
90
|
+
runnable: list[
|
|
91
|
+
tuple[
|
|
92
|
+
str,
|
|
93
|
+
CommandConstraint | MetricConstraint | ArtifactConstraint | RubricConstraint,
|
|
94
|
+
str,
|
|
95
|
+
]
|
|
96
|
+
] = []
|
|
97
|
+
for constraint_id in ready:
|
|
98
|
+
spec = self.contract.constraints[constraint_id]
|
|
99
|
+
pending_dependencies = [
|
|
100
|
+
dependency
|
|
101
|
+
for dependency in spec.needs
|
|
102
|
+
if dependency in results and results[dependency].verdict == Verdict.PENDING
|
|
103
|
+
]
|
|
104
|
+
unavailable = [
|
|
105
|
+
dependency
|
|
106
|
+
for dependency in spec.needs
|
|
107
|
+
if dependency not in results
|
|
108
|
+
or results[dependency].verdict
|
|
109
|
+
not in {
|
|
110
|
+
Verdict.PASS,
|
|
111
|
+
Verdict.WAIVED,
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
digest = constraint_input_digest(
|
|
115
|
+
self.project_root,
|
|
116
|
+
constraint_id,
|
|
117
|
+
spec,
|
|
118
|
+
contract_digest=self.contract_digest,
|
|
119
|
+
)
|
|
120
|
+
if pending_dependencies:
|
|
121
|
+
result = ConstraintResult(
|
|
122
|
+
constraint_id=constraint_id,
|
|
123
|
+
kind=spec.kind,
|
|
124
|
+
verdict=Verdict.PENDING,
|
|
125
|
+
enforcement=spec.enforcement,
|
|
126
|
+
input_digest=digest,
|
|
127
|
+
message=f"Dependencies are pending: {', '.join(pending_dependencies)}",
|
|
128
|
+
)
|
|
129
|
+
elif unavailable:
|
|
130
|
+
result = ConstraintResult(
|
|
131
|
+
constraint_id=constraint_id,
|
|
132
|
+
kind=spec.kind,
|
|
133
|
+
verdict=Verdict.ERROR,
|
|
134
|
+
enforcement=spec.enforcement,
|
|
135
|
+
input_digest=digest,
|
|
136
|
+
message=f"Dependencies did not pass: {', '.join(unavailable)}",
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
runnable.append((constraint_id, spec, digest))
|
|
140
|
+
continue
|
|
141
|
+
results[constraint_id] = result
|
|
142
|
+
|
|
143
|
+
deterministic = [item for item in runnable if not isinstance(item[1], RubricConstraint)]
|
|
144
|
+
with ThreadPoolExecutor(
|
|
145
|
+
max_workers=min(self.contract.settings.concurrency, max(1, len(deterministic)))
|
|
146
|
+
) as executor:
|
|
147
|
+
futures = {
|
|
148
|
+
constraint_id: executor.submit(
|
|
149
|
+
self._run_one,
|
|
150
|
+
constraint_id,
|
|
151
|
+
spec,
|
|
152
|
+
digest,
|
|
153
|
+
phase,
|
|
154
|
+
[results[key] for key in self.contract.constraints if key in results],
|
|
155
|
+
)
|
|
156
|
+
for constraint_id, spec, digest in deterministic
|
|
157
|
+
}
|
|
158
|
+
for constraint_id, _, _ in deterministic:
|
|
159
|
+
results[constraint_id] = futures[constraint_id].result()
|
|
160
|
+
|
|
161
|
+
for constraint_id, spec, digest in runnable:
|
|
162
|
+
if isinstance(spec, RubricConstraint):
|
|
163
|
+
results[constraint_id] = self._run_one(
|
|
164
|
+
constraint_id,
|
|
165
|
+
spec,
|
|
166
|
+
digest,
|
|
167
|
+
phase,
|
|
168
|
+
[results[key] for key in self.contract.constraints if key in results],
|
|
169
|
+
)
|
|
170
|
+
pending.difference_update(ready)
|
|
171
|
+
|
|
172
|
+
record = EvidenceRecord(
|
|
173
|
+
run_id=str(uuid.uuid4()),
|
|
174
|
+
project_root=str(self.project_root),
|
|
175
|
+
contract_digest=self.contract_digest,
|
|
176
|
+
phase=phase,
|
|
177
|
+
started_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(started_at)),
|
|
178
|
+
results=[results[key] for key in self.contract.constraints if key in results],
|
|
179
|
+
)
|
|
180
|
+
return record
|
|
181
|
+
|
|
182
|
+
def _run_one(
|
|
183
|
+
self,
|
|
184
|
+
constraint_id: str,
|
|
185
|
+
spec: CommandConstraint | MetricConstraint | ArtifactConstraint | RubricConstraint,
|
|
186
|
+
digest: str,
|
|
187
|
+
phase: Phase,
|
|
188
|
+
prior_results: list[ConstraintResult],
|
|
189
|
+
) -> ConstraintResult:
|
|
190
|
+
bundle = (
|
|
191
|
+
self._build_bundle(constraint_id, spec, prior_results)
|
|
192
|
+
if isinstance(spec, RubricConstraint)
|
|
193
|
+
else None
|
|
194
|
+
)
|
|
195
|
+
cache_digest = _rubric_cache_digest(digest, bundle) if bundle is not None else digest
|
|
196
|
+
if self.use_cache:
|
|
197
|
+
cached = load_cached_result(self.project_root, constraint_id, cache_digest)
|
|
198
|
+
if (
|
|
199
|
+
cached is not None
|
|
200
|
+
and cached.enforcement == spec.enforcement
|
|
201
|
+
and not (self.refresh_pending and cached.verdict == Verdict.PENDING)
|
|
202
|
+
):
|
|
203
|
+
if (
|
|
204
|
+
self.allow_waivers
|
|
205
|
+
and phase != Phase.CI
|
|
206
|
+
and not isinstance(spec, RubricConstraint)
|
|
207
|
+
):
|
|
208
|
+
reason = waiver_reason(self.project_root, cached, self.contract_digest)
|
|
209
|
+
if reason:
|
|
210
|
+
return cached.model_copy(
|
|
211
|
+
update={
|
|
212
|
+
"verdict": Verdict.WAIVED,
|
|
213
|
+
"message": f"Locally waived by a human: {reason}",
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
return cached
|
|
217
|
+
|
|
218
|
+
if isinstance(spec, CommandConstraint):
|
|
219
|
+
result = run_command_constraint(
|
|
220
|
+
self.project_root,
|
|
221
|
+
constraint_id,
|
|
222
|
+
spec,
|
|
223
|
+
digest,
|
|
224
|
+
self.contract.settings.evidence_output_limit,
|
|
225
|
+
)
|
|
226
|
+
elif isinstance(spec, MetricConstraint):
|
|
227
|
+
result = run_metric_constraint(
|
|
228
|
+
self.project_root,
|
|
229
|
+
constraint_id,
|
|
230
|
+
spec,
|
|
231
|
+
digest,
|
|
232
|
+
self.contract.settings.evidence_output_limit,
|
|
233
|
+
)
|
|
234
|
+
elif isinstance(spec, ArtifactConstraint):
|
|
235
|
+
result = run_artifact_constraint(self.project_root, constraint_id, spec, digest)
|
|
236
|
+
else:
|
|
237
|
+
assert bundle is not None
|
|
238
|
+
result = self._run_rubric(constraint_id, spec, digest, bundle)
|
|
239
|
+
|
|
240
|
+
if self.use_cache:
|
|
241
|
+
save_cached_result(self.project_root, result, cache_digest=cache_digest)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def _run_rubric(
|
|
245
|
+
self,
|
|
246
|
+
constraint_id: str,
|
|
247
|
+
spec: RubricConstraint,
|
|
248
|
+
digest: str,
|
|
249
|
+
bundle: EvaluationBundle,
|
|
250
|
+
) -> ConstraintResult:
|
|
251
|
+
started = time.monotonic()
|
|
252
|
+
try:
|
|
253
|
+
environment = load_project_environment(self.project_root)
|
|
254
|
+
except ValueError as exc:
|
|
255
|
+
return ConstraintResult(
|
|
256
|
+
constraint_id=constraint_id,
|
|
257
|
+
kind=spec.kind,
|
|
258
|
+
verdict=Verdict.UNCERTAIN,
|
|
259
|
+
enforcement=spec.enforcement,
|
|
260
|
+
input_digest=digest,
|
|
261
|
+
message=f"Could not load evaluator environment: {exc}",
|
|
262
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
263
|
+
)
|
|
264
|
+
if self.agent_adapter:
|
|
265
|
+
environment["CONSTRAINTLOOP_CALLER_ADAPTER"] = self.agent_adapter
|
|
266
|
+
evaluator = build_evaluator(
|
|
267
|
+
self.contract.evaluators[spec.evaluator],
|
|
268
|
+
cwd=self.project_root,
|
|
269
|
+
environment=environment,
|
|
270
|
+
)
|
|
271
|
+
verdicts = []
|
|
272
|
+
errors: list[str] = []
|
|
273
|
+
evaluator_calls: list[EvaluatorCallMetadata] = []
|
|
274
|
+
for _ in range(spec.runs):
|
|
275
|
+
try:
|
|
276
|
+
verdicts.append(evaluator.evaluate(bundle))
|
|
277
|
+
except EvaluatorError as exc:
|
|
278
|
+
errors.append(str(exc))
|
|
279
|
+
metadata = getattr(evaluator, "last_metadata", None)
|
|
280
|
+
if isinstance(metadata, EvaluatorCallMetadata):
|
|
281
|
+
evaluator_calls.append(metadata)
|
|
282
|
+
|
|
283
|
+
passes = sum(verdict.verdict == "pass" for verdict in verdicts)
|
|
284
|
+
fails = sum(verdict.verdict == "fail" for verdict in verdicts)
|
|
285
|
+
quorum = spec.pass_quorum or 1
|
|
286
|
+
findings = [finding for verdict in verdicts for finding in verdict.findings]
|
|
287
|
+
rationales = [verdict.rationale for verdict in verdicts]
|
|
288
|
+
if passes >= quorum:
|
|
289
|
+
verdict = Verdict.PASS
|
|
290
|
+
message = f"Rubric passed quorum ({passes}/{spec.runs}; required {quorum})"
|
|
291
|
+
elif errors or any(item.verdict == "uncertain" for item in verdicts):
|
|
292
|
+
verdict = Verdict.UNCERTAIN
|
|
293
|
+
message = f"Rubric did not reach a reliable quorum ({passes} pass, {fails} fail)"
|
|
294
|
+
else:
|
|
295
|
+
verdict = Verdict.FAIL
|
|
296
|
+
message = f"Rubric failed quorum ({passes}/{spec.runs}; required {quorum})"
|
|
297
|
+
details = rationales + errors
|
|
298
|
+
return ConstraintResult(
|
|
299
|
+
constraint_id=constraint_id,
|
|
300
|
+
kind=spec.kind,
|
|
301
|
+
verdict=verdict,
|
|
302
|
+
enforcement=spec.enforcement,
|
|
303
|
+
input_digest=digest,
|
|
304
|
+
message=message,
|
|
305
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
306
|
+
output_tail="\n\n".join(details)[-self.contract.settings.evidence_output_limit :]
|
|
307
|
+
or None,
|
|
308
|
+
findings=findings,
|
|
309
|
+
evaluator_calls=evaluator_calls,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _build_bundle(
|
|
313
|
+
self,
|
|
314
|
+
constraint_id: str,
|
|
315
|
+
spec: RubricConstraint,
|
|
316
|
+
deterministic_results: list[ConstraintResult],
|
|
317
|
+
) -> EvaluationBundle:
|
|
318
|
+
limit = self.contract.settings.evaluation_bundle_limit
|
|
319
|
+
diff = git_diff(self.project_root, patterns=spec.include, limit=limit // 4)
|
|
320
|
+
used = len(diff.encode())
|
|
321
|
+
files: dict[str, str] = {}
|
|
322
|
+
omitted: list[str] = []
|
|
323
|
+
query = f"{constraint_id} {spec.rubric} {self.goal or ''}".lower()
|
|
324
|
+
query_tokens = set(re.findall(r"[a-z0-9]{3,}", query))
|
|
325
|
+
changed = set(changed_files(self.project_root))
|
|
326
|
+
|
|
327
|
+
def priority(path: Path) -> tuple[int, int, int, int, str]:
|
|
328
|
+
relative = path.relative_to(self.project_root).as_posix()
|
|
329
|
+
path_tokens = set(re.findall(r"[a-z0-9]{3,}", relative.lower()))
|
|
330
|
+
relevance = len(query_tokens & path_tokens)
|
|
331
|
+
source = int(relative.startswith(("src/", "lib/", "app/")))
|
|
332
|
+
try:
|
|
333
|
+
modified = path.stat().st_mtime_ns
|
|
334
|
+
except OSError:
|
|
335
|
+
modified = 0
|
|
336
|
+
return (-relevance, -int(relative in changed), -source, -modified, relative)
|
|
337
|
+
|
|
338
|
+
matched = matching_files(self.project_root, spec.include)
|
|
339
|
+
for path in matched:
|
|
340
|
+
relative = path.relative_to(self.project_root).as_posix()
|
|
341
|
+
if not is_disclosable_path(relative):
|
|
342
|
+
omitted.append(relative)
|
|
343
|
+
candidates = sorted(
|
|
344
|
+
(
|
|
345
|
+
path
|
|
346
|
+
for path in matched
|
|
347
|
+
if is_disclosable_path(path.relative_to(self.project_root).as_posix())
|
|
348
|
+
),
|
|
349
|
+
key=priority,
|
|
350
|
+
)
|
|
351
|
+
for path in candidates:
|
|
352
|
+
relative = path.relative_to(self.project_root).as_posix()
|
|
353
|
+
try:
|
|
354
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
355
|
+
except OSError:
|
|
356
|
+
omitted.append(relative)
|
|
357
|
+
continue
|
|
358
|
+
cost = len(relative.encode()) + len(content.encode())
|
|
359
|
+
if used + cost > limit:
|
|
360
|
+
omitted.append(relative)
|
|
361
|
+
continue
|
|
362
|
+
files[relative] = redact_text(content)
|
|
363
|
+
used += cost
|
|
364
|
+
deterministic = [
|
|
365
|
+
cast(dict[str, Any], _redact_value(result.model_dump(mode="json")))
|
|
366
|
+
for result in deterministic_results
|
|
367
|
+
]
|
|
368
|
+
bundle = EvaluationBundle(
|
|
369
|
+
constraint_id=constraint_id,
|
|
370
|
+
rubric=spec.rubric,
|
|
371
|
+
goal=self.goal,
|
|
372
|
+
diff=diff,
|
|
373
|
+
deterministic_results=deterministic,
|
|
374
|
+
files=files,
|
|
375
|
+
omitted_files=omitted,
|
|
376
|
+
)
|
|
377
|
+
while len(bundle.model_dump_json().encode()) > limit and files:
|
|
378
|
+
relative = next(reversed(files))
|
|
379
|
+
files.pop(relative)
|
|
380
|
+
omitted.append(relative)
|
|
381
|
+
bundle = bundle.model_copy(
|
|
382
|
+
update={"files": dict(files), "omitted_files": list(omitted)}
|
|
383
|
+
)
|
|
384
|
+
if len(bundle.model_dump_json().encode()) > limit and bundle.diff:
|
|
385
|
+
overflow = len(bundle.model_dump_json().encode()) - limit
|
|
386
|
+
keep = max(0, len(bundle.diff.encode()) - overflow - 32)
|
|
387
|
+
bundle = bundle.model_copy(
|
|
388
|
+
update={
|
|
389
|
+
"diff": bundle.diff.encode()[:keep].decode("utf-8", errors="ignore")
|
|
390
|
+
+ "\n[diff truncated]"
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
if len(bundle.model_dump_json().encode()) > limit:
|
|
394
|
+
compact = []
|
|
395
|
+
for item in deterministic:
|
|
396
|
+
compact.append(
|
|
397
|
+
{
|
|
398
|
+
key: value
|
|
399
|
+
for key, value in item.items()
|
|
400
|
+
if key
|
|
401
|
+
in {
|
|
402
|
+
"constraint_id",
|
|
403
|
+
"kind",
|
|
404
|
+
"verdict",
|
|
405
|
+
"enforcement",
|
|
406
|
+
"message",
|
|
407
|
+
"cached",
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
bundle = bundle.model_copy(update={"deterministic_results": compact})
|
|
412
|
+
return bundle
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def blocking_results(record: EvidenceRecord) -> list[ConstraintResult]:
|
|
416
|
+
return [result for result in record.results if result.blocks]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _redact_value(value: object) -> object:
|
|
420
|
+
if isinstance(value, str):
|
|
421
|
+
return redact_text(value)
|
|
422
|
+
if isinstance(value, list):
|
|
423
|
+
return [_redact_value(item) for item in value]
|
|
424
|
+
if isinstance(value, dict):
|
|
425
|
+
return {key: _redact_value(item) for key, item in value.items()}
|
|
426
|
+
return value
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _rubric_cache_digest(base_digest: str, bundle: EvaluationBundle) -> str:
|
|
430
|
+
payload = bundle.model_dump(mode="json")
|
|
431
|
+
for result in payload["deterministic_results"]:
|
|
432
|
+
for volatile in ("cached", "duration_ms", "evaluator_calls"):
|
|
433
|
+
result.pop(volatile, None)
|
|
434
|
+
digest = hashlib.sha256()
|
|
435
|
+
digest.update(base_digest.encode())
|
|
436
|
+
digest.update(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
|
437
|
+
return digest.hexdigest()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def format_summary(record: EvidenceRecord, *, include_output: bool = False) -> str:
|
|
441
|
+
has_advisories = record.passed and any(
|
|
442
|
+
result.verdict
|
|
443
|
+
not in {
|
|
444
|
+
Verdict.PASS,
|
|
445
|
+
Verdict.SKIPPED,
|
|
446
|
+
Verdict.WAIVED,
|
|
447
|
+
}
|
|
448
|
+
for result in record.results
|
|
449
|
+
)
|
|
450
|
+
outcome = "PASS WITH ADVISORIES" if has_advisories else "PASS" if record.passed else "BLOCKED"
|
|
451
|
+
lines = [f"ConstraintLoop {record.phase.value}: {outcome} ({len(record.results)} constraints)"]
|
|
452
|
+
icons = {
|
|
453
|
+
Verdict.PASS: "PASS",
|
|
454
|
+
Verdict.PENDING: "PENDING",
|
|
455
|
+
Verdict.FAIL: "FAIL",
|
|
456
|
+
Verdict.ERROR: "ERROR",
|
|
457
|
+
Verdict.SKIPPED: "SKIP",
|
|
458
|
+
Verdict.UNCERTAIN: "UNCERTAIN",
|
|
459
|
+
Verdict.WAIVED: "WAIVED",
|
|
460
|
+
}
|
|
461
|
+
for result in record.results:
|
|
462
|
+
cache = " [cached]" if result.cached else ""
|
|
463
|
+
lines.append(f"- {icons[result.verdict]} {result.constraint_id}{cache}: {result.message}")
|
|
464
|
+
if include_output and result.output_tail and result.verdict != Verdict.PASS:
|
|
465
|
+
lines.append(result.output_tail)
|
|
466
|
+
return "\n".join(lines)
|