bytedigger-engine 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.
- ac_dsl.py +552 -0
- audit_gate.py +237 -0
- bytedigger_engine-0.1.0.dist-info/METADATA +186 -0
- bytedigger_engine-0.1.0.dist-info/RECORD +149 -0
- bytedigger_engine-0.1.0.dist-info/WHEEL +5 -0
- bytedigger_engine-0.1.0.dist-info/entry_points.txt +2 -0
- bytedigger_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- bytedigger_engine-0.1.0.dist-info/top_level.txt +46 -0
- config_provider.py +225 -0
- contracts.py +672 -0
- corpus_extract.py +122 -0
- derive_state.py +303 -0
- engine.py +1042 -0
- error_codes.py +300 -0
- error_locus.py +126 -0
- event_log.py +151 -0
- event_sink.py +87 -0
- execution_provenance.py +50 -0
- findings_sidecar.py +43 -0
- flags_catalog.py +552 -0
- forbidden_import.py +93 -0
- format_conversion_cases.py +65 -0
- helper_extraction.py +94 -0
- io_utils.py +16 -0
- lib/__init__.py +0 -0
- lib/authored_boundary.py +225 -0
- lib/bounded_spawn.py +41 -0
- lib/constitution_loader.py +211 -0
- lib/cost_rollup.py +105 -0
- lib/dbos_list_runs.py +81 -0
- lib/dbos_setup.py +428 -0
- lib/dbos_status_run.py +76 -0
- lib/directed_repair.py +626 -0
- lib/env_limit.py +36 -0
- lib/git_cwd.py +71 -0
- lib/git_port.py +210 -0
- lib/git_write_port.py +96 -0
- lib/llm_provider.py +145 -0
- lib/model_config.py +193 -0
- lib/mypy_baseline.py +88 -0
- lib/observability/emit_resolver.py +40 -0
- lib/phase_sentinel.py +326 -0
- lib/plugins/__init__.py +0 -0
- lib/plugins/anti_hallucination/__init__.py +1 -0
- lib/plugins/anti_hallucination/config.yaml +31 -0
- lib/plugins/anti_hallucination/helper.py +607 -0
- lib/plugins/anti_hallucination/producer_prompt_fragment.md +15 -0
- lib/plugins/anti_hallucination/prompt_fragment.md +21 -0
- lib/plugins/anti_hallucination/semantic_verifier.py +589 -0
- lib/plugins/checklist_convergence/__init__.py +27 -0
- lib/plugins/checklist_convergence/delta_retry_prompt.py +51 -0
- lib/plugins/checklist_convergence/delta_reviewer_prompt.py +165 -0
- lib/plugins/checklist_convergence/findings_extractor.py +150 -0
- lib/plugins/checklist_convergence/impl_delta_retry_prompt.py +57 -0
- lib/plugins/checklist_convergence/restricted_reviewer_prompt.py +58 -0
- lib/plugins/checklist_convergence/restricted_writer_prompt.py +66 -0
- lib/plugins/checklist_convergence/surgical_revise.py +157 -0
- lib/plugins/checklist_convergence/verdict_parser.py +85 -0
- lib/plugins/disk_truth/__init__.py +20 -0
- lib/plugins/disk_truth/git_diff.py +190 -0
- lib/plugins/disk_truth/schema.py +152 -0
- lib/plugins/disk_truth/suite_boyscout.py +189 -0
- lib/plugins/disk_truth/test_runner.py +256 -0
- lib/plugins/disk_truth/verdict_parser.py +77 -0
- lib/plugins/review_schema/__init__.py +16 -0
- lib/plugins/review_schema/canonical.py +89 -0
- lib/project_root.py +103 -0
- lib/recoverable_gate.py +134 -0
- lib/reference_backends/__init__.py +6 -0
- lib/reference_backends/agent_sdk.py +292 -0
- lib/reference_backends/anthropic_api.py +299 -0
- lib/reference_backends/anthropic_oauth.py +126 -0
- lib/reference_backends/pydantic_anthropic.py +295 -0
- lib/reference_backends/pydantic_openai.py +737 -0
- lib/restart_governor.py +314 -0
- lib/resume_keying.py +33 -0
- lib/revise_counter.py +78 -0
- lib/run_allowlist.py +417 -0
- lib/spec_defect_ledger.py +178 -0
- lib/spec_retry_cycle.py +119 -0
- lib/step_sentinel.py +182 -0
- lib/timeout_policy.py +198 -0
- lib/util/__init__.py +0 -0
- lib/util/path_classifier.py +44 -0
- lib/verdict_parse.py +175 -0
- lib/verdict_resolution.py +52 -0
- lib/worktree_root.py +24 -0
- llm_subprocess.py +2694 -0
- net_new_delta.py +78 -0
- precommit_lints.py +26 -0
- presence_triad.py +94 -0
- red_lint_checks.py +69 -0
- reentry_ac.py +97 -0
- reject_log.py +185 -0
- reject_stats.py +73 -0
- reproducibility.py +69 -0
- resume_key_lint.py +244 -0
- run.py +244 -0
- scope_inverse.py +35 -0
- scripts/__init__.py +0 -0
- scripts/red_lint/__init__.py +0 -0
- scripts/red_lint/rules.yml +125 -0
- security/__init__.py +3 -0
- security/except-pass-allowlist.txt +27 -0
- security/gitleaks.toml +4 -0
- security/secure-codegen-fragment.md +8 -0
- security/secure-codegen-rules.md +62 -0
- security/security_lint.py +254 -0
- security/semgrep-rules.yml +191 -0
- skip_logic.py +221 -0
- spec_cite.py +459 -0
- spec_class.py +49 -0
- spec_coverage.py +272 -0
- stub_passability.py +193 -0
- suite_safety.py +73 -0
- telemetry_ctx.py +89 -0
- tier_gate.py +117 -0
- token_consistency.py +55 -0
- verdict_anchor.py +205 -0
- verdict_gate.py +75 -0
- verdict_verify.py +470 -0
- workflows/__init__.py +46 -0
- workflows/_baseline_delta.py +79 -0
- workflows/_recoverable_policy.py +98 -0
- workflows/_standards_context.py +103 -0
- workflows/_task_description.py +25 -0
- workflows/echo.py +18 -0
- workflows/graph_source.py +126 -0
- workflows/phase_05_inject.py +1086 -0
- workflows/phase_0_6_artifact_detect.py +137 -0
- workflows/phase_0_research.py +86 -0
- workflows/phase_1_discovery.py +543 -0
- workflows/phase_2_explore.py +492 -0
- workflows/phase_3_clarify.py +380 -0
- workflows/phase_45_spec.py +4212 -0
- workflows/phase_45_spec_lite.py +1289 -0
- workflows/phase_4_architect.py +407 -0
- workflows/phase_5_devops_scan.py +391 -0
- workflows/phase_5_implement.py +5958 -0
- workflows/phase_5_integration_canary.py +233 -0
- workflows/phase_5_integrity.py +579 -0
- workflows/phase_6_fix_integrity.py +635 -0
- workflows/phase_6_review.py +4975 -0
- workflows/phase_6_review_simple_fastpath.py +95 -0
- workflows/phase_6_smoke.py +119 -0
- workflows/phase_7_synthesize.py +739 -0
- workflows/phase_8_post_deploy.py +1590 -0
- workflows/phase_devops_pipeline.py +69 -0
- workflows/phase_workflows_common.py +504 -0
ac_dsl.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""ac_dsl.py — AC-checks DSL: schema, parser, admission, mechanical runner.
|
|
2
|
+
|
|
3
|
+
Ship: GH517 Ship A1 (frozen spec: Decisions/
|
|
4
|
+
2026-07-11_GH517A1_ac_dsl_ship_spec.md). Sole source of the `### AC-checks`
|
|
5
|
+
schema — the registry `CHECK_TYPES` mirrors the pattern of
|
|
6
|
+
`flags_catalog.FLAGS` / `error_codes.ERROR_CODES` (hand-written dict, closed
|
|
7
|
+
by AC1's registry-closure test).
|
|
8
|
+
|
|
9
|
+
No env reads at import time. `verdict_verify` is imported as a plain sibling
|
|
10
|
+
module (both live in engine_py/, both are on sys.path via conftest.py /
|
|
11
|
+
the spec-compile.py CLI shim).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import verdict_verify
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# §1.1 CHECK_TYPES registry
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _req(**extra):
|
|
30
|
+
d = {"required": True}
|
|
31
|
+
d.update(extra)
|
|
32
|
+
return d
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _opt(**extra):
|
|
36
|
+
d = {"required": False}
|
|
37
|
+
d.update(extra)
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
CHECK_TYPES: dict[str, dict] = {
|
|
42
|
+
"file_contains": {
|
|
43
|
+
"description": "Assert a file on disk does/doesn't contain a pattern.",
|
|
44
|
+
"params": {
|
|
45
|
+
"path": _req(),
|
|
46
|
+
"pattern": _opt(),
|
|
47
|
+
"kind": _opt(enum=["fixed", "regex"], default="fixed"),
|
|
48
|
+
"expect": _opt(enum=["present", "absent"], default="present"),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"cmd_exit": {
|
|
52
|
+
"description": "Run a command and assert its exit code (and optional stdout/stderr regex).",
|
|
53
|
+
"params": {
|
|
54
|
+
"argv": _req(),
|
|
55
|
+
"cwd": _opt(),
|
|
56
|
+
"env": _opt(),
|
|
57
|
+
"exit_code": _req(),
|
|
58
|
+
"stdout_re": _opt(),
|
|
59
|
+
"stderr_re": _opt(),
|
|
60
|
+
"timeout_s": _opt(default=60),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"event_emitted": {
|
|
64
|
+
"description": "Assert a named event appears (or is absent) in a jsonl stream.",
|
|
65
|
+
"params": {
|
|
66
|
+
"stream": _req(),
|
|
67
|
+
"name": _req(),
|
|
68
|
+
"fields_subset": _opt(),
|
|
69
|
+
"min_count": _opt(default=1),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
"json_field": {
|
|
73
|
+
"description": "Assert a dot-path field in a JSON/JSONL file equals/matches/is-absent.",
|
|
74
|
+
"params": {
|
|
75
|
+
"file": _req(),
|
|
76
|
+
"pointer": _req(),
|
|
77
|
+
"op": _req(enum=["equals", "matches", "absent"]),
|
|
78
|
+
"value": _opt(),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"sha_anchor": {
|
|
82
|
+
"description": "Assert a verdict-anchor block verifies and covers the given files.",
|
|
83
|
+
"params": {
|
|
84
|
+
"doc": _req(),
|
|
85
|
+
"files": _req(),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
"count_delta": {
|
|
89
|
+
"description": "Assert a counter (cmd stdout or jsonl line count) moved by expected_delta.",
|
|
90
|
+
"params": {
|
|
91
|
+
"counter": _req(),
|
|
92
|
+
"kind": _req(enum=["cmd", "stream"]),
|
|
93
|
+
"baseline": _req(),
|
|
94
|
+
"expected_delta": _req(),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
"monotonic": {
|
|
98
|
+
"description": "Assert a field across a jsonl stream is non-decreasing.",
|
|
99
|
+
"params": {
|
|
100
|
+
"stream": _req(),
|
|
101
|
+
"field": _req(),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"llm_judged": {
|
|
105
|
+
"description": "Defer to LLM judgement against a rubric (not mechanically executed).",
|
|
106
|
+
"params": {
|
|
107
|
+
"rubric": _req(),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Dataclasses
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class CheckSpec:
|
|
120
|
+
ac_id: str
|
|
121
|
+
type: str
|
|
122
|
+
params: dict
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class AdmissionReport:
|
|
127
|
+
result: str
|
|
128
|
+
per_ac: dict = field(default_factory=dict)
|
|
129
|
+
reasons: list = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class CheckResult:
|
|
134
|
+
ac_id: str
|
|
135
|
+
type: str
|
|
136
|
+
status: str
|
|
137
|
+
reason: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# AC-id normalization (local, to avoid importing verdict_verify's private symbol)
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _normalize_ac_id(raw: str) -> str:
|
|
146
|
+
raw = str(raw).strip()
|
|
147
|
+
if raw.upper().startswith("AC"):
|
|
148
|
+
return "AC" + raw[2:]
|
|
149
|
+
return f"AC{raw}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_AC_CHECKS_HEADER_RE = re.compile(r"^### AC-checks\s*$", re.MULTILINE)
|
|
153
|
+
_NEXT_HEADING_RE = re.compile(r"^#{1,3}\s")
|
|
154
|
+
_YAML_FENCE_RE = re.compile(r"```yaml\s*\n(.*?)```", re.DOTALL)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_ac_checks(spec_text: str) -> dict[str, CheckSpec]:
|
|
158
|
+
"""Parse the `### AC-checks` section's single yaml block into CheckSpecs."""
|
|
159
|
+
header_match = _AC_CHECKS_HEADER_RE.search(spec_text)
|
|
160
|
+
if not header_match:
|
|
161
|
+
raise ValueError("AC-checks section not found (heading '### AC-checks' missing)")
|
|
162
|
+
|
|
163
|
+
lines = spec_text.splitlines()
|
|
164
|
+
# find the line index of the header
|
|
165
|
+
header_line_idx = spec_text.count("\n", 0, header_match.start())
|
|
166
|
+
|
|
167
|
+
end_idx = len(lines)
|
|
168
|
+
for i in range(header_line_idx + 1, len(lines)):
|
|
169
|
+
if _NEXT_HEADING_RE.match(lines[i]):
|
|
170
|
+
end_idx = i
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
section_text = "\n".join(lines[header_line_idx:end_idx])
|
|
174
|
+
|
|
175
|
+
fence_match = _YAML_FENCE_RE.search(section_text)
|
|
176
|
+
if fence_match:
|
|
177
|
+
yaml_text = fence_match.group(1)
|
|
178
|
+
else:
|
|
179
|
+
# bare yaml: everything after the header line
|
|
180
|
+
yaml_text = "\n".join(lines[header_line_idx + 1:end_idx])
|
|
181
|
+
|
|
182
|
+
# lazy import: PyYAML отсутствует в чистых uvx-окружениях (canary.sh гоняет
|
|
183
|
+
# spec-тесты через `uvx --from pytest`); top-level import ронял collection
|
|
184
|
+
# 12 модулей через цепочку phase_45_spec → ac_dsl
|
|
185
|
+
import yaml
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
data = yaml.safe_load(yaml_text)
|
|
189
|
+
except yaml.YAMLError as exc:
|
|
190
|
+
raise ValueError(f"AC-checks yaml unparseable: {exc}") from exc
|
|
191
|
+
|
|
192
|
+
if not isinstance(data, dict):
|
|
193
|
+
raise ValueError("AC-checks yaml root is not a mapping")
|
|
194
|
+
|
|
195
|
+
out: dict[str, CheckSpec] = {}
|
|
196
|
+
for raw_id, block in data.items():
|
|
197
|
+
ac_id = _normalize_ac_id(raw_id)
|
|
198
|
+
if not isinstance(block, dict):
|
|
199
|
+
raise ValueError(f"AC-checks block for {raw_id} is not a mapping")
|
|
200
|
+
block = dict(block)
|
|
201
|
+
ctype = block.pop("type", None)
|
|
202
|
+
out[ac_id] = CheckSpec(ac_id=ac_id, type=ctype, params=block)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# admit
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def admit(spec_text: str) -> AdmissionReport:
|
|
212
|
+
per_ac: dict = {}
|
|
213
|
+
reasons: list = []
|
|
214
|
+
|
|
215
|
+
spec_ac_ids = verdict_verify.parse_spec_ac_ids(spec_text)
|
|
216
|
+
if not spec_ac_ids:
|
|
217
|
+
return AdmissionReport(result="REJECT", per_ac={}, reasons=["no ACs found"])
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
checks = parse_ac_checks(spec_text)
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
return AdmissionReport(
|
|
223
|
+
result="REJECT",
|
|
224
|
+
per_ac={},
|
|
225
|
+
reasons=[f"AC-checks section missing/unparseable: {exc}"],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
check_ids = set(checks.keys())
|
|
229
|
+
|
|
230
|
+
for ac_id in spec_ac_ids:
|
|
231
|
+
if ac_id not in check_ids:
|
|
232
|
+
per_ac[ac_id] = {"status": "REJECT", "reason": "missing check block"}
|
|
233
|
+
|
|
234
|
+
for ac_id in check_ids:
|
|
235
|
+
if ac_id not in spec_ac_ids:
|
|
236
|
+
per_ac[ac_id] = {"status": "REJECT", "reason": "orphan check block (no §3 AC)"}
|
|
237
|
+
reasons.append(f"orphan check block (no §3 AC): {ac_id}")
|
|
238
|
+
|
|
239
|
+
# Type + param validation for every AC that has a check block and is not
|
|
240
|
+
# already rejected as orphan-only (orphan ACs still get validated too, but
|
|
241
|
+
# their status is already REJECT — validation may add more detail via reasons).
|
|
242
|
+
for ac_id, spec in checks.items():
|
|
243
|
+
if ac_id in per_ac and per_ac[ac_id]["status"] == "REJECT" and "orphan" in per_ac[ac_id]["reason"]:
|
|
244
|
+
# still validate for informative reasons, but don't overwrite orphan status
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
ctype = spec.type
|
|
248
|
+
if ctype is None or ctype not in CHECK_TYPES:
|
|
249
|
+
reason = f"unknown check type: {ctype}"
|
|
250
|
+
if ac_id not in per_ac or per_ac[ac_id]["status"] != "REJECT":
|
|
251
|
+
per_ac[ac_id] = {"status": "REJECT", "reason": reason}
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
type_schema = CHECK_TYPES[ctype]["params"]
|
|
255
|
+
param_reason = None
|
|
256
|
+
for pname in spec.params:
|
|
257
|
+
if pname not in type_schema:
|
|
258
|
+
param_reason = f"unknown parameter: {pname}"
|
|
259
|
+
break
|
|
260
|
+
if param_reason is None:
|
|
261
|
+
for pname, pschema in type_schema.items():
|
|
262
|
+
if pschema.get("required") and pname not in spec.params:
|
|
263
|
+
param_reason = f"missing required parameter: {pname}"
|
|
264
|
+
break
|
|
265
|
+
if param_reason is not None:
|
|
266
|
+
if ac_id not in per_ac or per_ac[ac_id]["status"] != "REJECT":
|
|
267
|
+
per_ac[ac_id] = {"status": "REJECT", "reason": param_reason}
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
if ctype == "llm_judged":
|
|
271
|
+
rubric = spec.params.get("rubric")
|
|
272
|
+
if not isinstance(rubric, str) or not rubric.strip():
|
|
273
|
+
if ac_id not in per_ac or per_ac[ac_id]["status"] != "REJECT":
|
|
274
|
+
per_ac[ac_id] = {"status": "REJECT", "reason": "llm_judged without rubric"}
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# Presence-gate: event_emitted with min_count==0 requires a paired positive
|
|
278
|
+
# (min_count>=1, same stream) somewhere in the check set.
|
|
279
|
+
for ac_id, spec in checks.items():
|
|
280
|
+
if spec.type != "event_emitted":
|
|
281
|
+
continue
|
|
282
|
+
if ac_id in per_ac and per_ac[ac_id]["status"] == "REJECT":
|
|
283
|
+
continue
|
|
284
|
+
min_count = spec.params.get("min_count", 1)
|
|
285
|
+
if min_count != 0:
|
|
286
|
+
continue
|
|
287
|
+
stream = spec.params.get("stream")
|
|
288
|
+
has_pair = False
|
|
289
|
+
for other_id, other in checks.items():
|
|
290
|
+
if other_id == ac_id or other.type != "event_emitted":
|
|
291
|
+
continue
|
|
292
|
+
other_min = other.params.get("min_count", 1)
|
|
293
|
+
if other.params.get("stream") == stream and other_min != 0:
|
|
294
|
+
has_pair = True
|
|
295
|
+
break
|
|
296
|
+
if not has_pair:
|
|
297
|
+
per_ac[ac_id] = {
|
|
298
|
+
"status": "REJECT",
|
|
299
|
+
"reason": "absence check without paired positive",
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for ac_id in check_ids:
|
|
303
|
+
if ac_id in spec_ac_ids and ac_id not in per_ac:
|
|
304
|
+
per_ac[ac_id] = {"status": "OK", "reason": ""}
|
|
305
|
+
|
|
306
|
+
result = "ACCEPT" if all(v["status"] == "OK" for v in per_ac.values()) else "REJECT"
|
|
307
|
+
return AdmissionReport(result=result, per_ac=per_ac, reasons=reasons)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# run_checks — mechanical executors
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _read_jsonl_objs(path: Path) -> list[dict]:
|
|
316
|
+
objs = []
|
|
317
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
318
|
+
line = line.strip()
|
|
319
|
+
if not line:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
obj = json.loads(line)
|
|
323
|
+
except (json.JSONDecodeError, ValueError):
|
|
324
|
+
continue
|
|
325
|
+
if isinstance(obj, dict):
|
|
326
|
+
objs.append(obj)
|
|
327
|
+
return objs
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _exec_file_contains(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
331
|
+
path = repo_root / params["path"]
|
|
332
|
+
pattern = params.get("pattern")
|
|
333
|
+
kind = params.get("kind", "fixed")
|
|
334
|
+
expect = params.get("expect", "present")
|
|
335
|
+
|
|
336
|
+
if pattern is None:
|
|
337
|
+
found = path.exists()
|
|
338
|
+
else:
|
|
339
|
+
if not path.exists():
|
|
340
|
+
found = False
|
|
341
|
+
else:
|
|
342
|
+
text = path.read_text(encoding="utf-8")
|
|
343
|
+
if kind == "regex":
|
|
344
|
+
found = re.search(pattern, text) is not None
|
|
345
|
+
else:
|
|
346
|
+
found = pattern in text
|
|
347
|
+
|
|
348
|
+
ok = found if expect == "present" else not found
|
|
349
|
+
return ("PASS", "") if ok else ("FAIL", f"file_contains expect={expect} found={found}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _exec_cmd_exit(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
353
|
+
argv = params["argv"]
|
|
354
|
+
cwd = repo_root / params["cwd"] if params.get("cwd") else repo_root
|
|
355
|
+
env_overlay = params.get("env") or {}
|
|
356
|
+
run_env = dict(os.environ)
|
|
357
|
+
run_env.update(env_overlay)
|
|
358
|
+
timeout_s = params.get("timeout_s", 60)
|
|
359
|
+
proc = subprocess.run(
|
|
360
|
+
argv, cwd=str(cwd), env=run_env, timeout=timeout_s,
|
|
361
|
+
capture_output=True, text=True,
|
|
362
|
+
)
|
|
363
|
+
ok = proc.returncode == params["exit_code"]
|
|
364
|
+
if ok and params.get("stdout_re"):
|
|
365
|
+
ok = re.search(params["stdout_re"], proc.stdout) is not None
|
|
366
|
+
if ok and params.get("stderr_re"):
|
|
367
|
+
ok = re.search(params["stderr_re"], proc.stderr) is not None
|
|
368
|
+
if ok:
|
|
369
|
+
return ("PASS", "")
|
|
370
|
+
return ("FAIL", f"cmd_exit returncode={proc.returncode} expected={params['exit_code']}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _exec_event_emitted(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
374
|
+
stream = repo_root / params["stream"]
|
|
375
|
+
name = params["name"]
|
|
376
|
+
fields_subset = params.get("fields_subset") or {}
|
|
377
|
+
min_count = params.get("min_count", 1)
|
|
378
|
+
|
|
379
|
+
if not stream.exists():
|
|
380
|
+
if min_count == 0:
|
|
381
|
+
return ("PASS", "")
|
|
382
|
+
return ("FAIL", f"event_emitted: stream not found: {stream}")
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
objs = _read_jsonl_objs(stream)
|
|
386
|
+
except OSError as exc:
|
|
387
|
+
if min_count == 0:
|
|
388
|
+
return ("PASS", "")
|
|
389
|
+
return ("FAIL", f"driver: {exc}")
|
|
390
|
+
|
|
391
|
+
count = 0
|
|
392
|
+
for obj in objs:
|
|
393
|
+
if obj.get("name") != name:
|
|
394
|
+
continue
|
|
395
|
+
if all(obj.get(k) == v for k, v in fields_subset.items()):
|
|
396
|
+
count += 1
|
|
397
|
+
|
|
398
|
+
if min_count == 0:
|
|
399
|
+
ok = count == 0
|
|
400
|
+
else:
|
|
401
|
+
ok = count >= min_count
|
|
402
|
+
if ok:
|
|
403
|
+
return ("PASS", "")
|
|
404
|
+
return ("FAIL", f"event_emitted count={count} min_count={min_count}")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_pointer(obj, pointer: str):
|
|
408
|
+
node = obj
|
|
409
|
+
for seg in pointer.split("."):
|
|
410
|
+
if isinstance(node, list):
|
|
411
|
+
idx = int(seg)
|
|
412
|
+
node = node[idx]
|
|
413
|
+
elif isinstance(node, dict):
|
|
414
|
+
if seg not in node:
|
|
415
|
+
raise KeyError(seg)
|
|
416
|
+
node = node[seg]
|
|
417
|
+
else:
|
|
418
|
+
raise KeyError(seg)
|
|
419
|
+
return node
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _exec_json_field(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
423
|
+
path = repo_root / params["file"]
|
|
424
|
+
pointer = params["pointer"]
|
|
425
|
+
op = params["op"]
|
|
426
|
+
value = params.get("value")
|
|
427
|
+
|
|
428
|
+
if str(path).endswith(".jsonl"):
|
|
429
|
+
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
430
|
+
text = lines[-1] if lines else "{}"
|
|
431
|
+
data = json.loads(text)
|
|
432
|
+
else:
|
|
433
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
node = _resolve_pointer(data, pointer)
|
|
437
|
+
resolved = True
|
|
438
|
+
except (KeyError, IndexError, ValueError, TypeError):
|
|
439
|
+
node = None
|
|
440
|
+
resolved = False
|
|
441
|
+
|
|
442
|
+
if op == "absent":
|
|
443
|
+
ok = not resolved
|
|
444
|
+
elif op == "equals":
|
|
445
|
+
ok = resolved and node == value
|
|
446
|
+
elif op == "matches":
|
|
447
|
+
ok = resolved and re.search(str(value), str(node)) is not None
|
|
448
|
+
else:
|
|
449
|
+
ok = False
|
|
450
|
+
|
|
451
|
+
if ok:
|
|
452
|
+
return ("PASS", "")
|
|
453
|
+
return ("FAIL", f"json_field op={op} resolved={resolved} node={node!r}")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _exec_sha_anchor(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
457
|
+
doc = repo_root / params["doc"]
|
|
458
|
+
text = doc.read_text(encoding="utf-8")
|
|
459
|
+
result = verdict_verify.verify_anchor(text, repo_root)
|
|
460
|
+
if result.result != "VERIFIED":
|
|
461
|
+
return ("FAIL", f"sha_anchor result={result.result} reason={result.reason}")
|
|
462
|
+
|
|
463
|
+
anchor_paths = {relpath for _kind, relpath, _sha in verdict_verify.parse_anchor(text)}
|
|
464
|
+
for f in params["files"]:
|
|
465
|
+
if f not in anchor_paths:
|
|
466
|
+
return ("FAIL", f"sha_anchor: file {f} not present in anchor block")
|
|
467
|
+
return ("PASS", "")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _exec_count_delta(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
471
|
+
kind = params["kind"]
|
|
472
|
+
counter = params["counter"]
|
|
473
|
+
baseline = params["baseline"]
|
|
474
|
+
expected_delta = params["expected_delta"]
|
|
475
|
+
|
|
476
|
+
if kind == "cmd":
|
|
477
|
+
proc = subprocess.run(
|
|
478
|
+
counter, cwd=str(repo_root), capture_output=True, text=True, timeout=60
|
|
479
|
+
)
|
|
480
|
+
m = re.search(r"-?\d+", proc.stdout)
|
|
481
|
+
current = int(m.group(0)) if m else 0
|
|
482
|
+
else:
|
|
483
|
+
path = repo_root / counter
|
|
484
|
+
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
485
|
+
current = len(lines)
|
|
486
|
+
|
|
487
|
+
delta = current - baseline
|
|
488
|
+
if delta == expected_delta:
|
|
489
|
+
return ("PASS", "")
|
|
490
|
+
return ("FAIL", f"count_delta current={current} baseline={baseline} delta={delta} expected={expected_delta}")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _exec_monotonic(params: dict, repo_root: Path) -> tuple[str, str]:
|
|
494
|
+
stream = repo_root / params["stream"]
|
|
495
|
+
field_name = params["field"]
|
|
496
|
+
objs = _read_jsonl_objs(stream)
|
|
497
|
+
values = [obj[field_name] for obj in objs if field_name in obj]
|
|
498
|
+
if not values:
|
|
499
|
+
return ("FAIL", "monotonic: no values found")
|
|
500
|
+
ok = all(values[i] <= values[i + 1] for i in range(len(values) - 1))
|
|
501
|
+
if ok:
|
|
502
|
+
return ("PASS", "")
|
|
503
|
+
return ("FAIL", f"monotonic: not non-decreasing: {values}")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
_EXECUTORS = {
|
|
507
|
+
"file_contains": _exec_file_contains,
|
|
508
|
+
"cmd_exit": _exec_cmd_exit,
|
|
509
|
+
"event_emitted": _exec_event_emitted,
|
|
510
|
+
"json_field": _exec_json_field,
|
|
511
|
+
"sha_anchor": _exec_sha_anchor,
|
|
512
|
+
"count_delta": _exec_count_delta,
|
|
513
|
+
"monotonic": _exec_monotonic,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def run_checks(checks: dict, repo_root, env=None) -> list:
|
|
518
|
+
repo_root = Path(repo_root)
|
|
519
|
+
results = []
|
|
520
|
+
for ac_id, spec in checks.items():
|
|
521
|
+
if spec.type == "llm_judged":
|
|
522
|
+
results.append(CheckResult(ac_id=ac_id, type=spec.type, status="JUDGE", reason=""))
|
|
523
|
+
continue
|
|
524
|
+
executor = _EXECUTORS.get(spec.type)
|
|
525
|
+
if executor is None:
|
|
526
|
+
results.append(
|
|
527
|
+
CheckResult(ac_id=ac_id, type=spec.type, status="FAIL", reason=f"unknown type: {spec.type}")
|
|
528
|
+
)
|
|
529
|
+
continue
|
|
530
|
+
try:
|
|
531
|
+
status, reason = executor(spec.params, repo_root)
|
|
532
|
+
except Exception as exc: # noqa: BLE001 - fail-closed driver errors (§lesson 221)
|
|
533
|
+
status, reason = "FAIL", f"driver: {exc}"
|
|
534
|
+
results.append(CheckResult(ac_id=ac_id, type=spec.type, status=status, reason=reason))
|
|
535
|
+
return results
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# render_checklist
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def render_checklist(results: list) -> str:
|
|
544
|
+
lines = []
|
|
545
|
+
for r in results:
|
|
546
|
+
if r.status == "JUDGE":
|
|
547
|
+
lines.append(f"- {r.ac_id}: LLM_JUDGED — rubric deferred")
|
|
548
|
+
elif r.status == "PASS":
|
|
549
|
+
lines.append(f"- {r.ac_id}: PASS")
|
|
550
|
+
else:
|
|
551
|
+
lines.append(f"- {r.ac_id}: FAIL ({r.reason})")
|
|
552
|
+
return "\n".join(lines) + ("\n" if lines else "")
|