@surlymochan/evalid 0.0.0-stage → 0.3.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 +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +315 -2
  4. package/RELEASE_NOTES.md +102 -0
  5. package/atoms/registry.json +758 -0
  6. package/bin/xflow.js +7208 -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 +83 -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 +1125 -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 +998 -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 +479 -0
  131. package/workflows/yolo.yaml +233 -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 +104 -0
  158. package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
  159. package/xflow/atoms/i6c_tdd_quality_review.py +357 -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 +747 -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 +38 -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 +480 -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 +66 -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,747 @@
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
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shlex
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+
23
+ MODES = ("red-green", "refactor", "docs")
24
+ CODE_EXTENSIONS = {
25
+ ".js", ".jsx", ".mjs", ".cjs",
26
+ ".ts", ".tsx",
27
+ ".py", ".go", ".rs", ".java", ".kt", ".rb", ".php",
28
+ }
29
+ TEST_PATTERNS = [
30
+ re.compile(r"(^|/)(test|tests|spec|__tests__)/"),
31
+ re.compile(r"(^|/)[^/]+(\.test|\.spec|_test)\.[^/]+$"),
32
+ ]
33
+ SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".pytest_cache"}
34
+ BINDING_FIELDS = (
35
+ "phase",
36
+ "attempt",
37
+ "command",
38
+ "exit_code",
39
+ "passed",
40
+ "expected",
41
+ "expectation_met",
42
+ "output_sha256",
43
+ "stdout_tail",
44
+ "stderr_tail",
45
+ "git_head",
46
+ "file_hashes",
47
+ "failure_kind",
48
+ "recorded_at",
49
+ )
50
+ ASSERTION_PATTERN = re.compile(
51
+ r"(?:"
52
+ r"\bassert\.(?:ok|equal|strictEqual|deepEqual|deepStrictEqual|notEqual|notStrictEqual|match|doesNotMatch|throws|rejects)\b"
53
+ r"|\bassert\s+(?!from\b)\S"
54
+ r"|\bself\.assert[A-Z]\w*"
55
+ r"|\bpytest\.raises\b"
56
+ r"|\bexpect\s*\("
57
+ r"|\bshould\."
58
+ r"|\.should\b"
59
+ r")"
60
+ )
61
+ TRIVIAL_ASSERTIONS = [
62
+ re.compile(r"assert\.(ok|equal|strictEqual)\(\s*(true|1)\s*(,\s*(true|1))?\s*\)"),
63
+ re.compile(r"expect\(\s*(true|1)\s*\)\.(toBe|toEqual)\(\s*(true|1)\s*\)"),
64
+ re.compile(r"expect\(\s*1\s*\)\.(toBe|toEqual)\(\s*1\s*\)"),
65
+ ]
66
+ SNAPSHOT_PATTERN = re.compile(r"\b(toMatchSnapshot|toMatchInlineSnapshot)\b")
67
+ MOCK_PATTERN = re.compile(r"\b(mock|stub|spy|jest\.fn|vi\.fn|sinon)\b", re.IGNORECASE)
68
+ WEAK_LINE = [
69
+ re.compile(r"\btypeof\b"),
70
+ re.compile(r"(?:!==?|!=)\s*(?:undefined|null)\b"),
71
+ re.compile(r"\.(?:toBeDefined|toBeUndefined)\s*\("),
72
+ ]
73
+ SKIP_SOURCE = [
74
+ re.compile(r"\b(?:test|it|describe|t)\.skip\b"),
75
+ re.compile(r"\b(?:test|it|describe|t)\.todo\b"),
76
+ re.compile(r"\b(?:test|it|describe|t)\.only\b"),
77
+ re.compile(r"\bxit\s*\("),
78
+ re.compile(r"\b(?:fit|fdescribe|fcontext)\s*\("),
79
+ re.compile(r"\b(?:skip|todo)\s*:\s*true\b"),
80
+ re.compile(r"@pytest\.mark\.(?:skip|skipif|xfail)\b"),
81
+ re.compile(r"@unittest\.skip(?:If|Unless)?\b"),
82
+ ]
83
+ FULL_SUITE = [
84
+ re.compile(r"^(?:npm|pnpm|yarn)\s+test\b"),
85
+ re.compile(r"^(?:python3?|py)\s+-m\s+(?:pytest|unittest)\b"),
86
+ re.compile(r"^pytest\b"),
87
+ re.compile(r"^node\s+--test\b"),
88
+ ]
89
+ SELECTOR_FLAGS = {"--test-name-pattern", "--testNamePattern", "--grep", "-g", "-k"}
90
+ NORMATIVE_CLAUSE = re.compile(
91
+ r"^(?P<subject>.+?)\s+\b(?P<modal>MUST|SHALL)\b\s+"
92
+ r"(?P<neg>NOT\s+)?"
93
+ r"(?:(?:still|always|only|also)\s+)*"
94
+ r"(?P<verb>return|be|equal|equals|yield)\s+"
95
+ r"(?P<value>`[^`]+`|'[^']+'|\"[^\"]+\"|null|none|nil|true|false|-?\d+(?:\.\d+)?|[A-Za-z_][\w.]*)"
96
+ r"\s*\.?\s*$",
97
+ re.IGNORECASE,
98
+ )
99
+
100
+
101
+ def is_test_file(path: str) -> bool:
102
+ if path.startswith(("docs/", "specs/", "test/fixtures/", "tests/fixtures/")):
103
+ return False
104
+ if Path(path).suffix not in CODE_EXTENSIONS:
105
+ return False
106
+ return any(pattern.search(path) for pattern in TEST_PATTERNS)
107
+
108
+
109
+ def is_code_file(path: str) -> bool:
110
+ suffix = Path(path).suffix
111
+ if suffix not in CODE_EXTENSIONS:
112
+ return False
113
+ if path.startswith(("docs/", "specs/changes/")):
114
+ return False
115
+ return not is_test_file(path)
116
+
117
+
118
+ def read_status(project_root: Path, change_id: str) -> dict:
119
+ path = project_root / "specs" / "changes" / change_id / "status.json"
120
+ try:
121
+ return json.loads(path.read_text(encoding="utf-8"))
122
+ except (OSError, json.JSONDecodeError):
123
+ return {}
124
+
125
+
126
+ def resolve_tdd_mode(project_root: Path, change_id: str, explicit: str | None) -> str:
127
+ raw = (explicit or "").strip()
128
+ if not raw:
129
+ raw = str(read_status(project_root, change_id).get("tdd_mode") or "").strip()
130
+ return raw or "red-green"
131
+
132
+
133
+ def sha256_bytes(data: bytes) -> str:
134
+ return hashlib.sha256(data).hexdigest()
135
+
136
+
137
+ def binding_payload(proof: dict) -> dict:
138
+ payload = {}
139
+ for field in BINDING_FIELDS:
140
+ payload[field] = proof.get(field)
141
+ return payload
142
+
143
+
144
+ def compute_binding(proof: dict) -> str:
145
+ raw = json.dumps(binding_payload(proof), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
146
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
147
+
148
+
149
+ def git_head(project_root: Path):
150
+ result = subprocess.run(
151
+ ["git", "rev-parse", "HEAD"],
152
+ cwd=project_root,
153
+ text=True,
154
+ capture_output=True,
155
+ )
156
+ if result.returncode != 0:
157
+ return None
158
+ return result.stdout.strip() or None
159
+
160
+
161
+ def tdd_dir(project_root: Path, change_id: str) -> Path:
162
+ return project_root / "specs" / "changes" / change_id / "tdd"
163
+
164
+
165
+ def snapshot_dir(project_root: Path, change_id: str, phase: str, attempt: int) -> Path:
166
+ return tdd_dir(project_root, change_id) / "snapshots" / f"{phase}-{attempt}"
167
+
168
+
169
+ def _skipped_walk(rel_dir: str, skip_prefix: str) -> bool:
170
+ if not rel_dir or rel_dir == ".":
171
+ return False
172
+ return rel_dir == skip_prefix or rel_dir.startswith(skip_prefix + "/")
173
+
174
+
175
+ def iter_project_files(project_root: Path, skip_prefix: str):
176
+ for dirpath, dirnames, filenames in os.walk(project_root):
177
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
178
+ rel_dir = Path(dirpath).relative_to(project_root).as_posix()
179
+ if _skipped_walk(rel_dir, skip_prefix):
180
+ dirnames[:] = []
181
+ continue
182
+ for name in filenames:
183
+ rel = name if rel_dir in ("", ".") else f"{rel_dir}/{name}"
184
+ full = Path(dirpath) / name
185
+ if full.is_symlink() or not full.is_file():
186
+ continue
187
+ yield rel, full
188
+
189
+
190
+ def write_snapshot(project_root: Path, change_id: str, phase: str, attempt: int) -> dict:
191
+ skip_prefix = (Path("specs") / "changes" / change_id / "tdd").as_posix()
192
+ destination = snapshot_dir(project_root, change_id, phase, attempt)
193
+ if destination.exists():
194
+ shutil.rmtree(destination)
195
+ destination.mkdir(parents=True, exist_ok=True)
196
+ hashes = {}
197
+ for rel, full in iter_project_files(project_root, skip_prefix):
198
+ data = full.read_bytes()
199
+ hashes[rel] = sha256_bytes(data)
200
+ target = destination / rel
201
+ target.parent.mkdir(parents=True, exist_ok=True)
202
+ target.write_bytes(data)
203
+ return hashes
204
+
205
+
206
+ def snapshot_tree_hashes(destination: Path):
207
+ if not destination.is_dir():
208
+ return None
209
+ hashes = {}
210
+ for dirpath, dirnames, filenames in os.walk(destination):
211
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
212
+ for name in filenames:
213
+ full = Path(dirpath) / name
214
+ rel = full.relative_to(destination).as_posix()
215
+ hashes[rel] = sha256_bytes(full.read_bytes())
216
+ return hashes
217
+
218
+
219
+ def output_digest(stdout: str, stderr: str) -> str:
220
+ return hashlib.sha256(f"{stdout}\0{stderr}".encode("utf-8")).hexdigest()
221
+
222
+
223
+ def _summary_count(text: str, label: str):
224
+ match = re.search(rf"#\s+{label}\s+(\d+)\b", text)
225
+ if not match:
226
+ return None
227
+ return int(match.group(1))
228
+
229
+
230
+ def output_non_result(text: str) -> bool:
231
+ """True when the runner reported skip/todo/xfail and no passing test."""
232
+ if not text:
233
+ return False
234
+ skipped = _summary_count(text, "skipped") or 0
235
+ todo = _summary_count(text, "todo") or 0
236
+ passed = _summary_count(text, "pass")
237
+ failed = _summary_count(text, "fail")
238
+ if skipped + todo > 0 and (passed or 0) == 0 and (failed or 0) == 0:
239
+ return True
240
+ py_skipped = sum(int(item) for item in re.findall(r"\b(\d+)\s+skipped\b", text))
241
+ py_skipped += sum(int(item) for item in re.findall(r"\b(\d+)\s+xfailed\b", text))
242
+ py_passed = sum(int(item) for item in re.findall(r"\b(\d+)\s+passed\b", text))
243
+ if py_skipped > 0 and py_passed == 0 and (failed or 0) == 0:
244
+ return True
245
+ return False
246
+
247
+
248
+ def classify_output(text: str, passed: bool) -> str:
249
+ if output_non_result(text):
250
+ return "skipped"
251
+ if passed:
252
+ return "passed"
253
+ if re.search(r"does not provide an export named|cannot import name\b", text, re.IGNORECASE):
254
+ return "test_error"
255
+ if re.search(r"\b(MODULE_NOT_FOUND|Cannot find module|SyntaxError|ModuleNotFoundError|ImportError|No module named)\b", text) and not re.search(
256
+ r"\b(AssertionError|ERR_ASSERTION)\b", text
257
+ ):
258
+ return "import_or_syntax"
259
+ if re.search(r"\b(AssertionError|ERR_ASSERTION)\b", text):
260
+ return "assertion"
261
+ 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):
262
+ return "test_error"
263
+ return "no_test_evidence"
264
+
265
+
266
+ def strip_quoted_strings(content: str) -> str:
267
+ chars = list(content)
268
+ quote = None
269
+ escaped = False
270
+ for index, char in enumerate(chars):
271
+ if quote:
272
+ if escaped:
273
+ chars[index] = " "
274
+ escaped = False
275
+ continue
276
+ if char == "\\":
277
+ chars[index] = " "
278
+ escaped = True
279
+ continue
280
+ if char == quote:
281
+ quote = None
282
+ continue
283
+ if char != "\n":
284
+ chars[index] = " "
285
+ continue
286
+ if char in ("'", '"', "`"):
287
+ quote = char
288
+ return "".join(chars)
289
+
290
+
291
+ def strip_comments(content: str) -> str:
292
+ content = re.sub(r"/\*.*?\*/", " ", content, flags=re.S)
293
+ content = re.sub(r"//.*?$", " ", content, flags=re.M)
294
+ content = re.sub(r"#.*?$", " ", content, flags=re.M)
295
+ return content
296
+
297
+
298
+ def prepare_source(content: str) -> str:
299
+ return strip_comments(strip_quoted_strings(content))
300
+
301
+
302
+ def _tokens(command: str) -> list[str]:
303
+ try:
304
+ return shlex.split(command)
305
+ except ValueError:
306
+ return command.split()
307
+
308
+
309
+ def _is_test_token(token: str) -> bool:
310
+ if token.startswith("-"):
311
+ return False
312
+ normalized = token.replace("\\", "/")
313
+ name = normalized.split("/")[-1]
314
+ if re.search(r"\.(test|spec)\.[cm]?[jt]sx?$", name):
315
+ return True
316
+ if re.search(r"(^|/)(test|tests|__tests__)/.+\.(py|js|mjs|cjs|ts|tsx)$", normalized):
317
+ return True
318
+ if name.startswith("test_") and name.endswith(".py"):
319
+ return True
320
+ if name.endswith("_test.py"):
321
+ return True
322
+ return False
323
+
324
+
325
+ def test_paths(command: str) -> list[str]:
326
+ return [token.replace("\\", "/") for token in _tokens(command) if _is_test_token(token)]
327
+
328
+
329
+ def is_full_suite(command: str) -> bool:
330
+ if test_paths(command):
331
+ return False
332
+ collapsed = " ".join(command.split())
333
+ return any(pattern.search(collapsed) for pattern in FULL_SUITE)
334
+
335
+
336
+ def selectors(command: str) -> tuple:
337
+ tokens = _tokens(command)
338
+ found = []
339
+ index = 0
340
+ while index < len(tokens):
341
+ token = tokens[index]
342
+ if token in SELECTOR_FLAGS and index + 1 < len(tokens):
343
+ found.append((token, tokens[index + 1]))
344
+ index += 2
345
+ continue
346
+ index += 1
347
+ return tuple(found)
348
+
349
+
350
+ def command_problem(red: str, green: str):
351
+ """None when green runs the red test or a superset of it."""
352
+ if not red or not green:
353
+ return None
354
+ if " ".join(red.split()) == " ".join(green.split()):
355
+ return None
356
+ red_sel = selectors(red)
357
+ green_sel = selectors(green)
358
+ if red_sel != green_sel and not (red_sel and not green_sel):
359
+ return "red_green_selector_mismatch"
360
+ red_paths = test_paths(red)
361
+ green_paths = test_paths(green)
362
+ if not red_paths and not is_full_suite(red):
363
+ return "red_command_not_a_test"
364
+ if is_full_suite(green) and not green_paths:
365
+ return None
366
+ if red_paths and set(red_paths) <= set(green_paths):
367
+ return None
368
+ if is_full_suite(red) and is_full_suite(green) and not green_paths:
369
+ return None
370
+ return "red_green_command_mismatch"
371
+
372
+
373
+ def defined_symbols(content: str) -> set[str]:
374
+ names = set()
375
+ names.update(re.findall(r"\b(?:async\s+)?function\s+([A-Za-z_]\w*)", content))
376
+ names.update(re.findall(r"\bclass\s+([A-Za-z_]\w*)", content))
377
+ names.update(re.findall(r"\bdef\s+([A-Za-z_]\w*)", content))
378
+ names.update(re.findall(r"\bexport\s+(?:async\s+)?(?:const|let|var|function|class)\s+([A-Za-z_]\w*)", content))
379
+ return names
380
+
381
+
382
+ def references_symbol(content: str, symbols: set[str]) -> bool:
383
+ return any(re.search(rf"\b{re.escape(name)}\b", content) for name in symbols)
384
+
385
+
386
+ def assertion_lines(content: str) -> list[str]:
387
+ prepared = prepare_source(content)
388
+ lines = []
389
+ for line in prepared.splitlines():
390
+ if ASSERTION_PATTERN.search(line):
391
+ lines.append(re.sub(r"\s+", " ", line).strip())
392
+ return lines
393
+
394
+
395
+ def analyze_test(content: str, implementation_sources: list[str] | None = None) -> list[dict]:
396
+ prepared = prepare_source(content)
397
+ findings = []
398
+ assertion_count = len(ASSERTION_PATTERN.findall(prepared))
399
+ snapshot_count = len(SNAPSHOT_PATTERN.findall(prepared))
400
+ mock_count = len(MOCK_PATTERN.findall(prepared))
401
+ assertion_line_list = [line for line in prepared.splitlines() if ASSERTION_PATTERN.search(line)]
402
+
403
+ for pattern in SKIP_SOURCE:
404
+ if pattern.search(prepared):
405
+ findings.append({"severity": "fail", "code": "skipped_or_todo_test"})
406
+ break
407
+
408
+ for pattern in TRIVIAL_ASSERTIONS:
409
+ if pattern.search(prepared):
410
+ findings.append({"severity": "fail", "code": "trivial_assertion"})
411
+ break
412
+
413
+ if snapshot_count > 0 and assertion_count == snapshot_count:
414
+ findings.append({"severity": "fail", "code": "snapshot_only_test"})
415
+
416
+ if mock_count >= 4 and assertion_count < 2:
417
+ findings.append({
418
+ "severity": "fail",
419
+ "code": "mock_heavy_without_real_assertions",
420
+ "mock_count": mock_count,
421
+ "assertion_count": assertion_count,
422
+ })
423
+
424
+ if assertion_line_list and all(any(pattern.search(line) for pattern in WEAK_LINE) for line in assertion_line_list):
425
+ findings.append({"severity": "fail", "code": "weak_assertion"})
426
+
427
+ if assertion_count == 0:
428
+ findings.append({"severity": "fail", "code": "no_assertions"})
429
+
430
+ # Gate v2: plausible-but-wrong checks. Each check is deliberately narrow:
431
+ # it only fires on a shape that cannot be a meaningful test, and every
432
+ # check ships with passing counter-examples in the test suite.
433
+ for pattern in SELF_COMPARISON_PATTERNS:
434
+ match = pattern.search(prepared)
435
+ if match:
436
+ findings.append({
437
+ "severity": "fail",
438
+ "code": "self_comparison_assertion",
439
+ "expression": match.group(1),
440
+ })
441
+ break
442
+
443
+ findings.extend(dead_callback_findings(prepared))
444
+
445
+ if implementation_sources:
446
+ findings.extend(same_source_expected_findings(prepared, implementation_sources))
447
+
448
+ return findings
449
+
450
+
451
+ # --- Gate v2 helpers --------------------------------------------------------
452
+
453
+ _LITERAL_RHS = r"(?:[-+]?\d|['\"]|\[|\{|true\b|false\b|True\b|False\b|None\b)"
454
+
455
+ IMPL_CONSTANT_PATTERNS = [
456
+ # JS/TS: [export] const|let|var NAME = <literal>
457
+ re.compile(rf"(?:\bexport\s+)?\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*{_LITERAL_RHS}"),
458
+ # Python module-level: NAME = <literal>
459
+ re.compile(rf"^([A-Za-z_][\w]*)\s*=\s*{_LITERAL_RHS}", re.M),
460
+ ]
461
+
462
+ TEST_LOCAL_DEF_PATTERNS = [
463
+ re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*="),
464
+ re.compile(r"^([A-Za-z_][\w]*)\s*=", re.M),
465
+ re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\("),
466
+ re.compile(r"\bdef\s+([A-Za-z_]\w*)\s*\("),
467
+ re.compile(r"\bclass\s+([A-Za-z_$][\w$]*)"),
468
+ ]
469
+
470
+ SELF_COMPARISON_PATTERNS = [
471
+ re.compile(r"\bassert\.(?:equal|strictEqual|deepEqual|deepStrictEqual)\(\s*([A-Za-z_$][\w$.[\]]*)\s*,\s*\1\s*\)"),
472
+ re.compile(r"\bexpect\(\s*([A-Za-z_$][\w$.[\]]*)\s*\)\.(?:toBe|toEqual|toStrictEqual|toDeepEqual)\(\s*\1\s*\)"),
473
+ re.compile(r"\bassertEqual\(\s*([A-Za-z_$][\w$.[\]]*)\s*,\s*\1\s*\)"),
474
+ re.compile(r"\bassert\s+([A-Za-z_$][\w$.[\]]*)\s*==\s*\1(?![\w$.[\]])"),
475
+ ]
476
+
477
+ _CALLBACK_DEF_PATTERNS = [
478
+ # JS/TS: function NAME(...) { ... }
479
+ ("js", re.compile(r"(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(")),
480
+ # JS/TS: const NAME = (...) => / const NAME = function
481
+ ("js", re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>")),
482
+ ("js", re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function\b")),
483
+ # Python: def NAME(...)
484
+ ("py", re.compile(r"^def\s+([A-Za-z_]\w*)\s*\(", re.M)),
485
+ ]
486
+
487
+
488
+ def _constant_like(name: str, exported: bool) -> bool:
489
+ if exported:
490
+ return len(name) >= 2
491
+ # Unexported implementation constants are only trusted when they use the
492
+ # screaming-snake convention; anything else collides too easily.
493
+ return bool(re.fullmatch(r"[A-Z][A-Z0-9_]{2,}", name))
494
+
495
+
496
+ def implementation_constants(sources: list[str]) -> set[str]:
497
+ names: set[str] = set()
498
+ for source in sources:
499
+ cleaned = strip_comments(source)
500
+ for pattern in IMPL_CONSTANT_PATTERNS:
501
+ for match in pattern.finditer(cleaned):
502
+ prefix = cleaned[max(0, match.start() - 8):match.start()]
503
+ exported = "export" in prefix
504
+ name = match.group(1)
505
+ if _constant_like(name, exported):
506
+ names.add(name)
507
+ return names
508
+
509
+
510
+ def test_local_names(prepared: str) -> set[str]:
511
+ names: set[str] = set()
512
+ for pattern in TEST_LOCAL_DEF_PATTERNS:
513
+ names.update(match.group(1) for match in pattern.finditer(prepared))
514
+ return names
515
+
516
+
517
+ def same_source_expected_findings(prepared: str, implementation_sources: list[str]) -> list[dict]:
518
+ """Flag assertions whose expected value is an implementation constant.
519
+
520
+ When a test asserts against a constant taken from the implementation,
521
+ editing that constant moves the expectation with it, so the test can
522
+ never fail on the value it claims to pin down. Independent literals in
523
+ the test are fine: they do not move. Names the test defines itself
524
+ shadow the implementation and are also fine.
525
+ """
526
+ constants = implementation_constants(implementation_sources)
527
+ if not constants:
528
+ return []
529
+ candidates = constants - test_local_names(prepared)
530
+ if not candidates:
531
+ return []
532
+ findings = []
533
+ seen: set[str] = set()
534
+ for line in prepared.splitlines():
535
+ if not ASSERTION_PATTERN.search(line):
536
+ continue
537
+ for name in sorted(candidates):
538
+ if name in seen:
539
+ continue
540
+ # The constant must be used as a value in the assertion, not
541
+ # called as a function.
542
+ if re.search(rf"\b{re.escape(name)}\b(?!\s*\()", line):
543
+ seen.add(name)
544
+ findings.append({
545
+ "severity": "fail",
546
+ "code": "expected_value_from_implementation",
547
+ "symbol": name,
548
+ })
549
+ return findings
550
+
551
+
552
+ def _js_body_span(prepared: str, search_from: int) -> tuple[int, int] | None:
553
+ opening = prepared.find("{", search_from)
554
+ if opening < 0:
555
+ return None
556
+ depth = 0
557
+ for index in range(opening, len(prepared)):
558
+ char = prepared[index]
559
+ if char == "{":
560
+ depth += 1
561
+ elif char == "}":
562
+ depth -= 1
563
+ if depth == 0:
564
+ return (opening, index + 1)
565
+ return None
566
+
567
+
568
+ def _py_body_span(prepared: str, def_start: int) -> tuple[int, int] | None:
569
+ # Skip the (possibly multi-line) def header: it ends at the first line
570
+ # whose stripped form ends with ':'.
571
+ position = def_start
572
+ body_start = None
573
+ for line in prepared[def_start:].splitlines(keepends=True):
574
+ position += len(line)
575
+ if line.strip().endswith(":"):
576
+ body_start = position
577
+ break
578
+ if body_start is None:
579
+ return None
580
+ end = body_start
581
+ position = body_start
582
+ for line in prepared[body_start:].splitlines(keepends=True):
583
+ if line.strip() == "" or line[0] in (" ", "\t"):
584
+ end = position + len(line)
585
+ position += len(line)
586
+ else:
587
+ break
588
+ return (body_start, end)
589
+
590
+
591
+ def dead_callback_findings(prepared: str) -> list[dict]:
592
+ """Flag named callbacks/functions whose assertions can never run.
593
+
594
+ A named function defined in a test file that contains assertions but is
595
+ never referenced again (never called, passed, or exported) is dead: its
596
+ assertions look like coverage but execute zero times. Framework entry
597
+ points are excluded: exported functions, Python ``test_*`` functions and
598
+ decorated functions.
599
+ """
600
+ findings = []
601
+ reported: set[str] = set()
602
+ for kind, pattern in _CALLBACK_DEF_PATTERNS:
603
+ for match in pattern.finditer(prepared):
604
+ name = match.group(1)
605
+ if name in reported:
606
+ continue
607
+ prefix = prepared[max(0, match.start() - 8):match.start()]
608
+ if "export" in prefix or match.group(0).startswith("export"):
609
+ continue
610
+ if kind == "py":
611
+ if name.startswith("test"):
612
+ continue
613
+ line_start = prepared.rfind("\n", 0, match.start()) + 1
614
+ previous = prepared[:line_start].rstrip("\n").splitlines()
615
+ if previous and previous[-1].lstrip().startswith("@"):
616
+ continue
617
+ span = _py_body_span(prepared, match.start())
618
+ else:
619
+ span = _js_body_span(prepared, match.end())
620
+ if span is None:
621
+ continue
622
+ body = prepared[span[0]:span[1]]
623
+ if not ASSERTION_PATTERN.search(body):
624
+ continue
625
+ outside = prepared[:match.start()] + prepared[span[1]:]
626
+ if re.search(rf"\b{re.escape(name)}\b", outside):
627
+ continue
628
+ reported.add(name)
629
+ findings.append({
630
+ "severity": "fail",
631
+ "code": "assertion_in_dead_callback",
632
+ "symbol": name,
633
+ })
634
+ return findings
635
+
636
+
637
+ def file_at_ref(project_root: Path, ref: str, relative: str):
638
+ result = subprocess.run(
639
+ ["git", "show", f"{ref}:{relative}"],
640
+ cwd=project_root,
641
+ capture_output=True,
642
+ )
643
+ if result.returncode != 0:
644
+ return None
645
+ return result.stdout
646
+
647
+
648
+ def test_env():
649
+ env = os.environ.copy()
650
+ for key in ("NODE_TEST_CONTEXT", "NODE_CHANNEL_FD"):
651
+ env.pop(key, None)
652
+ return env
653
+
654
+
655
+ def run_shell(command: str, cwd: Path, timeout: int = 120) -> subprocess.CompletedProcess:
656
+ return subprocess.run(
657
+ command,
658
+ shell=True,
659
+ cwd=cwd,
660
+ text=True,
661
+ capture_output=True,
662
+ timeout=timeout,
663
+ env=test_env(),
664
+ )
665
+
666
+
667
+ def reexec_snapshot(destination: Path, command: str, timeout: int = 120):
668
+ temp = Path(tempfile.mkdtemp(prefix="evalid-proof-"))
669
+ try:
670
+ if any(destination.iterdir()):
671
+ shutil.copytree(destination, temp, dirs_exist_ok=True)
672
+ result = run_shell(command, temp, timeout=timeout)
673
+ text = f"{result.stdout or ''}\n{result.stderr or ''}"
674
+ return result.returncode, text
675
+ finally:
676
+ shutil.rmtree(temp, ignore_errors=True)
677
+
678
+
679
+ def copy_project(project_root: Path, destination: Path) -> None:
680
+ def ignore(_directory, names):
681
+ return [name for name in names if name in SKIP_DIR_NAMES]
682
+
683
+ shutil.copytree(project_root, destination, ignore=ignore, dirs_exist_ok=True)
684
+
685
+
686
+ def norm_clause_token(value: str) -> str:
687
+ text = value.strip().strip("`").strip().strip("'").strip('"').strip()
688
+ text = text.rstrip(".").strip()
689
+ text = re.sub(r"\s+", " ", text)
690
+ return text.lower()
691
+
692
+
693
+ def normative_clauses(text: str, source: str) -> list[dict]:
694
+ clauses = []
695
+ for line_number, raw in enumerate(text.splitlines(), start=1):
696
+ line = raw.strip()
697
+ if not line:
698
+ continue
699
+ normalized = re.sub(r"^\s*(?:[-*]|\d+\.)\s+", "", line)
700
+ normalized = re.sub(r"^\[[ xX]\]\s+", "", normalized).strip()
701
+ match = NORMATIVE_CLAUSE.match(normalized)
702
+ if not match:
703
+ continue
704
+ subject = norm_clause_token(match.group("subject"))
705
+ verb = match.group("verb").lower()
706
+ if verb == "equals":
707
+ verb = "equal"
708
+ clauses.append({
709
+ "source": source,
710
+ "line": line_number,
711
+ "subject": subject,
712
+ "verb": verb,
713
+ "neg": bool(match.group("neg")),
714
+ "value": norm_clause_token(match.group("value")),
715
+ "text": normalized,
716
+ })
717
+ return clauses
718
+
719
+
720
+ def contradictory_requirements(groups: list[tuple[str, str]]) -> list[str]:
721
+ """groups are (source name, text). Same subject+verb with conflicting values fails."""
722
+ clauses = []
723
+ for source, text in groups:
724
+ clauses.extend(normative_clauses(text, source))
725
+ buckets = {}
726
+ for clause in clauses:
727
+ buckets.setdefault((clause["subject"], clause["verb"]), []).append(clause)
728
+ issues = []
729
+ for (subject, verb), items in buckets.items():
730
+ positives = {}
731
+ negatives = {}
732
+ for item in items:
733
+ target = negatives if item["neg"] else positives
734
+ target.setdefault(item["value"], set()).add(item["source"])
735
+ if len(positives) > 1:
736
+ rendered = ", ".join(
737
+ f"MUST {verb} {value} ({', '.join(sorted(sources))})"
738
+ for value, sources in sorted(positives.items())
739
+ )
740
+ issues.append(f'contradictory requirement for "{subject}" {verb}: {rendered}')
741
+ for value in sorted(set(positives) & set(negatives)):
742
+ yes = ", ".join(sorted(positives[value]))
743
+ no = ", ".join(sorted(negatives[value]))
744
+ issues.append(
745
+ f'contradictory requirement for "{subject}" {verb}: MUST {verb} {value} ({yes}) vs MUST NOT {verb} {value} ({no})'
746
+ )
747
+ return issues