@surlymochan/evalid 0.0.0-stage → 0.4.1

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 (192) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +21 -0
  3. package/README.md +315 -2
  4. package/RELEASE_NOTES.md +124 -0
  5. package/atoms/registry.json +758 -0
  6. package/bin/xflow.js +7261 -0
  7. package/docs/adoption/README.md +128 -0
  8. package/docs/adoption/openflow-release-hardening.md +61 -0
  9. package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
  10. package/docs/compatibility.md +52 -0
  11. package/docs/competitive-benchmark.md +108 -0
  12. package/docs/corps-operator-guide.md +75 -0
  13. package/docs/demo-proof.md +126 -0
  14. package/docs/examples-gallery.md +115 -0
  15. package/docs/fixtures/tracker-item.json +7 -0
  16. package/docs/goal-vs-codex.md +129 -0
  17. package/docs/gstack-comparison.md +88 -0
  18. package/docs/install-upgrade.md +133 -0
  19. package/docs/integrations.md +49 -0
  20. package/docs/launch-demo.md +123 -0
  21. package/docs/launch-dossier.md +180 -0
  22. package/docs/methodology.md +149 -0
  23. package/docs/npm-publish-handoff.md +96 -0
  24. package/docs/openspec-migration.md +106 -0
  25. package/docs/public-benchmark.md +68 -0
  26. package/docs/public-release.md +145 -0
  27. package/docs/quality-assessment.md +139 -0
  28. package/docs/quickstart.md +156 -0
  29. package/docs/reviewer-guide.md +43 -0
  30. package/docs/spec-kit-benchmark.md +73 -0
  31. package/docs/super-assistant-comparison.md +106 -0
  32. package/docs/superpowers-comparison.md +82 -0
  33. package/docs/team-adoption.md +123 -0
  34. package/docs/tooling-matrix.md +91 -0
  35. package/docs/walkthrough.md +76 -0
  36. package/docs/workflow-manual.md +1130 -0
  37. package/package.json +83 -4
  38. package/prompts/pencil-draft.md +46 -0
  39. package/prompts/pencil-refine.md +46 -0
  40. package/prompts/phase-artifact.md +24 -0
  41. package/prompts/phase-execution.md +32 -0
  42. package/schemas/competitor-reconstruction-contract.schema.json +91 -0
  43. package/schemas/llm-design-review-aggregate.schema.json +83 -0
  44. package/schemas/visual-benchmark.schema.json +978 -0
  45. package/schemas/workflow.schema.json +100 -0
  46. package/scripts/run-tests.mjs +23 -0
  47. package/scripts/takein_digest.py +139 -0
  48. package/specs/README.md +118 -0
  49. package/specs/architecture.md +36 -0
  50. package/specs/product.md +33 -0
  51. package/specs/workflow.md +349 -0
  52. package/src/agent_team/atoms/codex-app-bridge.js +8 -0
  53. package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
  54. package/src/agent_team/atoms/design/contract-freeze.js +8 -0
  55. package/src/agent_team/atoms/design/generation-contract.js +442 -0
  56. package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
  57. package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
  58. package/src/agent_team/atoms/patch-challenge.js +19 -0
  59. package/src/agent_team/atoms/pencil/accept.js +9 -0
  60. package/src/agent_team/atoms/pencil/draft.js +365 -0
  61. package/src/agent_team/atoms/pencil/refine.js +229 -0
  62. package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
  63. package/src/agent_team/atoms/team-run.js +380 -0
  64. package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
  65. package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
  66. package/src/agent_team/atoms/visual/benchmark.js +1652 -0
  67. package/src/agent_team/atoms/visual/design-selection.js +23 -0
  68. package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
  69. package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
  70. package/src/agent_team/atoms/visual/image-diff.js +186 -0
  71. package/src/agent_team/atoms/visual/layout-competition.js +8 -0
  72. package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
  73. package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
  74. package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
  75. package/src/agent_team/atoms/visual/review.js +77 -0
  76. package/src/agent_team/atoms/visual/visual-report.js +197 -0
  77. package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
  78. package/src/agent_team/common.js +311 -0
  79. package/src/core/atoms/agent-invoke.js +684 -0
  80. package/src/core/atoms/event/ingest.js +30 -0
  81. package/src/core/atoms/experience/digest.js +55 -0
  82. package/src/core/atoms/gate/artifact-verify.js +83 -0
  83. package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
  84. package/src/core/atoms/gate/evidence-collect.js +64 -0
  85. package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
  86. package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
  87. package/src/core/atoms/gate/workbench.js +79 -0
  88. package/src/core/atoms/intake/classify.js +15 -0
  89. package/src/core/atoms/intake/design-skill-select.js +16 -0
  90. package/src/core/atoms/intake/preview-or-refine.js +19 -0
  91. package/src/core/atoms/intake/recommend.js +20 -0
  92. package/src/core/atoms/intake/research-same-category.js +19 -0
  93. package/src/core/atoms/intervention/create.js +30 -0
  94. package/src/core/atoms/memory/manage.js +27 -0
  95. package/src/core/atoms/memory/mount.js +13 -0
  96. package/src/core/atoms/mission/create.js +19 -0
  97. package/src/core/atoms/mission/list.js +14 -0
  98. package/src/core/atoms/mission/show.js +26 -0
  99. package/src/core/atoms/notify/checkpoint.js +46 -0
  100. package/src/core/atoms/phase-run/complete.js +42 -0
  101. package/src/core/atoms/phase-run/fail.js +40 -0
  102. package/src/core/atoms/phase-run/persist.js +39 -0
  103. package/src/core/atoms/phase-run/start.js +30 -0
  104. package/src/core/atoms/plugin/run.js +76 -0
  105. package/src/core/atoms/policy/gate.js +90 -0
  106. package/src/core/atoms/policy/resolve.js +11 -0
  107. package/src/core/atoms/storage/sync.js +57 -0
  108. package/src/core/atoms/workflow/advance.js +25 -0
  109. package/src/core/atoms/workflow/guard.js +127 -0
  110. package/src/core/atoms/workflow/state-load.js +37 -0
  111. package/src/core/bindings.js +37 -0
  112. package/src/core/change-artifacts.js +134 -0
  113. package/src/core/competitive-score.js +265 -0
  114. package/src/core/concurrency-policy.js +35 -0
  115. package/src/core/execution-log.js +108 -0
  116. package/src/core/intake-recommendation.js +134 -0
  117. package/src/core/memory-state.js +62 -0
  118. package/src/core/mission-state.js +780 -0
  119. package/src/core/pending-gates.js +66 -0
  120. package/src/core/phase-cost.js +116 -0
  121. package/src/core/phase-run-read.js +27 -0
  122. package/src/core/policy-overlay.js +180 -0
  123. package/src/core/state-store.js +201 -0
  124. package/src/core/workflow-executor.js +1014 -0
  125. package/src/core/workflow-integrity.js +111 -0
  126. package/src/core/workflow-loader.js +163 -0
  127. package/src/core/workflow-rerun.js +84 -0
  128. package/src/core/workflow-state.js +119 -0
  129. package/src/server.js +1307 -0
  130. package/workflows/corps.yaml +481 -0
  131. package/workflows/yolo.yaml +241 -0
  132. package/xflow/README.md +76 -0
  133. package/xflow/aha/SKILL.md +82 -0
  134. package/xflow/aha/scripts/append_aha.py +50 -0
  135. package/xflow/archive/scripts/archive_change.py +338 -0
  136. package/xflow/archive/templates/merge-design.md +54 -0
  137. package/xflow/archive/templates/merge-workflow.md +26 -0
  138. package/xflow/atoms/__init__.py +6 -0
  139. package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
  140. package/xflow/atoms/a2_branch_create_and_link.py +42 -0
  141. package/xflow/atoms/a3_repo_doctor.py +11 -0
  142. package/xflow/atoms/a4_project_set_status.py +11 -0
  143. package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
  144. package/xflow/atoms/a6_pr_create.py +72 -0
  145. package/xflow/atoms/b1_change_scaffold.py +11 -0
  146. package/xflow/atoms/b2_status_read.py +16 -0
  147. package/xflow/atoms/b2b_status_write.py +20 -0
  148. package/xflow/atoms/b3_status_transition.py +27 -0
  149. package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
  150. package/xflow/atoms/b5_aha_append.py +11 -0
  151. package/xflow/atoms/b6_takein_digest.py +11 -0
  152. package/xflow/atoms/b6b_lookback_digest.py +11 -0
  153. package/xflow/atoms/b7_change_mission_bind.py +43 -0
  154. package/xflow/atoms/c1_mem.py +54 -0
  155. package/xflow/atoms/e6_gate_local_precheck.py +23 -0
  156. package/xflow/atoms/h1_design_lite_gate.py +74 -0
  157. package/xflow/atoms/i6a_tdd_run.py +107 -0
  158. package/xflow/atoms/i6b_tdd_proof_validate.py +184 -0
  159. package/xflow/atoms/i6c_tdd_quality_review.py +372 -0
  160. package/xflow/atoms/j1_tests_run.py +129 -0
  161. package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
  162. package/xflow/atoms/j4b_spec_delta_review.py +316 -0
  163. package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
  164. package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
  165. package/xflow/atoms/j6_cr_review.py +443 -0
  166. package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
  167. package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
  168. package/xflow/atoms/k3_handoff_refresh.py +14 -0
  169. package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
  170. package/xflow/atoms/k5_archive_publish.py +21 -0
  171. package/xflow/atoms/k6_aha_merge.py +42 -0
  172. package/xflow/atoms/tdd_lib.py +956 -0
  173. package/xflow/corps/SKILL.md +109 -0
  174. package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
  175. package/xflow/goal/SKILL.md +150 -0
  176. package/xflow/handoff/SKILL.md +95 -0
  177. package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
  178. package/xflow/lookback/SKILL.md +66 -0
  179. package/xflow/lookback/scripts/lookback_digest.py +46 -0
  180. package/xflow/openissue/scripts/open_issue_flow.py +438 -0
  181. package/xflow/plan/SKILL.md +47 -0
  182. package/xflow/ralph/SKILL.md +123 -0
  183. package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
  184. package/xflow/scripts/common.py +483 -0
  185. package/xflow/scripts/doctor.py +278 -0
  186. package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
  187. package/xflow/takein/SKILL.md +81 -0
  188. package/xflow/takein/scripts/takein_digest.py +88 -0
  189. package/xflow/yolo/SKILL.md +75 -0
  190. package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
  191. package/xflow/yolo/scripts/yolo_gate.py +312 -0
  192. package/xflow/yolo/templates/DESIGN.md +179 -0
@@ -0,0 +1,956 @@
1
+ #!/usr/bin/env python3
2
+ """Shared TDD proof and quality checks.
3
+
4
+ Threat model: I6a binds the recorded result to a snapshot of the tree it
5
+ actually ran against. I6b recomputes that binding and re-runs the command in
6
+ a copy of the snapshot. A hand-edited exit code, stdout tail, or failure class
7
+ fails unless the snapshot really exits that way and the binding is rebuilt.
8
+ An agent who can write the tree can still record a genuine failing snapshot
9
+ and later replace the working tree; the digest is not a trusted third party.
10
+ Byte-identical stdout is not required, because runners print timings.
11
+
12
+ Scoped sealing (issue #5 P1-4): inside a git work tree the snapshot copies
13
+ only the files that differ from HEAD plus the files the test command names.
14
+ Every other file is pinned to the recorded HEAD: I6b materializes it from
15
+ `git archive` at replay time, so the replayed tree is byte-identical to the
16
+ sealed one while the stored manifest stays proportional to the change.
17
+ Outside a git work tree the legacy full-tree copy is used unchanged.
18
+ """
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import shlex
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+ from pathlib import Path
28
+
29
+
30
+ MODES = ("red-green", "refactor", "docs")
31
+ CODE_EXTENSIONS = {
32
+ ".js", ".jsx", ".mjs", ".cjs",
33
+ ".ts", ".tsx",
34
+ ".py", ".go", ".rs", ".java", ".kt", ".rb", ".php",
35
+ }
36
+ TEST_PATTERNS = [
37
+ re.compile(r"(^|/)(test|tests|spec|__tests__)/"),
38
+ re.compile(r"(^|/)[^/]+(\.test|\.spec|_test)\.[^/]+$"),
39
+ ]
40
+ SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".pytest_cache"}
41
+ BINDING_FIELDS = (
42
+ "phase",
43
+ "attempt",
44
+ "command",
45
+ "exit_code",
46
+ "passed",
47
+ "expected",
48
+ "expectation_met",
49
+ "output_sha256",
50
+ "stdout_tail",
51
+ "stderr_tail",
52
+ "git_head",
53
+ "file_hashes",
54
+ "snapshot_scope",
55
+ "failure_kind",
56
+ "recorded_at",
57
+ )
58
+ ASSERTION_PATTERN = re.compile(
59
+ r"(?:"
60
+ r"\bassert\.(?:ok|equal|strictEqual|deepEqual|deepStrictEqual|notEqual|notStrictEqual|match|doesNotMatch|throws|rejects)\b"
61
+ r"|\bassert\s+(?!from\b)\S"
62
+ r"|\bself\.assert[A-Z]\w*"
63
+ r"|\bpytest\.raises\b"
64
+ r"|\bexpect\s*\("
65
+ r"|\bshould\."
66
+ r"|\.should\b"
67
+ r")"
68
+ )
69
+ TRIVIAL_ASSERTIONS = [
70
+ re.compile(r"assert\.(ok|equal|strictEqual)\(\s*(true|1)\s*(,\s*(true|1))?\s*\)"),
71
+ re.compile(r"expect\(\s*(true|1)\s*\)\.(toBe|toEqual)\(\s*(true|1)\s*\)"),
72
+ re.compile(r"expect\(\s*1\s*\)\.(toBe|toEqual)\(\s*1\s*\)"),
73
+ ]
74
+ SNAPSHOT_PATTERN = re.compile(r"\b(toMatchSnapshot|toMatchInlineSnapshot)\b")
75
+ MOCK_PATTERN = re.compile(r"\b(mock|stub|spy|jest\.fn|vi\.fn|sinon)\b", re.IGNORECASE)
76
+ WEAK_LINE = [
77
+ re.compile(r"\btypeof\b"),
78
+ re.compile(r"(?:!==?|!=)\s*(?:undefined|null)\b"),
79
+ re.compile(r"\.(?:toBeDefined|toBeUndefined)\s*\("),
80
+ ]
81
+ SKIP_SOURCE = [
82
+ re.compile(r"\b(?:test|it|describe|t)\.skip\b"),
83
+ re.compile(r"\b(?:test|it|describe|t)\.todo\b"),
84
+ re.compile(r"\b(?:test|it|describe|t)\.only\b"),
85
+ re.compile(r"\bxit\s*\("),
86
+ re.compile(r"\b(?:fit|fdescribe|fcontext)\s*\("),
87
+ re.compile(r"\b(?:skip|todo)\s*:\s*true\b"),
88
+ re.compile(r"@pytest\.mark\.(?:skip|skipif|xfail)\b"),
89
+ re.compile(r"@unittest\.skip(?:If|Unless)?\b"),
90
+ ]
91
+ FULL_SUITE = [
92
+ re.compile(r"^(?:npm|pnpm|yarn)\s+test\b"),
93
+ re.compile(r"^(?:python3?|py)\s+-m\s+(?:pytest|unittest)\b"),
94
+ re.compile(r"^pytest\b"),
95
+ re.compile(r"^node\s+--test\b"),
96
+ ]
97
+ SELECTOR_FLAGS = {"--test-name-pattern", "--testNamePattern", "--grep", "-g", "-k"}
98
+ NORMATIVE_CLAUSE = re.compile(
99
+ r"^(?P<subject>.+?)\s+\b(?P<modal>MUST|SHALL)\b\s+"
100
+ r"(?P<neg>NOT\s+)?"
101
+ r"(?:(?:still|always|only|also)\s+)*"
102
+ r"(?P<verb>return|be|equal|equals|yield)\s+"
103
+ r"(?P<value>`[^`]+`|'[^']+'|\"[^\"]+\"|null|none|nil|true|false|-?\d+(?:\.\d+)?|[A-Za-z_][\w.]*)"
104
+ r"\s*\.?\s*$",
105
+ re.IGNORECASE,
106
+ )
107
+
108
+
109
+ def is_test_file(path: str) -> bool:
110
+ if path.startswith(("docs/", "specs/", "test/fixtures/", "tests/fixtures/")):
111
+ return False
112
+ if Path(path).suffix not in CODE_EXTENSIONS:
113
+ return False
114
+ return any(pattern.search(path) for pattern in TEST_PATTERNS)
115
+
116
+
117
+ def is_code_file(path: str) -> bool:
118
+ suffix = Path(path).suffix
119
+ if suffix not in CODE_EXTENSIONS:
120
+ return False
121
+ if path.startswith(("docs/", "specs/changes/")):
122
+ return False
123
+ return not is_test_file(path)
124
+
125
+
126
+ def read_status(project_root: Path, change_id: str) -> dict:
127
+ path = project_root / "specs" / "changes" / change_id / "status.json"
128
+ try:
129
+ return json.loads(path.read_text(encoding="utf-8"))
130
+ except (OSError, json.JSONDecodeError):
131
+ return {}
132
+
133
+
134
+ def resolve_tdd_mode(project_root: Path, change_id: str, explicit: str | None) -> str:
135
+ raw = (explicit or "").strip()
136
+ if not raw:
137
+ raw = str(read_status(project_root, change_id).get("tdd_mode") or "").strip()
138
+ return raw or "red-green"
139
+
140
+
141
+ def sha256_bytes(data: bytes) -> str:
142
+ return hashlib.sha256(data).hexdigest()
143
+
144
+
145
+ # Optional binding fields join the payload only when present, so proofs
146
+ # sealed before the field existed keep verifying byte-for-byte.
147
+ OPTIONAL_BINDING_FIELDS = {"snapshot_scope"}
148
+
149
+
150
+ def binding_payload(proof: dict) -> dict:
151
+ payload = {}
152
+ for field in BINDING_FIELDS:
153
+ if field in OPTIONAL_BINDING_FIELDS and proof.get(field) is None:
154
+ continue
155
+ payload[field] = proof.get(field)
156
+ return payload
157
+
158
+
159
+ def compute_binding(proof: dict) -> str:
160
+ raw = json.dumps(binding_payload(proof), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
161
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
162
+
163
+
164
+ def git_head(project_root: Path):
165
+ result = subprocess.run(
166
+ ["git", "rev-parse", "HEAD"],
167
+ cwd=project_root,
168
+ text=True,
169
+ capture_output=True,
170
+ )
171
+ if result.returncode != 0:
172
+ return None
173
+ return result.stdout.strip() or None
174
+
175
+
176
+ def tdd_dir(project_root: Path, change_id: str) -> Path:
177
+ return project_root / "specs" / "changes" / change_id / "tdd"
178
+
179
+
180
+ def snapshot_dir(project_root: Path, change_id: str, phase: str, attempt: int) -> Path:
181
+ return tdd_dir(project_root, change_id) / "snapshots" / f"{phase}-{attempt}"
182
+
183
+
184
+ def _skipped_walk(rel_dir: str, skip_prefix: str) -> bool:
185
+ if not rel_dir or rel_dir == ".":
186
+ return False
187
+ return rel_dir == skip_prefix or rel_dir.startswith(skip_prefix + "/")
188
+
189
+
190
+ def iter_project_files(project_root: Path, skip_prefix: str):
191
+ for dirpath, dirnames, filenames in os.walk(project_root):
192
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
193
+ rel_dir = Path(dirpath).relative_to(project_root).as_posix()
194
+ if _skipped_walk(rel_dir, skip_prefix):
195
+ dirnames[:] = []
196
+ continue
197
+ for name in filenames:
198
+ rel = name if rel_dir in ("", ".") else f"{rel_dir}/{name}"
199
+ full = Path(dirpath) / name
200
+ if full.is_symlink() or not full.is_file():
201
+ continue
202
+ yield rel, full
203
+
204
+
205
+ def _git_status_paths(project_root: Path):
206
+ """(changed, deleted) path sets from `git status --porcelain -z`.
207
+
208
+ Returns None when the project is not a git work tree. Renames report the
209
+ new path as changed and the old path as deleted.
210
+ """
211
+ result = subprocess.run(
212
+ ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
213
+ cwd=project_root,
214
+ capture_output=True,
215
+ )
216
+ if result.returncode != 0:
217
+ return None
218
+ entries = result.stdout.decode("utf-8", errors="replace").split("\0")
219
+ changed: set[str] = set()
220
+ deleted: set[str] = set()
221
+ index = 0
222
+ while index < len(entries):
223
+ entry = entries[index]
224
+ index += 1
225
+ if len(entry) < 4:
226
+ continue
227
+ code, rel = entry[:2], entry[3:]
228
+ if "R" in code or "C" in code:
229
+ old = entries[index] if index < len(entries) else ""
230
+ index += 1
231
+ if old:
232
+ deleted.add(old)
233
+ if "D" in code:
234
+ deleted.add(rel)
235
+ elif rel:
236
+ changed.add(rel)
237
+ return changed, deleted
238
+
239
+
240
+ def changed_since(project_root: Path, ref: str) -> set[str]:
241
+ """Paths whose working-tree content differs from `ref`, plus untracked."""
242
+ paths: set[str] = set()
243
+ diff = subprocess.run(
244
+ ["git", "diff", "--name-only", "-z", ref],
245
+ cwd=project_root,
246
+ capture_output=True,
247
+ )
248
+ if diff.returncode == 0:
249
+ paths.update(p for p in diff.stdout.decode("utf-8", errors="replace").split("\0") if p)
250
+ others = subprocess.run(
251
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"],
252
+ cwd=project_root,
253
+ capture_output=True,
254
+ )
255
+ if others.returncode == 0:
256
+ paths.update(p for p in others.stdout.decode("utf-8", errors="replace").split("\0") if p)
257
+ return paths
258
+
259
+
260
+ def snapshot_scope(project_root: Path, change_id: str, command: str | None) -> dict | None:
261
+ """Files the proof actually depends on (issue #5 P1-4).
262
+
263
+ Scoped sealing only applies inside a git work tree: every file that
264
+ differs from HEAD is sealed by copy, and every other file is pinned to
265
+ the recorded HEAD and materialized from git at replay time. Returns None
266
+ when no HEAD exists to pin the rest of the tree to (legacy full copy).
267
+ """
268
+ if git_head(project_root) is None:
269
+ return None
270
+ status = _git_status_paths(project_root)
271
+ if status is None:
272
+ return None
273
+ changed, deleted = status
274
+ evidence_prefix = (Path("specs") / "changes" / change_id).as_posix() + "/"
275
+
276
+ def excluded(rel: str) -> bool:
277
+ return rel.startswith(evidence_prefix)
278
+
279
+ def noise(rel: str) -> bool:
280
+ return any(part in SKIP_DIR_NAMES for part in rel.split("/"))
281
+
282
+ files = {rel for rel in changed if not excluded(rel) and not noise(rel)}
283
+ for rel in test_paths(command or ""):
284
+ if not excluded(rel) and (project_root / rel).is_file():
285
+ files.add(rel)
286
+ return {
287
+ "version": 1,
288
+ "files": sorted(files),
289
+ "deleted": sorted(rel for rel in deleted if not excluded(rel) and not noise(rel)),
290
+ }
291
+
292
+
293
+ def write_snapshot(project_root: Path, change_id: str, phase: str, attempt: int,
294
+ command: str | None = None) -> tuple[dict, dict | None]:
295
+ """Seal the tree state a proof ran against.
296
+
297
+ With a scope (git work tree), only the scoped files are copied and
298
+ hashed; the manifest stays proportional to the change, not the tree.
299
+ Without one, the legacy full-tree copy is used.
300
+ """
301
+ skip_prefix = (Path("specs") / "changes" / change_id / "tdd").as_posix()
302
+ destination = snapshot_dir(project_root, change_id, phase, attempt)
303
+ if destination.exists():
304
+ shutil.rmtree(destination)
305
+ destination.mkdir(parents=True, exist_ok=True)
306
+ scope = snapshot_scope(project_root, change_id, command)
307
+ hashes = {}
308
+ if scope is None:
309
+ for rel, full in iter_project_files(project_root, skip_prefix):
310
+ data = full.read_bytes()
311
+ hashes[rel] = sha256_bytes(data)
312
+ target = destination / rel
313
+ target.parent.mkdir(parents=True, exist_ok=True)
314
+ target.write_bytes(data)
315
+ return hashes, None
316
+ for rel in scope["files"]:
317
+ full = project_root / rel
318
+ if full.is_symlink() or not full.is_file():
319
+ continue
320
+ data = full.read_bytes()
321
+ hashes[rel] = sha256_bytes(data)
322
+ target = destination / rel
323
+ target.parent.mkdir(parents=True, exist_ok=True)
324
+ target.write_bytes(data)
325
+ scope = {**scope, "files": sorted(hashes)}
326
+ return hashes, scope
327
+
328
+
329
+ def snapshot_tree_hashes(destination: Path):
330
+ if not destination.is_dir():
331
+ return None
332
+ hashes = {}
333
+ for dirpath, dirnames, filenames in os.walk(destination):
334
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
335
+ for name in filenames:
336
+ full = Path(dirpath) / name
337
+ rel = full.relative_to(destination).as_posix()
338
+ hashes[rel] = sha256_bytes(full.read_bytes())
339
+ return hashes
340
+
341
+
342
+ def output_digest(stdout: str, stderr: str) -> str:
343
+ return hashlib.sha256(f"{stdout}\0{stderr}".encode("utf-8")).hexdigest()
344
+
345
+
346
+ # Node's built-in test runner ends a run with a summary block of
347
+ # "<marker> <label> <count>" lines — the runner's own structured tally.
348
+ # The marker depends on the reporter: "#" for the TAP reporter (the piped
349
+ # default up to Node 22) and "ℹ" for the spec reporter (the piped default
350
+ # from Node 24, and selectable on any version). The gate reads only these
351
+ # line-anchored summary lines, never free-floating text, so test names or
352
+ # program output cannot forge a count.
353
+ _NODE_GATE_LABELS = ("pass", "fail", "skipped", "todo")
354
+ # Lines every node:test summary block carries and other TAP emitters do
355
+ # not; their joint presence is what marks a block as a node summary.
356
+ _NODE_SIGNATURE_LABELS = ("tests", "suites", "cancelled")
357
+
358
+
359
+ def _summary_count(text: str, label: str):
360
+ match = re.search(rf"^[#\u2139]\s*{label}\s+(\d+)\b", text, re.MULTILINE)
361
+ if not match:
362
+ return None
363
+ return int(match.group(1))
364
+
365
+
366
+ def node_run_counts(text: str):
367
+ """Summary counts from a node:test run, or None when there is no summary.
368
+
369
+ A dict is returned once a block carries the full node signature
370
+ (tests/suites/cancelled). A gate label the block does not carry maps
371
+ to None — unknown, never silently zero: a truncated or reshaped
372
+ block must not read as "zero skipped".
373
+ """
374
+ if not text:
375
+ return None
376
+ if any(_summary_count(text, label) is None for label in _NODE_SIGNATURE_LABELS):
377
+ return None
378
+ labels = _NODE_SIGNATURE_LABELS + _NODE_GATE_LABELS
379
+ return {label: _summary_count(text, label) for label in labels}
380
+
381
+
382
+ def output_counts_unknown(text: str) -> bool:
383
+ """True when a node:test summary is present but a gate count is unreadable."""
384
+ counts = node_run_counts(text)
385
+ if counts is None:
386
+ return False
387
+ return any(counts[label] is None for label in _NODE_GATE_LABELS)
388
+
389
+
390
+ def output_non_result(text: str) -> bool:
391
+ """True when the runner reported skip/todo/xfail and no passing test."""
392
+ if not text:
393
+ return False
394
+ counts = node_run_counts(text)
395
+ node_failed = None
396
+ if counts is not None:
397
+ skipped = counts["skipped"]
398
+ todo = counts["todo"]
399
+ passed = counts["pass"]
400
+ node_failed = counts["fail"]
401
+ if None not in (skipped, todo, passed, node_failed):
402
+ if skipped + todo > 0 and passed == 0 and node_failed == 0:
403
+ return True
404
+ py_skipped = sum(int(item) for item in re.findall(r"\b(\d+)\s+skipped\b", text))
405
+ py_skipped += sum(int(item) for item in re.findall(r"\b(\d+)\s+xfailed\b", text))
406
+ py_passed = sum(int(item) for item in re.findall(r"\b(\d+)\s+passed\b", text))
407
+ if py_skipped > 0 and py_passed == 0 and (node_failed or 0) == 0:
408
+ return True
409
+ return False
410
+
411
+
412
+ def classify_output(text: str, passed: bool) -> str:
413
+ if output_non_result(text):
414
+ return "skipped"
415
+ if passed:
416
+ # Fail closed: a node:test summary that cannot be fully read does
417
+ # not prove a clean pass — the skip/todo counts may sit in the
418
+ # part that failed to parse, so the run is unverifiable, not green.
419
+ if output_counts_unknown(text):
420
+ return "counts_unknown"
421
+ return "passed"
422
+ if re.search(r"does not provide an export named|cannot import name\b", text, re.IGNORECASE):
423
+ return "test_error"
424
+ if re.search(r"\b(MODULE_NOT_FOUND|Cannot find module|SyntaxError|ModuleNotFoundError|ImportError|No module named)\b", text) and not re.search(
425
+ r"\b(AssertionError|ERR_ASSERTION)\b", text
426
+ ):
427
+ return "import_or_syntax"
428
+ if re.search(r"\b(AssertionError|ERR_ASSERTION)\b", text):
429
+ return "assertion"
430
+ if re.search(r"\bnot ok\b", text) or re.search(r"\bERROR:", text) or re.search(r"\bFAILED\b", text) or re.search(r"\bFAIL\b", text):
431
+ return "test_error"
432
+ return "no_test_evidence"
433
+
434
+
435
+ def strip_quoted_strings(content: str) -> str:
436
+ chars = list(content)
437
+ quote = None
438
+ escaped = False
439
+ for index, char in enumerate(chars):
440
+ if quote:
441
+ if escaped:
442
+ chars[index] = " "
443
+ escaped = False
444
+ continue
445
+ if char == "\\":
446
+ chars[index] = " "
447
+ escaped = True
448
+ continue
449
+ if char == quote:
450
+ quote = None
451
+ continue
452
+ if char != "\n":
453
+ chars[index] = " "
454
+ continue
455
+ if char in ("'", '"', "`"):
456
+ quote = char
457
+ return "".join(chars)
458
+
459
+
460
+ def strip_comments(content: str) -> str:
461
+ content = re.sub(r"/\*.*?\*/", " ", content, flags=re.S)
462
+ content = re.sub(r"//.*?$", " ", content, flags=re.M)
463
+ content = re.sub(r"#.*?$", " ", content, flags=re.M)
464
+ return content
465
+
466
+
467
+ def prepare_source(content: str) -> str:
468
+ return strip_comments(strip_quoted_strings(content))
469
+
470
+
471
+ def _tokens(command: str) -> list[str]:
472
+ try:
473
+ return shlex.split(command)
474
+ except ValueError:
475
+ return command.split()
476
+
477
+
478
+ def _is_test_token(token: str) -> bool:
479
+ if token.startswith("-"):
480
+ return False
481
+ normalized = token.replace("\\", "/")
482
+ name = normalized.split("/")[-1]
483
+ if re.search(r"\.(test|spec)\.[cm]?[jt]sx?$", name):
484
+ return True
485
+ if re.search(r"(^|/)(test|tests|__tests__)/.+\.(py|js|mjs|cjs|ts|tsx)$", normalized):
486
+ return True
487
+ if name.startswith("test_") and name.endswith(".py"):
488
+ return True
489
+ if name.endswith("_test.py"):
490
+ return True
491
+ return False
492
+
493
+
494
+ def test_paths(command: str) -> list[str]:
495
+ return [token.replace("\\", "/") for token in _tokens(command) if _is_test_token(token)]
496
+
497
+
498
+ def is_full_suite(command: str) -> bool:
499
+ if test_paths(command):
500
+ return False
501
+ collapsed = " ".join(command.split())
502
+ return any(pattern.search(collapsed) for pattern in FULL_SUITE)
503
+
504
+
505
+ def selectors(command: str) -> tuple:
506
+ tokens = _tokens(command)
507
+ found = []
508
+ index = 0
509
+ while index < len(tokens):
510
+ token = tokens[index]
511
+ if token in SELECTOR_FLAGS and index + 1 < len(tokens):
512
+ found.append((token, tokens[index + 1]))
513
+ index += 2
514
+ continue
515
+ index += 1
516
+ return tuple(found)
517
+
518
+
519
+ def command_problem(red: str, green: str):
520
+ """None when green runs the red test or a superset of it."""
521
+ if not red or not green:
522
+ return None
523
+ if " ".join(red.split()) == " ".join(green.split()):
524
+ return None
525
+ red_sel = selectors(red)
526
+ green_sel = selectors(green)
527
+ if red_sel != green_sel and not (red_sel and not green_sel):
528
+ return "red_green_selector_mismatch"
529
+ red_paths = test_paths(red)
530
+ green_paths = test_paths(green)
531
+ if not red_paths and not is_full_suite(red):
532
+ return "red_command_not_a_test"
533
+ if is_full_suite(green) and not green_paths:
534
+ return None
535
+ if red_paths and set(red_paths) <= set(green_paths):
536
+ return None
537
+ if is_full_suite(red) and is_full_suite(green) and not green_paths:
538
+ return None
539
+ return "red_green_command_mismatch"
540
+
541
+
542
+ def defined_symbols(content: str) -> set[str]:
543
+ names = set()
544
+ names.update(re.findall(r"\b(?:async\s+)?function\s+([A-Za-z_]\w*)", content))
545
+ names.update(re.findall(r"\bclass\s+([A-Za-z_]\w*)", content))
546
+ names.update(re.findall(r"\bdef\s+([A-Za-z_]\w*)", content))
547
+ names.update(re.findall(r"\bexport\s+(?:async\s+)?(?:const|let|var|function|class)\s+([A-Za-z_]\w*)", content))
548
+ return names
549
+
550
+
551
+ def references_symbol(content: str, symbols: set[str]) -> bool:
552
+ return any(re.search(rf"\b{re.escape(name)}\b", content) for name in symbols)
553
+
554
+
555
+ def assertion_lines(content: str) -> list[str]:
556
+ prepared = prepare_source(content)
557
+ lines = []
558
+ for line in prepared.splitlines():
559
+ if ASSERTION_PATTERN.search(line):
560
+ lines.append(re.sub(r"\s+", " ", line).strip())
561
+ return lines
562
+
563
+
564
+ def analyze_test(content: str, implementation_sources: list[str] | None = None) -> list[dict]:
565
+ prepared = prepare_source(content)
566
+ findings = []
567
+ assertion_count = len(ASSERTION_PATTERN.findall(prepared))
568
+ snapshot_count = len(SNAPSHOT_PATTERN.findall(prepared))
569
+ mock_count = len(MOCK_PATTERN.findall(prepared))
570
+ assertion_line_list = [line for line in prepared.splitlines() if ASSERTION_PATTERN.search(line)]
571
+
572
+ for pattern in SKIP_SOURCE:
573
+ if pattern.search(prepared):
574
+ findings.append({"severity": "fail", "code": "skipped_or_todo_test"})
575
+ break
576
+
577
+ for pattern in TRIVIAL_ASSERTIONS:
578
+ if pattern.search(prepared):
579
+ findings.append({"severity": "fail", "code": "trivial_assertion"})
580
+ break
581
+
582
+ if snapshot_count > 0 and assertion_count == snapshot_count:
583
+ findings.append({"severity": "fail", "code": "snapshot_only_test"})
584
+
585
+ if mock_count >= 4 and assertion_count < 2:
586
+ findings.append({
587
+ "severity": "fail",
588
+ "code": "mock_heavy_without_real_assertions",
589
+ "mock_count": mock_count,
590
+ "assertion_count": assertion_count,
591
+ })
592
+
593
+ if assertion_line_list and all(any(pattern.search(line) for pattern in WEAK_LINE) for line in assertion_line_list):
594
+ findings.append({"severity": "fail", "code": "weak_assertion"})
595
+
596
+ if assertion_count == 0:
597
+ findings.append({"severity": "fail", "code": "no_assertions"})
598
+
599
+ # Gate v2: plausible-but-wrong checks. Each check is deliberately narrow:
600
+ # it only fires on a shape that cannot be a meaningful test, and every
601
+ # check ships with passing counter-examples in the test suite.
602
+ for pattern in SELF_COMPARISON_PATTERNS:
603
+ match = pattern.search(prepared)
604
+ if match:
605
+ findings.append({
606
+ "severity": "fail",
607
+ "code": "self_comparison_assertion",
608
+ "expression": match.group(1),
609
+ })
610
+ break
611
+
612
+ findings.extend(dead_callback_findings(prepared))
613
+
614
+ if implementation_sources:
615
+ findings.extend(same_source_expected_findings(prepared, implementation_sources))
616
+
617
+ return findings
618
+
619
+
620
+ # --- Gate v2 helpers --------------------------------------------------------
621
+
622
+ _LITERAL_RHS = r"(?:[-+]?\d|['\"]|\[|\{|true\b|false\b|True\b|False\b|None\b)"
623
+
624
+ IMPL_CONSTANT_PATTERNS = [
625
+ # JS/TS: [export] const|let|var NAME = <literal>
626
+ re.compile(rf"(?:\bexport\s+)?\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*{_LITERAL_RHS}"),
627
+ # Python module-level: NAME = <literal>
628
+ re.compile(rf"^([A-Za-z_][\w]*)\s*=\s*{_LITERAL_RHS}", re.M),
629
+ ]
630
+
631
+ TEST_LOCAL_DEF_PATTERNS = [
632
+ re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*="),
633
+ re.compile(r"^([A-Za-z_][\w]*)\s*=", re.M),
634
+ re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\("),
635
+ re.compile(r"\bdef\s+([A-Za-z_]\w*)\s*\("),
636
+ re.compile(r"\bclass\s+([A-Za-z_$][\w$]*)"),
637
+ ]
638
+
639
+ SELF_COMPARISON_PATTERNS = [
640
+ re.compile(r"\bassert\.(?:equal|strictEqual|deepEqual|deepStrictEqual)\(\s*([A-Za-z_$][\w$.[\]]*)\s*,\s*\1\s*\)"),
641
+ re.compile(r"\bexpect\(\s*([A-Za-z_$][\w$.[\]]*)\s*\)\.(?:toBe|toEqual|toStrictEqual|toDeepEqual)\(\s*\1\s*\)"),
642
+ re.compile(r"\bassertEqual\(\s*([A-Za-z_$][\w$.[\]]*)\s*,\s*\1\s*\)"),
643
+ re.compile(r"\bassert\s+([A-Za-z_$][\w$.[\]]*)\s*==\s*\1(?![\w$.[\]])"),
644
+ ]
645
+
646
+ _CALLBACK_DEF_PATTERNS = [
647
+ # JS/TS: function NAME(...) { ... }
648
+ ("js", re.compile(r"(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(")),
649
+ # JS/TS: const NAME = (...) => / const NAME = function
650
+ ("js", re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>")),
651
+ ("js", re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function\b")),
652
+ # Python: def NAME(...)
653
+ ("py", re.compile(r"^def\s+([A-Za-z_]\w*)\s*\(", re.M)),
654
+ ]
655
+
656
+
657
+ def _constant_like(name: str, exported: bool) -> bool:
658
+ if exported:
659
+ return len(name) >= 2
660
+ # Unexported implementation constants are only trusted when they use the
661
+ # screaming-snake convention; anything else collides too easily.
662
+ return bool(re.fullmatch(r"[A-Z][A-Z0-9_]{2,}", name))
663
+
664
+
665
+ def implementation_constants(sources: list[str]) -> set[str]:
666
+ names: set[str] = set()
667
+ for source in sources:
668
+ cleaned = strip_comments(source)
669
+ for pattern in IMPL_CONSTANT_PATTERNS:
670
+ for match in pattern.finditer(cleaned):
671
+ prefix = cleaned[max(0, match.start() - 8):match.start()]
672
+ exported = "export" in prefix
673
+ name = match.group(1)
674
+ if _constant_like(name, exported):
675
+ names.add(name)
676
+ return names
677
+
678
+
679
+ def test_local_names(prepared: str) -> set[str]:
680
+ names: set[str] = set()
681
+ for pattern in TEST_LOCAL_DEF_PATTERNS:
682
+ names.update(match.group(1) for match in pattern.finditer(prepared))
683
+ return names
684
+
685
+
686
+ def same_source_expected_findings(prepared: str, implementation_sources: list[str]) -> list[dict]:
687
+ """Flag assertions whose expected value is an implementation constant.
688
+
689
+ When a test asserts against a constant taken from the implementation,
690
+ editing that constant moves the expectation with it, so the test can
691
+ never fail on the value it claims to pin down. Independent literals in
692
+ the test are fine: they do not move. Names the test defines itself
693
+ shadow the implementation and are also fine.
694
+ """
695
+ constants = implementation_constants(implementation_sources)
696
+ if not constants:
697
+ return []
698
+ candidates = constants - test_local_names(prepared)
699
+ if not candidates:
700
+ return []
701
+ findings = []
702
+ seen: set[str] = set()
703
+ for line in prepared.splitlines():
704
+ if not ASSERTION_PATTERN.search(line):
705
+ continue
706
+ for name in sorted(candidates):
707
+ if name in seen:
708
+ continue
709
+ # The constant must be used as a value in the assertion, not
710
+ # called as a function.
711
+ if re.search(rf"\b{re.escape(name)}\b(?!\s*\()", line):
712
+ seen.add(name)
713
+ findings.append({
714
+ "severity": "fail",
715
+ "code": "expected_value_from_implementation",
716
+ "symbol": name,
717
+ })
718
+ return findings
719
+
720
+
721
+ def _js_body_span(prepared: str, search_from: int) -> tuple[int, int] | None:
722
+ opening = prepared.find("{", search_from)
723
+ if opening < 0:
724
+ return None
725
+ depth = 0
726
+ for index in range(opening, len(prepared)):
727
+ char = prepared[index]
728
+ if char == "{":
729
+ depth += 1
730
+ elif char == "}":
731
+ depth -= 1
732
+ if depth == 0:
733
+ return (opening, index + 1)
734
+ return None
735
+
736
+
737
+ def _py_body_span(prepared: str, def_start: int) -> tuple[int, int] | None:
738
+ # Skip the (possibly multi-line) def header: it ends at the first line
739
+ # whose stripped form ends with ':'.
740
+ position = def_start
741
+ body_start = None
742
+ for line in prepared[def_start:].splitlines(keepends=True):
743
+ position += len(line)
744
+ if line.strip().endswith(":"):
745
+ body_start = position
746
+ break
747
+ if body_start is None:
748
+ return None
749
+ end = body_start
750
+ position = body_start
751
+ for line in prepared[body_start:].splitlines(keepends=True):
752
+ if line.strip() == "" or line[0] in (" ", "\t"):
753
+ end = position + len(line)
754
+ position += len(line)
755
+ else:
756
+ break
757
+ return (body_start, end)
758
+
759
+
760
+ def dead_callback_findings(prepared: str) -> list[dict]:
761
+ """Flag named callbacks/functions whose assertions can never run.
762
+
763
+ A named function defined in a test file that contains assertions but is
764
+ never referenced again (never called, passed, or exported) is dead: its
765
+ assertions look like coverage but execute zero times. Framework entry
766
+ points are excluded: exported functions, Python ``test_*`` functions and
767
+ decorated functions.
768
+ """
769
+ findings = []
770
+ reported: set[str] = set()
771
+ for kind, pattern in _CALLBACK_DEF_PATTERNS:
772
+ for match in pattern.finditer(prepared):
773
+ name = match.group(1)
774
+ if name in reported:
775
+ continue
776
+ prefix = prepared[max(0, match.start() - 8):match.start()]
777
+ if "export" in prefix or match.group(0).startswith("export"):
778
+ continue
779
+ if kind == "py":
780
+ if name.startswith("test"):
781
+ continue
782
+ line_start = prepared.rfind("\n", 0, match.start()) + 1
783
+ previous = prepared[:line_start].rstrip("\n").splitlines()
784
+ if previous and previous[-1].lstrip().startswith("@"):
785
+ continue
786
+ span = _py_body_span(prepared, match.start())
787
+ else:
788
+ span = _js_body_span(prepared, match.end())
789
+ if span is None:
790
+ continue
791
+ body = prepared[span[0]:span[1]]
792
+ if not ASSERTION_PATTERN.search(body):
793
+ continue
794
+ outside = prepared[:match.start()] + prepared[span[1]:]
795
+ if re.search(rf"\b{re.escape(name)}\b", outside):
796
+ continue
797
+ reported.add(name)
798
+ findings.append({
799
+ "severity": "fail",
800
+ "code": "assertion_in_dead_callback",
801
+ "symbol": name,
802
+ })
803
+ return findings
804
+
805
+
806
+ def file_at_ref(project_root: Path, ref: str, relative: str):
807
+ result = subprocess.run(
808
+ ["git", "show", f"{ref}:{relative}"],
809
+ cwd=project_root,
810
+ capture_output=True,
811
+ )
812
+ if result.returncode != 0:
813
+ return None
814
+ return result.stdout
815
+
816
+
817
+ def test_env():
818
+ env = os.environ.copy()
819
+ for key in ("NODE_TEST_CONTEXT", "NODE_CHANNEL_FD"):
820
+ env.pop(key, None)
821
+ return env
822
+
823
+
824
+ def run_shell(command: str, cwd: Path, timeout: int = 120) -> subprocess.CompletedProcess:
825
+ return subprocess.run(
826
+ command,
827
+ shell=True,
828
+ cwd=cwd,
829
+ text=True,
830
+ capture_output=True,
831
+ timeout=timeout,
832
+ env=test_env(),
833
+ )
834
+
835
+
836
+ def reexec_snapshot(destination: Path, command: str, timeout: int = 120):
837
+ temp = Path(tempfile.mkdtemp(prefix="evalid-proof-"))
838
+ try:
839
+ if any(destination.iterdir()):
840
+ shutil.copytree(destination, temp, dirs_exist_ok=True)
841
+ result = run_shell(command, temp, timeout=timeout)
842
+ text = f"{result.stdout or ''}\n{result.stderr or ''}"
843
+ return result.returncode, text
844
+ finally:
845
+ shutil.rmtree(temp, ignore_errors=True)
846
+
847
+
848
+ def reexec_scoped_snapshot(project_root: Path, destination: Path, command: str,
849
+ git_head_ref: str, scope: dict, timeout: int = 120):
850
+ """Replay a scoped proof: rebuild the sealed tree, then re-run.
851
+
852
+ Unchanged files come from `git archive` at the recorded HEAD (their
853
+ seal-time content is HEAD content by construction of the scope), deleted
854
+ paths stay deleted, and the sealed scoped files are overlaid last so
855
+ they win over the archive.
856
+ """
857
+ temp = Path(tempfile.mkdtemp(prefix="evalid-proof-"))
858
+ try:
859
+ archive = subprocess.run(
860
+ ["git", "archive", git_head_ref],
861
+ cwd=project_root,
862
+ capture_output=True,
863
+ )
864
+ if archive.returncode != 0:
865
+ raise RuntimeError(f"git archive {git_head_ref} failed: {archive.stderr.decode('utf-8', errors='replace')[:300]}")
866
+ extract = subprocess.run(
867
+ ["tar", "-x", "-C", str(temp)],
868
+ input=archive.stdout,
869
+ capture_output=True,
870
+ )
871
+ if extract.returncode != 0:
872
+ raise RuntimeError(f"replay tree extraction failed: {extract.stderr.decode('utf-8', errors='replace')[:300]}")
873
+ for rel in list(scope.get("deleted", [])) + list(scope.get("files", [])):
874
+ target = temp / rel
875
+ if target.is_dir() and not target.is_symlink():
876
+ shutil.rmtree(target)
877
+ elif target.exists() or target.is_symlink():
878
+ target.unlink()
879
+ if any(destination.iterdir()):
880
+ shutil.copytree(destination, temp, dirs_exist_ok=True)
881
+ result = run_shell(command, temp, timeout=timeout)
882
+ text = f"{result.stdout or ''}\n{result.stderr or ''}"
883
+ return result.returncode, text
884
+ finally:
885
+ shutil.rmtree(temp, ignore_errors=True)
886
+
887
+
888
+ def copy_project(project_root: Path, destination: Path) -> None:
889
+ def ignore(_directory, names):
890
+ return [name for name in names if name in SKIP_DIR_NAMES]
891
+
892
+ shutil.copytree(project_root, destination, ignore=ignore, dirs_exist_ok=True)
893
+
894
+
895
+ def norm_clause_token(value: str) -> str:
896
+ text = value.strip().strip("`").strip().strip("'").strip('"').strip()
897
+ text = text.rstrip(".").strip()
898
+ text = re.sub(r"\s+", " ", text)
899
+ return text.lower()
900
+
901
+
902
+ def normative_clauses(text: str, source: str) -> list[dict]:
903
+ clauses = []
904
+ for line_number, raw in enumerate(text.splitlines(), start=1):
905
+ line = raw.strip()
906
+ if not line:
907
+ continue
908
+ normalized = re.sub(r"^\s*(?:[-*]|\d+\.)\s+", "", line)
909
+ normalized = re.sub(r"^\[[ xX]\]\s+", "", normalized).strip()
910
+ match = NORMATIVE_CLAUSE.match(normalized)
911
+ if not match:
912
+ continue
913
+ subject = norm_clause_token(match.group("subject"))
914
+ verb = match.group("verb").lower()
915
+ if verb == "equals":
916
+ verb = "equal"
917
+ clauses.append({
918
+ "source": source,
919
+ "line": line_number,
920
+ "subject": subject,
921
+ "verb": verb,
922
+ "neg": bool(match.group("neg")),
923
+ "value": norm_clause_token(match.group("value")),
924
+ "text": normalized,
925
+ })
926
+ return clauses
927
+
928
+
929
+ def contradictory_requirements(groups: list[tuple[str, str]]) -> list[str]:
930
+ """groups are (source name, text). Same subject+verb with conflicting values fails."""
931
+ clauses = []
932
+ for source, text in groups:
933
+ clauses.extend(normative_clauses(text, source))
934
+ buckets = {}
935
+ for clause in clauses:
936
+ buckets.setdefault((clause["subject"], clause["verb"]), []).append(clause)
937
+ issues = []
938
+ for (subject, verb), items in buckets.items():
939
+ positives = {}
940
+ negatives = {}
941
+ for item in items:
942
+ target = negatives if item["neg"] else positives
943
+ target.setdefault(item["value"], set()).add(item["source"])
944
+ if len(positives) > 1:
945
+ rendered = ", ".join(
946
+ f"MUST {verb} {value} ({', '.join(sorted(sources))})"
947
+ for value, sources in sorted(positives.items())
948
+ )
949
+ issues.append(f'contradictory requirement for "{subject}" {verb}: {rendered}')
950
+ for value in sorted(set(positives) & set(negatives)):
951
+ yes = ", ".join(sorted(positives[value]))
952
+ no = ", ".join(sorted(negatives[value]))
953
+ issues.append(
954
+ f'contradictory requirement for "{subject}" {verb}: MUST {verb} {value} ({yes}) vs MUST NOT {verb} {value} ({no})'
955
+ )
956
+ return issues