@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.
- package/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/RELEASE_NOTES.md +102 -0
- package/atoms/registry.json +758 -0
- package/bin/xflow.js +7208 -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 +83 -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 +1125 -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 +998 -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 +479 -0
- package/workflows/yolo.yaml +233 -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 +104 -0
- package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
- package/xflow/atoms/i6c_tdd_quality_review.py +357 -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 +747 -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 +38 -0
- package/xflow/ralph/SKILL.md +123 -0
- package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
- package/xflow/scripts/common.py +480 -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 +66 -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,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
|