@michaelxwb/code-flow 0.6.1 → 0.6.2

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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  3. package/src/adapters/claude/commands/cf-task/start.md +6 -7
  4. package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
  5. package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
  6. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  7. package/src/adapters/costrict/commands/cf-task/start.md +6 -7
  8. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
  9. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  10. package/src/adapters/opencode/commands/cf-task/start.md +6 -7
  11. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
  12. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
  13. package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
  14. package/src/core/code-flow/scripts/cf_core.py +20 -0
  15. package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
  16. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +5 -4
  17. package/src/core/code-flow/scripts/cf_spec_context.py +131 -16
  18. package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
  19. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  20. package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
  21. package/src/core/code-flow/scripts/cf_spec_router.py +102 -9
  22. package/src/core/code-flow/scripts/cf_spec_verify.py +118 -8
  23. package/src/core/code-flow/scripts/cf_stop_hook.py +2 -0
  24. package/src/core/code-flow/scripts/cf_task_runtime.py +48 -2
  25. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +2 -0
@@ -10,9 +10,11 @@ import json
10
10
  from pathlib import Path
11
11
  import re
12
12
  import sys
13
+ import time
13
14
  from typing import IO, Mapping, Optional, Sequence
14
15
 
15
16
  from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
17
+ from cf_core import phase_timing
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -212,13 +214,21 @@ def _parser() -> argparse.ArgumentParser:
212
214
 
213
215
 
214
216
  def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
217
+ started = time.monotonic()
215
218
  try:
216
219
  args = _parser().parse_args(argv)
220
+ phase_started = time.monotonic()
217
221
  context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
222
+ phase_timing("gate.load_context", phase_started)
223
+ phase_started = time.monotonic()
218
224
  result = validate_stage(context, args.stage, task_id=args.task)
225
+ phase_timing("gate.validate_stage", phase_started)
219
226
  if args.stage == "plan" and args.artifact:
227
+ phase_started = time.monotonic()
220
228
  result = _merge_results(result, validate_plan_coverage(context, args.artifact))
229
+ phase_timing("gate.validate_plan", phase_started)
221
230
  stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
231
+ phase_timing("gate.total", started)
222
232
  return 0 if result.decision == "pass" else 3
223
233
  except Exception as exc:
224
234
  sys.stderr.write(f"cf_spec_gate error: {exc}\n")
@@ -10,7 +10,6 @@ from pathlib import Path
10
10
  import re
11
11
  from typing import Mapping, Optional
12
12
 
13
- import yaml
14
13
 
15
14
 
16
15
  ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
@@ -20,6 +19,8 @@ _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
20
19
  _RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
21
20
  _EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
22
21
  _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
22
+ _FRONTMATTER_OPEN_RE = re.compile(br"---[ \t]*\r?\n")
23
+ _FRONTMATTER_CLOSE_RE = re.compile(br"---[ \t]*(?:\r?\n)?")
23
24
  _SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
24
25
  _H2_RE = re.compile(r"^##\s+")
25
26
  _BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
@@ -63,6 +64,13 @@ class SpecRule:
63
64
  line: int
64
65
 
65
66
 
67
+ @dataclass(frozen=True)
68
+ class SpecHeader:
69
+ id: str
70
+ stages: tuple[str, ...]
71
+ enforcement: str
72
+
73
+
66
74
  @dataclass(frozen=True)
67
75
  class SpecMetadata:
68
76
  id: str
@@ -116,6 +124,11 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
116
124
  if not match:
117
125
  raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
118
126
  raw = match.group(1)
127
+ return _parse_frontmatter(raw, path), raw, content[match.end():]
128
+
129
+
130
+ def _parse_frontmatter(raw: str, path: str) -> Mapping[str, object]:
131
+ import yaml
119
132
  try:
120
133
  loaded = yaml.safe_load(raw) or {}
121
134
  except yaml.YAMLError as exc:
@@ -126,7 +139,7 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
126
139
  raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
127
140
  if not all(isinstance(key, str) for key in loaded):
128
141
  raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
129
- return loaded, raw, content[match.end():]
142
+ return loaded
130
143
 
131
144
 
132
145
  def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
@@ -149,6 +162,46 @@ def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[s
149
162
  return stages
150
163
 
151
164
 
165
+ def _spec_header(meta: Mapping[str, object], raw: str, path: str) -> SpecHeader:
166
+ spec_id = _required_string(meta, "id", path, raw)
167
+ if not _ID_RE.fullmatch(spec_id):
168
+ raise SpecMetadataError(path, "id", _field_line(raw, "id"), "必须是 kebab-case")
169
+ stages = _validate_stages(meta, path, raw)
170
+ enforcement = _required_string(meta, "enforcement", path, raw)
171
+ if enforcement not in ALLOWED_ENFORCEMENT:
172
+ line = _field_line(raw, "enforcement")
173
+ raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
174
+ return SpecHeader(spec_id, stages, enforcement)
175
+
176
+
177
+ def _read_frontmatter(path: str) -> str:
178
+ lines: list[bytes] = []
179
+ try:
180
+ with Path(path).open("rb") as source:
181
+ if not _FRONTMATTER_OPEN_RE.fullmatch(source.readline()):
182
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
183
+ for line in source:
184
+ if _FRONTMATTER_CLOSE_RE.fullmatch(line):
185
+ break
186
+ lines.append(line)
187
+ else:
188
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
189
+ except OSError as exc:
190
+ raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
191
+ raw = b"".join(lines)
192
+ try:
193
+ return raw.decode("utf-8")
194
+ except UnicodeDecodeError as exc:
195
+ line = raw[:exc.start].count(b"\n") + 2
196
+ raise SpecMetadataError(path, "encoding", line, f"必须是 UTF-8: {exc}") from exc
197
+
198
+
199
+ def load_spec_header(path: str) -> SpecHeader:
200
+ """Load only routing fields, stopping at the frontmatter delimiter."""
201
+ raw = _read_frontmatter(path)
202
+ return _spec_header(_parse_frontmatter(raw, path), raw, path)
203
+
204
+
152
205
  def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
153
206
  value = meta.get("checks", [])
154
207
  if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
@@ -265,15 +318,8 @@ def _validate_links(
265
318
 
266
319
  def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
267
320
  meta, raw_frontmatter, body = _split_frontmatter(content, path)
268
- spec_id = _required_string(meta, "id", path, raw_frontmatter)
269
- if not _ID_RE.fullmatch(spec_id):
270
- raise SpecMetadataError(path, "id", _field_line(raw_frontmatter, "id"), "必须是 kebab-case")
321
+ header = _spec_header(meta, raw_frontmatter, path)
271
322
  description = _required_string(meta, "description", path, raw_frontmatter)
272
- stages = _validate_stages(meta, path, raw_frontmatter)
273
- enforcement = _required_string(meta, "enforcement", path, raw_frontmatter)
274
- if enforcement not in ALLOWED_ENFORCEMENT:
275
- line = _field_line(raw_frontmatter, "enforcement")
276
- raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
277
323
  owner = meta.get("owner")
278
324
  if owner is not None and (not isinstance(owner, str) or not owner.strip()):
279
325
  raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
@@ -289,7 +335,10 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
289
335
  metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
290
336
  rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
291
337
  )
292
- return SpecMetadata(spec_id, description, stages, enforcement, owner, checks, verifiers, rules, hashes, path)
338
+ return SpecMetadata(
339
+ header.id, description, header.stages, header.enforcement,
340
+ owner, checks, verifiers, rules, hashes, path,
341
+ )
293
342
 
294
343
 
295
344
  def load_spec_metadata(path: str) -> SpecMetadata:
@@ -8,9 +8,14 @@ import fnmatch
8
8
  from pathlib import Path
9
9
  from typing import Mapping, Optional, Sequence
10
10
 
11
- import yaml
12
-
13
- from cf_spec_metadata import ALLOWED_STAGES, SpecMetadata, SpecRule, load_spec_metadata
11
+ from cf_spec_metadata import (
12
+ ALLOWED_STAGES,
13
+ SpecHeader,
14
+ SpecMetadata,
15
+ SpecRule,
16
+ load_spec_header,
17
+ load_spec_metadata,
18
+ )
14
19
 
15
20
 
16
21
  SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
@@ -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")
@@ -180,6 +196,36 @@ def _candidate_for(
180
196
  return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
181
197
 
182
198
 
199
+ def _candidate_header_for(
200
+ path: Path,
201
+ spec_root: Path,
202
+ mapping: Mapping[str, object],
203
+ config_path: str,
204
+ stage: str,
205
+ paths: Sequence[str],
206
+ ) -> Optional[SpecCandidateHeader]:
207
+ relative = path.relative_to(spec_root).as_posix()
208
+ header: SpecHeader = load_spec_header(str(path))
209
+ if stage not in header.stages:
210
+ return None
211
+ domain = relative.split("/", 1)[0]
212
+ config = _domain_config(mapping, domain, config_path)
213
+ patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
214
+ scope, matched = _candidate_scope(relative, patterns, paths)
215
+ return SpecCandidateHeader(
216
+ header.id, relative, scope, SCOPE_PRIORITY[scope], matched, header.enforcement
217
+ )
218
+
219
+
220
+ def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
221
+ result: list[Path] = []
222
+ for path in sorted(spec_root.rglob("*.md")):
223
+ relative = path.relative_to(spec_root).as_posix()
224
+ if path.name != "_map.md" and not relative.startswith("_session/") and relative not in excluded:
225
+ result.append(path)
226
+ return tuple(result)
227
+
228
+
183
229
  def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
184
230
  if stage not in ALLOWED_STAGES:
185
231
  raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
@@ -193,10 +239,7 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
193
239
  raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
194
240
  candidates: list[SpecCandidate] = []
195
241
  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
242
+ for path in _spec_paths(spec_root, excluded):
200
243
  candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
201
244
  if candidate is None:
202
245
  continue
@@ -208,6 +251,34 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
208
251
  return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
209
252
 
210
253
 
254
+ def resolve_candidate_headers(
255
+ root: str, stage: str, paths: Sequence[str]
256
+ ) -> tuple[SpecCandidateHeader, ...]:
257
+ """Resolve candidates from routing frontmatter without parsing bodies or hashes."""
258
+ if stage not in ALLOWED_STAGES:
259
+ raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
260
+ normalized_paths = tuple(path.replace("\\", "/") for path in paths)
261
+ config = _load_config(root)
262
+ config_path = str(Path(root) / ".code-flow" / "config.yml")
263
+ mapping = _path_mapping(config, config_path)
264
+ excluded = _non_injectable(mapping, config_path)
265
+ spec_root = Path(root) / ".code-flow" / "specs"
266
+ if not spec_root.is_dir():
267
+ raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
268
+ candidates: list[SpecCandidateHeader] = []
269
+ ids: dict[str, str] = {}
270
+ for path in _spec_paths(spec_root, excluded):
271
+ candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
272
+ if candidate is None:
273
+ continue
274
+ if candidate.spec_id in ids:
275
+ detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
276
+ raise SpecResolutionError(str(spec_root), "spec_id", detail)
277
+ ids[candidate.spec_id] = candidate.path
278
+ candidates.append(candidate)
279
+ return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
280
+
281
+
211
282
  def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
212
283
  if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
213
284
  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,14 +139,23 @@ 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:
@@ -78,7 +169,8 @@ def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
78
169
  raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
79
170
  unused_frontmatter, content = parse_spec_frontmatter(raw)
80
171
  del unused_frontmatter
81
- specs.append({"path": candidate.path, "content": content.strip(), "tier": 1})
172
+ final_content = compress_content(content.strip()) if compress else content.strip()
173
+ specs.append({"path": candidate.path, "content": final_content, "tier": 1})
82
174
  text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
83
175
  return RouteResult("path", text, tuple(item.path for item in candidates))
84
176
 
@@ -145,5 +237,6 @@ def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResul
145
237
  if not config:
146
238
  return RouteResult("none", "", ())
147
239
  if paths:
148
- return _read_candidates(root, paths)
240
+ quality = config.get("quality_loop")
241
+ return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
149
242
  return _catalog_route(root, session_id, config)
@@ -11,13 +11,23 @@ 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
19
21
 
20
22
 
23
+ _RESULT_CACHE: dict[str, dict[str, object]] = {}
24
+ _RESULT_CACHE_ROOT: Optional[str] = None
25
+ _RESULT_CACHE_DIRTY = False
26
+ _RESULT_CACHE_LOCK = Lock()
27
+ _MAX_RESULT_CACHE_ENTRIES = 2048
28
+ _PURE_VERIFIER_TYPES = frozenset(("document", "regex", "ast"))
29
+
30
+
21
31
  @dataclass(frozen=True)
22
32
  class VerificationScope:
23
33
  root: str
@@ -44,6 +54,94 @@ class VerificationResult:
44
54
  passed: bool
45
55
 
46
56
 
57
+ def _cache_file(root: str) -> Path:
58
+ return Path(root) / ".code-flow" / ".verifier-cache.json"
59
+
60
+
61
+ def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
62
+ payload = {
63
+ "spec": metadata.hashes.file_sha256,
64
+ "rule": rule.text_sha256,
65
+ "verifier": {"type": verifier.type, "config": verifier.config},
66
+ "files": scope.files,
67
+ "diff": scope.diff_sha256,
68
+ "confirmation": confirmation or {},
69
+ }
70
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
71
+
72
+
73
+ def _load_result_cache(root: str) -> None:
74
+ global _RESULT_CACHE_ROOT, _RESULT_CACHE, _RESULT_CACHE_DIRTY
75
+ if _RESULT_CACHE_ROOT == root:
76
+ return
77
+ _RESULT_CACHE_ROOT = root
78
+ _RESULT_CACHE = {}
79
+ _RESULT_CACHE_DIRTY = False
80
+ path = _cache_file(root)
81
+ try:
82
+ loaded = json.loads(path.read_text(encoding="utf-8"))
83
+ if isinstance(loaded, dict):
84
+ _RESULT_CACHE.update({str(key): value for key, value in loaded.items() if isinstance(value, dict)})
85
+ except (OSError, ValueError):
86
+ pass
87
+
88
+
89
+ def _cached_evidence(key: str) -> Optional[VerificationEvidence]:
90
+ item = _RESULT_CACHE.get(key)
91
+ if not isinstance(item, dict) or item.get("status") != "verified":
92
+ return None
93
+ try:
94
+ return VerificationEvidence(**item)
95
+ except (TypeError, ValueError):
96
+ return None
97
+
98
+
99
+ def _save_result_cache(key: str, evidence: VerificationEvidence) -> None:
100
+ global _RESULT_CACHE_DIRTY
101
+ with _RESULT_CACHE_LOCK:
102
+ _RESULT_CACHE[key] = evidence.__dict__
103
+ _RESULT_CACHE_DIRTY = True
104
+ while len(_RESULT_CACHE) > _MAX_RESULT_CACHE_ENTRIES:
105
+ _RESULT_CACHE.pop(next(iter(_RESULT_CACHE)))
106
+
107
+
108
+ def _flush_result_cache(root: str) -> None:
109
+ global _RESULT_CACHE_DIRTY
110
+ with _RESULT_CACHE_LOCK:
111
+ if not _RESULT_CACHE_DIRTY:
112
+ return
113
+ path = _cache_file(root)
114
+ try:
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ temporary = path.with_suffix(".json.tmp")
117
+ temporary.write_text(
118
+ json.dumps(_RESULT_CACHE, ensure_ascii=False, sort_keys=True, default=str), encoding="utf-8"
119
+ )
120
+ temporary.replace(path)
121
+ _RESULT_CACHE_DIRTY = False
122
+ except OSError:
123
+ pass
124
+
125
+
126
+ def _cached_or_run(
127
+ metadata: SpecMetadata,
128
+ rule: SpecRule,
129
+ verifier: SpecVerifier,
130
+ scope: VerificationScope,
131
+ confirmation: Optional[Mapping[str, object]],
132
+ ) -> VerificationEvidence:
133
+ cacheable = verifier.type in _PURE_VERIFIER_TYPES
134
+ key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
135
+ if cacheable:
136
+ cached = _cached_evidence(key)
137
+ if cached is not None:
138
+ return cached
139
+ current = _evidence(metadata, rule, verifier, scope, confirmation)
140
+ if cacheable and current.status == "verified":
141
+ _save_result_cache(key, current)
142
+ return current
143
+
144
+
47
145
  @dataclass(frozen=True)
48
146
  class _Outcome:
49
147
  passed: bool
@@ -290,7 +388,9 @@ def run_all_verifiers(
290
388
  ) -> VerificationResult:
291
389
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
292
390
  confirmation_by_rule = confirmations or {}
293
- evidence: list[VerificationEvidence] = []
391
+ _load_result_cache(scope.root)
392
+ evidence_by_ref: dict[str, VerificationEvidence] = {}
393
+ parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
294
394
  started = time.monotonic()
295
395
  for rule in metadata.rules:
296
396
  if rule.enforcement != "required":
@@ -298,13 +398,12 @@ def run_all_verifiers(
298
398
  if timeout_budget is not None:
299
399
  remaining = timeout_budget - (time.monotonic() - started)
300
400
  if remaining <= 0:
301
- evidence.append(_budget_evidence(metadata, rule, scope, timeout_budget))
401
+ evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
302
402
  continue
303
403
  verifier = verifier_by_rule.get(rule.ref)
304
404
  if verifier is None:
305
405
  details = {"rule": rule.ref}
306
- evidence.append(
307
- VerificationEvidence(
406
+ evidence_by_ref[rule.ref] = VerificationEvidence(
308
407
  f"{metadata.id}#{rule.ref}",
309
408
  datetime.now(timezone.utc).isoformat(),
310
409
  "unverified",
@@ -315,14 +414,25 @@ def run_all_verifiers(
315
414
  "verifier_missing",
316
415
  details,
317
416
  )
318
- )
319
417
  continue
320
418
  if skip_command and verifier.type in ("command", "test"):
321
- evidence.append(_skipped_evidence(metadata, rule, scope))
419
+ evidence_by_ref[rule.ref] = _skipped_evidence(metadata, rule, scope)
420
+ continue
421
+ confirmation = confirmation_by_rule.get(rule.ref)
422
+ if timeout_budget is None and verifier.type in ("document", "regex", "ast"):
423
+ parallel.append((rule, verifier, confirmation))
322
424
  continue
323
425
  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)
426
+ 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)
427
+ if parallel:
428
+ with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
429
+ futures = {
430
+ rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
431
+ for rule, verifier, confirmation in parallel
432
+ }
433
+ evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
434
+ result = tuple(evidence_by_ref[rule.ref] for rule in metadata.rules if rule.ref in evidence_by_ref)
435
+ _flush_result_cache(scope.root)
326
436
  return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
327
437
 
328
438
 
@@ -28,6 +28,7 @@ from cf_core import (
28
28
  resolve_enforcement,
29
29
  resolve_quality_loop,
30
30
  resolve_session_id,
31
+ timing_log,
31
32
  )
32
33
 
33
34
  TOTAL_BUDGET_SECONDS = 30.0
@@ -310,3 +311,4 @@ def main() -> None:
310
311
 
311
312
  if __name__ == "__main__":
312
313
  main()
314
+ timing_log("cf_stop_hook")