@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.
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-task/archive.md +5 -5
- package/src/adapters/claude/commands/cf-task/block.md +15 -9
- package/src/adapters/claude/commands/cf-task/graph.md +8 -1
- package/src/adapters/claude/commands/cf-task/note.md +1 -4
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +22 -22
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/claude/commands/cf-validate.md +12 -13
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
- package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
- package/src/adapters/costrict/commands/cf-task/block.md +15 -9
- package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
- package/src/adapters/costrict/commands/cf-task/note.md +1 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +22 -22
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/costrict/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
- package/src/adapters/opencode/commands/cf-task/block.md +15 -9
- package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
- package/src/adapters/opencode/commands/cf-task/note.md +1 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +22 -22
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/opencode/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
- package/src/cli.js +177 -63
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +26 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
- package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
- package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
- package/src/core/code-flow/scripts/cf_task_state.py +3 -0
- package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
- package/src/core/code-flow/scripts/cf_validation.py +81 -0
- package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
61
|
-
if
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
187
|
-
return _Outcome(False, "command_unavailable", None, {"argv": argv, "error":
|
|
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
|
|
191
|
-
"stdout_sha256": _hash_bytes(completed
|
|
192
|
-
"stderr_sha256": _hash_bytes(completed
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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,
|