@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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/RELEASE_NOTES.md +124 -0
- package/atoms/registry.json +758 -0
- package/bin/xflow.js +7261 -0
- package/docs/adoption/README.md +128 -0
- package/docs/adoption/openflow-release-hardening.md +61 -0
- package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
- package/docs/compatibility.md +52 -0
- package/docs/competitive-benchmark.md +108 -0
- package/docs/corps-operator-guide.md +75 -0
- package/docs/demo-proof.md +126 -0
- package/docs/examples-gallery.md +115 -0
- package/docs/fixtures/tracker-item.json +7 -0
- package/docs/goal-vs-codex.md +129 -0
- package/docs/gstack-comparison.md +88 -0
- package/docs/install-upgrade.md +133 -0
- package/docs/integrations.md +49 -0
- package/docs/launch-demo.md +123 -0
- package/docs/launch-dossier.md +180 -0
- package/docs/methodology.md +149 -0
- package/docs/npm-publish-handoff.md +96 -0
- package/docs/openspec-migration.md +106 -0
- package/docs/public-benchmark.md +68 -0
- package/docs/public-release.md +145 -0
- package/docs/quality-assessment.md +139 -0
- package/docs/quickstart.md +156 -0
- package/docs/reviewer-guide.md +43 -0
- package/docs/spec-kit-benchmark.md +73 -0
- package/docs/super-assistant-comparison.md +106 -0
- package/docs/superpowers-comparison.md +82 -0
- package/docs/team-adoption.md +123 -0
- package/docs/tooling-matrix.md +91 -0
- package/docs/walkthrough.md +76 -0
- package/docs/workflow-manual.md +1130 -0
- package/package.json +83 -4
- package/prompts/pencil-draft.md +46 -0
- package/prompts/pencil-refine.md +46 -0
- package/prompts/phase-artifact.md +24 -0
- package/prompts/phase-execution.md +32 -0
- package/schemas/competitor-reconstruction-contract.schema.json +91 -0
- package/schemas/llm-design-review-aggregate.schema.json +83 -0
- package/schemas/visual-benchmark.schema.json +978 -0
- package/schemas/workflow.schema.json +100 -0
- package/scripts/run-tests.mjs +23 -0
- package/scripts/takein_digest.py +139 -0
- package/specs/README.md +118 -0
- package/specs/architecture.md +36 -0
- package/specs/product.md +33 -0
- package/specs/workflow.md +349 -0
- package/src/agent_team/atoms/codex-app-bridge.js +8 -0
- package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
- package/src/agent_team/atoms/design/contract-freeze.js +8 -0
- package/src/agent_team/atoms/design/generation-contract.js +442 -0
- package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
- package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
- package/src/agent_team/atoms/patch-challenge.js +19 -0
- package/src/agent_team/atoms/pencil/accept.js +9 -0
- package/src/agent_team/atoms/pencil/draft.js +365 -0
- package/src/agent_team/atoms/pencil/refine.js +229 -0
- package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
- package/src/agent_team/atoms/team-run.js +380 -0
- package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
- package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
- package/src/agent_team/atoms/visual/benchmark.js +1652 -0
- package/src/agent_team/atoms/visual/design-selection.js +23 -0
- package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
- package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
- package/src/agent_team/atoms/visual/image-diff.js +186 -0
- package/src/agent_team/atoms/visual/layout-competition.js +8 -0
- package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
- package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
- package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
- package/src/agent_team/atoms/visual/review.js +77 -0
- package/src/agent_team/atoms/visual/visual-report.js +197 -0
- package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
- package/src/agent_team/common.js +311 -0
- package/src/core/atoms/agent-invoke.js +684 -0
- package/src/core/atoms/event/ingest.js +30 -0
- package/src/core/atoms/experience/digest.js +55 -0
- package/src/core/atoms/gate/artifact-verify.js +83 -0
- package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
- package/src/core/atoms/gate/evidence-collect.js +64 -0
- package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
- package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
- package/src/core/atoms/gate/workbench.js +79 -0
- package/src/core/atoms/intake/classify.js +15 -0
- package/src/core/atoms/intake/design-skill-select.js +16 -0
- package/src/core/atoms/intake/preview-or-refine.js +19 -0
- package/src/core/atoms/intake/recommend.js +20 -0
- package/src/core/atoms/intake/research-same-category.js +19 -0
- package/src/core/atoms/intervention/create.js +30 -0
- package/src/core/atoms/memory/manage.js +27 -0
- package/src/core/atoms/memory/mount.js +13 -0
- package/src/core/atoms/mission/create.js +19 -0
- package/src/core/atoms/mission/list.js +14 -0
- package/src/core/atoms/mission/show.js +26 -0
- package/src/core/atoms/notify/checkpoint.js +46 -0
- package/src/core/atoms/phase-run/complete.js +42 -0
- package/src/core/atoms/phase-run/fail.js +40 -0
- package/src/core/atoms/phase-run/persist.js +39 -0
- package/src/core/atoms/phase-run/start.js +30 -0
- package/src/core/atoms/plugin/run.js +76 -0
- package/src/core/atoms/policy/gate.js +90 -0
- package/src/core/atoms/policy/resolve.js +11 -0
- package/src/core/atoms/storage/sync.js +57 -0
- package/src/core/atoms/workflow/advance.js +25 -0
- package/src/core/atoms/workflow/guard.js +127 -0
- package/src/core/atoms/workflow/state-load.js +37 -0
- package/src/core/bindings.js +37 -0
- package/src/core/change-artifacts.js +134 -0
- package/src/core/competitive-score.js +265 -0
- package/src/core/concurrency-policy.js +35 -0
- package/src/core/execution-log.js +108 -0
- package/src/core/intake-recommendation.js +134 -0
- package/src/core/memory-state.js +62 -0
- package/src/core/mission-state.js +780 -0
- package/src/core/pending-gates.js +66 -0
- package/src/core/phase-cost.js +116 -0
- package/src/core/phase-run-read.js +27 -0
- package/src/core/policy-overlay.js +180 -0
- package/src/core/state-store.js +201 -0
- package/src/core/workflow-executor.js +1014 -0
- package/src/core/workflow-integrity.js +111 -0
- package/src/core/workflow-loader.js +163 -0
- package/src/core/workflow-rerun.js +84 -0
- package/src/core/workflow-state.js +119 -0
- package/src/server.js +1307 -0
- package/workflows/corps.yaml +481 -0
- package/workflows/yolo.yaml +241 -0
- package/xflow/README.md +76 -0
- package/xflow/aha/SKILL.md +82 -0
- package/xflow/aha/scripts/append_aha.py +50 -0
- package/xflow/archive/scripts/archive_change.py +338 -0
- package/xflow/archive/templates/merge-design.md +54 -0
- package/xflow/archive/templates/merge-workflow.md +26 -0
- package/xflow/atoms/__init__.py +6 -0
- package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
- package/xflow/atoms/a2_branch_create_and_link.py +42 -0
- package/xflow/atoms/a3_repo_doctor.py +11 -0
- package/xflow/atoms/a4_project_set_status.py +11 -0
- package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
- package/xflow/atoms/a6_pr_create.py +72 -0
- package/xflow/atoms/b1_change_scaffold.py +11 -0
- package/xflow/atoms/b2_status_read.py +16 -0
- package/xflow/atoms/b2b_status_write.py +20 -0
- package/xflow/atoms/b3_status_transition.py +27 -0
- package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
- package/xflow/atoms/b5_aha_append.py +11 -0
- package/xflow/atoms/b6_takein_digest.py +11 -0
- package/xflow/atoms/b6b_lookback_digest.py +11 -0
- package/xflow/atoms/b7_change_mission_bind.py +43 -0
- package/xflow/atoms/c1_mem.py +54 -0
- package/xflow/atoms/e6_gate_local_precheck.py +23 -0
- package/xflow/atoms/h1_design_lite_gate.py +74 -0
- package/xflow/atoms/i6a_tdd_run.py +107 -0
- package/xflow/atoms/i6b_tdd_proof_validate.py +184 -0
- package/xflow/atoms/i6c_tdd_quality_review.py +372 -0
- package/xflow/atoms/j1_tests_run.py +129 -0
- package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
- package/xflow/atoms/j4b_spec_delta_review.py +316 -0
- package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
- package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
- package/xflow/atoms/j6_cr_review.py +443 -0
- package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
- package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
- package/xflow/atoms/k3_handoff_refresh.py +14 -0
- package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
- package/xflow/atoms/k5_archive_publish.py +21 -0
- package/xflow/atoms/k6_aha_merge.py +42 -0
- package/xflow/atoms/tdd_lib.py +956 -0
- package/xflow/corps/SKILL.md +109 -0
- package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
- package/xflow/goal/SKILL.md +150 -0
- package/xflow/handoff/SKILL.md +95 -0
- package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
- package/xflow/lookback/SKILL.md +66 -0
- package/xflow/lookback/scripts/lookback_digest.py +46 -0
- package/xflow/openissue/scripts/open_issue_flow.py +438 -0
- package/xflow/plan/SKILL.md +47 -0
- package/xflow/ralph/SKILL.md +123 -0
- package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
- package/xflow/scripts/common.py +483 -0
- package/xflow/scripts/doctor.py +278 -0
- package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
- package/xflow/takein/SKILL.md +81 -0
- package/xflow/takein/scripts/takein_digest.py +88 -0
- package/xflow/yolo/SKILL.md +75 -0
- package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
- package/xflow/yolo/scripts/yolo_gate.py +312 -0
- 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
|