devlyn-cli 3.1.1 → 3.1.3

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.
@@ -21,7 +21,8 @@ Default mode (BUILD_GATE invocation, no args):
21
21
  (3) For generated criteria and legacy handwritten specs without a sibling,
22
22
  source markdown extract reads `pipeline.state.json:
23
23
  source.{spec_path | criteria_path}` and extracts a `## Verification`
24
- ```json``` block. If present, overwrite `.devlyn/spec-verify.json`.
24
+ ```json``` block. Stage executable commands; an explicit pure-design
25
+ contract removes stale `.devlyn/spec-verify.json` instead.
25
26
  (4) If no json block in source AND source.type=="generated": emit
26
27
  CRITICAL `correctness.spec-verify-malformed` so the fix-loop reruns
27
28
  BUILD.
@@ -38,14 +39,13 @@ Default mode (BUILD_GATE invocation, no args):
38
39
  run-fixture.sh's combined-stream matching semantics.
39
40
 
40
41
  Check mode (`--check <markdown_path>`):
41
- - Used by /devlyn:ideate after writing each item spec to validate that the
42
- generated `## Verification` ```json``` block parses + matches the schema,
43
- and that present `complexity` frontmatter has a supported value.
44
- - Exits 0 if the block is well-formed (or absent — ideate's check applies
45
- to both new specs that include the block and pre-carrier handwritten
46
- specs that omit it; absence is not failure here, only malformed JSON or
47
- shape error is). Exits 2 on malformed json, shape error, or unsupported
48
- `complexity` value.
42
+ - Spec-source authoring preflight: a present sibling `spec.expected.json`
43
+ takes precedence; otherwise validate the legacy inline carrier. Validate
44
+ metadata against the given Markdown path, including non-canonical names.
45
+ - Exits 2 on malformed carrier or metadata, without falling back from a bad
46
+ sibling to inline content. An absent sibling and sentinel retain opt-in
47
+ success. Never stages or executes commands. Generated-source runtime has
48
+ its own inline validation and must not use this sibling-precedence route.
49
49
 
50
50
  Expected-contract check mode (`--check-expected <json_path>`):
51
51
  - Used by /devlyn:ideate after writing sibling `spec.expected.json`.
@@ -844,10 +844,28 @@ def validate_expected_shape(data) -> str | None:
844
844
  return None
845
845
 
846
846
 
847
- def validate_expected_against_sibling_spec(expected_path: Path, data: object) -> str | None:
847
+ def validate_inline_shape(data: object) -> str | None:
848
+ if isinstance(data, dict):
849
+ unknown = sorted(set(data) - {"verification_commands", "pure_design"})
850
+ if unknown:
851
+ return (
852
+ f"unsupported inline key(s): {', '.join(unknown)}; inline carriers "
853
+ "support only verification_commands and pure_design. Encode these checks as commands, "
854
+ "or use a real spec with sibling spec.expected.json."
855
+ )
856
+ error = validate_expected_shape(data)
857
+ if error:
858
+ return error
859
+ if isinstance(data, dict) and data.get("pure_design") is True:
860
+ if data.get("verification_commands") != []:
861
+ return "inline pure_design: true requires an explicit empty verification_commands list"
862
+ return None
863
+ return validate_shape(data)
864
+
865
+
866
+ def validate_expected_against_sibling_spec(spec_path: Path, data: object) -> str | None:
848
867
  if not isinstance(data, dict):
849
868
  return None
850
- spec_path = expected_path.with_name("spec.md")
851
869
  if not spec_path.is_file():
852
870
  return None
853
871
  try:
@@ -881,8 +899,7 @@ def validate_expected_against_sibling_spec(expected_path: Path, data: object) ->
881
899
  )
882
900
 
883
901
 
884
- def validate_sibling_spec_complexity(expected_path: Path) -> str | None:
885
- spec_path = expected_path.with_name("spec.md")
902
+ def validate_sibling_spec_complexity(spec_path: Path) -> str | None:
886
903
  if not spec_path.is_file():
887
904
  return None
888
905
  try:
@@ -1149,9 +1166,11 @@ def risk_probes_state_error(state: dict) -> str | None:
1149
1166
 
1150
1167
 
1151
1168
  def source_integrity_error(src_type: str | None, state: dict, source_md: Path | None) -> str | None:
1169
+ src = state.get("source") if isinstance(state.get("source"), dict) else {}
1152
1170
  if source_md is None:
1171
+ if src_type == "generated":
1172
+ return f"source.criteria_path must identify an existing generated criteria file; declared path: {src.get('criteria_path')!r}."
1153
1173
  return None
1154
- src = state.get("source") if isinstance(state.get("source"), dict) else {}
1155
1174
  if src_type == "generated":
1156
1175
  field = "criteria_sha256"
1157
1176
  required = True
@@ -1188,33 +1207,33 @@ def load_expected_contract(expected_path: Path) -> tuple[dict | None, str | None
1188
1207
  return (data, None)
1189
1208
 
1190
1209
 
1191
- def stage_from_source(md: Path, devlyn_dir: Path) -> tuple[bool, str | None]:
1210
+ def stage_from_source(md: Path, devlyn_dir: Path) -> tuple[bool, bool, str | None]:
1192
1211
  """Materialize .devlyn/spec-verify.json from the json block in `md`.
1193
1212
 
1194
- Returns (staged, error). staged=True → wrote spec-verify.json. error
1195
- non-None → carrier was found but malformed (caller emits CRITICAL) —
1196
- this now also covers a `<!-- devlyn:verification -->` sentinel present
1197
- with no fenced ```json``` block inside it (the author clearly intended a
1198
- contract; a missing fence is a mistake, not a no-op). staged=False,
1199
- error=None → the sentinel is absent entirely (handwritten spec or
1200
- generated source missing the contract).
1213
+ Returns (found, staged, error). found=False means no sentinel. A present
1214
+ sentinel without a valid fenced JSON contract returns an error. A valid
1215
+ pure-design contract removes stale staging and returns (True, False, None);
1216
+ executable commands return (True, True, None) after writing spec-verify.json.
1201
1217
  """
1202
1218
  section_found, block = extract_verification_block(md.read_text(encoding="utf-8"))
1203
1219
  if not section_found:
1204
- return (False, None)
1220
+ return (False, False, None)
1205
1221
  if block is None:
1206
- return (False, f"`<!-- devlyn:verification -->` section in {md} has no fenced ```json``` block")
1222
+ return (True, False, f"`<!-- devlyn:verification -->` section in {md} has no fenced ```json``` block")
1207
1223
  try:
1208
1224
  data = loads_strict_json(block)
1209
1225
  except ValueError as e:
1210
- return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1211
- err = validate_shape(data)
1226
+ return (True, False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1227
+ err = validate_inline_shape(data)
1212
1228
  if err:
1213
- return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md}: {err}")
1229
+ return (True, False, f"`<!-- devlyn:verification -->` ```json``` block in {md}: {err}")
1230
+ if data["verification_commands"] == []:
1231
+ (devlyn_dir / "spec-verify.json").unlink(missing_ok=True)
1232
+ return (True, False, None)
1214
1233
  normalized = {"verification_commands": data["verification_commands"]}
1215
1234
  devlyn_dir.mkdir(parents=True, exist_ok=True)
1216
1235
  (devlyn_dir / "spec-verify.json").write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
1217
- return (True, None)
1236
+ return (True, True, None)
1218
1237
 
1219
1238
 
1220
1239
  def stage_from_expected(
@@ -1236,6 +1255,9 @@ def stage_from_expected(
1236
1255
  if err:
1237
1256
  return (True, False, err, expected_path, None)
1238
1257
  assert data is not None
1258
+ err = validate_sibling_spec_complexity(md) or validate_expected_against_sibling_spec(md, data)
1259
+ if err:
1260
+ return (True, False, f"{expected_path}: {err}", expected_path, None)
1239
1261
  commands = data.get("verification_commands")
1240
1262
  if not commands:
1241
1263
  spec_path = devlyn_dir / "spec-verify.json"
@@ -1322,7 +1344,7 @@ def diff_text_for_expected(work: Path, devlyn_dir: Path, state: dict) -> tuple[s
1322
1344
  external_diff = devlyn_dir / "external-diff.patch"
1323
1345
  if external_diff.is_file():
1324
1346
  try:
1325
- return (external_diff.read_text(encoding="utf-8"), None)
1347
+ return (external_diff.read_text(encoding="utf-8", errors="surrogateescape"), None)
1326
1348
  except OSError as e:
1327
1349
  return ("", f"cannot read {external_diff}: {e}")
1328
1350
  base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
@@ -1361,28 +1383,46 @@ def count_deps_added(work: Path, state: dict) -> int:
1361
1383
  return count
1362
1384
 
1363
1385
 
1364
- def changed_files(work: Path, state: dict, devlyn_dir: Path) -> list[str]:
1386
+ def changed_files(work: Path, state: dict, devlyn_dir: Path) -> tuple[list[str], str | None]:
1365
1387
  external_diff = devlyn_dir / "external-diff.patch"
1366
1388
  if external_diff.is_file():
1367
1389
  names: list[str] = []
1368
1390
  try:
1369
- external_text = external_diff.read_text(encoding="utf-8")
1370
- except OSError:
1371
- return []
1372
- for line in external_text.splitlines():
1373
- if line.startswith("diff --git "):
1374
- parts = line.split()
1375
- if len(parts) >= 4:
1376
- names.append(parts[3].removeprefix("b/"))
1377
- return names
1391
+ external_bytes = external_diff.read_bytes()
1392
+ except OSError as error:
1393
+ return ([], f"cannot read {external_diff}: {error}")
1394
+ if not external_bytes:
1395
+ return ([], None)
1396
+ # Prefix depth is otherwise ambiguous (src/x becomes x with -p1).
1397
+ headers = [line for line in external_bytes.split(b"\n") if line.startswith(b"diff --git ")]
1398
+ if not headers or any(not re.fullmatch(
1399
+ rb'diff --git (a/[^"\r]*|"a/(?:[^"\\]|\\.)*") (b/[^"\r]*|"b/(?:[^"\\]|\\.)*")\r?', line,
1400
+ ) for line in headers):
1401
+ return ([], "external diff requires Git a/ and b/ path prefixes; regenerate with git diff --binary --src-prefix=a/ --dst-prefix=b/")
1402
+ # Statistics mode never applies the patch. Reverse parsing includes
1403
+ # rename sources as well as destinations without parsing quoted headers.
1404
+ for direction in ([], ["--reverse"]):
1405
+ proc = subprocess.run(
1406
+ ["git", "apply", "--numstat", "-z", "-p1", "--whitespace=nowarn", *direction], cwd=str(work),
1407
+ input=external_bytes, capture_output=True,
1408
+ )
1409
+ if proc.returncode != 0:
1410
+ return ([], (proc.stderr or proc.stdout).decode("utf-8", "replace").strip() or "git apply --numstat failed")
1411
+ for record in proc.stdout.split(b"\0"):
1412
+ if record:
1413
+ fields = record.split(b"\t", 2)
1414
+ if len(fields) != 3 or not fields[2]:
1415
+ return ([], "invalid git apply --numstat path record")
1416
+ names.append(fields[2].decode("utf-8", "surrogateescape"))
1417
+ return (list(dict.fromkeys(names)), None)
1378
1418
  base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
1379
- cmd = ["git", "diff", "--name-only"]
1419
+ cmd = ["git", "diff", "--name-only", "-z", "--no-renames"]
1380
1420
  if base_sha:
1381
1421
  cmd.append(base_sha)
1382
- proc = subprocess.run(cmd, cwd=str(work), capture_output=True, text=True, encoding="utf-8")
1422
+ proc = subprocess.run(cmd, cwd=str(work), capture_output=True)
1383
1423
  if proc.returncode != 0:
1384
- return []
1385
- return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]
1424
+ return ([], (proc.stderr or proc.stdout).decode("utf-8", "replace").strip() or "git diff --name-only failed")
1425
+ return ([path.decode("utf-8", "surrogateescape") for path in proc.stdout.split(b"\0") if path], None)
1386
1426
 
1387
1427
 
1388
1428
  def expected_contract_findings(
@@ -1398,6 +1438,8 @@ def expected_contract_findings(
1398
1438
  findings: list[dict] = []
1399
1439
  seq = finding_start
1400
1440
  diff_text, diff_error = diff_text_for_expected(work, devlyn_dir, state)
1441
+ paths, paths_error = changed_files(work, state, devlyn_dir) if expected_data.get("forbidden_files") else ([], None)
1442
+ diff_error = diff_error or paths_error
1401
1443
  if diff_error and (
1402
1444
  expected_data.get("forbidden_patterns") or expected_data.get("forbidden_files")
1403
1445
  ):
@@ -1407,12 +1449,12 @@ def expected_contract_findings(
1407
1449
  "level": "error",
1408
1450
  "severity": "CRITICAL",
1409
1451
  "confidence": 1.0,
1410
- "message": f"Cannot compute diff for forbidden_patterns: {diff_error}",
1452
+ "message": f"Cannot compute diff for expected contract: {diff_error}",
1411
1453
  "file": str(expected_path or "spec.expected.json"),
1412
1454
  "line": 1,
1413
1455
  "phase": output_phase(),
1414
- "criterion_ref": "spec.expected.json/forbidden_patterns",
1415
- "fix_hint": "Ensure pipeline.state.json has base_ref.sha or provide .devlyn/external-diff.patch.",
1456
+ "criterion_ref": "spec.expected.json/forbidden_files" if paths_error else "spec.expected.json/forbidden_patterns",
1457
+ "fix_hint": "Ensure base_ref.sha is valid and any external-diff.patch is a readable Git patch with a/ and b/ prefixes.",
1416
1458
  "blocking": True,
1417
1459
  "status": "open",
1418
1460
  })
@@ -1438,7 +1480,7 @@ def expected_contract_findings(
1438
1480
  "status": "open",
1439
1481
  })
1440
1482
  seq += 1
1441
- changed = set(changed_files(work, state, devlyn_dir))
1483
+ changed = set(paths)
1442
1484
  for i, required in enumerate(expected_data.get("required_files", []) or []):
1443
1485
  if (work / required).exists():
1444
1486
  continue
@@ -1757,7 +1799,16 @@ def authorized_surface_findings(
1757
1799
 
1758
1800
  findings: list[dict] = []
1759
1801
  seq = finding_start
1760
- for path in changed_files(work, state, devlyn_dir):
1802
+ paths, paths_error = changed_files(work, state, devlyn_dir)
1803
+ if paths_error is not None:
1804
+ return ([scope_finding(
1805
+ finding_start,
1806
+ "scope.authorized-surface-malformed",
1807
+ f"Cannot read changed files: {paths_error}",
1808
+ ".devlyn/external-diff.patch" if (devlyn_dir / "external-diff.patch").is_file() else ".devlyn/pipeline.state.json",
1809
+ "Ensure base_ref.sha is valid and any external-diff.patch is a readable Git patch with a/ and b/ prefixes.",
1810
+ )], finding_start + 1)
1811
+ for path in paths:
1761
1812
  if path_matches_surface(path, surface):
1762
1813
  continue
1763
1814
  findings.append(scope_finding(
@@ -1846,13 +1897,8 @@ def run_write_untracked_baseline(work: Path, devlyn_dir: Path) -> int:
1846
1897
 
1847
1898
 
1848
1899
  def run_check_mode(md_path: Path) -> int:
1849
- """`--check <markdown>` — validate the verification carrier without
1850
- running any commands. Used by /devlyn:ideate after item-spec write.
1851
-
1852
- Exit 0: `<!-- devlyn:verification -->` sentinel absent OR section present
1853
- and well-formed.
1854
- Exit 2: sentinel present but the section is malformed — no fenced json
1855
- block, invalid JSON, or bad shape (so ideate can re-prompt).
1900
+ """Validate a spec's authoritative sibling or legacy inline carrier;
1901
+ return 0/2 without staging or execution. This is not generated-mode staging.
1856
1902
  """
1857
1903
  if not md_path.is_file():
1858
1904
  print(f"[spec-verify --check] error: {md_path} not found", file=sys.stderr)
@@ -1870,6 +1916,14 @@ def run_check_mode(md_path: Path) -> int:
1870
1916
  if solo_ceiling_err:
1871
1917
  print(f"[spec-verify --check] {md_path}: {solo_ceiling_err}", file=sys.stderr)
1872
1918
  return 2
1919
+ expected_path = md_path.with_name("spec.expected.json")
1920
+ if expected_path.is_file():
1921
+ data, err = load_expected_contract(expected_path)
1922
+ err = err or validate_expected_against_sibling_spec(md_path, data)
1923
+ if err:
1924
+ print(f"[spec-verify --check] {md_path}: sibling {expected_path}: {err}", file=sys.stderr)
1925
+ return 2
1926
+ return 0
1873
1927
  section_found, block = extract_verification_block(text)
1874
1928
  if not section_found:
1875
1929
  # Sentinel absent entirely — opt-in nature preserved for ideate (a
@@ -1892,7 +1946,7 @@ def run_check_mode(md_path: Path) -> int:
1892
1946
  file=sys.stderr,
1893
1947
  )
1894
1948
  return 2
1895
- err = validate_shape(data)
1949
+ err = validate_inline_shape(data)
1896
1950
  if err:
1897
1951
  print(f"[spec-verify --check] {md_path}: shape error: {err}", file=sys.stderr)
1898
1952
  return 2
@@ -1915,11 +1969,11 @@ def run_check_expected_mode(expected_path: Path) -> int:
1915
1969
  if err:
1916
1970
  print(f"[spec-verify --check-expected] {expected_path}: shape error: {err}", file=sys.stderr)
1917
1971
  return 2
1918
- complexity_err = validate_sibling_spec_complexity(expected_path)
1972
+ complexity_err = validate_sibling_spec_complexity(expected_path.with_name("spec.md"))
1919
1973
  if complexity_err:
1920
1974
  print(f"[spec-verify --check-expected] {expected_path}: shape error: {complexity_err}", file=sys.stderr)
1921
1975
  return 2
1922
- sibling_err = validate_expected_against_sibling_spec(expected_path, _data)
1976
+ sibling_err = validate_expected_against_sibling_spec(expected_path.with_name("spec.md"), _data)
1923
1977
  if sibling_err:
1924
1978
  print(f"[spec-verify --check-expected] {expected_path}: shape error: {sibling_err}", file=sys.stderr)
1925
1979
  return 2
@@ -2047,7 +2101,7 @@ def run_self_test() -> int:
2047
2101
  + "\n```\n",
2048
2102
  encoding="utf-8",
2049
2103
  )
2050
- staged, inline_error = stage_from_source(
2104
+ _found, staged, inline_error = stage_from_source(
2051
2105
  inline_timeout_spec, inline_timeout_devlyn
2052
2106
  )
2053
2107
  if staged or not inline_error or "timeout_sec" not in inline_error:
@@ -2067,7 +2121,7 @@ def run_self_test() -> int:
2067
2121
  + "\n```\n",
2068
2122
  encoding="utf-8",
2069
2123
  )
2070
- staged, inline_error = stage_from_source(
2124
+ _found, staged, inline_error = stage_from_source(
2071
2125
  inline_timeout_spec, inline_timeout_devlyn
2072
2126
  )
2073
2127
  inline_staged = loads_strict_json(
@@ -3628,6 +3682,56 @@ def run_self_test() -> int:
3628
3682
  "```\n",
3629
3683
  encoding="utf-8",
3630
3684
  )
3685
+ generated_raw = generated_criteria.read_bytes()
3686
+ generated_criteria.unlink()
3687
+ missing_source_marker = generated_user / "missing-source-command-ran"
3688
+ for pointer, bench, phase in (
3689
+ ({}, False, "build_gate"),
3690
+ ({"criteria_path": None}, False, "build_gate"),
3691
+ ({"criteria_path": ""}, False, "build_gate"),
3692
+ ({"criteria_path": ".devlyn/criteria.generated.md"}, False, "build_gate"),
3693
+ ({"criteria_path": str(generated_criteria)}, False, "build_gate"),
3694
+ ({"criteria_path": str(generated_user)}, False, "build_gate"),
3695
+ ({"criteria_path": str(generated_criteria)}, True, "build_gate"),
3696
+ ({"criteria_path": str(generated_criteria)}, False, "verify_mechanical"),
3697
+ ):
3698
+ (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3699
+ "source": {"type": "generated", "criteria_sha256": hashlib.sha256(generated_raw).hexdigest(), **pointer},
3700
+ "risk_profile": {"risk_probes_enabled": True},
3701
+ }), encoding="utf-8")
3702
+ (generated_devlyn / "spec-verify.json").write_text(json.dumps({
3703
+ "verification_commands": [{"cmd": "printf unexpected > missing-source-command-ran"}],
3704
+ }), encoding="utf-8")
3705
+ (generated_devlyn / "spec-verify.results.json").unlink(missing_ok=True)
3706
+ missing_source_env = dict(os.environ, SPEC_VERIFY_PHASE=phase)
3707
+ if bench:
3708
+ missing_source_env["BENCH_WORKDIR"] = str(generated_user)
3709
+ missing_source_run = subprocess.run(
3710
+ [sys.executable, script_path, "--include-risk-probes"],
3711
+ cwd=generated_user, env=missing_source_env,
3712
+ capture_output=True, text=True, encoding="utf-8",
3713
+ )
3714
+ if (
3715
+ missing_source_run.returncode != 1
3716
+ or "source.criteria_path" not in missing_source_run.stderr
3717
+ or f"declared path: {pointer.get('criteria_path')!r}" not in missing_source_run.stderr
3718
+ ):
3719
+ print(f"missing generated source was not rejected: {pointer}, {bench}, {phase}: {missing_source_run.stderr}", file=sys.stderr)
3720
+ return 1
3721
+ missing_findings = [loads_strict_json(line) for line in
3722
+ (generated_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()]
3723
+ if (
3724
+ len(missing_findings) != 1
3725
+ or missing_findings[0]["rule_id"] != "correctness.spec-verify-malformed"
3726
+ or missing_findings[0]["severity"] != "CRITICAL"
3727
+ or missing_findings[0]["file"] != ".devlyn/pipeline.state.json"
3728
+ or missing_findings[0]["phase"] != phase
3729
+ or missing_source_marker.exists()
3730
+ or (generated_devlyn / "spec-verify.results.json").exists() != (phase == "build_gate")
3731
+ ):
3732
+ print(f"missing generated source lost its finding or executed a stale command: {missing_findings}", file=sys.stderr)
3733
+ return 1
3734
+ generated_criteria.write_bytes(generated_raw)
3631
3735
  (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3632
3736
  "source": {"type": "generated", "criteria_path": str(generated_criteria)}
3633
3737
  }), encoding="utf-8")
@@ -3691,6 +3795,54 @@ def run_self_test() -> int:
3691
3795
  print("generated criteria carrier was not staged into .devlyn/spec-verify.json", file=sys.stderr)
3692
3796
  return 1
3693
3797
 
3798
+ inline_marker = generated_user / "inline-command-ran"
3799
+ inline_command = "printf bad > inline-command-ran; printf bad"
3800
+ for source_type in ("generated", "spec"):
3801
+ for contract, diagnostic in (
3802
+ ({"verification_commands": [{"cmd": inline_command}],
3803
+ "required_files": ["missing.txt"]}, "unsupported inline key(s): required_files"),
3804
+ ({"verification_commands": [{"cmd": inline_command,
3805
+ "stdout_not_contians": ["bad"]}]}, "unknown key(s): stdout_not_contians"),
3806
+ ({"verification_commands": [{"cmd": inline_command,
3807
+ "stdout_not_contains": ["bad"]}]}, None),
3808
+ ):
3809
+ inline_marker.unlink(missing_ok=True)
3810
+ (generated_devlyn / output_findings_name()).unlink(missing_ok=True)
3811
+ generated_criteria.write_text(
3812
+ "# Inline constraints\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n"
3813
+ + json.dumps(contract) + "\n```\n", encoding="utf-8",
3814
+ )
3815
+ prefix = "criteria" if source_type == "generated" else "spec"
3816
+ (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3817
+ "source": {
3818
+ "type": source_type,
3819
+ f"{prefix}_path": str(generated_criteria),
3820
+ f"{prefix}_sha256": hashlib.sha256(generated_criteria.read_bytes()).hexdigest(),
3821
+ }
3822
+ }), encoding="utf-8")
3823
+ # A stale valid carrier must not bypass malformed source validation.
3824
+ (generated_devlyn / "spec-verify.json").write_text(json.dumps({
3825
+ "verification_commands": [{"cmd": inline_command}]
3826
+ }), encoding="utf-8")
3827
+ inline_check = subprocess.run(
3828
+ [sys.executable, script_path, "--check", str(generated_criteria)],
3829
+ cwd=generated_user, capture_output=True, text=True, encoding="utf-8",
3830
+ )
3831
+ inline_run = subprocess.run(
3832
+ [sys.executable, script_path], cwd=generated_user,
3833
+ capture_output=True, text=True, encoding="utf-8",
3834
+ )
3835
+ assert inline_check.returncode == (2 if diagnostic else 0)
3836
+ assert inline_run.returncode == 1
3837
+ if diagnostic:
3838
+ assert diagnostic in inline_check.stderr and diagnostic in inline_run.stderr
3839
+ assert not inline_marker.exists(), "malformed inline constraint executed a command"
3840
+ else:
3841
+ assert inline_marker.exists(), "valid inline output guard was not executed"
3842
+ assert "correctness.spec-literal-mismatch" in (
3843
+ generated_devlyn / output_findings_name()
3844
+ ).read_text(encoding="utf-8")
3845
+
3694
3846
  generated_criteria.write_text(
3695
3847
  "# Criteria\n\n## Verification\n\n- generated criteria omitted its machine-readable carrier.\n",
3696
3848
  encoding="utf-8",
@@ -3735,6 +3887,13 @@ def run_self_test() -> int:
3735
3887
  (real_devlyn / "pipeline.state.json").write_text(json.dumps({
3736
3888
  "source": {"type": "spec", "spec_path": str(real_spec)}
3737
3889
  }), encoding="utf-8")
3890
+ sibling_check = subprocess.run(
3891
+ [sys.executable, script_path, "--check", str(real_spec)],
3892
+ cwd=real_user, capture_output=True, text=True, encoding="utf-8",
3893
+ )
3894
+ if sibling_check.returncode != 0 or (real_devlyn / "spec-verify.json").exists():
3895
+ print(f"sibling authoring failed or staged commands: {sibling_check.stderr}", file=sys.stderr)
3896
+ return 1
3738
3897
  sibling_run = subprocess.run(
3739
3898
  [sys.executable, script_path],
3740
3899
  cwd=real_user,
@@ -3750,6 +3909,140 @@ def run_self_test() -> int:
3750
3909
  print("sibling spec.expected.json was not staged into .devlyn/spec-verify.json", file=sys.stderr)
3751
3910
  return 1
3752
3911
 
3912
+ pure_prose = "# Design\n\n<!-- devlyn:verification -->\n## Verification\n\n- No runtime verification commands.\n"
3913
+ stale_command = {"verification_commands": [{"cmd": "printf unexpected-command"}]}
3914
+ pure_inline = pure_prose + "\n```json\n" + json.dumps(stale_command) + "\n```\n"
3915
+ pure_contract = {"pure_design": True, "verification_commands": []}
3916
+ for name, filename, source, contract, expected_rule, source_type in (
3917
+ ("pure-prose", "spec.md", pure_prose, {"pure_design": True}, None, "spec"),
3918
+ ("pure-inline", "design-notes.md", pure_inline, {"pure_design": True, "verification_commands": []}, None, "spec"),
3919
+ ("pure-bad-inline", "design-notes.md", pure_prose + "\n```json\n{broken\n```\n", {"pure_design": True}, None, "spec"),
3920
+ ("empty-runtime", "design-notes.md", pure_inline, {"verification_commands": []}, "correctness.spec-verify-malformed", "spec"),
3921
+ ("contradictory-pure", "spec.md", pure_inline, {"pure_design": True, **stale_command}, "correctness.spec-verify-malformed", "spec"),
3922
+ ("pure-required-file", "spec.md", pure_inline, {"pure_design": True, "required_files": ["missing.md"]}, "correctness.required-file-missing", "spec"),
3923
+ ("generated-pure", ".devlyn/criteria.generated.md", pure_contract, None, None, "generated"),
3924
+ ("legacy-inline-pure", "design-notes.md", pure_contract, None, None, "spec"),
3925
+ ("generated-empty-section", ".devlyn/criteria.generated.md", pure_prose, None, "correctness.spec-verify-malformed", "generated"),
3926
+ ("generated-unmarked-empty", ".devlyn/criteria.generated.md", {"verification_commands": []}, None, "correctness.spec-verify-malformed", "generated"),
3927
+ ("generated-false-empty", ".devlyn/criteria.generated.md", {**pure_contract, "pure_design": False}, None, "correctness.spec-verify-malformed", "generated"),
3928
+ ("generated-missing-list", ".devlyn/criteria.generated.md", {"pure_design": True}, None, "correctness.spec-verify-malformed", "generated"),
3929
+ ("generated-nonboolean-pure", ".devlyn/criteria.generated.md", {**pure_contract, "pure_design": 1}, None, "correctness.spec-verify-malformed", "generated"),
3930
+ ("generated-contradictory-pure", ".devlyn/criteria.generated.md", {**stale_command, "pure_design": True}, None, "correctness.spec-verify-malformed", "generated"),
3931
+ ("generated-unsupported-field", ".devlyn/criteria.generated.md", {**pure_contract, "required_files": ["missing.md"]}, None, "correctness.spec-verify-malformed", "generated"),
3932
+ ):
3933
+ case_root = work / name
3934
+ case_root.mkdir()
3935
+ case_devlyn = case_root / ".devlyn"
3936
+ case_devlyn.mkdir()
3937
+ case_spec = case_root / filename
3938
+ if isinstance(source, dict):
3939
+ source = pure_prose + "\n```json\n" + json.dumps(source) + "\n```\n"
3940
+ case_spec.write_text(source, encoding="utf-8")
3941
+ if contract is not None:
3942
+ (case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
3943
+ (case_devlyn / "spec-verify.json").write_text(json.dumps(stale_command), encoding="utf-8")
3944
+ pointer = "criteria" if source_type == "generated" else "spec"
3945
+ (case_devlyn / "pipeline.state.json").write_text(json.dumps({
3946
+ "source": {"type": source_type, pointer + "_path": str(case_spec),
3947
+ pointer + "_sha256": hashlib.sha256(case_spec.read_bytes()).hexdigest()},
3948
+ }), encoding="utf-8")
3949
+ case_check = subprocess.run(
3950
+ [sys.executable, script_path, "--check", str(case_spec)],
3951
+ cwd=case_root, capture_output=True, text=True, encoding="utf-8",
3952
+ )
3953
+ if case_check.returncode != (2 if expected_rule == "correctness.spec-verify-malformed" else 0):
3954
+ print(f"{name}: authoring returned {case_check.returncode}: {case_check.stderr}", file=sys.stderr)
3955
+ return 1
3956
+ case_run = subprocess.run(
3957
+ [sys.executable, script_path], cwd=case_root,
3958
+ capture_output=True, text=True, encoding="utf-8",
3959
+ )
3960
+ if case_run.returncode != (1 if expected_rule else 0):
3961
+ print(f"{name}: contract execution returned {case_run.returncode}: {case_run.stderr}", file=sys.stderr)
3962
+ return 1
3963
+ case_findings = [
3964
+ loads_strict_json(line) for line in
3965
+ (case_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()
3966
+ ]
3967
+ if [finding["rule_id"] for finding in case_findings] != ([expected_rule] if expected_rule else []):
3968
+ print(f"{name}: unexpected contract findings: {case_findings}", file=sys.stderr)
3969
+ return 1
3970
+ case_results = case_devlyn / "spec-verify.results.json"
3971
+ if expected_rule == "correctness.spec-verify-malformed":
3972
+ rejected_results = loads_strict_json(case_results.read_text(encoding="utf-8"))
3973
+ if (rejected_results["commands"] != [] or rejected_results["process_evidence"] is not None
3974
+ or "preflight_failure" not in rejected_results):
3975
+ print(f"{name}: malformed contract reached command execution", file=sys.stderr)
3976
+ return 1
3977
+ elif (
3978
+ loads_strict_json(case_results.read_text(encoding="utf-8"))
3979
+ != {"commands": [], "process_evidence": None}
3980
+ or (case_devlyn / "spec-verify.json").exists()
3981
+ ):
3982
+ print(f"{name}: pure-design contract ran or retained a stale command", file=sys.stderr)
3983
+ return 1
3984
+
3985
+ generated_root = work / "generated-pure"
3986
+ generated_devlyn = generated_root / ".devlyn"
3987
+ for prestaged in (False, True):
3988
+ if prestaged:
3989
+ (generated_devlyn / "spec-verify.json").write_text(json.dumps(pure_contract), encoding="utf-8")
3990
+ bench_pure = subprocess.run(
3991
+ [sys.executable, script_path], cwd=generated_root,
3992
+ env=dict(os.environ, BENCH_WORKDIR=str(generated_root)),
3993
+ capture_output=True, text=True, encoding="utf-8",
3994
+ )
3995
+ if bench_pure.returncode != (1 if prestaged else 0):
3996
+ print(f"pure-design benchmark precedence changed: {bench_pure.stderr}", file=sys.stderr)
3997
+ return 1
3998
+ if prestaged and "verification_commands must contain at least one entry" not in bench_pure.stderr:
3999
+ print(f"prestaged empty benchmark did not fail command validation: {bench_pure.stderr}", file=sys.stderr)
4000
+ return 1
4001
+
4002
+ generated_state_path = generated_devlyn / "pipeline.state.json"
4003
+ generated_state = loads_strict_json(generated_state_path.read_text(encoding="utf-8"))
4004
+ generated_state["risk_profile"] = {"risk_probes_enabled": True, "risk_probes_explicit": True}
4005
+ generated_state_path.write_text(json.dumps(generated_state), encoding="utf-8")
4006
+ missing_pure_probe = subprocess.run(
4007
+ [sys.executable, script_path, "--include-risk-probes"], cwd=generated_root,
4008
+ capture_output=True, text=True, encoding="utf-8",
4009
+ )
4010
+ if missing_pure_probe.returncode != 1 or "risk probes integrity failed" not in missing_pure_probe.stderr:
4011
+ print(f"pure-design contract bypassed required probes: {missing_pure_probe.stderr}", file=sys.stderr)
4012
+ return 1
4013
+
4014
+ generated_source = generated_devlyn / "criteria.generated.md"
4015
+ generated_source.write_text(
4016
+ generated_source.read_text(encoding="utf-8") + "\n- probe must pass visible marker.\n",
4017
+ encoding="utf-8",
4018
+ )
4019
+ generated_state["source"]["criteria_sha256"] = hashlib.sha256(generated_source.read_bytes()).hexdigest()
4020
+ (generated_devlyn / "probes").mkdir()
4021
+ (generated_devlyn / "probes/P1.py").write_bytes(probe_script.read_bytes())
4022
+ (generated_devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n", encoding="utf-8")
4023
+ generated_state["risk_probes_digest"], pure_probe_error = risk_probes_digest(generated_devlyn)
4024
+ if pure_probe_error:
4025
+ print(pure_probe_error, file=sys.stderr)
4026
+ return 1
4027
+ generated_state_path.write_text(json.dumps(generated_state), encoding="utf-8")
4028
+ valid_pure_probe = subprocess.run(
4029
+ [sys.executable, script_path, "--include-risk-probes"], cwd=generated_root,
4030
+ capture_output=True, text=True, encoding="utf-8",
4031
+ )
4032
+ if valid_pure_probe.returncode != 0:
4033
+ print(f"pure-design contract did not execute valid probe: {valid_pure_probe.stderr}", file=sys.stderr)
4034
+ return 1
4035
+ pure_probe_results = loads_strict_json((generated_devlyn / "spec-verify.results.json").read_text(encoding="utf-8"))
4036
+ if (
4037
+ len(pure_probe_results["commands"]) != 1
4038
+ or pure_probe_results["commands"][0]["evidence_id"] != "risk-probe-0001"
4039
+ or not pure_probe_results["commands"][0]["pass"]
4040
+ or pure_probe_results["process_evidence"] is None
4041
+ or (generated_devlyn / "spec-verify.json").exists()
4042
+ ):
4043
+ print("pure-design probe did not retain exactly one verified process result", file=sys.stderr)
4044
+ return 1
4045
+
3753
4046
  malformed = work / "malformed-sibling"
3754
4047
  malformed.mkdir()
3755
4048
  malformed_devlyn = malformed / ".devlyn"
@@ -3788,7 +4081,7 @@ def run_self_test() -> int:
3788
4081
  }), encoding="utf-8")
3789
4082
  (devlyn / "spec-verify.json").write_text(json.dumps({
3790
4083
  "verification_commands": [
3791
- {"cmd": "printf bench-staged", "stdout_contains": ["bench-staged"]}
4084
+ {"cmd": "printf bench-staged", "stdout_contains": ["bench-staged"], "contract_refs": ["R1"]}
3792
4085
  ]
3793
4086
  }) + "\n", encoding="utf-8")
3794
4087
  bench_pre_staged = subprocess.run(
@@ -3807,6 +4100,38 @@ def run_self_test() -> int:
3807
4100
  print("benchmark pre-staged contract was overwritten", file=sys.stderr)
3808
4101
  return 1
3809
4102
 
4103
+ bench_marker = work / "bench-command-ran"
4104
+ bench_command = {"cmd": "printf bad > bench-command-ran; printf bad"}
4105
+ for phase in ("build_gate", "verify_mechanical"):
4106
+ for carrier, diagnostic in (
4107
+ ({"verification_commands": [{**bench_command, "stdout_not_contians": ["bad"]}]}, "unknown key(s): stdout_not_contians"),
4108
+ ({"verification_commands": [{**bench_command, "contract_refs": [""]}]}, "contract_refs must be a list of non-empty strings"),
4109
+ ({"verification_commands": [bench_command], "required_files": ["missing.txt"]}, "unsupported inline key(s): required_files"),
4110
+ ({"verification_commands": [bench_command], "pure_design": True}, "requires an explicit empty verification_commands list"),
4111
+ ({"verification_commands": [], "pure_design": True}, "must contain at least one entry"),
4112
+ ):
4113
+ (devlyn / "spec-verify.json").write_text(json.dumps(carrier), encoding="utf-8")
4114
+ old_bench_results = (devlyn / "spec-verify.results.json").read_bytes()
4115
+ rejected_bench = subprocess.run(
4116
+ [sys.executable, script_path], cwd=work,
4117
+ env={**env, "SPEC_VERIFY_PHASE": phase},
4118
+ capture_output=True, text=True, encoding="utf-8",
4119
+ )
4120
+ rejected_findings = [loads_strict_json(line) for line in
4121
+ (devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()]
4122
+ if (
4123
+ rejected_bench.returncode != 1 or diagnostic not in rejected_bench.stderr
4124
+ or bench_marker.exists()
4125
+ or (phase == "verify_mechanical" and
4126
+ (devlyn / "spec-verify.results.json").read_bytes() != old_bench_results)
4127
+ or len(rejected_findings) != 1
4128
+ or rejected_findings[0]["rule_id"] != "correctness.spec-verify-malformed"
4129
+ or rejected_findings[0]["severity"] != "CRITICAL"
4130
+ or rejected_findings[0]["phase"] != phase
4131
+ ):
4132
+ print(f"benchmark {phase} failed closed incorrectly for {carrier}: {rejected_bench.stderr}", file=sys.stderr)
4133
+ return 1
4134
+
3810
4135
  verify_output = work / "verify-output"
3811
4136
  verify_output.mkdir()
3812
4137
  verify_devlyn = verify_output / ".devlyn"
@@ -4442,6 +4767,84 @@ def run_self_test() -> int:
4442
4767
  print("atomic_batch_state without success-order evidence was accepted", file=sys.stderr)
4443
4768
  return 1
4444
4769
 
4770
+ # Literal diff paths must survive Git quoting, renames and both consumers.
4771
+ literal_root = work / "literal-paths"
4772
+ literal_root.mkdir()
4773
+ literal_devlyn = literal_root / ".devlyn"
4774
+ literal_devlyn.mkdir()
4775
+ (literal_root / ".gitignore").write_text(".devlyn/\n", encoding="utf-8")
4776
+ literal_names = ["plain.txt", "설정.txt", "my file.txt"]
4777
+ if os.name != "nt":
4778
+ literal_names += ["a\tb.txt", "a\nb.txt", " leading.txt", "trailing.txt ", 'quote".txt', "back\\slash.txt", "literal*.txt"]
4779
+ for name in literal_names + ["old name.txt"]:
4780
+ (literal_root / name).write_text("original\n", encoding="utf-8")
4781
+ subprocess.run(["git", "init", "-q"], cwd=literal_root, check=True)
4782
+ subprocess.run(["git", "add", "-A"], cwd=literal_root, check=True)
4783
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "base"], cwd=literal_root, check=True)
4784
+ for name in literal_names:
4785
+ (literal_root / name).write_text("updated\n", encoding="utf-8")
4786
+ subprocess.run(["git", "mv", "old name.txt", "new name.txt"], cwd=literal_root, check=True)
4787
+ literal_names += ["old name.txt", "new name.txt"]
4788
+ literal_state = {"base_ref": {"sha": "HEAD"}}
4789
+ literal_expected = {"forbidden_files": literal_names}
4790
+ (literal_devlyn / "untracked.baseline").write_text("", encoding="utf-8")
4791
+ (literal_devlyn / "plan.md").write_text(
4792
+ "<!-- devlyn:authorized-surface -->\n## Files to touch\n\n```json\n"
4793
+ + json.dumps({"authorized_surface": literal_names}) + "\n```\n", encoding="utf-8",
4794
+ )
4795
+ literal_patch = subprocess.check_output(
4796
+ ["git", "diff", "--binary", "--src-prefix=a/", "--dst-prefix=b/", "HEAD"], cwd=literal_root,
4797
+ )
4798
+ literal_external = literal_devlyn / "external-diff.patch"
4799
+ for external in (False, True):
4800
+ if external:
4801
+ literal_external.write_bytes(literal_patch)
4802
+ hits, _ = expected_contract_findings(literal_expected, None, literal_root, literal_devlyn, literal_state, 1)
4803
+ assert len(hits) == len(literal_names) and all(hit["rule_id"] == "scope.forbidden-file-touched" for hit in hits), hits
4804
+ scope_hits, _ = authorized_surface_findings(literal_root, literal_devlyn, literal_state, 1)
4805
+ assert not scope_hits, scope_hits
4806
+ edit = b"diff --git a/plain.txt b/plain.txt\n--- a/plain.txt\n+++ b/plain.txt\n@@ -1 +1 @@\n-original\n+updated\n"
4807
+ (literal_devlyn / "plan.md").write_text(
4808
+ '<!-- devlyn:authorized-surface -->\n## Files to touch\n\n```json\n{"authorized_surface":["untouched.txt"]}\n```\n', encoding="utf-8",
4809
+ )
4810
+ literal_external.write_bytes(edit)
4811
+ scope_hits, _ = authorized_surface_findings(literal_root, literal_devlyn, literal_state, 1)
4812
+ assert len(scope_hits) == 1 and scope_hits[0]["rule_id"] == "scope.out-of-scope-file", scope_hits
4813
+ literal_external.write_bytes(edit.replace(b"+updated", b"+updated "))
4814
+ subprocess.run(["git", "config", "apply.whitespace", "error"], cwd=literal_root, check=True)
4815
+ try:
4816
+ names, error = changed_files(literal_root, literal_state, literal_devlyn)
4817
+ assert names == ["plain.txt"] and error is None, (names, error)
4818
+ finally:
4819
+ subprocess.run(["git", "config", "--unset", "apply.whitespace"], cwd=literal_root, check=True)
4820
+ for patch, paths in (
4821
+ (b"", []),
4822
+ (edit.replace(b"\n", b"\r\n"), ["plain.txt"]),
4823
+ (b"diff --git a/logo.png b/logo.png\nindex 1111111..2222222 100644\nBinary files a/logo.png and b/logo.png differ\n", ["logo.png"]),
4824
+ (b"diff --git a/script b/script\nold mode 100644\nnew mode 100755\n", ["script"]),
4825
+ (b"diff --git a/keep.py b/new.py\nsimilarity index 100%\ncopy from keep.py\ncopy to new.py\n", ["new.py", "keep.py"]),
4826
+ ):
4827
+ literal_external.write_bytes(patch)
4828
+ names, error = changed_files(literal_root, literal_state, literal_devlyn)
4829
+ assert names == paths and error is None, (names, error)
4830
+ for bad_patch in (
4831
+ b"not a patch\n", b"\n", edit[:-5],
4832
+ edit.replace(b"a/plain.txt", b"plain.txt").replace(b"b/plain.txt", b"plain.txt"),
4833
+ edit.replace(b"a/plain.txt", b"src/plain.txt").replace(b"b/plain.txt", b"src/plain.txt"),
4834
+ edit.replace(b"a/plain.txt", b"i/plain.txt").replace(b"b/plain.txt", b"w/plain.txt"),
4835
+ ):
4836
+ literal_external.write_bytes(bad_patch)
4837
+ hits, _ = expected_contract_findings(literal_expected, None, literal_root, literal_devlyn, literal_state, 1)
4838
+ assert len(hits) == 1 and hits[0]["rule_id"] == "correctness.expected-contract-unverifiable", hits
4839
+ scope_hits, _ = authorized_surface_findings(literal_root, literal_devlyn, literal_state, 1)
4840
+ assert len(scope_hits) == 1 and scope_hits[0]["file"] == ".devlyn/external-diff.patch", scope_hits
4841
+ literal_external.unlink()
4842
+ names, error = changed_files(literal_root, {"base_ref": {"sha": "missing-literal-ref"}}, literal_devlyn)
4843
+ assert not names and error, (names, error)
4844
+ assert subprocess.check_output(
4845
+ ["git", "diff", "--binary", "--src-prefix=a/", "--dst-prefix=b/", "HEAD"], cwd=literal_root,
4846
+ ) == literal_patch
4847
+
4445
4848
  # iter-0046: PLAN-declared authorized_surface enforced at BUILD_GATE.
4446
4849
  scope_root = work / "scope-gate"
4447
4850
  scope_root.mkdir()
@@ -4856,7 +5259,8 @@ def main() -> int:
4856
5259
  # continue to legacy source-extract.
4857
5260
  # 3. Source-extract reads
4858
5261
  # `pipeline.state.json:source.{spec_path | criteria_path}`. If it has
4859
- # a json block, overwrite .devlyn/spec-verify.json with it.
5262
+ # a json block, stage its commands or clear stale staging for an
5263
+ # explicit pure-design contract.
4860
5264
  # 4. If source has no json block AND source.type=="generated":
4861
5265
  # CRITICAL spec-verify-malformed — generated criteria must ship a
4862
5266
  # verifiable contract per the generated-criteria output contract.
@@ -4871,6 +5275,39 @@ def main() -> int:
4871
5275
  trust_bench_staged = bench_mode and pre_staged
4872
5276
  src_type, source_md = read_source(work, devlyn_dir)
4873
5277
  state = read_state(devlyn_dir)
5278
+
5279
+ def reject_preflight() -> int:
5280
+ # A completed rejection is not an interrupted checker. Preserve actual
5281
+ # earlier gate observations without inventing an executed command.
5282
+ if validate_risk_probes_only or output_phase() != "build_gate":
5283
+ return 1
5284
+ runner = process_evidence_module()
5285
+ phase, run_id, round_, relative = mechanical_evidence_identity(state, runner)
5286
+ carrier = None
5287
+ try:
5288
+ if os.path.lexists(work / relative):
5289
+ carrier = runner.validate_manifest(
5290
+ work, relative, run_id, phase, round_, require_expectations=False,
5291
+ )
5292
+ findings = devlyn_dir / output_findings_name()
5293
+ result = {
5294
+ "commands": runner.bound_carrier_summary_commands(work, carrier) if carrier else [],
5295
+ "process_evidence": carrier,
5296
+ "preflight_failure": {
5297
+ "run_id": run_id, "phase": phase, "round": round_,
5298
+ "findings": {
5299
+ "path": findings.relative_to(work).as_posix(),
5300
+ "sha256": hashlib.sha256(findings.read_bytes()).hexdigest(),
5301
+ },
5302
+ },
5303
+ }
5304
+ (devlyn_dir / "spec-verify.results.json").write_text(
5305
+ json.dumps(result, indent=2) + "\n", encoding="utf-8",
5306
+ )
5307
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
5308
+ print(f"[spec-verify] rejection evidence could not be finalized: {exc}", file=sys.stderr)
5309
+ return 1
5310
+
4874
5311
  external_diff = devlyn_dir / "external-diff.patch"
4875
5312
  if external_diff.is_file() and state.get("mode") != "verify-only":
4876
5313
  error = (
@@ -4888,14 +5325,15 @@ def main() -> int:
4888
5325
  "intentionally verifying an external patch."
4889
5326
  ),
4890
5327
  )
4891
- return 1
5328
+ return reject_preflight()
4892
5329
  integrity_error = source_integrity_error(src_type, state, source_md)
4893
5330
  if integrity_error:
4894
5331
  print(f"[spec-verify] carrier malformed: {integrity_error}", file=sys.stderr)
4895
5332
  write_malformed_finding(devlyn_dir, integrity_error, source_md)
4896
- return 1
5333
+ return reject_preflight()
4897
5334
  expected_data: dict | None = None
4898
5335
  expected_path: Path | None = None
5336
+ contract_found = False
4899
5337
  if validate_risk_probes_only:
4900
5338
  _risk_probes, risk_error = load_risk_probes(
4901
5339
  devlyn_dir, source_md, require_present=True
@@ -4903,29 +5341,29 @@ def main() -> int:
4903
5341
  if risk_error:
4904
5342
  print(f"[spec-verify] risk probes malformed: {risk_error}", file=sys.stderr)
4905
5343
  write_malformed_finding(devlyn_dir, risk_error, devlyn_dir / "risk-probes.jsonl")
4906
- return 1
5344
+ return reject_preflight()
4907
5345
  print("[spec-verify] risk probes valid", file=sys.stderr)
4908
5346
  return 0
4909
5347
  if source_md is not None and not trust_bench_staged:
4910
5348
  if src_type == "spec":
4911
- expected_found, expected_staged, expected_error, expected_path, expected_data = stage_from_expected(
5349
+ contract_found, _staged, expected_error, expected_path, expected_data = stage_from_expected(
4912
5350
  source_md, devlyn_dir
4913
5351
  )
4914
5352
  if expected_error is not None:
4915
5353
  print(f"[spec-verify] carrier malformed: {expected_error}", file=sys.stderr)
4916
5354
  write_malformed_finding(devlyn_dir, expected_error, expected_path)
4917
- return 1
4918
- if expected_staged:
4919
- staged, error = (True, None)
5355
+ return reject_preflight()
5356
+ if contract_found:
5357
+ error = None
4920
5358
  else:
4921
- staged, error = stage_from_source(source_md, devlyn_dir)
5359
+ contract_found, _staged, error = stage_from_source(source_md, devlyn_dir)
4922
5360
  else:
4923
- staged, error = stage_from_source(source_md, devlyn_dir)
5361
+ contract_found, _staged, error = stage_from_source(source_md, devlyn_dir)
4924
5362
  if error is not None:
4925
5363
  print(f"[spec-verify] carrier malformed: {error}", file=sys.stderr)
4926
5364
  write_malformed_finding(devlyn_dir, error, source_md)
4927
- return 1
4928
- if not staged:
5365
+ return reject_preflight()
5366
+ if not contract_found:
4929
5367
  if src_type == "generated":
4930
5368
  msg = (
4931
5369
  f"generated {source_md.name} must include a "
@@ -4934,9 +5372,9 @@ def main() -> int:
4934
5372
  )
4935
5373
  print(f"[spec-verify] {msg}", file=sys.stderr)
4936
5374
  write_malformed_finding(devlyn_dir, msg, source_md)
4937
- return 1
5375
+ return reject_preflight()
4938
5376
  # source.type=="spec", no block in spec markdown.
4939
- if not bench_mode and expected_data is None:
5377
+ if not bench_mode:
4940
5378
  # Real-user handwritten spec: silent no-op. Drop any stale
4941
5379
  # pre-staged file so a killed prior run cannot poison this
4942
5380
  # run's gate.
@@ -4958,11 +5396,10 @@ def main() -> int:
4958
5396
 
4959
5397
  commands: list[dict] = []
4960
5398
  if not spec_path.exists():
4961
- # No source markdown carrier AND no pre-staged file. Silent no-op
4962
- # for benchmark misconfigurations (no fixture to gate against) and
4963
- # for real-user runs without spec/criteria. Generated source case
4964
- # is handled above.
4965
- if expected_data is None:
5399
+ # A declared pure-design contract continues through probes/results.
5400
+ # Missing handwritten/benchmark contracts remain an opt-in no-op;
5401
+ # missing generated contracts were rejected above.
5402
+ if not contract_found:
4966
5403
  return 0
4967
5404
  else:
4968
5405
  try:
@@ -4971,27 +5408,26 @@ def main() -> int:
4971
5408
  print(f"[spec-verify] error: cannot parse {spec_path}: {e}", file=sys.stderr)
4972
5409
  return 2
4973
5410
 
4974
- # iter-0019.8 (Codex R2 #2): apply full shape validation to pre-staged
4975
- # carriers too — bool exit_code, empty list, whitespace-only cmd were
4976
- # silently accepted on the benchmark path. Empty list is rejected
4977
- # because "all 0 commands passed" is vacuously true.
4978
- shape_err = validate_shape(spec)
5411
+ # Pre-staged commands need the strict carrier vocabulary too: unknown
5412
+ # expectations were silently ignored. Keep the nonempty shape gate;
5413
+ # a benchmark's staged empty list must not become a pure-design pass.
5414
+ shape_err = validate_shape(spec) or validate_inline_shape(spec)
4979
5415
  if shape_err:
4980
5416
  print(f"[spec-verify] error: {spec_path}: {shape_err}", file=sys.stderr)
4981
5417
  write_malformed_finding(devlyn_dir, f"{spec_path}: {shape_err}", None)
4982
- return 1
5418
+ return reject_preflight()
4983
5419
  commands = list(spec["verification_commands"])
4984
5420
  if include_risk_probes:
4985
5421
  risk_state_error = risk_probes_state_error(state)
4986
5422
  if risk_state_error:
4987
5423
  print(f"[spec-verify] risk probes malformed: {risk_state_error}", file=sys.stderr)
4988
5424
  write_malformed_finding(devlyn_dir, risk_state_error, Path("pipeline.state.json"))
4989
- return 1
5425
+ return reject_preflight()
4990
5426
  integrity_error = risk_probe_integrity_error(state, devlyn_dir)
4991
5427
  if integrity_error:
4992
5428
  print(f"[spec-verify] risk probes integrity failed: {integrity_error}", file=sys.stderr)
4993
5429
  write_risk_probe_integrity_finding(devlyn_dir, integrity_error)
4994
- return 1
5430
+ return reject_preflight()
4995
5431
  risk_probes, risk_error = load_risk_probes(
4996
5432
  devlyn_dir,
4997
5433
  source_md,
@@ -5000,7 +5436,7 @@ def main() -> int:
5000
5436
  if risk_error:
5001
5437
  print(f"[spec-verify] risk probes malformed: {risk_error}", file=sys.stderr)
5002
5438
  write_malformed_finding(devlyn_dir, risk_error, devlyn_dir / "risk-probes.jsonl")
5003
- return 1
5439
+ return reject_preflight()
5004
5440
  commands.extend(risk_probes)
5005
5441
 
5006
5442
  devlyn_dir.mkdir(parents=True, exist_ok=True)