devlyn-cli 4.3.0 → 4.3.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.
Files changed (62) hide show
  1. package/bin/skill-history.json +3 -0
  2. package/config/skills/_shared/harness-store.py +2 -1
  3. package/config/skills/_shared/task-completion.md +6 -3
  4. package/config/skills/_shared/warm-build-dirs.py +97 -2
  5. package/config/skills/devlyn-ideate/scripts/queue.py +10 -1
  6. package/harness/AGENTS.md +37 -0
  7. package/harness/CLAUDE.md +37 -0
  8. package/native/claude/.claude-plugin/plugin.json +6 -0
  9. package/native/claude/skills/_shared/.ruff.toml +2 -0
  10. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  11. package/native/claude/skills/_shared/adapters/codex.md +14 -0
  12. package/native/claude/skills/_shared/adapters/omp.md +6 -0
  13. package/native/claude/skills/_shared/codex-monitored.sh +263 -0
  14. package/native/claude/skills/_shared/engine-doctor.sh +70 -0
  15. package/native/claude/skills/_shared/engine-preflight.md +29 -0
  16. package/native/claude/skills/_shared/expected-contract.py +242 -0
  17. package/native/claude/skills/_shared/expected.schema.json +120 -0
  18. package/native/claude/skills/_shared/harness-store.py +202 -0
  19. package/native/claude/skills/_shared/invocation-receipt.py +499 -0
  20. package/native/claude/skills/_shared/platform-support.py +395 -0
  21. package/native/claude/skills/_shared/role-config.py +247 -0
  22. package/native/claude/skills/_shared/runtime-principles.md +35 -0
  23. package/native/claude/skills/_shared/task-complete.py +2865 -0
  24. package/native/claude/skills/_shared/task-completion.md +249 -0
  25. package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
  26. package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
  27. package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
  28. package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
  29. package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
  30. package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
  31. package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
  32. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
  33. package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
  34. package/native/codex/.agents/plugins/marketplace.json +12 -0
  35. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
  36. package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
  37. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  38. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
  39. package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
  40. package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
  41. package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
  42. package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
  43. package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
  44. package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
  45. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +202 -0
  46. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
  47. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +395 -0
  48. package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
  49. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
  50. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
  51. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
  52. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
  53. package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
  54. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
  55. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
  56. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
  57. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
  58. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
  59. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
  60. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
  61. package/package.json +1 -1
  62. package/release.json +1630 -0
@@ -0,0 +1,882 @@
1
+ #!/usr/bin/env python3
2
+ """Loop acceptance: derive a task result from its committed source and evidence.
3
+
4
+ `run` executes the declared checks on the committed candidate; the executor may
5
+ call it before review. `accept` derives the drain result: it binds the contract,
6
+ requires the owned committed source, reuses the intact outcomes of a wholly clean
7
+ runner result for the same source and contract, executes the rest, evaluates
8
+ file/diff guards, confirms the source is unchanged and checks review records. A
9
+ malformed executor record fails acceptance with its reason. An executor's summary
10
+ verdict is never evidence. Protocol: ../references/loop.md.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import functools
16
+ import hashlib
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ import re
21
+ import runpy
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import unittest
26
+
27
+ SKILLS = Path(__file__).resolve().parents[2]
28
+ LOOP_DIR = ".devlyn/loop"
29
+ QUEUE = "docs/specs/queue.md"
30
+ SHA_RE = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
31
+ BLOCKERS = ("failed", "needs-review", "blocked-infrastructure")
32
+ REVIEW_KEYS = {"schema_version", "kind", "task", "engine", "model", "source_sha", "contract_sha256",
33
+ "expected_sha256", "requirements", "findings"}
34
+ # findings, cleanup, handoff and summary are unread; allow them so earlier-template submissions validate.
35
+ SUBMISSION_KEYS = {"schema_version", "task", "source_sha", "runner_results", "reviews", "findings", "cleanup",
36
+ "handoff", "assumptions", "blockers", "summary"}
37
+ PACKET_KEYS = {"task", "worktree", "branch", "inputs_sha", "allocation_base", "contract", "expected", "meta",
38
+ "requirements", "review_requirements"}
39
+
40
+
41
+ class AcceptanceError(Exception):
42
+ pass
43
+
44
+
45
+ @functools.lru_cache(maxsize=None)
46
+ def shared(name):
47
+ return runpy.run_path(str(SKILLS / "_shared" / f"{name}.py"))
48
+
49
+
50
+ def git(work, *args, ok=(0,)):
51
+ result = subprocess.run(["git", "-C", str(work), *args], capture_output=True)
52
+ if result.returncode not in ok:
53
+ raise AcceptanceError(f"git {' '.join(args[:2])}: {result.stderr.decode('utf-8', 'replace').strip()}")
54
+ return result
55
+
56
+
57
+ def text(work, *args):
58
+ return git(work, *args).stdout.decode("utf-8").strip()
59
+
60
+
61
+ def sha256(raw):
62
+ return hashlib.sha256(raw).hexdigest()
63
+
64
+
65
+ def read_json(path):
66
+ try:
67
+ return shared("expected-contract")["loads_strict_json"](Path(path).read_text(encoding="utf-8"))
68
+ except (OSError, UnicodeError, ValueError) as exc:
69
+ raise AcceptanceError(f"{path}: {exc}") from exc
70
+
71
+
72
+ def atomic_write(path, data):
73
+ path = Path(path)
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
76
+ try:
77
+ with os.fdopen(fd, "wb") as stream:
78
+ stream.write(data)
79
+ os.replace(temporary, path)
80
+ finally:
81
+ Path(temporary).unlink(missing_ok=True)
82
+
83
+
84
+ def write_json(path, value):
85
+ atomic_write(path, (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8"))
86
+
87
+
88
+ def load_packet(path):
89
+ packet = read_json(path)
90
+ if not isinstance(packet, dict) or PACKET_KEYS - set(packet):
91
+ raise AcceptanceError(f"{path}: task packet lacks {sorted(PACKET_KEYS - set(packet if isinstance(packet, dict) else {}))}")
92
+ helper = shared("harness-store")
93
+ try:
94
+ helper["verify_packet"](packet, path, SKILLS)
95
+ except (helper["HarnessError"], OSError) as exc:
96
+ raise AcceptanceError(str(exc)) from exc
97
+ return packet
98
+
99
+
100
+ def contract_digests(packet):
101
+ return {key: packet[key]["sha256"] for key in ("contract", "expected", "meta")}
102
+
103
+
104
+ def relative(work, path):
105
+ return Path(path).resolve().relative_to(Path(work).resolve()).as_posix()
106
+
107
+
108
+ def evidence_file(work, path, under=LOOP_DIR):
109
+ candidate = Path(path) if Path(path).is_absolute() else Path(work) / path
110
+ resolved = candidate.resolve()
111
+ if not resolved.is_relative_to((Path(work) / under).resolve()) or not resolved.is_file():
112
+ raise AcceptanceError(f"evidence must be a file under {under} in the task worktree: {path}")
113
+ return resolved
114
+
115
+
116
+ def bound_contract(packet):
117
+ """Return the committed expected contract after checking every input against the packet digests."""
118
+ work = Path(packet["worktree"])
119
+ raw = {}
120
+ for key in ("contract", "expected", "meta"):
121
+ rel = relative(work, packet[key]["path"])
122
+ raw[key] = git(work, "show", f"{packet['inputs_sha']}:{rel}").stdout
123
+ if sha256(raw[key]) != packet[key]["sha256"]:
124
+ raise AcceptanceError(f"{rel} at the inputs commit differs from the task packet")
125
+ contract = shared("expected-contract")
126
+ try:
127
+ expected = contract["loads_strict_json"](raw["expected"].decode("utf-8"))
128
+ except (UnicodeError, ValueError) as exc:
129
+ raise AcceptanceError(f"expected contract: {exc}") from exc
130
+ error = contract["validate_expected_shape"](expected)
131
+ if error:
132
+ raise AcceptanceError(f"expected contract: {error}")
133
+ return expected
134
+
135
+
136
+ def source_problems(packet, source):
137
+ """The candidate must be the owned task commit, descend from the inputs, leave them untouched and be clean."""
138
+ work = Path(packet["worktree"])
139
+ if not SHA_RE.fullmatch(source) or git(work, "cat-file", "-e", source + "^{commit}", ok=(0, 1, 128)).returncode:
140
+ return [f"candidate {source!r} is not a commit"]
141
+ problems = []
142
+ head = git(work, "symbolic-ref", "-q", "HEAD", ok=(0, 1)).stdout.decode("utf-8").strip()
143
+ branch = text(work, "rev-parse", "refs/heads/" + packet["branch"])
144
+ if (source, head) != (branch, "refs/heads/" + packet["branch"]):
145
+ problems.append(f"candidate {source} is not the owned task commit checked out on its branch (branch {branch}, HEAD {head or 'detached'})")
146
+ if git(work, "merge-base", "--is-ancestor", packet["inputs_sha"], source, ok=(0, 1)).returncode:
147
+ problems.append(f"candidate {source} does not descend from the committed inputs")
148
+ else:
149
+ package = relative(work, packet["meta"]["path"]).rpartition("/")[0] + "/"
150
+ edits = [path for path in changed_paths(work, packet["inputs_sha"], source) if path == QUEUE or path.startswith(package)]
151
+ if edits:
152
+ problems.append("candidate edits loop inputs: " + ", ".join(edits))
153
+ if dirty := git(work, "status", "--porcelain", "--untracked-files=all").stdout.decode("utf-8", "replace").splitlines():
154
+ problems.append("worktree has uncommitted or untracked changes (undeclared source delta): " + ", ".join(line[3:] for line in dirty))
155
+ return problems
156
+
157
+
158
+ def changed_paths(work, old, new):
159
+ raw = git(work, "diff", "--name-only", "-z", "--no-renames", old, new).stdout
160
+ return [path.decode("utf-8", "surrogateescape") for path in raw.split(b"\0") if path]
161
+
162
+
163
+ def evaluate(command, outcome, combined):
164
+ if outcome["kind"] != "exit":
165
+ return False, outcome["kind"]
166
+ expected_exit = command.get("exit_code", 0)
167
+ if outcome["exit_code"] != expected_exit:
168
+ return False, f"exit {outcome['exit_code']}, expected {expected_exit}"
169
+ missing = [value for value in command.get("stdout_contains", []) if value.encode("utf-8") not in combined]
170
+ if missing:
171
+ return False, f"output lacks {missing!r}"
172
+ found = [value for value in command.get("stdout_not_contains", []) if value.encode("utf-8") in combined]
173
+ if found:
174
+ return False, f"output contains forbidden {found!r}"
175
+ return True, None
176
+
177
+
178
+ def execute(work, command, index, run_dir):
179
+ timeout = shared("expected-contract")["verification_timeout_sec"](command)
180
+ env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} # A Python check would leave __pycache__/ in the source it checks.
181
+ try:
182
+ if "argv" in command:
183
+ proc = subprocess.run(shared("platform-support")["native_argv"](command["argv"], cwd=work), cwd=work,
184
+ capture_output=True, timeout=timeout, env=env)
185
+ else:
186
+ proc = subprocess.run(command["cmd"], cwd=work, shell=True, capture_output=True, timeout=timeout, env=env)
187
+ out, err = proc.stdout, proc.stderr
188
+ outcome = ({"kind": "exit", "exit_code": proc.returncode} if proc.returncode >= 0
189
+ else {"kind": "signal", "signal": -proc.returncode})
190
+ except subprocess.TimeoutExpired as exc:
191
+ out, err, outcome = exc.stdout or b"", exc.stderr or b"", {"kind": "timeout", "timeout_sec": timeout}
192
+ except OSError as exc:
193
+ out, err, outcome = b"", str(exc).encode("utf-8"), {"kind": "spawn_error", "error": str(exc)}
194
+ record = {"index": index, "outcome": outcome}
195
+ for name, raw in (("stdout", out), ("stderr", err)):
196
+ path = run_dir / f"cmd-{index}.{name}"
197
+ path.write_bytes(raw)
198
+ record[name] = {"path": relative(work, path), "sha256": sha256(raw), "bytes": len(raw)}
199
+ record["passed"], record["reason"] = evaluate(command, outcome, out + err)
200
+ return record
201
+
202
+
203
+ def new_run_dir(work):
204
+ runs = Path(work) / LOOP_DIR / "runs"
205
+ runs.mkdir(parents=True, exist_ok=True)
206
+ number = 1 + max((int(path.name) for path in runs.iterdir() if path.name.isdigit()), default=0)
207
+ run_dir = runs / str(number)
208
+ run_dir.mkdir()
209
+ return run_dir
210
+
211
+
212
+ def intact(work, record):
213
+ try:
214
+ return all(sha256(evidence_file(work, record[name]["path"]).read_bytes()) == record[name]["sha256"]
215
+ for name in ("stdout", "stderr"))
216
+ except (AcceptanceError, KeyError, TypeError, OSError):
217
+ return False
218
+
219
+
220
+ def reusable(packet, source, paths):
221
+ """Passed outcomes of wholly clean runner results (no reasons, so no failed check or source change during
222
+ the checks) for this exact source and contract, with intact raw streams."""
223
+ work = Path(packet["worktree"])
224
+ reused = {}
225
+ for path in paths:
226
+ result_path = evidence_file(work, path)
227
+ result = read_json(result_path)
228
+ if not isinstance(result, dict) or (result.get("kind"), result.get("task"), result.get("source_sha"), result.get("contract"),
229
+ result.get("reasons")) != ("loop-checks", packet["task"], source, contract_digests(packet), []):
230
+ continue
231
+ records = result.get("commands")
232
+ if not isinstance(records, list) or not all(isinstance(record, dict) and type(record.get("index")) is int for record in records):
233
+ raise AcceptanceError(f"runner result {relative(work, result_path)} has malformed command records")
234
+ for record in records:
235
+ if record.get("passed") is True and intact(work, record):
236
+ reused.setdefault(record["index"], dict(record, reused_from=relative(work, result_path)))
237
+ return reused
238
+
239
+
240
+ def run_checks(packet, expected, reused):
241
+ work = Path(packet["worktree"])
242
+ records, run_dir = [], None
243
+ for index, command in enumerate(expected.get("verification_commands", [])):
244
+ if index in reused:
245
+ records.append(reused[index])
246
+ continue
247
+ run_dir = run_dir or new_run_dir(work)
248
+ records.append(execute(work, command, index, run_dir))
249
+ for record, command in zip(records, expected.get("verification_commands", [])):
250
+ record["command"] = {key: command[key] for key in ("cmd", "argv") if key in command}
251
+ record["contract_refs"] = command.get("contract_refs", [])
252
+ return records, run_dir
253
+
254
+
255
+ def guard_results(packet, expected, source):
256
+ work, inputs = Path(packet["worktree"]), packet["inputs_sha"]
257
+ contract = shared("expected-contract")
258
+ diff = git(work, "diff", "--no-color", "--no-ext-diff", inputs, source).stdout.decode("utf-8", "surrogateescape")
259
+ changed = set(changed_paths(work, inputs, source))
260
+ results = []
261
+ for index, pattern in enumerate(expected.get("forbidden_patterns", [])):
262
+ try:
263
+ matched = re.search(pattern["pattern"], contract["slice_diff_to_files"](diff, pattern.get("files") or [])) is not None
264
+ except re.error as exc:
265
+ raise AcceptanceError(f"forbidden_patterns/{index} is not a valid regex: {exc}") from exc
266
+ results.append({"rule": f"forbidden_patterns/{index}", "subject": pattern["description"], "passed": not matched,
267
+ "blocking": pattern["severity"] == "disqualifier"})
268
+ for path in expected.get("required_files", []):
269
+ present = git(work, "cat-file", "-e", f"{source}:{path}", ok=(0, 1, 128)).returncode == 0
270
+ results.append({"rule": "required_files", "subject": path, "passed": present, "blocking": True})
271
+ for path in expected.get("forbidden_files", []):
272
+ results.append({"rule": "forbidden_files", "subject": path, "passed": path not in changed, "blocking": True})
273
+ limit = expected.get("max_deps_added", 0)
274
+ try:
275
+ before = dependency_names(work, inputs)
276
+ except ValueError as exc:
277
+ subject, passed = (f"inputs package.json at {inputs} unreadable; limit cannot apply. "
278
+ f"Fix base: regular npm-valid file; start a new loop. {exc}"), False
279
+ else:
280
+ try:
281
+ added = len(dependency_names(work, source) - before)
282
+ subject, passed = f"{added} added, limit {limit}", added <= limit
283
+ except ValueError as exc:
284
+ subject, passed = f"candidate package.json at {source} is invalid; fix it in the candidate. {exc}", False
285
+ results.append({"rule": "max_deps_added", "subject": subject, "passed": passed, "blocking": True})
286
+ return results
287
+
288
+
289
+ def dependency_names(work, commit):
290
+ """Package names under dependencies and devDependencies of the root package.json at `commit`; a missing file has none."""
291
+ entry = git(work, "ls-tree", commit, "--", "package.json").stdout
292
+ if not entry:
293
+ return set()
294
+ mode, kind, _ = entry.split(maxsplit=2)
295
+ if mode not in (b"100644", b"100755") or kind != b"blob":
296
+ kind = "symlink" if mode == b"120000" else "gitlink" if mode == b"160000" else kind.decode("ascii")
297
+ if mode == b"120000":
298
+ target = git(work, "show", f"{commit}:package.json").stdout.decode("utf-8", "backslashreplace")
299
+ kind += f" to {target!r}"
300
+ raise ValueError(f"{kind} (mode {mode.decode('ascii')}); expected a regular file")
301
+ try:
302
+ # Read as npm reads it: a leading BOM is ignored, a duplicate key's last value wins, a null section is empty and
303
+ # NaN or Infinity is invalid.
304
+ data = json.loads(git(work, "show", f"{commit}:package.json").stdout.decode("utf-8-sig"),
305
+ parse_constant=shared("expected-contract")["reject_json_constant"])
306
+ sections = [data.get(key) or {} for key in ("dependencies", "devDependencies")] if isinstance(data, dict) else [None]
307
+ if not all(isinstance(section, dict) for section in sections):
308
+ raise ValueError("it must be an object whose dependencies and devDependencies are objects")
309
+ except ValueError as exc:
310
+ raise ValueError(f"package.json at {commit} is invalid: {exc}") from exc
311
+ return {name for section in sections for name in section}
312
+
313
+
314
+ def review_problem(packet, record, source):
315
+ """(problem, malformed): a malformed record fails acceptance; a well-formed record bound elsewhere does not count.
316
+ Keys acceptance does not read are ignored: every key a blocking decision reads is required."""
317
+ if not isinstance(record, dict) or not REVIEW_KEYS <= set(record):
318
+ return f"review record keys must include {sorted(REVIEW_KEYS)}", True
319
+ if (record["schema_version"], record["kind"]) != (1, "devlyn-review"):
320
+ return "review record is not a schema 1 devlyn-review", True
321
+ if not all(isinstance(record[key], str) and record[key].strip() for key in ("engine", "model")):
322
+ return "review record must name its engine and model", True
323
+ if not isinstance(record["requirements"], list) or not all(req in packet["requirements"] for req in record["requirements"]):
324
+ return "review requirements must name task requirement IDs", True
325
+ findings = record["findings"]
326
+ if not isinstance(findings, list) or not all(
327
+ isinstance(f, dict) and {"id", "binding", "disposition"} <= set(f) and isinstance(f["id"], str)
328
+ and isinstance(f["binding"], bool) and f["disposition"] in ("open", "resolved", "rejected")
329
+ and (f["disposition"] != "rejected" or (isinstance(f.get("reason"), str) and f["reason"].strip()))
330
+ for f in findings):
331
+ return "findings need id, boolean binding and disposition open|resolved|rejected (rejected needs a reason)", True
332
+ if record["task"] != packet["task"]:
333
+ return f"review is for task {record['task']!r}, not {packet['task']}", False
334
+ if record["source_sha"] != source:
335
+ return f"review is bound to source {record['source_sha']}, not {source}", False
336
+ if (record["contract_sha256"], record["expected_sha256"]) != (packet["contract"]["sha256"], packet["expected"]["sha256"]):
337
+ return "review is bound to a different contract", False
338
+ return None, False
339
+
340
+
341
+ def review_results(packet, paths, source):
342
+ work = Path(packet["worktree"])
343
+ counted, ignored = [], []
344
+ for path in paths:
345
+ try:
346
+ record_path = evidence_file(work, path, ".devlyn")
347
+ raw = record_path.read_bytes()
348
+ record = shared("expected-contract")["loads_strict_json"](raw.decode("utf-8"))
349
+ problem, malformed = review_problem(packet, record, source)
350
+ except (AcceptanceError, OSError, UnicodeError, ValueError) as exc:
351
+ problem, malformed = str(exc), True
352
+ if malformed:
353
+ raise AcceptanceError(f"malformed review record {path}: {problem}")
354
+ if problem:
355
+ ignored.append({"path": str(path), "reason": problem})
356
+ continue
357
+ counted.append({"path": relative(work, record_path), "sha256": sha256(raw), "engine": record["engine"],
358
+ "model": record["model"], "requirements": record["requirements"],
359
+ "open_binding_findings": [f["id"] for f in record["findings"] if f["binding"] and f["disposition"] == "open"]})
360
+ return counted, ignored
361
+
362
+
363
+ def check_submission(packet, submission):
364
+ if not isinstance(submission, dict) or not {"schema_version", "task", "source_sha"} <= set(submission) <= SUBMISSION_KEYS:
365
+ raise AcceptanceError(f"submission keys must include schema_version, task, source_sha and stay within {sorted(SUBMISSION_KEYS)}")
366
+ if (submission["schema_version"], submission["task"]) != (1, packet["task"]) or not isinstance(submission["source_sha"], str):
367
+ raise AcceptanceError("submission is not schema 1 for this task with a string source_sha")
368
+ for key in ("runner_results", "reviews", "assumptions"):
369
+ value = submission.get(key, [])
370
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
371
+ raise AcceptanceError(f"submission {key} must be a list of strings")
372
+ blockers = submission.get("blockers", [])
373
+ if not isinstance(blockers, list) or not all(
374
+ isinstance(b, dict) and {"kind", "detail"} <= set(b) and b["kind"] in BLOCKERS and isinstance(b["detail"], str) and b["detail"].strip()
375
+ for b in blockers):
376
+ raise AcceptanceError(f"submission blockers must include {{kind, detail}} with kind in {sorted(BLOCKERS)}")
377
+
378
+
379
+ def result_shell(packet, kind, source):
380
+ return {"schema_version": 1, "kind": kind, "task": packet["task"], "source_sha": source,
381
+ "inputs_sha": packet["inputs_sha"], "allocation_base": packet["allocation_base"],
382
+ "contract": contract_digests(packet), "commands": [], "guards": []}
383
+
384
+
385
+ def check_reasons(result):
386
+ reasons = [f"failed: command {r['index']} ({', '.join(r['contract_refs']) or 'no refs'}) {r['reason']}"
387
+ for r in result["commands"] if not r["passed"]]
388
+ return reasons + [f"failed: guard {g['rule']} {g['subject']}" for g in result["guards"] if g["blocking"] and not g["passed"]]
389
+
390
+
391
+ def run(packet_path, source=None):
392
+ """Executor-side runner: checks on the committed candidate, before review."""
393
+ packet = load_packet(packet_path)
394
+ work = Path(packet["worktree"])
395
+ source = source or text(work, "rev-parse", "HEAD")
396
+ expected = bound_contract(packet)
397
+ problems = source_problems(packet, source)
398
+ if problems:
399
+ raise AcceptanceError("; ".join(problems))
400
+ result = result_shell(packet, "loop-checks", source)
401
+ result["commands"], run_dir = run_checks(packet, expected, {})
402
+ result["guards"] = guard_results(packet, expected, source)
403
+ result["reasons"] = check_reasons(result) + [f"failed: source changed during checks: {p}" for p in source_problems(packet, source)]
404
+ path = (run_dir or new_run_dir(work)) / "result.json"
405
+ write_json(path, result)
406
+ return path, result
407
+
408
+
409
+ def accept(packet_path, submission_path, failure=None):
410
+ """Drain-side derivation; writes .devlyn/loop/acceptance.json for task-complete custody. A drain-recorded
411
+ `failure` (changed inputs, an unobservable interrupted execution) fails the task without its submission."""
412
+ packet = load_packet(packet_path)
413
+ work = Path(packet["worktree"])
414
+ result = result_shell(packet, "loop", text(work, "rev-parse", "refs/heads/" + packet["branch"]))
415
+ result.update(reviews=[], ignored_reviews=[], assumptions=[], reasons=[failure] if failure else [])
416
+ try:
417
+ if not failure:
418
+ submission = read_json(submission_path)
419
+ check_submission(packet, submission)
420
+ result["assumptions"] = submission.get("assumptions", [])
421
+ if submission.get("blockers"):
422
+ result["reasons"] = [f"{b['kind']}: {b['detail']}" for b in submission["blockers"]]
423
+ else:
424
+ derive(packet, submission, result)
425
+ except AcceptanceError as exc:
426
+ result["reasons"].append(f"failed: {exc}")
427
+ result["verdict"] = "FAILED" if result["reasons"] else "ACCEPTED"
428
+ result["evidence"] = sorted({r[name]["path"] for r in result["commands"] for name in ("stdout", "stderr")}
429
+ | {review["path"] for review in result["reviews"]})
430
+ write_json(work / LOOP_DIR / "acceptance.json", result)
431
+ return result
432
+
433
+
434
+ def derive(packet, submission, result):
435
+ source = submission["source_sha"]
436
+ expected = bound_contract(packet)
437
+ problems = source_problems(packet, source)
438
+ if problems:
439
+ result["reasons"] += [f"failed: {problem}" for problem in problems]
440
+ return
441
+ result["source_sha"] = source
442
+ result["commands"], _ = run_checks(packet, expected, reusable(packet, source, submission.get("runner_results", [])))
443
+ result["guards"] = guard_results(packet, expected, source)
444
+ result["reasons"] += check_reasons(result)
445
+ result["reasons"] += [f"failed: source changed during checks: {p}" for p in source_problems(packet, source)]
446
+ result["reviews"], result["ignored_reviews"] = review_results(packet, submission.get("reviews", []), source)
447
+ covered = {req for review in result["reviews"] for req in review["requirements"]}
448
+ missing = [req for req in packet["review_requirements"] if req not in covered]
449
+ if missing:
450
+ result["reasons"].append("failed: required review coverage missing for " + ", ".join(missing))
451
+ unresolved = [finding for review in result["reviews"] for finding in review["open_binding_findings"]]
452
+ if unresolved:
453
+ result["reasons"].append("failed: unresolved binding review findings " + ", ".join(unresolved))
454
+
455
+
456
+ class AcceptanceTests(unittest.TestCase):
457
+ def setUp(self):
458
+ self.temp = tempfile.TemporaryDirectory(prefix="devlyn-acceptance ")
459
+ self.addCleanup(self.temp.cleanup)
460
+ self.root = Path(self.temp.name).resolve()
461
+ self.work = self.root / "work tree"
462
+ env = {"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"), "GIT_AUTHOR_NAME": "Fixture",
463
+ "GIT_AUTHOR_EMAIL": "fixture@example.invalid", "GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"}
464
+ self.saved = {key: os.environ.get(key) for key in env}
465
+ os.environ.update(env)
466
+ self.addCleanup(lambda: [os.environ.pop(k, None) if v is None else os.environ.__setitem__(k, v) for k, v in self.saved.items()])
467
+ subprocess.run(["git", "init", "-q", "--initial-branch=main", str(self.work)], check=True)
468
+ self.write(".gitignore", ".devlyn/\n")
469
+ self.commit("base")
470
+ self.g("switch", "-q", "-c", "devlyn/l/t")
471
+ self.commands = [
472
+ {"argv": [sys.executable, "-c", "import pathlib; print(pathlib.Path('product.txt').read_text())"],
473
+ "stdout_contains": ["ready"], "contract_refs": ["R1"]},
474
+ {"cmd": "echo shell-ok", "stdout_contains": ["shell-ok"], "contract_refs": ["R1"]},
475
+ ]
476
+
477
+ def g(self, *args):
478
+ return text(self.work, *args)
479
+
480
+ def write(self, rel, data):
481
+ path = self.work / rel
482
+ path.parent.mkdir(parents=True, exist_ok=True)
483
+ path.write_text(data, encoding="utf-8")
484
+
485
+ def commit(self, message):
486
+ self.g("add", "-A")
487
+ self.g("commit", "-q", "-m", message)
488
+ return self.g("rev-parse", "HEAD")
489
+
490
+ def inputs(self, expected=None, review=()):
491
+ base = self.g("rev-parse", "HEAD")
492
+ files = {"docs/specs/l/meta.md": "# meta\n", "docs/specs/l/t/spec.md": "# task R1 R2\n",
493
+ "docs/specs/l/t/spec.expected.json": json.dumps(expected or {"verification_commands": self.commands}),
494
+ QUEUE: "- [ ] l.t [T](docs/specs/l/t/spec.md)\n"}
495
+ for rel, data in files.items():
496
+ self.write(rel, data)
497
+ inputs = self.commit("inputs")
498
+ digest = {rel: sha256((self.work / rel).read_bytes()) for rel in files}
499
+ self.packet = {"task": "l.t", "worktree": str(self.work), "branch": "devlyn/l/t", "inputs_sha": inputs, "allocation_base": base,
500
+ "contract": {"path": str(self.work / "docs/specs/l/t/spec.md"), "sha256": digest["docs/specs/l/t/spec.md"]},
501
+ "expected": {"path": str(self.work / "docs/specs/l/t/spec.expected.json"), "sha256": digest["docs/specs/l/t/spec.expected.json"]},
502
+ "meta": {"path": str(self.work / "docs/specs/l/meta.md"), "sha256": digest["docs/specs/l/meta.md"]},
503
+ "requirements": ["R1", "R2"], "review_requirements": list(review)}
504
+ self.packet_path = self.root / "packet.json"
505
+ self.packet_path.write_text(json.dumps(self.packet), encoding="utf-8")
506
+
507
+ def product(self, body="ready"):
508
+ self.write("product.txt", body)
509
+ return self.commit("product")
510
+
511
+ def review(self, source, name="review.json", **changes):
512
+ record = {"schema_version": 1, "kind": "devlyn-review", "task": "l.t", "engine": "codex", "model": "fixture-model",
513
+ "source_sha": source, "contract_sha256": self.packet["contract"]["sha256"],
514
+ "expected_sha256": self.packet["expected"]["sha256"], "requirements": ["R2"], "findings": [], **changes}
515
+ self.write(f".devlyn/reviews/{name}", json.dumps(record))
516
+ return f".devlyn/reviews/{name}"
517
+
518
+ def submit(self, source, **fields):
519
+ path = self.root / "submission.json"
520
+ path.write_text(json.dumps({"schema_version": 1, "task": "l.t", "source_sha": source, **fields}), encoding="utf-8")
521
+ return accept(self.packet_path, path)
522
+
523
+ def test_accepts_owned_source_with_checks_and_bound_review(self):
524
+ self.inputs(review=["R2"])
525
+ source = self.product()
526
+ result = self.submit(source, reviews=[self.review(source)])
527
+ self.assertEqual((result["verdict"], result["reasons"]), ("ACCEPTED", []))
528
+ self.assertEqual(json.loads((self.work / LOOP_DIR / "acceptance.json").read_text(encoding="utf-8"))["source_sha"], source)
529
+ self.assertIn(".devlyn/reviews/review.json", result["evidence"])
530
+ for path in result["evidence"]:
531
+ self.assertTrue((self.work / path).is_file(), path)
532
+
533
+ def test_command_assertions_timeout_and_spawn_failure(self):
534
+ python = sys.executable
535
+ self.inputs({"verification_commands": [
536
+ {"argv": [python, "-c", "raise SystemExit(3)"], "contract_refs": ["R1"]},
537
+ {"argv": [python, "-c", "print('nope')"], "stdout_contains": ["yes"], "contract_refs": ["R1"]},
538
+ {"argv": [python, "-c", "print('secret')"], "stdout_not_contains": ["secret"], "contract_refs": ["R1"]},
539
+ {"argv": [python, "-c", "import time; time.sleep(5)"], "timeout_sec": 1, "contract_refs": ["R2"]},
540
+ {"argv": ["devlyn-missing-binary-for-test"], "contract_refs": ["R2"]},
541
+ ]})
542
+ result = self.submit(self.product())
543
+ self.assertEqual(result["verdict"], "FAILED")
544
+ self.assertEqual([r["outcome"]["kind"] for r in result["commands"]], ["exit", "exit", "exit", "timeout", "spawn_error"])
545
+ self.assertEqual([bool(r["reason"]) for r in result["commands"]], [True] * 5)
546
+ self.assertIn("exit 3, expected 0", " ".join(result["reasons"]))
547
+ self.assertTrue(all((self.work / r["stdout"]["path"]).is_file() for r in result["commands"]))
548
+
549
+ def test_source_and_contract_binding(self):
550
+ self.inputs()
551
+ source = self.product()
552
+ self.write("untracked.txt", "delta")
553
+ self.assertIn("undeclared source delta", " ".join(self.submit(source)["reasons"]))
554
+ (self.work / "untracked.txt").unlink()
555
+ self.g("checkout", "-q", "--detach", source)
556
+ self.assertIn("is not the owned task commit", " ".join(self.submit(source)["reasons"]))
557
+ self.g("switch", "-q", "devlyn/l/t")
558
+ self.assertIn("is not the owned task commit", " ".join(self.submit(self.packet["inputs_sha"])["reasons"]))
559
+ self.write("docs/specs/l/t/spec.md", "# weakened\n")
560
+ weakened = self.commit("weaken")
561
+ self.assertIn("candidate edits loop inputs: docs/specs/l/t/spec.md", " ".join(self.submit(weakened)["reasons"]))
562
+ self.packet["contract"]["sha256"] = "0" * 64
563
+ self.packet_path.write_text(json.dumps(self.packet), encoding="utf-8")
564
+ self.assertIn("differs from the task packet", " ".join(self.submit(weakened)["reasons"]))
565
+
566
+ def test_review_binding_and_findings(self):
567
+ self.inputs(review=["R2"])
568
+ first = self.product()
569
+ stale = self.review(first, "stale.json")
570
+ source = self.product("ready again")
571
+ for reviews, message in (
572
+ ([stale], "required review coverage missing for R2"),
573
+ ([self.review(source, "contract.json", expected_sha256="0" * 64)], "required review coverage missing for R2"),
574
+ ([self.review(source, "rejected.json", findings=[{"id": "F1", "binding": True, "disposition": "rejected"}])], "malformed review record"),
575
+ ([self.review(source, "open.json", findings=[{"id": "F2", "binding": True, "disposition": "open"}])], "unresolved binding review findings F2"),
576
+ ):
577
+ result = self.submit(source, reviews=reviews)
578
+ self.assertEqual(result["verdict"], "FAILED")
579
+ self.assertIn(message, " ".join(result["reasons"]))
580
+ result = self.submit(source, reviews=[stale, self.review(source, findings=[{"id": "F3", "binding": False, "disposition": "open"}])])
581
+ self.assertEqual(result["verdict"], "ACCEPTED")
582
+ self.assertEqual([item["path"] for item in result["ignored_reviews"]], [stale])
583
+
584
+ def test_review_records_and_findings_ignore_other_keys(self):
585
+ """E2. Prediction: a review record with an extra top-level key and an extra key in a finding counts, and an open
586
+ binding finding carrying an extra key still fails acceptance with its id named. Before: both records failed as
587
+ "malformed review record", so the outcome depended on which engine wrote the record."""
588
+ self.inputs(review=["R2"])
589
+ source = self.product()
590
+ extra = {"evidence": ["checks pass"]}
591
+ counted = self.review(source, "extra.json", **extra, findings=[{"id": "F1", "binding": True, "disposition": "resolved", **extra}])
592
+ result = self.submit(source, reviews=[counted])
593
+ self.assertEqual((result["verdict"], [review["path"] for review in result["reviews"]]), ("ACCEPTED", [counted]))
594
+ blocking = self.review(source, "open.json", findings=[{"id": "F2", "binding": True, "disposition": "open", **extra}])
595
+ result = self.submit(source, reviews=[blocking])
596
+ self.assertEqual((result["verdict"], result["reasons"]), ("FAILED", ["failed: unresolved binding review findings F2"]))
597
+
598
+ def test_blockers_ignore_keys_beyond_kind_and_detail(self):
599
+ """T6. Prediction: a needs-review blocker carrying a key beyond kind and detail records its question as the reason,
600
+ as one without it does, so the report and queue row keep the question. Before: the extra key failed the submission
601
+ "submission blockers must be {kind, detail} ...", and the question was lost."""
602
+ self.inputs()
603
+ result = self.submit(self.product(), blockers=[{"kind": "needs-review", "detail": "Which store wins?", "requirement": "R1"}])
604
+ self.assertEqual((result["verdict"], result["reasons"]), ("FAILED", ["needs-review: Which store wins?"]))
605
+
606
+ def test_python_checks_leave_no_bytecode_and_a_dirty_worktree_names_its_paths(self):
607
+ """H7. Prediction: a check that imports a module committed in the task worktree, where nothing ignores
608
+ __pycache__/, writes no bytecode there, so the runner and acceptance both pass; a file left untracked fails
609
+ acceptance with its path named. Before: the import wrote __pycache__/ into the worktree, so the correct task failed
610
+ "source changed during checks: worktree has uncommitted or untracked changes (undeclared source delta)", naming
611
+ no path."""
612
+ self.inputs({"verification_commands": [{"argv": [sys.executable, "-c", "import greeting; print(greeting.greet())"],
613
+ "stdout_contains": ["hello"], "contract_refs": ["R1", "R2"]}]})
614
+ self.write("greeting.py", "def greet():\n return 'hello'\n")
615
+ source = self.commit("product")
616
+ _, checks = run(self.packet_path)
617
+ result = self.submit(source)
618
+ self.assertEqual((checks["reasons"], result["verdict"], result["reasons"], (self.work / "__pycache__").exists()), ([], "ACCEPTED", [], False))
619
+ self.write("notes.txt", "left behind\n")
620
+ self.assertEqual(self.submit(source)["reasons"], ["failed: worktree has uncommitted or untracked changes (undeclared source delta): notes.txt"])
621
+
622
+ def test_runner_result_with_a_source_change_is_never_reused(self):
623
+ mutate = [sys.executable, "-c", "import pathlib; p = pathlib.Path('product.txt'); p.write_text(p.read_text() + '!'); print('ok')"]
624
+ self.inputs({"verification_commands": [{"argv": mutate, "stdout_contains": ["ok"], "contract_refs": ["R1", "R2"]}]})
625
+ source = self.product()
626
+ path, checks = run(self.packet_path)
627
+ self.assertTrue(checks["commands"][0]["passed"])
628
+ self.assertIn("failed: source changed during checks", " ".join(checks["reasons"]))
629
+ self.g("checkout", "--", "product.txt")
630
+ result = self.submit(source, runner_results=[relative(self.work, path)])
631
+ self.assertEqual(result["verdict"], "FAILED")
632
+ self.assertNotIn("reused_from", result["commands"][0])
633
+ self.assertIn("failed: source changed during checks", " ".join(result["reasons"]))
634
+
635
+ def test_malformed_executor_records_fail_with_a_named_reason(self):
636
+ self.inputs(review=["R2"])
637
+ source = self.product()
638
+ malformed = self.review(source, "malformed.json", requirements=[{"id": "R2"}])
639
+ result = self.submit(source, reviews=[malformed])
640
+ self.assertEqual(result["verdict"], "FAILED")
641
+ self.assertIn(f"malformed review record {malformed}", " ".join(result["reasons"]))
642
+ path, _ = run(self.packet_path)
643
+ checks = json.loads(path.read_text(encoding="utf-8"))
644
+ del checks["commands"][0]["index"]
645
+ path.write_text(json.dumps(checks), encoding="utf-8")
646
+ result = self.submit(source, runner_results=[relative(self.work, path)], reviews=[self.review(source)])
647
+ self.assertEqual(result["verdict"], "FAILED")
648
+ self.assertIn("malformed command records", " ".join(result["reasons"]))
649
+ blocked = self.submit(source, blockers=[{"kind": ["failed"], "detail": "unhashable kind"}])
650
+ self.assertEqual(blocked["verdict"], "FAILED")
651
+ self.assertIn("submission blockers must include", " ".join(blocked["reasons"]))
652
+
653
+ def test_reuses_intact_runner_results_and_reexecutes_altered_evidence(self):
654
+ counter = [sys.executable, "-c", "import pathlib; p = pathlib.Path('.devlyn/count'); p.parent.mkdir(exist_ok=True); "
655
+ "p.write_text(str(int(p.read_text()) + 1) if p.exists() else '1'); print('counted')"]
656
+ self.inputs({"verification_commands": [{"argv": counter, "stdout_contains": ["counted"], "contract_refs": ["R1", "R2"]}]})
657
+ source = self.product()
658
+ path, checks = run(self.packet_path)
659
+ self.assertEqual(checks["reasons"], [])
660
+ runner = relative(self.work, path)
661
+ self.assertEqual(self.submit(source, runner_results=[runner])["commands"][0]["reused_from"], runner)
662
+ self.assertEqual((self.work / ".devlyn/count").read_text(), "1")
663
+ (self.work / checks["commands"][0]["stdout"]["path"]).write_text("altered", encoding="utf-8")
664
+ result = self.submit(source, runner_results=[runner])
665
+ self.assertNotIn("reused_from", result["commands"][0])
666
+ self.assertEqual(((self.work / ".devlyn/count").read_text(), result["verdict"]), ("2", "ACCEPTED"))
667
+
668
+ def test_windows_runs_node_command_shims_without_cmd(self):
669
+ """Windows resolution mocked: verification argv such as ["npm", "test"] runs the Node and script its command
670
+ shim names, as cmd.exe would resolve it; any other batch file is refused with an actionable message."""
671
+ from types import SimpleNamespace
672
+ from unittest import mock
673
+ npm_cmd = "\r\n".join(( # npm 10 and 11 ship this npm.cmd byte for byte.
674
+ ":: Created by npm, please don't edit manually.", "@ECHO OFF", "", "SETLOCAL", "",
675
+ 'SET "NODE_EXE=%~dp0\\node.exe"', 'IF NOT EXIST "%NODE_EXE%" (', ' SET "NODE_EXE=node"', ")", "",
676
+ 'SET "NPM_PREFIX_JS=%~dp0\\node_modules\\npm\\bin\\npm-prefix.js"', 'SET "NPM_CLI_JS=%~dp0\\node_modules\\npm\\bin\\npm-cli.js"',
677
+ 'FOR /F "delims=" %%F IN (\'CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"\') DO (',
678
+ ' SET "NPM_PREFIX_NPM_CLI_JS=%%F\\node_modules\\npm\\bin\\npm-cli.js"', ")",
679
+ 'IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (', ' SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"', ")", "", '"%NODE_EXE%" "%NPM_CLI_JS%" %*', ""))
680
+ cmd_shim = "\r\n".join(( # npm's cmd-shim 6 (npm install -g, node_modules/.bin).
681
+ "@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start", "SETLOCAL", "CALL :find_dp0", "",
682
+ 'IF EXIST "%dp0%\\node.exe" (', ' SET "_prog=%dp0%\\node.exe"', ") ELSE (", ' SET "_prog=node"',
683
+ " SET PATHEXT=%PATHEXT:;.JS;=;%", ")", "",
684
+ 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\pnpm\\bin\\pnpm.cjs" %*', ""))
685
+ corepack = "\r\n".join(( # @zkochan/cmd-shim, as corepack enable and pnpm write it.
686
+ "@SETLOCAL", '@IF EXIST "%~dp0\\node.exe" (', ' "%~dp0\\node.exe" "%~dp0\\node_modules\\corepack\\dist\\yarn.js" %*', ") ELSE (",
687
+ " @SET PATHEXT=%PATHEXT:;.JS;=;%", ' node "%~dp0\\node_modules\\corepack\\dist\\yarn.js" %*', ")", ""))
688
+ node_path = corepack.replace("@SETLOCAL\r\n", '@SETLOCAL\r\n@SET "NODE_PATH=C:\\proj\\node_modules\\.pnpm\\node_modules"\r\n')
689
+ nodejs, tools, prefix = self.root / "nodejs", self.root / "tools", self.root / "prefix"
690
+ node = nodejs / "node.exe"
691
+ files = {nodejs / "npm.cmd": npm_cmd, node: "", tools / "pnpm.cmd": cmd_shim, tools / "yarn.cmd": corepack,
692
+ tools / "vitest.cmd": node_path, tools / "build.bat": "@echo off\r\necho built\r\n"}
693
+ for path in (nodejs / "node_modules/npm/bin/npm-cli.js", nodejs / "node_modules/npm/bin/npm-prefix.js",
694
+ tools / "node_modules/pnpm/bin/pnpm.cjs", tools / "node_modules/corepack/dist/yarn.js"):
695
+ files[path] = ""
696
+ for path, body in files.items():
697
+ path.parent.mkdir(parents=True, exist_ok=True)
698
+ path.write_bytes(body.encode("utf-8"))
699
+ found = {"npm": nodejs / "npm.cmd", "node.exe": node, **{path.stem: path for path in tools.glob("*.*")}}
700
+ platform = shared("platform-support")
701
+ lookups = []
702
+
703
+ def npm_prefix(argv, cwd=None, **kwargs): # what node npm-prefix.js prints, recorded with the directory it ran in
704
+ lookups.append((argv, cwd))
705
+ return SimpleNamespace(stdout=f"{prefix}\n")
706
+ windows = {"os": SimpleNamespace(name="nt"), "shutil": SimpleNamespace(which=lambda name: str(found[name]) if name in found else None),
707
+ "subprocess": SimpleNamespace(run=npm_prefix)}
708
+ with mock.patch.dict(platform["native_argv"].__globals__, windows):
709
+ resolve = platform["native_argv"]
710
+ self.assertEqual(resolve(["npm", "test"]), [str(node), str(nodejs / "node_modules/npm/bin/npm-cli.js"), "test"])
711
+ upgraded = prefix / "node_modules/npm/bin/npm-cli.js" # npm install -g npm: npm.cmd defers to the global prefix's npm.
712
+ upgraded.parent.mkdir(parents=True)
713
+ upgraded.write_bytes(b"")
714
+ self.assertEqual(resolve(["npm", "test"], cwd=self.root), [str(node), str(upgraded), "test"])
715
+ self.assertEqual(lookups[-1], ([str(node), str(nodejs / "node_modules/npm/bin/npm-prefix.js")], self.root))
716
+ self.assertEqual(resolve(["pnpm", "test"]), [str(node), str(tools / "node_modules/pnpm/bin/pnpm.cjs"), "test"])
717
+ self.assertEqual(resolve(["yarn", "test"]), [str(node), str(tools / "node_modules/corepack/dist/yarn.js"), "test"])
718
+ for name in ("vitest", "build"):
719
+ with self.subTest(name=name), self.assertRaisesRegex(OSError, "use argv with an explicit interpreter"):
720
+ resolve([name, "run"])
721
+
722
+ def test_guards_and_blockers(self):
723
+ self.inputs({"verification_commands": self.commands, "required_files": ["missing.txt"], "forbidden_files": ["product.txt"],
724
+ "forbidden_patterns": [{"pattern": "rea+dy", "description": "no ready", "severity": "disqualifier"},
725
+ {"pattern": "ready", "description": "warn only", "severity": "warning"}]})
726
+ source = self.product()
727
+ result = self.submit(source)
728
+ failed = {g["rule"] for g in result["guards"] if not g["passed"]}
729
+ self.assertEqual(failed, {"forbidden_patterns/0", "forbidden_patterns/1", "required_files", "forbidden_files"})
730
+ self.assertNotIn("warn only", " ".join(result["reasons"]))
731
+ blocked = self.submit(source, blockers=[{"kind": "needs-review", "detail": "Which store wins?"}])
732
+ self.assertEqual((blocked["verdict"], blocked["reasons"], blocked["commands"]), ("FAILED", ["needs-review: Which store wins?"], []))
733
+
734
+ def test_max_deps_added_counts_the_names_the_root_package_json_gains(self):
735
+ """P4-3. Prediction: under the default limit 0 with a passing check, one dependency added to a one-line
736
+ package.json (none at the inputs) counts 1 and fails acceptance; two appended to a multi-line one count 2; a name
737
+ moved from dependencies to devDependencies counts 0 and is accepted; an invalid package.json fails the guard,
738
+ naming it. A leading UTF-8 BOM, which npm and Node accept, is ignored: with one at both commits, one added
739
+ counts 1. A duplicate key (its last value wins) and a null dependencies section, which npm also reads, count 0
740
+ and are accepted; NaN, which npm rejects, fails the guard. Invalid inputs fail before an invalid candidate,
741
+ naming inputs and the base repair/new loop action; invalid candidates name the candidate and its repair.
742
+ The action survives the queue's 200-character first-reason collapse, with parser detail after it."""
743
+ passing = {"verification_commands": [{"argv": [sys.executable, "-c", "pass"], "contract_refs": ["R1"]}]}
744
+
745
+ def guard(inputs, source):
746
+ if inputs is not None:
747
+ self.write("package.json", json.dumps(inputs, indent=2))
748
+ self.inputs(passing)
749
+ self.write("package.json", source)
750
+ commit = self.commit("product")
751
+ result = self.submit(commit)
752
+ return commit, result, next(g for g in result["guards"] if g["rule"] == "max_deps_added")
753
+
754
+ with self.subTest("one-line add"):
755
+ _, result, deps = guard(None, json.dumps({"name": "app", "dependencies": {"left-pad": "1.0.0"}}))
756
+ self.assertEqual((deps["subject"], result["reasons"]), ("1 added, limit 0", ["failed: guard max_deps_added 1 added, limit 0"]))
757
+ with self.subTest("multi-line add of 2"):
758
+ _, _, deps = guard({"dependencies": {"a": "1"}}, json.dumps({"dependencies": {"a": "1", "b": "1", "c": "1"}}, indent=2))
759
+ self.assertEqual((deps["subject"], deps["passed"]), ("2 added, limit 0", False))
760
+ with self.subTest("move"):
761
+ _, result, deps = guard({"dependencies": {"a": "1", "b": "1"}, "devDependencies": {"c": "1"}},
762
+ json.dumps({"dependencies": {"b": "1"}, "devDependencies": {"a": "1", "c": "1"}}, indent=2))
763
+ self.assertEqual((deps["subject"], result["verdict"]), ("0 added, limit 0", "ACCEPTED"))
764
+ with self.subTest("invalid"):
765
+ commit, result, deps = guard({"dependencies": {"a": "1"}}, '{"dependencies": {"a": "1",}}\n')
766
+ self.assertEqual((deps["passed"], result["verdict"]), (False, "FAILED"))
767
+ self.assertTrue(deps["subject"].startswith(f"candidate package.json at {commit} is invalid; fix it in the candidate."))
768
+ with self.subTest("BOM"):
769
+ self.write("package.json", "\ufeff" + json.dumps({"dependencies": {"a": "1"}}))
770
+ _, _, deps = guard(None, "\ufeff" + json.dumps({"dependencies": {"a": "1", "b": "1"}}))
771
+ self.assertEqual(deps["subject"], "1 added, limit 0")
772
+ with self.subTest("duplicate key and null"):
773
+ self.write("package.json", '{"dependencies": null, "scripts": {"a": "1"}, "scripts": {"b": "2"}}')
774
+ _, result, deps = guard(None, '{"dependencies": {"a": "1"}, "dependencies": null}')
775
+ self.assertEqual((deps["subject"], result["verdict"]), ("0 added, limit 0", "ACCEPTED"))
776
+ with self.subTest("NaN"):
777
+ commit, result, deps = guard({"dependencies": {"a": "1"}}, '{"config": {"threshold": NaN}}')
778
+ self.assertEqual((deps["passed"], result["verdict"]), (False, "FAILED"))
779
+ self.assertTrue(deps["subject"].startswith(f"candidate package.json at {commit} is invalid; fix it in the candidate."))
780
+ with self.subTest("invalid inputs precede invalid candidate"):
781
+ self.write("package.json", "{")
782
+ _, result, deps = guard(None, "[")
783
+ self.assertEqual((deps["passed"], result["verdict"]), (False, "FAILED"))
784
+ self.assertTrue(deps["subject"].startswith(f"inputs package.json at {self.packet['inputs_sha']} unreadable; limit cannot apply."))
785
+ one_line = runpy.run_path(str(Path(__file__).with_name("queue.py")))["one_line"]
786
+ for size in (40, 64):
787
+ reason = result["reasons"][0].replace(self.packet["inputs_sha"], "a" * size)
788
+ self.assertIn("Fix base: regular npm-valid file; start a new loop", one_line(reason))
789
+
790
+ @unittest.skipIf(os.name == "nt", "a symbolic link needs a privilege on Windows")
791
+ def test_max_deps_added_rejects_symlinks(self):
792
+ """Prediction: a candidate package.json symlink to a file named '{}' containing a dependency fails the guard,
793
+ naming the candidate, symlink and target; before, the target text parsed as an empty object and passed limit 0.
794
+ An ordinary candidate symlink also names its target and candidate repair. An inputs symlink fails first even
795
+ when the candidate is invalid JSON, naming inputs and the base repair/new loop action."""
796
+ passing = {"verification_commands": [{"argv": [sys.executable, "-c", "pass"], "contract_refs": ["R1"]}]}
797
+ package = self.work / "package.json"
798
+ one_line = runpy.run_path(str(Path(__file__).with_name("queue.py")))["one_line"]
799
+ for side, target in (("candidate", "{}"), ("inputs", "deps.json"), ("candidate", "deps.json")):
800
+ with self.subTest(side=side, target=target):
801
+ package.unlink(missing_ok=True)
802
+ self.write(target, json.dumps({"dependencies": {"hidden": "1"}}))
803
+ if side == "inputs":
804
+ package.symlink_to(target)
805
+ else:
806
+ self.write("package.json", "{}")
807
+ self.inputs(passing)
808
+ package.unlink()
809
+ if side == "candidate":
810
+ package.symlink_to(target)
811
+ else:
812
+ self.write("package.json", "[")
813
+ source = self.commit("product")
814
+ result = self.submit(source)
815
+ deps = next(g for g in result["guards"] if g["rule"] == "max_deps_added")
816
+ self.assertEqual((deps["passed"], result["verdict"]), (False, "FAILED"))
817
+ commit = self.packet["inputs_sha"] if side == "inputs" else source
818
+ self.assertTrue(deps["subject"].startswith(f"{side} package.json at {commit}"))
819
+ self.assertIn(f"symlink to {target!r}", deps["subject"])
820
+ action = "Fix base: regular npm-valid file; start a new loop" if side == "inputs" else "fix it in the candidate."
821
+ self.assertIn(action, one_line(result["reasons"][0]))
822
+
823
+ def test_max_deps_added_requires_regular_blobs(self):
824
+ """Prediction: both regular blob modes count names; a tree or gitlink at package.json fails the guard with
825
+ its kind and candidate repair, rather than escaping as an AcceptanceError without a guard subject."""
826
+ self.write("package.json", '{"dependencies": {"a": "1"}}')
827
+ self.inputs()
828
+ inputs = self.packet["inputs_sha"]
829
+ blob = self.g("rev-parse", f"{inputs}:package.json")
830
+ tree = self.g("rev-parse", f"{inputs}^{{tree}}")
831
+ for mode, kind, oid in (("100644", "blob", blob), ("100755", "blob", blob),
832
+ ("040000", "tree", tree), ("160000", "commit", inputs)):
833
+ with self.subTest(mode=mode):
834
+ made = subprocess.run(["git", "-C", str(self.work), "mktree"],
835
+ input=f"{mode} {kind} {oid}\tpackage.json\n".encode(), capture_output=True, check=True)
836
+ source = self.g("commit-tree", made.stdout.decode().strip(), "-p", inputs, "-m", "package kind")
837
+ deps = next(g for g in guard_results(self.packet, {}, source) if g["rule"] == "max_deps_added")
838
+ if kind == "blob":
839
+ self.assertEqual(dependency_names(self.work, source), {"a"})
840
+ self.assertEqual((deps["passed"], deps["subject"]), (True, "0 added, limit 0"))
841
+ else:
842
+ self.assertFalse(deps["passed"])
843
+ self.assertTrue(deps["subject"].startswith(f"candidate package.json at {source} is invalid; fix it in the candidate."))
844
+ self.assertIn("gitlink" if kind == "commit" else kind, deps["subject"])
845
+
846
+
847
+ def main():
848
+ parser = argparse.ArgumentParser(description=__doc__)
849
+ parser.add_argument("--self-test", action="store_true")
850
+ actions = parser.add_subparsers(dest="action")
851
+ runner = actions.add_parser("run")
852
+ runner.add_argument("--packet", required=True)
853
+ runner.add_argument("--source")
854
+ acceptance = actions.add_parser("accept")
855
+ acceptance.add_argument("--packet", required=True)
856
+ acceptance.add_argument("--submission", required=True)
857
+ args = parser.parse_args()
858
+ if args.self_test:
859
+ result = unittest.TextTestRunner(verbosity=1).run(unittest.defaultTestLoader.loadTestsFromTestCase(AcceptanceTests))
860
+ if result.wasSuccessful():
861
+ print(f"acceptance self-test: PASS ({result.testsRun} tests)")
862
+ return 0 if result.wasSuccessful() else 1
863
+ if args.action is None:
864
+ parser.error("run or accept is required")
865
+ try:
866
+ if args.action == "run":
867
+ path, result = run(args.packet, args.source)
868
+ output = {"status": "CHECKED", "result": str(path), "passed": not result["reasons"], "reasons": result["reasons"]}
869
+ else:
870
+ result = accept(args.packet, args.submission)
871
+ output = {"status": result["verdict"], "result": str(Path(load_packet(args.packet)["worktree"]) / LOOP_DIR / "acceptance.json"),
872
+ "reasons": result["reasons"]}
873
+ print(json.dumps(output, sort_keys=True))
874
+ return 0
875
+ except (AcceptanceError, OSError, KeyError, TypeError, ValueError) as exc:
876
+ print(json.dumps({"status": "BLOCKED", "reason": str(exc)}, sort_keys=True))
877
+ return 1
878
+
879
+
880
+ if __name__ == "__main__":
881
+ shared("platform-support")["configure_utf8"]()
882
+ sys.exit(main())