@michaelxwb/code-flow 0.6.4 → 0.8.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/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -4,24 +4,29 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
import hashlib
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
import tempfile
|
|
12
|
+
import codeflow.cli.cf_spec_session as cf_spec_session
|
|
11
13
|
from typing import Mapping, Optional, Sequence
|
|
12
14
|
|
|
13
|
-
from cf_core import (
|
|
15
|
+
from codeflow.core.cf_core import (
|
|
14
16
|
assemble_context,
|
|
17
|
+
ConfigError,
|
|
15
18
|
build_effective_mapping,
|
|
16
19
|
build_spec_catalog,
|
|
17
|
-
|
|
20
|
+
estimate_tokens,
|
|
18
21
|
load_config,
|
|
19
|
-
parse_spec_frontmatter,
|
|
20
22
|
resolve_compress,
|
|
21
23
|
)
|
|
22
|
-
from
|
|
23
|
-
from
|
|
24
|
-
from
|
|
24
|
+
from codeflow.core.cf_log import warn
|
|
25
|
+
from codeflow.cli.cf_spec_context import ContextError, SpecContext, load_active_task, load_context
|
|
26
|
+
from codeflow.core.cf_context_cache import load_snapshot
|
|
27
|
+
from codeflow.spec.cf_spec_resolver import resolve_candidate_headers
|
|
28
|
+
from codeflow.spec.cf_spec_context_model import context_sha256
|
|
29
|
+
from codeflow.spec.cf_task_projection import project_task_session
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
_PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
|
|
@@ -33,6 +38,7 @@ class RouteResult:
|
|
|
33
38
|
text: str
|
|
34
39
|
specs: tuple[str, ...]
|
|
35
40
|
context_sha256: Optional[str] = None
|
|
41
|
+
expanded_specs: tuple[str, ...] = ()
|
|
36
42
|
|
|
37
43
|
|
|
38
44
|
class RouterError(ValueError):
|
|
@@ -59,12 +65,21 @@ def _projection_cache_path(root: str) -> Path:
|
|
|
59
65
|
return Path(root) / _PROJECTION_CACHE_PATH
|
|
60
66
|
|
|
61
67
|
|
|
68
|
+
def _context_stat(task_dir: Path) -> list[int]:
|
|
69
|
+
info = (task_dir / "spec-context.yml").stat()
|
|
70
|
+
return [info.st_mtime_ns, info.st_ctime_ns, info.st_size]
|
|
71
|
+
|
|
72
|
+
|
|
62
73
|
def _load_projection_cache(root: str) -> Mapping[str, object]:
|
|
63
74
|
try:
|
|
64
75
|
value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
|
|
65
76
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
66
77
|
return {}
|
|
67
|
-
|
|
78
|
+
if not isinstance(value, dict):
|
|
79
|
+
return {}
|
|
80
|
+
checksum = value.pop("checksum", None)
|
|
81
|
+
expected = hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
82
|
+
return value if checksum == expected else {}
|
|
68
83
|
|
|
69
84
|
|
|
70
85
|
def _cached_projection(
|
|
@@ -90,6 +105,12 @@ def _cached_projection(
|
|
|
90
105
|
return None
|
|
91
106
|
if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
|
|
92
107
|
return None
|
|
108
|
+
if cache.get("context_stat") != _context_stat(task_dir):
|
|
109
|
+
return None
|
|
110
|
+
if cache.get("projector_sha256") != hashlib.sha256(Path(cf_spec_session.__file__).read_bytes()).hexdigest():
|
|
111
|
+
return None
|
|
112
|
+
if cache.get("task_sha256") != hashlib.sha256(task_file.read_bytes()).hexdigest():
|
|
113
|
+
return None
|
|
93
114
|
return str(cache["text"]), bool(cache["truncated"])
|
|
94
115
|
|
|
95
116
|
|
|
@@ -107,13 +128,17 @@ def _save_projection_cache(
|
|
|
107
128
|
"version": 1,
|
|
108
129
|
"task_id": task_id,
|
|
109
130
|
"context_sha256": current_hash,
|
|
131
|
+
"context_stat": _context_stat(task_dir),
|
|
132
|
+
"projector_sha256": hashlib.sha256(Path(cf_spec_session.__file__).read_bytes()).hexdigest(),
|
|
110
133
|
"task_file": str(task_file),
|
|
111
134
|
"task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
|
|
112
135
|
"task_mtime_ns": task_file.stat().st_mtime_ns,
|
|
136
|
+
"task_sha256": hashlib.sha256(task_file.read_bytes()).hexdigest(),
|
|
113
137
|
"text": text,
|
|
114
138
|
"truncated": truncated,
|
|
115
139
|
}
|
|
116
140
|
path = _projection_cache_path(root)
|
|
141
|
+
payload["checksum"] = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
117
142
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
143
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
119
144
|
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
@@ -126,23 +151,48 @@ def _save_projection_cache(
|
|
|
126
151
|
pass
|
|
127
152
|
|
|
128
153
|
|
|
129
|
-
def
|
|
154
|
+
def _verify_bound_specs(root: str, context: SpecContext) -> None:
|
|
155
|
+
spec_root = Path(root) / ".code-flow/specs"
|
|
156
|
+
for binding in context.bindings:
|
|
157
|
+
target = spec_root / binding.path
|
|
158
|
+
try:
|
|
159
|
+
current = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
raise RouterError("bound_spec_missing", f"{binding.path}: {exc}; refresh Context") from exc
|
|
162
|
+
if current != binding.hashes.file_sha256:
|
|
163
|
+
raise RouterError("bound_spec_drift", f"{binding.path} changed; refresh Context before coding")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _scope_expansion(root: str, context: SpecContext, paths: Sequence[str]) -> tuple[str, ...]:
|
|
167
|
+
bound = {binding.spec_id for binding in context.bindings}
|
|
168
|
+
candidates = resolve_candidate_headers(
|
|
169
|
+
root, "code", paths, matched_only=True,
|
|
170
|
+
excluded_paths=tuple(binding.path for binding in context.bindings),
|
|
171
|
+
)
|
|
172
|
+
return tuple(item.spec_id for item in candidates
|
|
173
|
+
if item.spec_id not in bound and item.enforcement == "required")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _active_route(root: str, paths: Sequence[str] = (), check_scope: bool = False) -> RouteResult:
|
|
130
177
|
try:
|
|
131
178
|
active = load_active_task(root)
|
|
132
179
|
task_dir = (Path(root) / active.task_dir).resolve()
|
|
133
|
-
context =
|
|
180
|
+
context, current_hash = load_snapshot(root, str(task_dir / "spec-context.yml"), load_context)
|
|
134
181
|
except (ContextError, OSError, ValueError) as exc:
|
|
135
182
|
raise RouterError("invalid_active_task", str(exc)) from exc
|
|
136
|
-
current_hash = context_sha256(context)
|
|
137
183
|
if current_hash != active.context_sha256:
|
|
138
184
|
raise RouterError(
|
|
139
185
|
"active_context_drift",
|
|
140
|
-
"active marker Context hash does not match; run `
|
|
186
|
+
"active marker Context hash does not match; run `code-flow spec refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `code-flow spec active doctor --resync` with the hash from `code-flow spec status --json`",
|
|
141
187
|
)
|
|
188
|
+
_verify_bound_specs(root, context)
|
|
142
189
|
cached = _cached_projection(root, task_dir, active.task_id, current_hash)
|
|
143
190
|
if cached is None:
|
|
144
191
|
task_file = _task_file(task_dir, active.task_id)
|
|
145
|
-
|
|
192
|
+
try:
|
|
193
|
+
projection = project_task_session(context, str(task_file), active.task_id, context_hash=current_hash)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
raise RouterError("invalid_task_projection", str(exc)) from exc
|
|
146
196
|
_save_projection_cache(
|
|
147
197
|
root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
|
|
148
198
|
)
|
|
@@ -151,30 +201,26 @@ def _active_route(root: str) -> RouteResult:
|
|
|
151
201
|
text, truncated = cached
|
|
152
202
|
if truncated:
|
|
153
203
|
raise RouterError("task_projection_truncated", "split the TASK before coding")
|
|
154
|
-
refs = tuple(binding.spec_id for binding in context.bindings
|
|
155
|
-
|
|
204
|
+
refs = tuple(binding.spec_id for binding in context.bindings
|
|
205
|
+
if any(f"`{binding.spec_id}#{rule.ref}`" in text for rule in binding.rules))
|
|
206
|
+
expanded = _scope_expansion(root, context, paths) if check_scope else ()
|
|
207
|
+
return RouteResult("task", text, refs, current_hash, expanded)
|
|
156
208
|
|
|
157
209
|
|
|
158
210
|
def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
|
|
211
|
+
from codeflow.spec.cf_spec_budget import SpecBudgetError, path_specs
|
|
159
212
|
try:
|
|
160
|
-
|
|
161
|
-
except
|
|
162
|
-
raise RouterError(
|
|
163
|
-
specs = []
|
|
164
|
-
for candidate in candidates:
|
|
165
|
-
if candidate.scope == "unmatched":
|
|
166
|
-
continue
|
|
167
|
-
target = Path(root) / ".code-flow/specs" / candidate.path
|
|
168
|
-
try:
|
|
169
|
-
raw = target.read_text(encoding="utf-8")
|
|
170
|
-
except OSError as exc:
|
|
171
|
-
raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
|
|
172
|
-
unused_frontmatter, content = parse_spec_frontmatter(raw)
|
|
173
|
-
del unused_frontmatter
|
|
174
|
-
final_content = compress_content(content.strip()) if compress else content.strip()
|
|
175
|
-
specs.append({"path": candidate.path, "content": final_content, "tier": 1})
|
|
213
|
+
specs = path_specs(root, paths, compress)
|
|
214
|
+
except SpecBudgetError as exc:
|
|
215
|
+
raise RouterError(exc.code, str(exc)) from exc
|
|
176
216
|
text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
|
|
177
|
-
|
|
217
|
+
budget = load_config(root).get("budget", {})
|
|
218
|
+
if not isinstance(budget, dict):
|
|
219
|
+
raise RouterError("invalid_budget", "budget must be a mapping")
|
|
220
|
+
maximum = min(int(budget.get("total", 2500)), int(budget.get("l1_max", 1700)))
|
|
221
|
+
if maximum <= 0 or estimate_tokens(text) > maximum:
|
|
222
|
+
raise RouterError("spec_budget_exceeded", f"Matched Specs exceed {maximum} estimated tokens; create a focused TASK Context or split the editing scope. Required constraints were not truncated.")
|
|
223
|
+
return RouteResult("path", text, tuple(item["path"] for item in specs))
|
|
178
224
|
|
|
179
225
|
|
|
180
226
|
def _catalog_path(root: str) -> Path:
|
|
@@ -191,6 +237,8 @@ def _load_catalog_state(root: str) -> dict[str, object]:
|
|
|
191
237
|
raise RouterError("invalid_catalog_state", str(exc)) from exc
|
|
192
238
|
if not isinstance(value, dict) or value.get("version") != 1:
|
|
193
239
|
raise RouterError("invalid_catalog_state", "Catalog state schema must be version 1")
|
|
240
|
+
if any(type(value.get(key)) is not int or value[key] < 0 for key in ("prompt_count", "last_emitted")):
|
|
241
|
+
raise RouterError("invalid_catalog_state", "Catalog counters must be nonnegative integers")
|
|
194
242
|
return value
|
|
195
243
|
|
|
196
244
|
|
|
@@ -208,7 +256,7 @@ def _save_catalog_state(root: str, state: Mapping[str, object]) -> None:
|
|
|
208
256
|
except OSError as exc:
|
|
209
257
|
if temporary and Path(temporary).exists():
|
|
210
258
|
Path(temporary).unlink()
|
|
211
|
-
|
|
259
|
+
warn(f"Spec Catalog dedup state could not be saved: {exc}")
|
|
212
260
|
|
|
213
261
|
|
|
214
262
|
def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) -> RouteResult:
|
|
@@ -221,20 +269,32 @@ def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) ->
|
|
|
221
269
|
window = int(catalog_config.get("dedup_window", 5))
|
|
222
270
|
due = window <= 0 or last == 0 or count - last >= window
|
|
223
271
|
next_state = {"version": 1, "session_id": session_id, "prompt_count": count, "last_emitted": count if due else last}
|
|
224
|
-
_save_catalog_state(root, next_state)
|
|
225
272
|
if not due:
|
|
273
|
+
_save_catalog_state(root, next_state)
|
|
226
274
|
return RouteResult("catalog", "", ())
|
|
227
275
|
budget = config.get("budget") if isinstance(config.get("budget"), dict) else {}
|
|
228
276
|
maximum = int(budget.get("catalog_max", 200))
|
|
229
277
|
mapping = build_effective_mapping(root, config.get("path_mapping") or {})
|
|
230
|
-
|
|
278
|
+
text = build_spec_catalog(root, mapping, maximum)
|
|
279
|
+
from codeflow.spec.cf_injection import defer_commit
|
|
280
|
+
defer_commit(lambda: _save_catalog_state(root, next_state))
|
|
281
|
+
return RouteResult("catalog", text, ())
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def route_prompt(root: str, paths: Sequence[str], session_id: str, *, check_scope: bool = False) -> RouteResult:
|
|
285
|
+
try:
|
|
286
|
+
return _route_prompt(root, paths, session_id, check_scope)
|
|
287
|
+
except RouterError:
|
|
288
|
+
raise
|
|
289
|
+
except (ConfigError, ContextError, OSError, UnicodeError, ValueError, TypeError) as exc:
|
|
290
|
+
raise RouterError(getattr(exc, "code", "invalid_routing_input"), str(exc)) from exc
|
|
231
291
|
|
|
232
292
|
|
|
233
|
-
def
|
|
293
|
+
def _route_prompt(root: str, paths: Sequence[str], session_id: str, check_scope: bool) -> RouteResult:
|
|
234
294
|
"""Choose exactly one route; an invalid active marker never falls back."""
|
|
235
295
|
marker = Path(root) / ".code-flow/.active-task.json"
|
|
236
296
|
if marker.exists():
|
|
237
|
-
return _active_route(root)
|
|
297
|
+
return _active_route(root, paths, check_scope)
|
|
238
298
|
config = load_config(root)
|
|
239
299
|
if not config:
|
|
240
300
|
return RouteResult("none", "", ())
|
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import ast
|
|
7
|
-
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
import fnmatch
|
|
10
10
|
import hashlib
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
12
13
|
from pathlib import Path
|
|
13
|
-
import subprocess
|
|
14
14
|
from threading import Lock
|
|
15
15
|
import time
|
|
16
16
|
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from contextvars import copy_context
|
|
17
18
|
from typing import Mapping, Optional, Sequence
|
|
18
19
|
|
|
19
|
-
from cf_checks import run_regex_verifier
|
|
20
|
-
from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
21
|
-
from cf_exec_base import execution_session, run_command
|
|
20
|
+
from codeflow.acceptance.cf_checks import run_regex_verifier
|
|
21
|
+
from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
22
|
+
from codeflow.core.cf_exec_base import execution_session, record_report, redact_sensitive_text, report_requested, run_command
|
|
23
|
+
from codeflow.core import cf_git
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
_RESULT_CACHE: dict[str, dict[str, object]] = {}
|
|
@@ -59,19 +61,82 @@ def _cache_file(root: str) -> Path:
|
|
|
59
61
|
return Path(root) / ".code-flow" / ".verifier-cache.json"
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
|
|
64
|
+
def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]], stage: str = "code") -> str:
|
|
63
65
|
payload = {
|
|
64
66
|
"spec": metadata.hashes.file_sha256,
|
|
65
67
|
"rule": rule.text_sha256,
|
|
66
68
|
"verifier": {"type": verifier.type, "config": verifier.config},
|
|
67
69
|
"files": scope.files,
|
|
68
70
|
"diff": scope.diff_sha256,
|
|
71
|
+
"stage": stage,
|
|
69
72
|
"confirmation": confirmation or {},
|
|
70
73
|
"artifact_content": _artifact_fingerprint(verifier, scope),
|
|
71
74
|
}
|
|
72
75
|
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
73
76
|
|
|
74
77
|
|
|
78
|
+
def _normalize_scope_path(value: str) -> str:
|
|
79
|
+
return value.replace("\\", "/").strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
|
|
83
|
+
for pattern in patterns:
|
|
84
|
+
if fnmatch.fnmatchcase(path, pattern):
|
|
85
|
+
return True
|
|
86
|
+
bare = pattern.rstrip("/")
|
|
87
|
+
if not any(char in pattern for char in "*?[") and path.startswith(bare + "/"):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
|
|
93
|
+
"""作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
|
|
94
|
+
|
|
95
|
+
决议仓库级集合(而非仅当前任务 diff)才能让未触及作用域的其他任务命中缓存。
|
|
96
|
+
"""
|
|
97
|
+
if not verifier.files:
|
|
98
|
+
return ()
|
|
99
|
+
candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
|
|
100
|
+
candidates.update(cf_git.tracked_files(scope.root))
|
|
101
|
+
return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _scope_fingerprint(root: str, files: Sequence[str]) -> str:
|
|
105
|
+
digest = hashlib.sha256()
|
|
106
|
+
for relative in files:
|
|
107
|
+
path = Path(root) / relative
|
|
108
|
+
digest.update(relative.encode("utf-8"))
|
|
109
|
+
try:
|
|
110
|
+
digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
|
|
111
|
+
except OSError:
|
|
112
|
+
digest.update(b"<unreadable>")
|
|
113
|
+
return digest.hexdigest()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _scoped_cache_key(
|
|
117
|
+
metadata: SpecMetadata,
|
|
118
|
+
rule: SpecRule,
|
|
119
|
+
verifier: SpecVerifier,
|
|
120
|
+
scope: VerificationScope,
|
|
121
|
+
confirmation: Optional[Mapping[str, object]],
|
|
122
|
+
stage: str,
|
|
123
|
+
) -> Optional[str]:
|
|
124
|
+
"""command/test 仅在声明 files 作用域时缓存;未声明保持全量执行。"""
|
|
125
|
+
if verifier.type not in ("command", "test") or not verifier.files:
|
|
126
|
+
return None
|
|
127
|
+
files = scoped_files(verifier, scope)
|
|
128
|
+
payload = {
|
|
129
|
+
"spec": metadata.hashes.file_sha256,
|
|
130
|
+
"rule": rule.text_sha256,
|
|
131
|
+
"verifier": {"type": verifier.type, "config": verifier.config, "files": list(verifier.files)},
|
|
132
|
+
"stage": stage,
|
|
133
|
+
"scope_files": files,
|
|
134
|
+
"scope_sha256": _scope_fingerprint(scope.root, files),
|
|
135
|
+
"confirmation": confirmation or {},
|
|
136
|
+
}
|
|
137
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
138
|
+
|
|
139
|
+
|
|
75
140
|
def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
|
|
76
141
|
if verifier.type != "document":
|
|
77
142
|
return None
|
|
@@ -143,13 +208,38 @@ def _cached_or_run(
|
|
|
143
208
|
verifier: SpecVerifier,
|
|
144
209
|
scope: VerificationScope,
|
|
145
210
|
confirmation: Optional[Mapping[str, object]],
|
|
211
|
+
stage: str = "code",
|
|
146
212
|
) -> VerificationEvidence:
|
|
213
|
+
if verifier.type == "regex" and not _regex_scope_files(verifier.config, metadata, scope):
|
|
214
|
+
# The fallback checks tracked files outside the diff. Do not reuse a
|
|
215
|
+
# diff-keyed result when those files may have changed independently.
|
|
216
|
+
return _evidence(metadata, rule, verifier, scope, confirmation)
|
|
147
217
|
cacheable = verifier.type in _PURE_VERIFIER_TYPES
|
|
148
|
-
|
|
218
|
+
scoped_key: Optional[str] = None
|
|
219
|
+
if cacheable:
|
|
220
|
+
key = _cache_key(metadata, rule, verifier, scope, confirmation, stage)
|
|
221
|
+
else:
|
|
222
|
+
scoped_key = _scoped_cache_key(metadata, rule, verifier, scope, confirmation, stage)
|
|
223
|
+
if scoped_key is None:
|
|
224
|
+
key = ""
|
|
225
|
+
else:
|
|
226
|
+
cacheable, key = True, scoped_key
|
|
149
227
|
if cacheable:
|
|
150
228
|
cached = _cached_evidence(key)
|
|
151
229
|
if cached is not None:
|
|
152
|
-
|
|
230
|
+
record_report({"kind": "spec_check", "check_id": f"{metadata.id}#{rule.ref}",
|
|
231
|
+
"reused": 1, "execution_ms": 0.0})
|
|
232
|
+
if scoped_key is None:
|
|
233
|
+
return cached
|
|
234
|
+
details = dict(cached.details or {})
|
|
235
|
+
details["cache_reused"] = True
|
|
236
|
+
details["scope_files"] = list(scoped_files(verifier, scope))
|
|
237
|
+
return replace(
|
|
238
|
+
cached,
|
|
239
|
+
executed_at=datetime.now(timezone.utc).isoformat(),
|
|
240
|
+
diff_sha256=scope.diff_sha256,
|
|
241
|
+
details=details,
|
|
242
|
+
)
|
|
153
243
|
current = _evidence(metadata, rule, verifier, scope, confirmation)
|
|
154
244
|
if cacheable and current.status == "verified":
|
|
155
245
|
_save_result_cache(key, current)
|
|
@@ -162,6 +252,8 @@ class _Outcome:
|
|
|
162
252
|
error_code: Optional[str]
|
|
163
253
|
artifact_sha256: Optional[str]
|
|
164
254
|
details: Mapping[str, object]
|
|
255
|
+
execution_ms: Optional[float] = None
|
|
256
|
+
execution_reused: bool = False
|
|
165
257
|
|
|
166
258
|
|
|
167
259
|
def _hash_bytes(data: bytes) -> str:
|
|
@@ -204,10 +296,31 @@ def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcom
|
|
|
204
296
|
except (OSError, UnicodeError) as exc:
|
|
205
297
|
return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
|
|
206
298
|
expected = _config_string(config, "artifact_sha256")
|
|
207
|
-
|
|
299
|
+
from codeflow.spec.cf_rule_readiness import referenced_item, unresolved_text
|
|
300
|
+
selected = referenced_item(content, section, item)
|
|
301
|
+
present = bool(selected)
|
|
208
302
|
hash_matches = expected is None or expected == artifact_hash
|
|
209
|
-
|
|
210
|
-
|
|
303
|
+
unresolved = unresolved_text(selected)
|
|
304
|
+
details = {"artifact": relative, "section_present": section in content, "item_present": bool(selected),
|
|
305
|
+
"hash_matches": hash_matches, "unresolved": unresolved}
|
|
306
|
+
error = "rule_applied_unresolved" if unresolved else "document_mismatch"
|
|
307
|
+
passed = present and hash_matches and not unresolved
|
|
308
|
+
return _Outcome(passed, None if passed else error, artifact_hash, details)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _regex_file_pattern(config: Mapping[str, object], metadata: SpecMetadata) -> str:
|
|
312
|
+
check_id = _config_string(config, "check_id")
|
|
313
|
+
check = next((item for item in metadata.checks if item.get("id") == check_id), None)
|
|
314
|
+
if check is not None and isinstance(check.get("files"), str):
|
|
315
|
+
return str(check["files"])
|
|
316
|
+
return _config_string(config, "files") or "*"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _regex_scope_files(config: Mapping[str, object], metadata: SpecMetadata,
|
|
320
|
+
scope: VerificationScope) -> tuple[str, ...]:
|
|
321
|
+
files = _regex_file_pattern(config, metadata)
|
|
322
|
+
return tuple(relative for relative in scope.files
|
|
323
|
+
if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file())
|
|
211
324
|
|
|
212
325
|
|
|
213
326
|
def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
|
|
@@ -222,11 +335,16 @@ def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: Verifica
|
|
|
222
335
|
files = check.get("files") if isinstance(check.get("files"), str) else "*"
|
|
223
336
|
if pattern is None:
|
|
224
337
|
return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
|
|
225
|
-
selected =
|
|
226
|
-
|
|
227
|
-
for relative in scope.
|
|
228
|
-
|
|
229
|
-
|
|
338
|
+
selected = _regex_scope_files(config, metadata, scope)
|
|
339
|
+
if not selected:
|
|
340
|
+
tracked = tuple(relative for relative in cf_git.tracked_files(scope.root)
|
|
341
|
+
if fnmatch.fnmatch(relative, files))
|
|
342
|
+
if any(not (Path(scope.root) / relative).is_file() for relative in tracked):
|
|
343
|
+
return _Outcome(False, "no_matching_files", None,
|
|
344
|
+
{"checked_files": 0, "files": files, "reason": "tracked_files_missing"})
|
|
345
|
+
selected = tracked
|
|
346
|
+
if not selected:
|
|
347
|
+
return _Outcome(False, "no_matching_files", None, {"checked_files": 0, "files": files})
|
|
230
348
|
contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
|
|
231
349
|
if error is not None:
|
|
232
350
|
return error
|
|
@@ -284,6 +402,13 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
|
|
|
284
402
|
allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
|
|
285
403
|
deadline = None if timeout_cap is None else time.monotonic() + timeout_cap
|
|
286
404
|
completed = run_command(argv, str(Path(scope.root) / cwd), timeout, deadline)
|
|
405
|
+
outcome = _command_result(completed, argv, timeout, allowed)
|
|
406
|
+
return replace(outcome, execution_ms=float(completed.get("execution_ms", 0)),
|
|
407
|
+
execution_reused=bool(completed.get("execution_reused")))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _command_result(completed: Mapping[str, object], argv: Sequence[str], timeout: float,
|
|
411
|
+
allowed: Sequence[int]) -> _Outcome:
|
|
287
412
|
if completed["status"] in ("timeout", "deadline_exceeded"):
|
|
288
413
|
return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
|
|
289
414
|
if completed["status"] == "spawn_error":
|
|
@@ -293,7 +418,7 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
|
|
|
293
418
|
"exit_code": completed["returncode"],
|
|
294
419
|
"stdout_sha256": _hash_bytes(str(completed["stdout"]).encode()),
|
|
295
420
|
"stderr_sha256": _hash_bytes(str(completed["stderr"]).encode()),
|
|
296
|
-
"output": (str(completed["stdout"]) + str(completed["stderr"]))[-4000:],
|
|
421
|
+
"output": redact_sensitive_text((str(completed["stdout"]) + str(completed["stderr"]))[-4000:]),
|
|
297
422
|
}
|
|
298
423
|
passed = completed["returncode"] in allowed
|
|
299
424
|
return _Outcome(passed, None if passed else "command_failed", None, details)
|
|
@@ -339,7 +464,20 @@ def _evidence(
|
|
|
339
464
|
confirmation: Optional[Mapping[str, object]],
|
|
340
465
|
timeout_cap: Optional[float] = None,
|
|
341
466
|
) -> VerificationEvidence:
|
|
467
|
+
started = time.monotonic()
|
|
342
468
|
outcome = _run(verifier, metadata, scope, confirmation, timeout_cap)
|
|
469
|
+
execution_ms = (time.monotonic() - started) * 1000
|
|
470
|
+
if outcome.execution_ms is not None:
|
|
471
|
+
execution_ms = outcome.execution_ms
|
|
472
|
+
if report_requested():
|
|
473
|
+
identity_data = [_cache_key(metadata, rule, verifier, scope, confirmation, verifier.stage),
|
|
474
|
+
str(Path(scope.root).resolve()), dict(os.environ), timeout_cap]
|
|
475
|
+
identity = hashlib.sha256(json.dumps(identity_data, sort_keys=True, default=str).encode()).hexdigest()
|
|
476
|
+
report = {"kind": "spec_check", "check_id": f"{metadata.id}#{rule.ref}",
|
|
477
|
+
"reused": int(outcome.execution_reused), "execution_ms": execution_ms}
|
|
478
|
+
if not outcome.execution_reused:
|
|
479
|
+
report["identity"] = identity
|
|
480
|
+
record_report(report)
|
|
343
481
|
status = "verified" if outcome.passed else "unverified"
|
|
344
482
|
diff_hash = None if verifier.type == "document" else scope.diff_sha256
|
|
345
483
|
return VerificationEvidence(
|
|
@@ -370,8 +508,47 @@ def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verificatio
|
|
|
370
508
|
)
|
|
371
509
|
|
|
372
510
|
|
|
511
|
+
def _deferral_kind(reason: str) -> str:
|
|
512
|
+
"""Map a task-gate deferral reason to its evidence category.
|
|
513
|
+
|
|
514
|
+
Budget and env deferrals are recorded but never treated as a pass: the
|
|
515
|
+
required check did not actually run. Only review-stage work may move to the
|
|
516
|
+
requirement-level verify-e2e gate.
|
|
517
|
+
"""
|
|
518
|
+
if reason in ("out_of_task_scope", "review_stage"):
|
|
519
|
+
return "review_stage"
|
|
520
|
+
if reason in ("budget", "over_finish_budget", "verifier_budget_exhausted"):
|
|
521
|
+
return "budget"
|
|
522
|
+
if reason in ("env", "env_blocked", "environment_unavailable"):
|
|
523
|
+
return "env"
|
|
524
|
+
return "unknown"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _deferred_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope,
|
|
528
|
+
reason: str) -> VerificationEvidence:
|
|
529
|
+
"""任务级 Done Gate 延后执行(范围外/预算/环境);需求级 verify-e2e 只补 review-stage。"""
|
|
530
|
+
kind = _deferral_kind(reason)
|
|
531
|
+
error_code = {
|
|
532
|
+
"review_stage": "deferred_to_review",
|
|
533
|
+
"budget": "verifier_budget_exhausted",
|
|
534
|
+
"env": "env_blocked",
|
|
535
|
+
}.get(kind, "deferred_to_review")
|
|
536
|
+
details = {"reason": reason, "deferral_kind": kind, "deferred_to_review": kind == "review_stage"}
|
|
537
|
+
return VerificationEvidence(
|
|
538
|
+
f"{metadata.id}#{rule.ref}",
|
|
539
|
+
datetime.now(timezone.utc).isoformat(),
|
|
540
|
+
"unverified",
|
|
541
|
+
rule.text_sha256,
|
|
542
|
+
None,
|
|
543
|
+
scope.diff_sha256,
|
|
544
|
+
_result_hash("unverified", error_code, details),
|
|
545
|
+
error_code,
|
|
546
|
+
details,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
373
550
|
def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
|
|
374
|
-
details = {"budget": budget}
|
|
551
|
+
details = {"budget": budget, "deferral_kind": "budget"}
|
|
375
552
|
return VerificationEvidence(
|
|
376
553
|
f"{metadata.id}#{rule.ref}",
|
|
377
554
|
datetime.now(timezone.utc).isoformat(),
|
|
@@ -394,15 +571,16 @@ def _missing_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verificatio
|
|
|
394
571
|
)
|
|
395
572
|
|
|
396
573
|
|
|
397
|
-
def _run_all_verifiers(
|
|
398
|
-
metadata: SpecMetadata,
|
|
399
|
-
scope: VerificationScope,
|
|
574
|
+
def _run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
|
|
400
575
|
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
401
576
|
skip_command: bool = False,
|
|
402
577
|
timeout_budget: Optional[float] = None,
|
|
578
|
+
stage: Optional[str] = "code",
|
|
579
|
+
deferred: Optional[Mapping[str, str]] = None,
|
|
403
580
|
) -> VerificationResult:
|
|
404
581
|
verifier_by_rule = {item.rule: item for item in metadata.verifiers}
|
|
405
582
|
confirmation_by_rule = confirmations or {}
|
|
583
|
+
deferred_by_rule = deferred or {}
|
|
406
584
|
_load_result_cache(scope.root)
|
|
407
585
|
evidence_by_ref: dict[str, VerificationEvidence] = {}
|
|
408
586
|
parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
|
|
@@ -410,12 +588,17 @@ def _run_all_verifiers(
|
|
|
410
588
|
for rule in metadata.rules:
|
|
411
589
|
if rule.enforcement != "required":
|
|
412
590
|
continue
|
|
591
|
+
verifier = verifier_by_rule.get(rule.ref)
|
|
592
|
+
if verifier is not None and stage is not None and verifier.stage != stage:
|
|
593
|
+
continue
|
|
594
|
+
if rule.ref in deferred_by_rule:
|
|
595
|
+
evidence_by_ref[rule.ref] = _deferred_evidence(metadata, rule, scope, deferred_by_rule[rule.ref])
|
|
596
|
+
continue
|
|
413
597
|
if timeout_budget is not None:
|
|
414
598
|
remaining = timeout_budget - (time.monotonic() - started)
|
|
415
599
|
if remaining <= 0:
|
|
416
600
|
evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
|
|
417
601
|
continue
|
|
418
|
-
verifier = verifier_by_rule.get(rule.ref)
|
|
419
602
|
if verifier is None:
|
|
420
603
|
evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
|
|
421
604
|
continue
|
|
@@ -427,11 +610,11 @@ def _run_all_verifiers(
|
|
|
427
610
|
parallel.append((rule, verifier, confirmation))
|
|
428
611
|
continue
|
|
429
612
|
cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
|
|
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)
|
|
613
|
+
evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation, stage) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
|
|
431
614
|
if parallel:
|
|
432
615
|
with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
|
|
433
616
|
futures = {
|
|
434
|
-
rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
|
|
617
|
+
rule.ref: pool.submit(copy_context().run, _cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
|
|
435
618
|
for rule, verifier, confirmation in parallel
|
|
436
619
|
}
|
|
437
620
|
evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
|
|
@@ -442,9 +625,11 @@ def _run_all_verifiers(
|
|
|
442
625
|
|
|
443
626
|
def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
|
|
444
627
|
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
445
|
-
skip_command: bool = False, timeout_budget: Optional[float] = None
|
|
628
|
+
skip_command: bool = False, timeout_budget: Optional[float] = None,
|
|
629
|
+
stage: Optional[str] = "code",
|
|
630
|
+
deferred: Optional[Mapping[str, str]] = None) -> VerificationResult:
|
|
446
631
|
with execution_session():
|
|
447
|
-
return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
|
|
632
|
+
return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage, deferred)
|
|
448
633
|
|
|
449
634
|
|
|
450
635
|
def evidence_is_fresh(
|
|
@@ -459,3 +644,44 @@ def evidence_is_fresh(
|
|
|
459
644
|
and evidence.artifact_sha256 == artifact_sha256
|
|
460
645
|
and evidence.diff_sha256 == diff_sha256
|
|
461
646
|
)
|
|
647
|
+
|
|
648
|
+
def store_verification_evidence(
|
|
649
|
+
metadata: SpecMetadata,
|
|
650
|
+
scope: VerificationScope,
|
|
651
|
+
result: VerificationResult,
|
|
652
|
+
requirement: str = "",
|
|
653
|
+
) -> tuple[dict[str, object], ...]:
|
|
654
|
+
"""Persist verifier runs in the central sidecar (audit/compaction input)."""
|
|
655
|
+
from codeflow.evidence.cf_evidence_store import EvidenceStore, git_commit
|
|
656
|
+
|
|
657
|
+
store = EvidenceStore(scope.root, requirement)
|
|
658
|
+
commit = git_commit(store.root)
|
|
659
|
+
outcomes: list[dict[str, object]] = []
|
|
660
|
+
for item in result.evidence:
|
|
661
|
+
details = dict(item.details or {})
|
|
662
|
+
identity = "|".join((
|
|
663
|
+
item.verifier_ref, item.status, item.result_sha256,
|
|
664
|
+
item.diff_sha256 or "", item.rule_text_sha256,
|
|
665
|
+
))
|
|
666
|
+
argv = details.get("argv")
|
|
667
|
+
timeout = details.get("timeout")
|
|
668
|
+
run = {
|
|
669
|
+
"run_id": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32],
|
|
670
|
+
"check_id": item.verifier_ref,
|
|
671
|
+
"command": argv if isinstance(argv, list) and all(isinstance(value, str) for value in argv) else [],
|
|
672
|
+
"cwd": ".",
|
|
673
|
+
"timeout": timeout if isinstance(timeout, (int, float)) else 0,
|
|
674
|
+
"input_hash": item.diff_sha256 or item.result_sha256,
|
|
675
|
+
"exit_code": details.get("exit_code") if isinstance(details.get("exit_code"), int) else (0 if item.status == "verified" else 1),
|
|
676
|
+
"status": "passed" if item.status == "verified" else "failed",
|
|
677
|
+
"executed_at": item.executed_at,
|
|
678
|
+
"source": "local",
|
|
679
|
+
"commit": commit,
|
|
680
|
+
"run_url": "",
|
|
681
|
+
"rule_text_sha256": item.rule_text_sha256,
|
|
682
|
+
"result_sha256": item.result_sha256,
|
|
683
|
+
"error_code": item.error_code,
|
|
684
|
+
"details": details,
|
|
685
|
+
}
|
|
686
|
+
outcomes.append(store.write_run(item.verifier_ref, run))
|
|
687
|
+
return tuple(outcomes)
|