devlyn-cli 3.1.2 → 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`.
@@ -846,14 +846,21 @@ def validate_expected_shape(data) -> str | None:
846
846
 
847
847
  def validate_inline_shape(data: object) -> str | None:
848
848
  if isinstance(data, dict):
849
- unknown = sorted(set(data) - {"verification_commands"})
849
+ unknown = sorted(set(data) - {"verification_commands", "pure_design"})
850
850
  if unknown:
851
851
  return (
852
852
  f"unsupported inline key(s): {', '.join(unknown)}; inline carriers "
853
- "support only verification_commands. Encode these checks as commands, "
853
+ "support only verification_commands and pure_design. Encode these checks as commands, "
854
854
  "or use a real spec with sibling spec.expected.json."
855
855
  )
856
- return validate_shape(data) or validate_expected_shape(data)
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)
857
864
 
858
865
 
859
866
  def validate_expected_against_sibling_spec(spec_path: Path, data: object) -> str | None:
@@ -1159,9 +1166,11 @@ def risk_probes_state_error(state: dict) -> str | None:
1159
1166
 
1160
1167
 
1161
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 {}
1162
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}."
1163
1173
  return None
1164
- src = state.get("source") if isinstance(state.get("source"), dict) else {}
1165
1174
  if src_type == "generated":
1166
1175
  field = "criteria_sha256"
1167
1176
  required = True
@@ -1198,33 +1207,33 @@ def load_expected_contract(expected_path: Path) -> tuple[dict | None, str | None
1198
1207
  return (data, None)
1199
1208
 
1200
1209
 
1201
- 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]:
1202
1211
  """Materialize .devlyn/spec-verify.json from the json block in `md`.
1203
1212
 
1204
- Returns (staged, error). staged=True → wrote spec-verify.json. error
1205
- non-None → carrier was found but malformed (caller emits CRITICAL) —
1206
- this now also covers a `<!-- devlyn:verification -->` sentinel present
1207
- with no fenced ```json``` block inside it (the author clearly intended a
1208
- contract; a missing fence is a mistake, not a no-op). staged=False,
1209
- error=None → the sentinel is absent entirely (handwritten spec or
1210
- 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.
1211
1217
  """
1212
1218
  section_found, block = extract_verification_block(md.read_text(encoding="utf-8"))
1213
1219
  if not section_found:
1214
- return (False, None)
1220
+ return (False, False, None)
1215
1221
  if block is None:
1216
- 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")
1217
1223
  try:
1218
1224
  data = loads_strict_json(block)
1219
1225
  except ValueError as e:
1220
- return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1226
+ return (True, False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1221
1227
  err = validate_inline_shape(data)
1222
1228
  if err:
1223
- 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)
1224
1233
  normalized = {"verification_commands": data["verification_commands"]}
1225
1234
  devlyn_dir.mkdir(parents=True, exist_ok=True)
1226
1235
  (devlyn_dir / "spec-verify.json").write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
1227
- return (True, None)
1236
+ return (True, True, None)
1228
1237
 
1229
1238
 
1230
1239
  def stage_from_expected(
@@ -1335,7 +1344,7 @@ def diff_text_for_expected(work: Path, devlyn_dir: Path, state: dict) -> tuple[s
1335
1344
  external_diff = devlyn_dir / "external-diff.patch"
1336
1345
  if external_diff.is_file():
1337
1346
  try:
1338
- return (external_diff.read_text(encoding="utf-8"), None)
1347
+ return (external_diff.read_text(encoding="utf-8", errors="surrogateescape"), None)
1339
1348
  except OSError as e:
1340
1349
  return ("", f"cannot read {external_diff}: {e}")
1341
1350
  base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
@@ -1374,28 +1383,46 @@ def count_deps_added(work: Path, state: dict) -> int:
1374
1383
  return count
1375
1384
 
1376
1385
 
1377
- 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]:
1378
1387
  external_diff = devlyn_dir / "external-diff.patch"
1379
1388
  if external_diff.is_file():
1380
1389
  names: list[str] = []
1381
1390
  try:
1382
- external_text = external_diff.read_text(encoding="utf-8")
1383
- except OSError:
1384
- return []
1385
- for line in external_text.splitlines():
1386
- if line.startswith("diff --git "):
1387
- parts = line.split()
1388
- if len(parts) >= 4:
1389
- names.append(parts[3].removeprefix("b/"))
1390
- 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)
1391
1418
  base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
1392
- cmd = ["git", "diff", "--name-only"]
1419
+ cmd = ["git", "diff", "--name-only", "-z", "--no-renames"]
1393
1420
  if base_sha:
1394
1421
  cmd.append(base_sha)
1395
- 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)
1396
1423
  if proc.returncode != 0:
1397
- return []
1398
- 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)
1399
1426
 
1400
1427
 
1401
1428
  def expected_contract_findings(
@@ -1411,6 +1438,8 @@ def expected_contract_findings(
1411
1438
  findings: list[dict] = []
1412
1439
  seq = finding_start
1413
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
1414
1443
  if diff_error and (
1415
1444
  expected_data.get("forbidden_patterns") or expected_data.get("forbidden_files")
1416
1445
  ):
@@ -1420,12 +1449,12 @@ def expected_contract_findings(
1420
1449
  "level": "error",
1421
1450
  "severity": "CRITICAL",
1422
1451
  "confidence": 1.0,
1423
- "message": f"Cannot compute diff for forbidden_patterns: {diff_error}",
1452
+ "message": f"Cannot compute diff for expected contract: {diff_error}",
1424
1453
  "file": str(expected_path or "spec.expected.json"),
1425
1454
  "line": 1,
1426
1455
  "phase": output_phase(),
1427
- "criterion_ref": "spec.expected.json/forbidden_patterns",
1428
- "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.",
1429
1458
  "blocking": True,
1430
1459
  "status": "open",
1431
1460
  })
@@ -1451,7 +1480,7 @@ def expected_contract_findings(
1451
1480
  "status": "open",
1452
1481
  })
1453
1482
  seq += 1
1454
- changed = set(changed_files(work, state, devlyn_dir))
1483
+ changed = set(paths)
1455
1484
  for i, required in enumerate(expected_data.get("required_files", []) or []):
1456
1485
  if (work / required).exists():
1457
1486
  continue
@@ -1770,7 +1799,16 @@ def authorized_surface_findings(
1770
1799
 
1771
1800
  findings: list[dict] = []
1772
1801
  seq = finding_start
1773
- 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:
1774
1812
  if path_matches_surface(path, surface):
1775
1813
  continue
1776
1814
  findings.append(scope_finding(
@@ -1859,13 +1897,8 @@ def run_write_untracked_baseline(work: Path, devlyn_dir: Path) -> int:
1859
1897
 
1860
1898
 
1861
1899
  def run_check_mode(md_path: Path) -> int:
1862
- """`--check <markdown>` — validate the verification carrier without
1863
- running any commands. Used by /devlyn:ideate after item-spec write.
1864
-
1865
- Exit 0: `<!-- devlyn:verification -->` sentinel absent OR section present
1866
- and well-formed.
1867
- Exit 2: sentinel present but the section is malformed — no fenced json
1868
- 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.
1869
1902
  """
1870
1903
  if not md_path.is_file():
1871
1904
  print(f"[spec-verify --check] error: {md_path} not found", file=sys.stderr)
@@ -1883,6 +1916,14 @@ def run_check_mode(md_path: Path) -> int:
1883
1916
  if solo_ceiling_err:
1884
1917
  print(f"[spec-verify --check] {md_path}: {solo_ceiling_err}", file=sys.stderr)
1885
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
1886
1927
  section_found, block = extract_verification_block(text)
1887
1928
  if not section_found:
1888
1929
  # Sentinel absent entirely — opt-in nature preserved for ideate (a
@@ -2060,7 +2101,7 @@ def run_self_test() -> int:
2060
2101
  + "\n```\n",
2061
2102
  encoding="utf-8",
2062
2103
  )
2063
- staged, inline_error = stage_from_source(
2104
+ _found, staged, inline_error = stage_from_source(
2064
2105
  inline_timeout_spec, inline_timeout_devlyn
2065
2106
  )
2066
2107
  if staged or not inline_error or "timeout_sec" not in inline_error:
@@ -2080,7 +2121,7 @@ def run_self_test() -> int:
2080
2121
  + "\n```\n",
2081
2122
  encoding="utf-8",
2082
2123
  )
2083
- staged, inline_error = stage_from_source(
2124
+ _found, staged, inline_error = stage_from_source(
2084
2125
  inline_timeout_spec, inline_timeout_devlyn
2085
2126
  )
2086
2127
  inline_staged = loads_strict_json(
@@ -3641,6 +3682,56 @@ def run_self_test() -> int:
3641
3682
  "```\n",
3642
3683
  encoding="utf-8",
3643
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)
3644
3735
  (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3645
3736
  "source": {"type": "generated", "criteria_path": str(generated_criteria)}
3646
3737
  }), encoding="utf-8")
@@ -3796,6 +3887,13 @@ def run_self_test() -> int:
3796
3887
  (real_devlyn / "pipeline.state.json").write_text(json.dumps({
3797
3888
  "source": {"type": "spec", "spec_path": str(real_spec)}
3798
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
3799
3897
  sibling_run = subprocess.run(
3800
3898
  [sys.executable, script_path],
3801
3899
  cwd=real_user,
@@ -3814,51 +3912,137 @@ def run_self_test() -> int:
3814
3912
  pure_prose = "# Design\n\n<!-- devlyn:verification -->\n## Verification\n\n- No runtime verification commands.\n"
3815
3913
  stale_command = {"verification_commands": [{"cmd": "printf unexpected-command"}]}
3816
3914
  pure_inline = pure_prose + "\n```json\n" + json.dumps(stale_command) + "\n```\n"
3817
- for name, filename, source, contract, expected_rule in (
3818
- ("pure-prose", "spec.md", pure_prose, {"pure_design": True}, None),
3819
- ("pure-inline", "design-notes.md", pure_inline, {"pure_design": True, "verification_commands": []}, None),
3820
- ("empty-runtime", "design-notes.md", pure_inline, {"verification_commands": []}, "correctness.spec-verify-malformed"),
3821
- ("contradictory-pure", "spec.md", pure_inline, {"pure_design": True, **stale_command}, "correctness.spec-verify-malformed"),
3822
- ("pure-required-file", "spec.md", pure_inline, {"pure_design": True, "required_files": ["missing.md"]}, "correctness.required-file-missing"),
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"),
3823
3932
  ):
3824
3933
  case_root = work / name
3825
3934
  case_root.mkdir()
3826
3935
  case_devlyn = case_root / ".devlyn"
3827
3936
  case_devlyn.mkdir()
3828
3937
  case_spec = case_root / filename
3938
+ if isinstance(source, dict):
3939
+ source = pure_prose + "\n```json\n" + json.dumps(source) + "\n```\n"
3829
3940
  case_spec.write_text(source, encoding="utf-8")
3830
- (case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
3941
+ if contract is not None:
3942
+ (case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
3831
3943
  (case_devlyn / "spec-verify.json").write_text(json.dumps(stale_command), encoding="utf-8")
3944
+ pointer = "criteria" if source_type == "generated" else "spec"
3832
3945
  (case_devlyn / "pipeline.state.json").write_text(json.dumps({
3833
- "source": {"type": "spec", "spec_path": str(case_spec)},
3946
+ "source": {"type": source_type, pointer + "_path": str(case_spec),
3947
+ pointer + "_sha256": hashlib.sha256(case_spec.read_bytes()).hexdigest()},
3834
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
3835
3956
  case_run = subprocess.run(
3836
3957
  [sys.executable, script_path], cwd=case_root,
3837
3958
  capture_output=True, text=True, encoding="utf-8",
3838
3959
  )
3839
3960
  if case_run.returncode != (1 if expected_rule else 0):
3840
- print(f"{name}: sibling execution returned {case_run.returncode}: {case_run.stderr}", file=sys.stderr)
3961
+ print(f"{name}: contract execution returned {case_run.returncode}: {case_run.stderr}", file=sys.stderr)
3841
3962
  return 1
3842
3963
  case_findings = [
3843
3964
  loads_strict_json(line) for line in
3844
3965
  (case_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()
3845
3966
  ]
3846
3967
  if [finding["rule_id"] for finding in case_findings] != ([expected_rule] if expected_rule else []):
3847
- print(f"{name}: unexpected sibling findings: {case_findings}", file=sys.stderr)
3968
+ print(f"{name}: unexpected contract findings: {case_findings}", file=sys.stderr)
3848
3969
  return 1
3849
3970
  case_results = case_devlyn / "spec-verify.results.json"
3850
3971
  if expected_rule == "correctness.spec-verify-malformed":
3851
- if case_results.exists():
3852
- print(f"{name}: malformed sibling reached command execution", file=sys.stderr)
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)
3853
3976
  return 1
3854
3977
  elif (
3855
3978
  loads_strict_json(case_results.read_text(encoding="utf-8"))
3856
3979
  != {"commands": [], "process_evidence": None}
3857
3980
  or (case_devlyn / "spec-verify.json").exists()
3858
3981
  ):
3859
- print(f"{name}: pure-design sibling ran or retained a stale command", file=sys.stderr)
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)
3860
4000
  return 1
3861
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
+
3862
4046
  malformed = work / "malformed-sibling"
3863
4047
  malformed.mkdir()
3864
4048
  malformed_devlyn = malformed / ".devlyn"
@@ -3897,7 +4081,7 @@ def run_self_test() -> int:
3897
4081
  }), encoding="utf-8")
3898
4082
  (devlyn / "spec-verify.json").write_text(json.dumps({
3899
4083
  "verification_commands": [
3900
- {"cmd": "printf bench-staged", "stdout_contains": ["bench-staged"]}
4084
+ {"cmd": "printf bench-staged", "stdout_contains": ["bench-staged"], "contract_refs": ["R1"]}
3901
4085
  ]
3902
4086
  }) + "\n", encoding="utf-8")
3903
4087
  bench_pre_staged = subprocess.run(
@@ -3916,6 +4100,38 @@ def run_self_test() -> int:
3916
4100
  print("benchmark pre-staged contract was overwritten", file=sys.stderr)
3917
4101
  return 1
3918
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
+
3919
4135
  verify_output = work / "verify-output"
3920
4136
  verify_output.mkdir()
3921
4137
  verify_devlyn = verify_output / ".devlyn"
@@ -4551,6 +4767,84 @@ def run_self_test() -> int:
4551
4767
  print("atomic_batch_state without success-order evidence was accepted", file=sys.stderr)
4552
4768
  return 1
4553
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
+
4554
4848
  # iter-0046: PLAN-declared authorized_surface enforced at BUILD_GATE.
4555
4849
  scope_root = work / "scope-gate"
4556
4850
  scope_root.mkdir()
@@ -4965,7 +5259,8 @@ def main() -> int:
4965
5259
  # continue to legacy source-extract.
4966
5260
  # 3. Source-extract reads
4967
5261
  # `pipeline.state.json:source.{spec_path | criteria_path}`. If it has
4968
- # 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.
4969
5264
  # 4. If source has no json block AND source.type=="generated":
4970
5265
  # CRITICAL spec-verify-malformed — generated criteria must ship a
4971
5266
  # verifiable contract per the generated-criteria output contract.
@@ -4980,6 +5275,39 @@ def main() -> int:
4980
5275
  trust_bench_staged = bench_mode and pre_staged
4981
5276
  src_type, source_md = read_source(work, devlyn_dir)
4982
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
+
4983
5311
  external_diff = devlyn_dir / "external-diff.patch"
4984
5312
  if external_diff.is_file() and state.get("mode") != "verify-only":
4985
5313
  error = (
@@ -4997,14 +5325,15 @@ def main() -> int:
4997
5325
  "intentionally verifying an external patch."
4998
5326
  ),
4999
5327
  )
5000
- return 1
5328
+ return reject_preflight()
5001
5329
  integrity_error = source_integrity_error(src_type, state, source_md)
5002
5330
  if integrity_error:
5003
5331
  print(f"[spec-verify] carrier malformed: {integrity_error}", file=sys.stderr)
5004
5332
  write_malformed_finding(devlyn_dir, integrity_error, source_md)
5005
- return 1
5333
+ return reject_preflight()
5006
5334
  expected_data: dict | None = None
5007
5335
  expected_path: Path | None = None
5336
+ contract_found = False
5008
5337
  if validate_risk_probes_only:
5009
5338
  _risk_probes, risk_error = load_risk_probes(
5010
5339
  devlyn_dir, source_md, require_present=True
@@ -5012,29 +5341,29 @@ def main() -> int:
5012
5341
  if risk_error:
5013
5342
  print(f"[spec-verify] risk probes malformed: {risk_error}", file=sys.stderr)
5014
5343
  write_malformed_finding(devlyn_dir, risk_error, devlyn_dir / "risk-probes.jsonl")
5015
- return 1
5344
+ return reject_preflight()
5016
5345
  print("[spec-verify] risk probes valid", file=sys.stderr)
5017
5346
  return 0
5018
5347
  if source_md is not None and not trust_bench_staged:
5019
5348
  if src_type == "spec":
5020
- 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(
5021
5350
  source_md, devlyn_dir
5022
5351
  )
5023
5352
  if expected_error is not None:
5024
5353
  print(f"[spec-verify] carrier malformed: {expected_error}", file=sys.stderr)
5025
5354
  write_malformed_finding(devlyn_dir, expected_error, expected_path)
5026
- return 1
5027
- if expected_found:
5028
- staged, error = (expected_staged, None)
5355
+ return reject_preflight()
5356
+ if contract_found:
5357
+ error = None
5029
5358
  else:
5030
- staged, error = stage_from_source(source_md, devlyn_dir)
5359
+ contract_found, _staged, error = stage_from_source(source_md, devlyn_dir)
5031
5360
  else:
5032
- staged, error = stage_from_source(source_md, devlyn_dir)
5361
+ contract_found, _staged, error = stage_from_source(source_md, devlyn_dir)
5033
5362
  if error is not None:
5034
5363
  print(f"[spec-verify] carrier malformed: {error}", file=sys.stderr)
5035
5364
  write_malformed_finding(devlyn_dir, error, source_md)
5036
- return 1
5037
- if not staged:
5365
+ return reject_preflight()
5366
+ if not contract_found:
5038
5367
  if src_type == "generated":
5039
5368
  msg = (
5040
5369
  f"generated {source_md.name} must include a "
@@ -5043,9 +5372,9 @@ def main() -> int:
5043
5372
  )
5044
5373
  print(f"[spec-verify] {msg}", file=sys.stderr)
5045
5374
  write_malformed_finding(devlyn_dir, msg, source_md)
5046
- return 1
5375
+ return reject_preflight()
5047
5376
  # source.type=="spec", no block in spec markdown.
5048
- if not bench_mode and expected_data is None:
5377
+ if not bench_mode:
5049
5378
  # Real-user handwritten spec: silent no-op. Drop any stale
5050
5379
  # pre-staged file so a killed prior run cannot poison this
5051
5380
  # run's gate.
@@ -5067,11 +5396,10 @@ def main() -> int:
5067
5396
 
5068
5397
  commands: list[dict] = []
5069
5398
  if not spec_path.exists():
5070
- # No source markdown carrier AND no pre-staged file. Silent no-op
5071
- # for benchmark misconfigurations (no fixture to gate against) and
5072
- # for real-user runs without spec/criteria. Generated source case
5073
- # is handled above.
5074
- 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:
5075
5403
  return 0
5076
5404
  else:
5077
5405
  try:
@@ -5080,27 +5408,26 @@ def main() -> int:
5080
5408
  print(f"[spec-verify] error: cannot parse {spec_path}: {e}", file=sys.stderr)
5081
5409
  return 2
5082
5410
 
5083
- # iter-0019.8 (Codex R2 #2): apply full shape validation to pre-staged
5084
- # carriers too — bool exit_code, empty list, whitespace-only cmd were
5085
- # silently accepted on the benchmark path. Empty list is rejected
5086
- # because "all 0 commands passed" is vacuously true.
5087
- 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)
5088
5415
  if shape_err:
5089
5416
  print(f"[spec-verify] error: {spec_path}: {shape_err}", file=sys.stderr)
5090
5417
  write_malformed_finding(devlyn_dir, f"{spec_path}: {shape_err}", None)
5091
- return 1
5418
+ return reject_preflight()
5092
5419
  commands = list(spec["verification_commands"])
5093
5420
  if include_risk_probes:
5094
5421
  risk_state_error = risk_probes_state_error(state)
5095
5422
  if risk_state_error:
5096
5423
  print(f"[spec-verify] risk probes malformed: {risk_state_error}", file=sys.stderr)
5097
5424
  write_malformed_finding(devlyn_dir, risk_state_error, Path("pipeline.state.json"))
5098
- return 1
5425
+ return reject_preflight()
5099
5426
  integrity_error = risk_probe_integrity_error(state, devlyn_dir)
5100
5427
  if integrity_error:
5101
5428
  print(f"[spec-verify] risk probes integrity failed: {integrity_error}", file=sys.stderr)
5102
5429
  write_risk_probe_integrity_finding(devlyn_dir, integrity_error)
5103
- return 1
5430
+ return reject_preflight()
5104
5431
  risk_probes, risk_error = load_risk_probes(
5105
5432
  devlyn_dir,
5106
5433
  source_md,
@@ -5109,7 +5436,7 @@ def main() -> int:
5109
5436
  if risk_error:
5110
5437
  print(f"[spec-verify] risk probes malformed: {risk_error}", file=sys.stderr)
5111
5438
  write_malformed_finding(devlyn_dir, risk_error, devlyn_dir / "risk-probes.jsonl")
5112
- return 1
5439
+ return reject_preflight()
5113
5440
  commands.extend(risk_probes)
5114
5441
 
5115
5442
  devlyn_dir.mkdir(parents=True, exist_ok=True)