@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,184 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ I6b.tdd.proof_validate — Validate TDD proof structure, binding, and re-execution.
4
+
5
+ Required fields: phase, attempt, command, exit_code, passed, recorded_at.
6
+ Red must prove a failing test. Green/refactor must prove passing tests.
7
+ The binding covers the recorded command, exit code, output hash, failure
8
+ class, and snapshot file hashes. This gate recomputes it and re-runs the
9
+ command in a copy of that snapshot. Green also requires snapshotted test
10
+ files to still match the working tree.
11
+
12
+ tdd_mode refactor does not require a red proof. tdd_mode docs does not
13
+ require a red or green proof. A proof that is present is still checked.
14
+ An agent who rebuilds a self-consistent failing snapshot can still produce
15
+ a binding that verifies; the check raises the bar from "edit the JSON" to
16
+ "possess a tree on which this command really exits as claimed".
17
+ """
18
+ import sys, os, json, argparse, subprocess
19
+ from pathlib import Path
20
+
21
+ import tdd_lib
22
+
23
+ REQUIRED_FIELDS = ['phase', 'attempt', 'command', 'exit_code', 'passed', 'recorded_at']
24
+
25
+ def expected_result(phase: str):
26
+ if phase == 'red':
27
+ return False
28
+ if phase in {'green', 'refactor', 'run'}:
29
+ return True
30
+ return None
31
+
32
+ def emit(payload, code):
33
+ print(json.dumps(payload))
34
+ sys.exit(code)
35
+
36
+ def main():
37
+ p = argparse.ArgumentParser()
38
+ p.add_argument('--project-root', default='.')
39
+ p.add_argument('--change-id', required=True)
40
+ p.add_argument('--phase', default='run')
41
+ p.add_argument('--attempt', type=int, default=0)
42
+ p.add_argument('--tdd-mode', default='', help='red-green | refactor | docs; empty reads status.json')
43
+ args = p.parse_args()
44
+
45
+ project = Path(args.project_root).resolve()
46
+ mode = tdd_lib.resolve_tdd_mode(project, args.change_id, args.tdd_mode)
47
+ if mode not in tdd_lib.MODES:
48
+ emit({'ok': False, 'verdict': 'invalid_tdd_mode', 'tdd_mode': mode, 'phase': args.phase}, 1)
49
+
50
+ proof_file = os.path.join(
51
+ args.project_root, 'specs', 'changes', args.change_id, 'tdd',
52
+ f'{args.phase}-{args.attempt}.json'
53
+ )
54
+
55
+ if not os.path.exists(proof_file):
56
+ if mode == 'docs' or (mode == 'refactor' and args.phase == 'red'):
57
+ emit({
58
+ 'ok': True,
59
+ 'verdict': 'not_required',
60
+ 'phase': args.phase,
61
+ 'tdd_mode': mode,
62
+ 'file': proof_file,
63
+ }, 0)
64
+ emit({'ok': False, 'verdict': 'missing', 'file': proof_file, 'phase': args.phase, 'tdd_mode': mode}, 1)
65
+
66
+ try:
67
+ proof = json.loads(open(proof_file).read())
68
+ except json.JSONDecodeError as e:
69
+ emit({'ok': False, 'verdict': 'invalid_json', 'error': str(e), 'phase': args.phase}, 1)
70
+
71
+ missing = [f for f in REQUIRED_FIELDS if f not in proof]
72
+ if missing:
73
+ emit({'ok': False, 'verdict': 'schema_error', 'missing_fields': missing, 'phase': args.phase}, 1)
74
+
75
+ if proof.get('phase') != args.phase:
76
+ emit({
77
+ 'ok': False,
78
+ 'verdict': 'phase_mismatch',
79
+ 'expected_phase': args.phase,
80
+ 'actual_phase': proof.get('phase'),
81
+ 'phase': args.phase,
82
+ }, 1)
83
+
84
+ if not proof.get('binding') or not proof.get('failure_kind') or not isinstance(proof.get('file_hashes'), dict):
85
+ emit({'ok': False, 'verdict': 'binding_missing', 'phase': args.phase}, 1)
86
+
87
+ if tdd_lib.compute_binding(proof) != proof.get('binding'):
88
+ emit({'ok': False, 'verdict': 'binding_mismatch', 'phase': args.phase}, 1)
89
+
90
+ if bool(proof.get('passed')) != (proof.get('exit_code') == 0):
91
+ emit({'ok': False, 'verdict': 'proof_inconsistent', 'phase': args.phase}, 1)
92
+
93
+ destination = tdd_lib.snapshot_dir(project, args.change_id, args.phase, args.attempt)
94
+ found = tdd_lib.snapshot_tree_hashes(destination)
95
+ if found is None or found != proof.get('file_hashes'):
96
+ emit({'ok': False, 'verdict': 'snapshot_mismatch', 'phase': args.phase}, 1)
97
+
98
+ scope = proof.get('snapshot_scope')
99
+ scoped = isinstance(scope, dict) and bool(proof.get('git_head'))
100
+ try:
101
+ if scoped:
102
+ actual_exit, actual_text = tdd_lib.reexec_scoped_snapshot(
103
+ project, destination, proof.get('command') or '', proof['git_head'], scope, timeout=120
104
+ )
105
+ else:
106
+ actual_exit, actual_text = tdd_lib.reexec_snapshot(destination, proof.get('command') or '', timeout=120)
107
+ except RuntimeError as exc:
108
+ emit({'ok': False, 'verdict': 'replay_source_missing', 'phase': args.phase, 'error': str(exc)[:300]}, 1)
109
+ except subprocess.TimeoutExpired:
110
+ emit({'ok': False, 'verdict': 'reexec_timeout', 'phase': args.phase}, 1)
111
+
112
+ if actual_exit != proof.get('exit_code'):
113
+ emit({
114
+ 'ok': False,
115
+ 'verdict': 'reexec_exit_mismatch',
116
+ 'phase': args.phase,
117
+ 'expected_exit': proof.get('exit_code'),
118
+ 'actual_exit': actual_exit,
119
+ }, 1)
120
+
121
+ actual_kind = 'timeout' if proof.get('failure_kind') == 'timeout' else tdd_lib.classify_output(actual_text, actual_exit == 0)
122
+ if proof.get('failure_kind') != actual_kind:
123
+ emit({
124
+ 'ok': False,
125
+ 'verdict': 'reexec_result_mismatch',
126
+ 'phase': args.phase,
127
+ 'expected_failure_kind': proof.get('failure_kind'),
128
+ 'actual_failure_kind': actual_kind,
129
+ }, 1)
130
+
131
+ if args.phase in {'green', 'refactor', 'run'}:
132
+ mismatched = []
133
+ for rel, digest in proof.get('file_hashes', {}).items():
134
+ if not tdd_lib.is_test_file(rel):
135
+ continue
136
+ path = project / rel
137
+ if not path.is_file() or tdd_lib.sha256_bytes(path.read_bytes()) != digest:
138
+ mismatched.append(rel)
139
+ if scoped:
140
+ # Test files outside the sealed scope were pinned to the recorded
141
+ # HEAD at seal time; any that have drifted since are unsealed edits.
142
+ sealed = set(proof.get('file_hashes', {}))
143
+ for rel in tdd_lib.changed_since(project, proof['git_head']):
144
+ if rel not in sealed and tdd_lib.is_test_file(rel):
145
+ mismatched.append(rel)
146
+ if mismatched:
147
+ emit({
148
+ 'ok': False,
149
+ 'verdict': 'green_tree_mismatch',
150
+ 'phase': args.phase,
151
+ 'files': mismatched,
152
+ }, 1)
153
+
154
+ if proof.get('failure_kind') == 'skipped' or tdd_lib.output_non_result(actual_text):
155
+ emit({'ok': False, 'verdict': 'tests_skipped_or_todo', 'phase': args.phase}, 1)
156
+
157
+ if proof.get('failure_kind') == 'counts_unknown' or tdd_lib.output_counts_unknown(actual_text):
158
+ emit({'ok': False, 'verdict': 'tests_counts_unknown', 'phase': args.phase}, 1)
159
+
160
+ expected_passed = expected_result(args.phase)
161
+ if expected_passed is not None and proof.get('passed') is not expected_passed:
162
+ emit({
163
+ 'ok': False,
164
+ 'verdict': 'semantic_error',
165
+ 'phase': args.phase,
166
+ 'expected_passed': expected_passed,
167
+ 'actual_passed': proof.get('passed'),
168
+ }, 1)
169
+
170
+ if proof.get('expectation_met') is False:
171
+ emit({'ok': False, 'verdict': 'expectation_not_met', 'phase': args.phase}, 1)
172
+
173
+ emit({
174
+ 'ok': True,
175
+ 'verdict': 'valid',
176
+ 'proof_file': proof_file,
177
+ 'passed': proof['passed'],
178
+ 'exit_code': proof['exit_code'],
179
+ 'phase': args.phase,
180
+ 'tdd_mode': mode,
181
+ }, 0)
182
+
183
+ if __name__ == '__main__':
184
+ main()
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ I6c.tdd.quality_review - deterministic review for TDD test quality.
4
+
5
+ This gate checks the parts that red/green proof cannot prove by itself:
6
+ code changes should be accompanied by test changes, changed tests must not
7
+ rely on empty assertions or snapshot-only coverage, and mock-heavy tests are
8
+ rejected when they lack enough real assertions.
9
+
10
+ tdd_mode red-green (default) requires a failing red proof and a passing
11
+ green proof. tdd_mode refactor instead requires the existing tests that
12
+ touch the changed code to pass on the pre-change tree and the current tree,
13
+ with no assertion lines removed. tdd_mode docs requires the diff to stay on
14
+ non-code paths. A semantically wrong but plausible expected value is not
15
+ decidable from the diff in general; gate v2 still catches three decidable
16
+ shapes of it: expected values taken from the implementation's own constants
17
+ (the expectation moves when the constant is edited), assertions inside
18
+ named callbacks that are never referenced again (they execute zero times),
19
+ and self-comparison assertions (x == x). Type-only or existence-only
20
+ assertions are flagged only when they are the sole assertions.
21
+ """
22
+ import argparse
23
+ import json
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+
30
+ import tdd_lib
31
+
32
+
33
+ def run_git(project_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
34
+ return subprocess.run(
35
+ ["git", *args],
36
+ cwd=project_root,
37
+ text=True,
38
+ capture_output=True,
39
+ )
40
+
41
+
42
+ def changed_files(project_root: Path, diff_ref: str) -> list[str]:
43
+ result = run_git(project_root, ["diff", "--name-only", "--diff-filter=ACMR", diff_ref])
44
+ tracked = [] if result.returncode != 0 else [
45
+ line.strip() for line in result.stdout.splitlines() if line.strip()
46
+ ]
47
+ untracked_result = run_git(project_root, ["ls-files", "--others", "--exclude-standard"])
48
+ untracked = [] if untracked_result.returncode != 0 else [
49
+ line.strip() for line in untracked_result.stdout.splitlines() if line.strip()
50
+ ]
51
+ return sorted(set(tracked + untracked))
52
+
53
+
54
+ def deleted_files(project_root: Path, diff_ref: str) -> list[str]:
55
+ result = run_git(project_root, ["diff", "--name-only", "--diff-filter=D", diff_ref])
56
+ if result.returncode != 0:
57
+ return []
58
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
59
+
60
+
61
+ def read_json(path: Path) -> dict:
62
+ try:
63
+ return json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, json.JSONDecodeError):
65
+ return {}
66
+
67
+
68
+ def read_text(path: Path) -> str:
69
+ try:
70
+ return path.read_text(encoding="utf-8")
71
+ except OSError:
72
+ return ""
73
+
74
+
75
+ def review_test_file(project_root: Path, relative_path: str, implementation_sources: list[str] | None = None) -> list[dict]:
76
+ path = project_root / relative_path
77
+ try:
78
+ content = path.read_text(encoding="utf-8")
79
+ except OSError:
80
+ return [{"severity": "fail", "code": "test_unreadable", "path": relative_path}]
81
+ findings = []
82
+ for finding in tdd_lib.analyze_test(content, implementation_sources=implementation_sources):
83
+ item = dict(finding)
84
+ item["path"] = relative_path
85
+ findings.append(item)
86
+ return findings
87
+
88
+
89
+ def proof_skipped(proof: dict) -> bool:
90
+ if not proof:
91
+ return False
92
+ if proof.get("failure_kind") == "skipped":
93
+ return True
94
+ blob = f"{proof.get('stdout_tail') or ''}\n{proof.get('stderr_tail') or ''}"
95
+ return tdd_lib.output_non_result(blob)
96
+
97
+
98
+ def proof_counts_unknown(proof: dict) -> bool:
99
+ if not proof:
100
+ return False
101
+ if proof.get("failure_kind") == "counts_unknown":
102
+ return True
103
+ blob = f"{proof.get('stdout_tail') or ''}\n{proof.get('stderr_tail') or ''}"
104
+ return tdd_lib.output_counts_unknown(blob)
105
+
106
+
107
+ def symbols_in(project_root: Path, code_files: list[str]) -> set[str]:
108
+ symbols = set()
109
+ for relative in code_files:
110
+ symbols |= tdd_lib.defined_symbols(read_text(project_root / relative))
111
+ return symbols
112
+
113
+
114
+ def iter_repo_tests(project_root: Path) -> list[str]:
115
+ found = []
116
+ skip = tdd_lib.SKIP_DIR_NAMES
117
+ for dirpath, dirnames, filenames in os_walk(project_root):
118
+ dirnames[:] = [name for name in dirnames if name not in skip]
119
+ for name in filenames:
120
+ full = Path(dirpath) / name
121
+ rel = full.relative_to(project_root).as_posix()
122
+ if tdd_lib.is_test_file(rel):
123
+ found.append(rel)
124
+ return found
125
+
126
+
127
+ def os_walk(project_root: Path):
128
+ import os
129
+ return os.walk(project_root)
130
+
131
+
132
+ def refactor_findings(project_root: Path, diff_ref: str, code_files: list[str], test_files: list[str], green_proof: dict) -> list[dict]:
133
+ findings = []
134
+ command = green_proof.get("command") or ""
135
+ if not command:
136
+ return findings
137
+
138
+ for relative in code_files:
139
+ if tdd_lib.file_at_ref(project_root, diff_ref, relative) is None:
140
+ findings.append({
141
+ "severity": "fail",
142
+ "code": "refactor_new_code",
143
+ "path": relative,
144
+ "message": "Refactor mode cannot introduce a code file that is not in the baseline.",
145
+ })
146
+
147
+ for relative in deleted_files(project_root, diff_ref):
148
+ if tdd_lib.is_test_file(relative):
149
+ findings.append({
150
+ "severity": "fail",
151
+ "code": "assertions_removed",
152
+ "path": relative,
153
+ "message": "A test file was deleted during a declared refactor.",
154
+ })
155
+
156
+ for relative in test_files:
157
+ previous = tdd_lib.file_at_ref(project_root, diff_ref, relative)
158
+ if previous is None:
159
+ continue
160
+ old_lines = tdd_lib.assertion_lines(previous.decode("utf-8", errors="replace"))
161
+ new_lines = tdd_lib.assertion_lines(read_text(project_root / relative))
162
+ leftover = list(old_lines)
163
+ for line in new_lines:
164
+ if line in leftover:
165
+ leftover.remove(line)
166
+ if leftover:
167
+ findings.append({
168
+ "severity": "fail",
169
+ "code": "assertions_removed",
170
+ "path": relative,
171
+ "message": "Refactor mode removed or rewrote an existing assertion.",
172
+ })
173
+
174
+ symbols = symbols_in(project_root, code_files)
175
+ covering = []
176
+ if symbols:
177
+ for relative in iter_repo_tests(project_root):
178
+ if tdd_lib.references_symbol(read_text(project_root / relative), symbols):
179
+ covering.append(relative)
180
+ if not covering:
181
+ findings.append({
182
+ "severity": "fail",
183
+ "code": "refactor_missing_covering_test",
184
+ "message": "No existing test references a symbol defined in the changed code.",
185
+ })
186
+ if findings:
187
+ return findings
188
+
189
+ temp_root = Path(tempfile.mkdtemp(prefix="evalid-refactor-"))
190
+ try:
191
+ before = temp_root / "before"
192
+ after = temp_root / "after"
193
+ before.mkdir()
194
+ after.mkdir()
195
+ tdd_lib.copy_project(project_root, before)
196
+ tdd_lib.copy_project(project_root, after)
197
+ for relative in code_files:
198
+ baseline = tdd_lib.file_at_ref(project_root, diff_ref, relative)
199
+ if baseline is None:
200
+ continue
201
+ target = before / relative
202
+ target.parent.mkdir(parents=True, exist_ok=True)
203
+ target.write_bytes(baseline)
204
+ try:
205
+ before_run = tdd_lib.run_shell(command, before, timeout=120)
206
+ after_run = tdd_lib.run_shell(command, after, timeout=120)
207
+ except subprocess.TimeoutExpired:
208
+ findings.append({"severity": "fail", "code": "refactor_command_timeout"})
209
+ return findings
210
+ if before_run.returncode != 0:
211
+ findings.append({
212
+ "severity": "fail",
213
+ "code": "refactor_tests_failed_before",
214
+ "message": "Existing tests must pass on the pre-change tree.",
215
+ })
216
+ if after_run.returncode != 0:
217
+ findings.append({
218
+ "severity": "fail",
219
+ "code": "refactor_tests_failed_after",
220
+ "message": "Existing tests must pass after the refactor.",
221
+ })
222
+ finally:
223
+ import shutil
224
+ shutil.rmtree(temp_root, ignore_errors=True)
225
+ return findings
226
+
227
+
228
+ def main() -> None:
229
+ parser = argparse.ArgumentParser()
230
+ parser.add_argument("--project-root", default=".")
231
+ parser.add_argument("--change-id", required=True)
232
+ parser.add_argument("--diff-ref", default="HEAD")
233
+ parser.add_argument("--tdd-mode", default="", help="red-green | refactor | docs; empty reads status.json")
234
+ args = parser.parse_args()
235
+
236
+ project_root = Path(args.project_root).resolve()
237
+ mode = tdd_lib.resolve_tdd_mode(project_root, args.change_id, args.tdd_mode)
238
+ change_dir = project_root / "specs" / "changes" / args.change_id
239
+ proof_dir = change_dir / "tdd"
240
+ red_proof = read_json(proof_dir / "red-0.json")
241
+ green_proof = read_json(proof_dir / "green-0.json")
242
+ status = tdd_lib.read_status(project_root, args.change_id)
243
+
244
+ files = changed_files(project_root, args.diff_ref)
245
+ test_files = [path for path in files if tdd_lib.is_test_file(path)]
246
+ code_files = [path for path in files if tdd_lib.is_code_file(path)]
247
+
248
+ findings = []
249
+ if mode not in tdd_lib.MODES:
250
+ findings.append({"severity": "fail", "code": "invalid_tdd_mode", "tdd_mode": mode})
251
+
252
+ if mode == "docs":
253
+ if code_files or test_files:
254
+ findings.append({
255
+ "severity": "fail",
256
+ "code": "docs_mode_with_code_change",
257
+ "message": "Docs mode was declared, but the diff changes code or tests.",
258
+ "code_files": code_files,
259
+ "test_files": test_files,
260
+ })
261
+ if green_proof and green_proof.get("passed") is not True:
262
+ findings.append({"severity": "fail", "code": "green_proof_not_passing"})
263
+ elif mode == "refactor":
264
+ if status.get("behavior_change") is True:
265
+ findings.append({
266
+ "severity": "fail",
267
+ "code": "refactor_declares_behavior_change",
268
+ "message": "Refactor mode cannot declare a public behavior change.",
269
+ })
270
+ if green_proof.get("passed") is not True:
271
+ findings.append({"severity": "fail", "code": "green_proof_not_passing"})
272
+ else:
273
+ findings.extend(refactor_findings(project_root, args.diff_ref, code_files, test_files, green_proof))
274
+ else:
275
+ if code_files and not test_files:
276
+ findings.append({
277
+ "severity": "fail",
278
+ "code": "code_without_test_change",
279
+ "message": "Code changed without a matching changed test file.",
280
+ "code_files": code_files,
281
+ })
282
+ if red_proof.get("passed") is not False:
283
+ findings.append({"severity": "fail", "code": "red_proof_not_failing"})
284
+ if green_proof.get("passed") is not True:
285
+ findings.append({"severity": "fail", "code": "green_proof_not_passing"})
286
+ if code_files or test_files:
287
+ problem = tdd_lib.command_problem(red_proof.get("command") or "", green_proof.get("command") or "")
288
+ if problem:
289
+ findings.append({
290
+ "severity": "fail",
291
+ "code": problem,
292
+ "message": "Red and green must run the same test, or green must be a superset of the red test.",
293
+ "red_command": red_proof.get("command"),
294
+ "green_command": green_proof.get("command"),
295
+ })
296
+ kind = red_proof.get("failure_kind")
297
+ if kind in {"import_or_syntax", "no_test_evidence", "timeout"}:
298
+ findings.append({
299
+ "severity": "fail",
300
+ "code": "red_failure_not_assertion",
301
+ "message": "The red failure is not an assertion or test error in the changed test.",
302
+ "failure_kind": kind,
303
+ })
304
+ if proof_skipped(red_proof) or proof_skipped(green_proof):
305
+ findings.append({
306
+ "severity": "fail",
307
+ "code": "tests_skipped_or_todo",
308
+ "message": "A recorded run skipped or marked todo the tests it claims to have executed.",
309
+ })
310
+ if proof_counts_unknown(red_proof) or proof_counts_unknown(green_proof):
311
+ findings.append({
312
+ "severity": "fail",
313
+ "code": "tests_counts_unknown",
314
+ "message": "A recorded run's summary counts could not be fully parsed, so its skip/todo counts are unknown and the run cannot be verified.",
315
+ })
316
+ symbols = symbols_in(project_root, code_files)
317
+ if symbols and test_files:
318
+ for relative in test_files:
319
+ if not tdd_lib.references_symbol(read_text(project_root / relative), symbols):
320
+ findings.append({
321
+ "severity": "fail",
322
+ "code": "test_misses_changed_symbol",
323
+ "path": relative,
324
+ "message": "A changed test does not reference a symbol defined in the changed code.",
325
+ })
326
+
327
+ reviewed = set(test_files)
328
+ if mode == "refactor":
329
+ symbols = symbols_in(project_root, code_files)
330
+ for relative in iter_repo_tests(project_root):
331
+ if symbols and tdd_lib.references_symbol(read_text(project_root / relative), symbols):
332
+ reviewed.add(relative)
333
+ implementation_sources = [read_text(project_root / relative) for relative in code_files]
334
+ for test_file in sorted(reviewed):
335
+ findings.extend(review_test_file(project_root, test_file, implementation_sources))
336
+
337
+ failed = any(finding.get("severity") == "fail" for finding in findings)
338
+ verdict = "failed" if failed else "passed"
339
+ proof = {
340
+ "ok": not failed,
341
+ "verdict": verdict,
342
+ "tdd_mode": mode,
343
+ "diff_ref": args.diff_ref,
344
+ "changed_files": files,
345
+ "code_files": code_files,
346
+ "test_files": test_files,
347
+ "findings": findings,
348
+ "recorded_at": datetime.now(timezone.utc).isoformat(),
349
+ }
350
+
351
+ proof_dir.mkdir(parents=True, exist_ok=True)
352
+ proof_file = proof_dir / "quality-0.json"
353
+ proof_file.write_text(json.dumps(proof, indent=2) + "\n", encoding="utf-8")
354
+
355
+ print(json.dumps({
356
+ "ok": proof["ok"],
357
+ "verdict": verdict,
358
+ "tdd_mode": mode,
359
+ "proof_file": str(proof_file),
360
+ "failures": sum(1 for finding in findings if finding.get("severity") == "fail"),
361
+ "warnings": sum(1 for finding in findings if finding.get("severity") == "warn"),
362
+ "failed_codes": [
363
+ finding.get("code")
364
+ for finding in findings
365
+ if finding.get("severity") == "fail"
366
+ ],
367
+ }))
368
+ sys.exit(0 if proof["ok"] else 1)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ J1.tests.run — Run planned verification commands, capture pass/fail proof.
4
+ Reads commands from plan.md (lines starting with ` ``` bash` blocks or --commands arg).
5
+ """
6
+ import sys, os, json, argparse, subprocess, re
7
+ from datetime import datetime, timezone
8
+
9
+ def extract_commands_from_plan(plan_path):
10
+ """Extract bash commands from plan.md code blocks."""
11
+ if not os.path.exists(plan_path):
12
+ return []
13
+ content = open(plan_path).read()
14
+ # Find ```bash ... ``` blocks
15
+ blocks = re.findall(r'```bash\n(.*?)```', content, re.DOTALL)
16
+ commands = []
17
+ for block in blocks:
18
+ for line in block.strip().splitlines():
19
+ line = line.strip()
20
+ if line and not line.startswith('#'):
21
+ commands.append(line)
22
+ return commands
23
+
24
+ def normalize_commands(value):
25
+ if isinstance(value, str):
26
+ command = value.strip()
27
+ return [command] if command else []
28
+ if isinstance(value, list):
29
+ commands = []
30
+ for item in value:
31
+ if isinstance(item, str) and item.strip():
32
+ commands.append(item.strip())
33
+ elif isinstance(item, dict) and isinstance(item.get('command'), str) and item.get('command').strip():
34
+ commands.append(item['command'].strip())
35
+ return commands
36
+ return []
37
+
38
+ def extract_commands_from_change_contract(project_root, change_id):
39
+ change_dir = os.path.join(project_root, 'specs', 'changes', change_id)
40
+ candidates = [
41
+ 'plan.json',
42
+ 'corps-input.json',
43
+ 'yolo-input.json',
44
+ 'input.json',
45
+ 'intake.json',
46
+ ]
47
+ command_keys = [
48
+ 'verification_commands',
49
+ 'test_commands',
50
+ 'qa_commands',
51
+ 'commands',
52
+ ]
53
+ fallback_keys = [
54
+ 'tdd_green_command',
55
+ ]
56
+ for filename in candidates:
57
+ path = os.path.join(change_dir, filename)
58
+ if not os.path.exists(path):
59
+ continue
60
+ try:
61
+ data = json.load(open(path))
62
+ except (OSError, json.JSONDecodeError):
63
+ continue
64
+ for key in command_keys:
65
+ commands = normalize_commands(data.get(key))
66
+ if commands:
67
+ return commands
68
+ for key in fallback_keys:
69
+ commands = normalize_commands(data.get(key))
70
+ if commands:
71
+ return commands
72
+ return []
73
+
74
+ def verification_command_env():
75
+ env = os.environ.copy()
76
+ # J1's own orchestration change id should not alter the behavior of the
77
+ # verification commands it runs. Commands that need a CHANGE_ID must set it.
78
+ env.pop('CHANGE_ID', None)
79
+ return env
80
+
81
+ def main():
82
+ p = argparse.ArgumentParser()
83
+ p.add_argument('--project-root', default='.')
84
+ p.add_argument('--change-id', required=True)
85
+ p.add_argument('--commands', nargs='*', help='Explicit commands to run')
86
+ args = p.parse_args()
87
+
88
+ commands = args.commands
89
+ if not commands:
90
+ plan_md = os.path.join(args.project_root, 'specs', 'changes', args.change_id, 'plan.md')
91
+ commands = extract_commands_from_plan(plan_md)
92
+ if not commands:
93
+ commands = extract_commands_from_change_contract(args.project_root, args.change_id)
94
+
95
+ if not commands:
96
+ print(json.dumps({'ok': False, 'verdict': 'no_commands', 'message': 'No test commands found in plan.md or change contract'}))
97
+ sys.exit(1)
98
+
99
+ results = []
100
+ all_passed = True
101
+ command_env = verification_command_env()
102
+ for cmd in commands:
103
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=args.project_root, timeout=120, env=command_env)
104
+ passed = r.returncode == 0
105
+ all_passed = all_passed and passed
106
+ results.append({
107
+ 'command': cmd,
108
+ 'exit_code': r.returncode,
109
+ 'passed': passed,
110
+ 'stdout_tail': r.stdout[-1000:] if r.stdout else '',
111
+ })
112
+
113
+ proof = {
114
+ 'all_passed': all_passed,
115
+ 'results': results,
116
+ 'recorded_at': datetime.now(timezone.utc).isoformat(),
117
+ }
118
+
119
+ proof_dir = os.path.join(args.project_root, 'specs', 'changes', args.change_id)
120
+ os.makedirs(proof_dir, exist_ok=True)
121
+ proof_file = os.path.join(proof_dir, 'verify_proof.json')
122
+ with open(proof_file, 'w') as f:
123
+ json.dump(proof, f, indent=2)
124
+
125
+ print(json.dumps({'ok': all_passed, 'proof_file': proof_file, 'total': len(results), 'passed': sum(1 for r in results if r['passed'])}))
126
+ sys.exit(0 if all_passed else 1)
127
+
128
+ if __name__ == '__main__':
129
+ main()