@michaelxwb/code-flow 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  3. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  4. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  5. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  6. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  7. package/src/adapters/claude/commands/cf-task/start.md +22 -22
  8. package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
  9. package/src/adapters/claude/commands/cf-validate.md +12 -13
  10. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  11. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  12. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  13. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  14. package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
  15. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  16. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  17. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  18. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  19. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  20. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  21. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  22. package/src/adapters/costrict/commands/cf-task/start.md +22 -22
  23. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
  24. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  25. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  27. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  29. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  30. package/src/adapters/opencode/commands/cf-task/start.md +22 -22
  31. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
  32. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  33. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  34. package/src/cli.js +177 -63
  35. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  36. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
  37. package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
  38. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  39. package/src/core/code-flow/scripts/cf_core.py +26 -0
  40. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  41. package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
  42. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
  43. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  44. package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
  45. package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
  46. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  47. package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
  48. package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
  49. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  50. package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
  51. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  52. package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
  53. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  54. package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
  55. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  56. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
  58. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  59. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  60. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
@@ -8,12 +8,17 @@ import fnmatch
8
8
  from pathlib import Path
9
9
  from typing import Mapping, Optional, Sequence
10
10
 
11
- import yaml
11
+ from cf_spec_metadata import (
12
+ ALLOWED_STAGES,
13
+ SpecHeader,
14
+ SpecMetadata,
15
+ SpecRule,
16
+ load_spec_header,
17
+ load_spec_metadata,
18
+ )
12
19
 
13
- from cf_spec_metadata import ALLOWED_STAGES, SpecMetadata, SpecRule, load_spec_metadata
14
20
 
15
-
16
- SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
21
+ SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
17
22
  _config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
18
23
  _metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
19
24
 
@@ -44,6 +49,16 @@ class SpecCandidate:
44
49
  return sum(rule.enforcement == "required" for rule in self.metadata.rules)
45
50
 
46
51
 
52
+ @dataclass(frozen=True)
53
+ class SpecCandidateHeader:
54
+ spec_id: str
55
+ path: str
56
+ scope: str
57
+ priority: int
58
+ matched_paths: tuple[str, ...]
59
+ enforcement: str
60
+
61
+
47
62
  @dataclass(frozen=True)
48
63
  class ResolvedRule:
49
64
  ref: str
@@ -93,6 +108,7 @@ def _fingerprint(path: Path, field: str) -> tuple[int, int]:
93
108
 
94
109
 
95
110
  def _load_config(root: str) -> Mapping[str, object]:
111
+ import yaml
96
112
  path = Path(root) / ".code-flow" / "config.yml"
97
113
  if not path.is_file():
98
114
  raise SpecResolutionError(str(path), "config", "缺失 config.yml")
@@ -158,7 +174,12 @@ def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str
158
174
  if relative.startswith("_session/"):
159
175
  return "task", ()
160
176
  matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
161
- return ("path", matched) if matched else ("global", ())
177
+ if matched:
178
+ return "path", matched
179
+ # A domain without patterns is truly global. A domain WITH patterns that
180
+ # match nothing is irrelevant to these paths ("unmatched") — discovery
181
+ # keeps it, but injection and scope expansion must not consume it.
182
+ return ("global", ()) if not patterns else ("unmatched", ())
162
183
 
163
184
 
164
185
  def _candidate_for(
@@ -180,6 +201,36 @@ def _candidate_for(
180
201
  return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
181
202
 
182
203
 
204
+ def _candidate_header_for(
205
+ path: Path,
206
+ spec_root: Path,
207
+ mapping: Mapping[str, object],
208
+ config_path: str,
209
+ stage: str,
210
+ paths: Sequence[str],
211
+ ) -> Optional[SpecCandidateHeader]:
212
+ relative = path.relative_to(spec_root).as_posix()
213
+ header: SpecHeader = load_spec_header(str(path))
214
+ if stage not in header.stages:
215
+ return None
216
+ domain = relative.split("/", 1)[0]
217
+ config = _domain_config(mapping, domain, config_path)
218
+ patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
219
+ scope, matched = _candidate_scope(relative, patterns, paths)
220
+ return SpecCandidateHeader(
221
+ header.id, relative, scope, SCOPE_PRIORITY[scope], matched, header.enforcement
222
+ )
223
+
224
+
225
+ def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
226
+ result: list[Path] = []
227
+ for path in sorted(spec_root.rglob("*.md")):
228
+ relative = path.relative_to(spec_root).as_posix()
229
+ if path.name != "_map.md" and not relative.startswith("_session/") and relative not in excluded:
230
+ result.append(path)
231
+ return tuple(result)
232
+
233
+
183
234
  def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
184
235
  if stage not in ALLOWED_STAGES:
185
236
  raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
@@ -193,10 +244,7 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
193
244
  raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
194
245
  candidates: list[SpecCandidate] = []
195
246
  ids: dict[str, str] = {}
196
- for path in sorted(spec_root.rglob("*.md")):
197
- relative = path.relative_to(spec_root).as_posix()
198
- if path.name == "_map.md" or relative.startswith("_session/") or relative in excluded:
199
- continue
247
+ for path in _spec_paths(spec_root, excluded):
200
248
  candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
201
249
  if candidate is None:
202
250
  continue
@@ -208,6 +256,34 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
208
256
  return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
209
257
 
210
258
 
259
+ def resolve_candidate_headers(
260
+ root: str, stage: str, paths: Sequence[str]
261
+ ) -> tuple[SpecCandidateHeader, ...]:
262
+ """Resolve candidates from routing frontmatter without parsing bodies or hashes."""
263
+ if stage not in ALLOWED_STAGES:
264
+ raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
265
+ normalized_paths = tuple(path.replace("\\", "/") for path in paths)
266
+ config = _load_config(root)
267
+ config_path = str(Path(root) / ".code-flow" / "config.yml")
268
+ mapping = _path_mapping(config, config_path)
269
+ excluded = _non_injectable(mapping, config_path)
270
+ spec_root = Path(root) / ".code-flow" / "specs"
271
+ if not spec_root.is_dir():
272
+ raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
273
+ candidates: list[SpecCandidateHeader] = []
274
+ ids: dict[str, str] = {}
275
+ for path in _spec_paths(spec_root, excluded):
276
+ candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
277
+ if candidate is None:
278
+ continue
279
+ if candidate.spec_id in ids:
280
+ detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
281
+ raise SpecResolutionError(str(spec_root), "spec_id", detail)
282
+ ids[candidate.spec_id] = candidate.path
283
+ candidates.append(candidate)
284
+ return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
285
+
286
+
211
287
  def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
212
288
  if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
213
289
  return "advisory"
@@ -8,14 +8,25 @@ import json
8
8
  import os
9
9
  from pathlib import Path
10
10
  import tempfile
11
- from typing import Mapping, Sequence
12
-
13
- from cf_core import assemble_context, build_effective_mapping, build_spec_catalog, load_config, parse_spec_frontmatter
11
+ from typing import Mapping, Optional, Sequence
12
+
13
+ from cf_core import (
14
+ assemble_context,
15
+ build_effective_mapping,
16
+ build_spec_catalog,
17
+ compress_content,
18
+ load_config,
19
+ parse_spec_frontmatter,
20
+ resolve_compress,
21
+ )
14
22
  from cf_spec_context import ContextError, load_active_task, load_context
15
23
  from cf_spec_resolver import resolve_candidates
16
24
  from cf_spec_session import context_sha256, project_task_session
17
25
 
18
26
 
27
+ _PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
28
+
29
+
19
30
  @dataclass(frozen=True)
20
31
  class RouteResult:
21
32
  mode: str
@@ -44,6 +55,77 @@ def _task_file(task_dir: Path, task_id: str) -> Path:
44
55
  return matches[0]
45
56
 
46
57
 
58
+ def _projection_cache_path(root: str) -> Path:
59
+ return Path(root) / _PROJECTION_CACHE_PATH
60
+
61
+
62
+ def _load_projection_cache(root: str) -> Mapping[str, object]:
63
+ try:
64
+ value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
65
+ except (OSError, UnicodeError, json.JSONDecodeError):
66
+ return {}
67
+ return value if isinstance(value, dict) else {}
68
+
69
+
70
+ def _cached_projection(
71
+ root: str, task_dir: Path, task_id: str, current_hash: str
72
+ ) -> Optional[tuple[str, bool]]:
73
+ cache = _load_projection_cache(root)
74
+ if (
75
+ cache.get("version") != 1
76
+ or cache.get("task_id") != task_id
77
+ or cache.get("context_sha256") != current_hash
78
+ or not isinstance(cache.get("task_file"), str)
79
+ or not isinstance(cache.get("text"), str)
80
+ or not isinstance(cache.get("truncated"), bool)
81
+ ):
82
+ return None
83
+ task_file = Path(str(cache["task_file"]))
84
+ if task_file.parent != task_dir or not task_file.is_file():
85
+ return None
86
+ try:
87
+ directory_mtime = task_dir.stat().st_mtime_ns
88
+ task_mtime = task_file.stat().st_mtime_ns
89
+ except OSError:
90
+ return None
91
+ if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
92
+ return None
93
+ return str(cache["text"]), bool(cache["truncated"])
94
+
95
+
96
+ def _save_projection_cache(
97
+ root: str,
98
+ task_dir: Path,
99
+ task_file: Path,
100
+ task_id: str,
101
+ current_hash: str,
102
+ text: str,
103
+ truncated: bool,
104
+ ) -> None:
105
+ try:
106
+ payload = {
107
+ "version": 1,
108
+ "task_id": task_id,
109
+ "context_sha256": current_hash,
110
+ "task_file": str(task_file),
111
+ "task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
112
+ "task_mtime_ns": task_file.stat().st_mtime_ns,
113
+ "text": text,
114
+ "truncated": truncated,
115
+ }
116
+ path = _projection_cache_path(root)
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
119
+ temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
120
+ os.replace(temporary, path)
121
+ except (OSError, TypeError, UnicodeError):
122
+ if "temporary" in locals() and temporary.exists():
123
+ try:
124
+ temporary.unlink()
125
+ except OSError:
126
+ pass
127
+
128
+
47
129
  def _active_route(root: str) -> RouteResult:
48
130
  try:
49
131
  active = load_active_task(root)
@@ -57,20 +139,31 @@ def _active_route(root: str) -> RouteResult:
57
139
  "active_context_drift",
58
140
  "active marker Context hash does not match; run `cf-spec doctor` (resync) to re-sync the marker before continuing",
59
141
  )
60
- projection = project_task_session(context, str(_task_file(task_dir, active.task_id)), active.task_id)
61
- if projection.truncated:
142
+ cached = _cached_projection(root, task_dir, active.task_id, current_hash)
143
+ if cached is None:
144
+ task_file = _task_file(task_dir, active.task_id)
145
+ projection = project_task_session(context, str(task_file), active.task_id)
146
+ _save_projection_cache(
147
+ root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
148
+ )
149
+ text, truncated = projection.text, projection.truncated
150
+ else:
151
+ text, truncated = cached
152
+ if truncated:
62
153
  raise RouterError("task_projection_truncated", "split the TASK before coding")
63
154
  refs = tuple(binding.spec_id for binding in context.bindings)
64
- return RouteResult("task", projection.text, refs, current_hash)
155
+ return RouteResult("task", text, refs, current_hash)
65
156
 
66
157
 
67
- def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
158
+ def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
68
159
  try:
69
160
  candidates = resolve_candidates(root, "code", tuple(paths))
70
161
  except ValueError as exc:
71
162
  raise RouterError("invalid_spec_metadata", str(exc)) from exc
72
163
  specs = []
73
164
  for candidate in candidates:
165
+ if candidate.scope == "unmatched":
166
+ continue
74
167
  target = Path(root) / ".code-flow/specs" / candidate.path
75
168
  try:
76
169
  raw = target.read_text(encoding="utf-8")
@@ -78,7 +171,8 @@ def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
78
171
  raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
79
172
  unused_frontmatter, content = parse_spec_frontmatter(raw)
80
173
  del unused_frontmatter
81
- specs.append({"path": candidate.path, "content": content.strip(), "tier": 1})
174
+ final_content = compress_content(content.strip()) if compress else content.strip()
175
+ specs.append({"path": candidate.path, "content": final_content, "tier": 1})
82
176
  text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
83
177
  return RouteResult("path", text, tuple(item.path for item in candidates))
84
178
 
@@ -145,5 +239,6 @@ def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResul
145
239
  if not config:
146
240
  return RouteResult("none", "", ())
147
241
  if paths:
148
- return _read_candidates(root, paths)
242
+ quality = config.get("quality_loop")
243
+ return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
149
244
  return _catalog_route(root, session_id, config)
@@ -36,7 +36,8 @@ def _task_section(text: str, task_id: str) -> str:
36
36
 
37
37
 
38
38
  def _refs(section: str) -> tuple[str, ...]:
39
- match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:\s*(.+)$", section)
39
+ # [ \t]* 而非 \s*:空 Spec-Refs 不得跨行吞掉下一字段(Acceptance-Refs 等)
40
+ match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:[ \t]*(.*)$", section)
40
41
  if match is None:
41
42
  raise ValueError("spec_refs_missing")
42
43
  return tuple(item.strip() for item in match.group(1).split(",") if item.strip())
@@ -83,7 +84,7 @@ def project_task_session(
83
84
  if len(header + "".join(lines) + candidate + footer) > max_chars:
84
85
  break
85
86
  lines.append(candidate)
86
- truncated = len(lines) < len(refs)
87
+ truncated = len(lines) < len(refs) or len(header + "".join(lines) + footer) > max_chars
87
88
  if truncated:
88
89
  footer = "\n> 投影超过预算;完整 Context 保留在需求目录,请拆分 TASK。\n" + footer
89
90
  text = (header + "".join(lines) + footer)[:max_chars]
@@ -11,11 +11,22 @@ import hashlib
11
11
  import json
12
12
  from pathlib import Path
13
13
  import subprocess
14
+ from threading import Lock
14
15
  import time
16
+ from concurrent.futures import ThreadPoolExecutor
15
17
  from typing import Mapping, Optional, Sequence
16
18
 
17
19
  from cf_checks import run_regex_verifier
18
20
  from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
21
+ from cf_exec_base import execution_session, run_command
22
+
23
+
24
+ _RESULT_CACHE: dict[str, dict[str, object]] = {}
25
+ _RESULT_CACHE_ROOT: Optional[str] = None
26
+ _RESULT_CACHE_DIRTY = False
27
+ _RESULT_CACHE_LOCK = Lock()
28
+ _MAX_RESULT_CACHE_ENTRIES = 2048
29
+ _PURE_VERIFIER_TYPES = frozenset(("document", "regex", "ast"))
19
30
 
20
31
 
21
32
  @dataclass(frozen=True)
@@ -44,6 +55,107 @@ class VerificationResult:
44
55
  passed: bool
45
56
 
46
57
 
58
+ def _cache_file(root: str) -> Path:
59
+ return Path(root) / ".code-flow" / ".verifier-cache.json"
60
+
61
+
62
+ def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
63
+ payload = {
64
+ "spec": metadata.hashes.file_sha256,
65
+ "rule": rule.text_sha256,
66
+ "verifier": {"type": verifier.type, "config": verifier.config},
67
+ "files": scope.files,
68
+ "diff": scope.diff_sha256,
69
+ "confirmation": confirmation or {},
70
+ "artifact_content": _artifact_fingerprint(verifier, scope),
71
+ }
72
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
73
+
74
+
75
+ def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
76
+ if verifier.type != "document":
77
+ return None
78
+ relative = verifier.config.get("artifact")
79
+ if not isinstance(relative, str):
80
+ return "invalid"
81
+ try:
82
+ return hashlib.sha256((Path(scope.root) / relative).read_bytes()).hexdigest()
83
+ except OSError as exc:
84
+ return f"unreadable:{exc}"
85
+
86
+
87
+ def _load_result_cache(root: str) -> None:
88
+ global _RESULT_CACHE_ROOT, _RESULT_CACHE, _RESULT_CACHE_DIRTY
89
+ if _RESULT_CACHE_ROOT == root:
90
+ return
91
+ _RESULT_CACHE_ROOT = root
92
+ _RESULT_CACHE = {}
93
+ _RESULT_CACHE_DIRTY = False
94
+ path = _cache_file(root)
95
+ try:
96
+ loaded = json.loads(path.read_text(encoding="utf-8"))
97
+ if isinstance(loaded, dict):
98
+ _RESULT_CACHE.update({str(key): value for key, value in loaded.items() if isinstance(value, dict)})
99
+ except (OSError, ValueError):
100
+ pass
101
+
102
+
103
+ def _cached_evidence(key: str) -> Optional[VerificationEvidence]:
104
+ item = _RESULT_CACHE.get(key)
105
+ if not isinstance(item, dict) or item.get("status") != "verified":
106
+ return None
107
+ try:
108
+ return VerificationEvidence(**item)
109
+ except (TypeError, ValueError):
110
+ return None
111
+
112
+
113
+ def _save_result_cache(key: str, evidence: VerificationEvidence) -> None:
114
+ global _RESULT_CACHE_DIRTY
115
+ with _RESULT_CACHE_LOCK:
116
+ _RESULT_CACHE[key] = evidence.__dict__
117
+ _RESULT_CACHE_DIRTY = True
118
+ while len(_RESULT_CACHE) > _MAX_RESULT_CACHE_ENTRIES:
119
+ _RESULT_CACHE.pop(next(iter(_RESULT_CACHE)))
120
+
121
+
122
+ def _flush_result_cache(root: str) -> None:
123
+ global _RESULT_CACHE_DIRTY
124
+ with _RESULT_CACHE_LOCK:
125
+ if not _RESULT_CACHE_DIRTY:
126
+ return
127
+ path = _cache_file(root)
128
+ try:
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ temporary = path.with_suffix(".json.tmp")
131
+ temporary.write_text(
132
+ json.dumps(_RESULT_CACHE, ensure_ascii=False, sort_keys=True, default=str), encoding="utf-8"
133
+ )
134
+ temporary.replace(path)
135
+ _RESULT_CACHE_DIRTY = False
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def _cached_or_run(
141
+ metadata: SpecMetadata,
142
+ rule: SpecRule,
143
+ verifier: SpecVerifier,
144
+ scope: VerificationScope,
145
+ confirmation: Optional[Mapping[str, object]],
146
+ ) -> VerificationEvidence:
147
+ cacheable = verifier.type in _PURE_VERIFIER_TYPES
148
+ key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
149
+ if cacheable:
150
+ cached = _cached_evidence(key)
151
+ if cached is not None:
152
+ return cached
153
+ current = _evidence(metadata, rule, verifier, scope, confirmation)
154
+ if cacheable and current.status == "verified":
155
+ _save_result_cache(key, current)
156
+ return current
157
+
158
+
47
159
  @dataclass(frozen=True)
48
160
  class _Outcome:
49
161
  passed: bool
@@ -168,30 +280,22 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
168
280
  cwd = _config_string(config, "cwd") or "."
169
281
  timeout_value = config.get("timeout", 30)
170
282
  timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
171
- if timeout_cap is not None:
172
- timeout = min(timeout, timeout_cap)
173
283
  allowed_value = config.get("allowed_exit_codes", [0])
174
284
  allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
175
- try:
176
- completed = subprocess.run(
177
- argv,
178
- cwd=str(Path(scope.root) / cwd),
179
- capture_output=True,
180
- text=True,
181
- timeout=timeout,
182
- check=False,
183
- )
184
- except subprocess.TimeoutExpired:
285
+ deadline = None if timeout_cap is None else time.monotonic() + timeout_cap
286
+ completed = run_command(argv, str(Path(scope.root) / cwd), timeout, deadline)
287
+ if completed["status"] in ("timeout", "deadline_exceeded"):
185
288
  return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
186
- except OSError as exc:
187
- return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": str(exc)})
289
+ if completed["status"] == "spawn_error":
290
+ return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": completed["stderr"]})
188
291
  details = {
189
292
  "argv": argv,
190
- "exit_code": completed.returncode,
191
- "stdout_sha256": _hash_bytes(completed.stdout.encode()),
192
- "stderr_sha256": _hash_bytes(completed.stderr.encode()),
293
+ "exit_code": completed["returncode"],
294
+ "stdout_sha256": _hash_bytes(str(completed["stdout"]).encode()),
295
+ "stderr_sha256": _hash_bytes(str(completed["stderr"]).encode()),
296
+ "output": (str(completed["stdout"]) + str(completed["stderr"]))[-4000:],
193
297
  }
194
- passed = completed.returncode in allowed
298
+ passed = completed["returncode"] in allowed
195
299
  return _Outcome(passed, None if passed else "command_failed", None, details)
196
300
 
197
301
 
@@ -281,7 +385,16 @@ def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verification
281
385
  )
282
386
 
283
387
 
284
- def run_all_verifiers(
388
+ def _missing_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
389
+ details = {"rule": rule.ref}
390
+ return VerificationEvidence(
391
+ f"{metadata.id}#{rule.ref}", datetime.now(timezone.utc).isoformat(),
392
+ "unverified", rule.text_sha256, None, scope.diff_sha256,
393
+ _result_hash("unverified", "verifier_missing", details), "verifier_missing", details,
394
+ )
395
+
396
+
397
+ def _run_all_verifiers(
285
398
  metadata: SpecMetadata,
286
399
  scope: VerificationScope,
287
400
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
@@ -290,7 +403,9 @@ def run_all_verifiers(
290
403
  ) -> VerificationResult:
291
404
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
292
405
  confirmation_by_rule = confirmations or {}
293
- evidence: list[VerificationEvidence] = []
406
+ _load_result_cache(scope.root)
407
+ evidence_by_ref: dict[str, VerificationEvidence] = {}
408
+ parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
294
409
  started = time.monotonic()
295
410
  for rule in metadata.rules:
296
411
  if rule.enforcement != "required":
@@ -298,34 +413,40 @@ def run_all_verifiers(
298
413
  if timeout_budget is not None:
299
414
  remaining = timeout_budget - (time.monotonic() - started)
300
415
  if remaining <= 0:
301
- evidence.append(_budget_evidence(metadata, rule, scope, timeout_budget))
416
+ evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
302
417
  continue
303
418
  verifier = verifier_by_rule.get(rule.ref)
304
419
  if verifier is None:
305
- details = {"rule": rule.ref}
306
- evidence.append(
307
- VerificationEvidence(
308
- f"{metadata.id}#{rule.ref}",
309
- datetime.now(timezone.utc).isoformat(),
310
- "unverified",
311
- rule.text_sha256,
312
- None,
313
- scope.diff_sha256,
314
- _result_hash("unverified", "verifier_missing", details),
315
- "verifier_missing",
316
- details,
317
- )
318
- )
420
+ evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
319
421
  continue
320
422
  if skip_command and verifier.type in ("command", "test"):
321
- evidence.append(_skipped_evidence(metadata, rule, scope))
423
+ evidence_by_ref[rule.ref] = _skipped_evidence(metadata, rule, scope)
424
+ continue
425
+ confirmation = confirmation_by_rule.get(rule.ref)
426
+ if timeout_budget is None and verifier.type in ("document", "regex", "ast"):
427
+ parallel.append((rule, verifier, confirmation))
322
428
  continue
323
429
  cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
324
- evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref), cap))
325
- result = tuple(evidence)
430
+ evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
431
+ if parallel:
432
+ with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
433
+ futures = {
434
+ rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
435
+ for rule, verifier, confirmation in parallel
436
+ }
437
+ evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
438
+ result = tuple(evidence_by_ref[rule.ref] for rule in metadata.rules if rule.ref in evidence_by_ref)
439
+ _flush_result_cache(scope.root)
326
440
  return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
327
441
 
328
442
 
443
+ def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
444
+ confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
445
+ skip_command: bool = False, timeout_budget: Optional[float] = None) -> VerificationResult:
446
+ with execution_session():
447
+ return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
448
+
449
+
329
450
  def evidence_is_fresh(
330
451
  evidence: VerificationEvidence,
331
452
  rule_text_sha256: str,