@michaelxwb/code-flow 0.6.5 → 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 -42
- 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 +52 -39
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
- 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 -42
- 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 +52 -39
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
- 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 -42
- 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 +52 -39
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
- 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 -42
- 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 +53 -45
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
- 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 -1741
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
- 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 -389
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
- 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} +5 -2
- 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} +163 -35
- 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 -304
- 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
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
"""Shared requirement verification for CLI and parallel batch orchestration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Mapping, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
|
|
11
|
+
from codeflow.acceptance.cf_acceptance_risk import accepted_risk
|
|
12
|
+
from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids, verified_evidence
|
|
13
|
+
from codeflow.acceptance.cf_acceptance_selection import select_owned, select_profile
|
|
14
|
+
from codeflow.cli.cf_acceptance_runner import run_manifest
|
|
15
|
+
from codeflow.cli.cf_task_index import project_task_nodes
|
|
16
|
+
from codeflow.core import cf_git
|
|
17
|
+
from codeflow.core.cf_exec_base import record_report, report_session, worktree_fingerprint
|
|
18
|
+
from codeflow.spec.cf_active_task import load_active_task
|
|
19
|
+
from codeflow.spec.cf_spec_context_model import ContextError, SpecContext, context_sha256, load_context, save_context
|
|
20
|
+
from codeflow.spec.cf_spec_metadata import SpecMetadata, load_spec_metadata
|
|
21
|
+
from codeflow.spec.cf_spec_verify import (VerificationEvidence, VerificationResult, VerificationScope, run_all_verifiers,
|
|
22
|
+
store_verification_evidence)
|
|
23
|
+
from codeflow.spec.cf_stage_gate import validate_stage
|
|
24
|
+
from codeflow.spec.cf_workflow_service import FINISHED_STATUSES, complete_requirement_verification
|
|
25
|
+
from codeflow.spec.cf_workflow_transaction import recover_transition
|
|
26
|
+
from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
|
|
27
|
+
from codeflow.task.cf_task_runtime import integrate_verification_evidence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def demand_tasks(directory: Path) -> list[tuple[Path, str, str]]:
|
|
31
|
+
tasks = []
|
|
32
|
+
seen = set()
|
|
33
|
+
for task_file in sorted(directory.glob("*.md")):
|
|
34
|
+
if task_file.name.endswith((".design.md", ".prd.md", ".scenarios.md")):
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
text = task_file.read_text(encoding="utf-8")
|
|
38
|
+
except (OSError, UnicodeError) as exc:
|
|
39
|
+
raise ValueError(f"task_read_error: {exc}") from exc
|
|
40
|
+
try:
|
|
41
|
+
document = parse_task_document(text, source=str(task_file))
|
|
42
|
+
except TaskDocumentError as exc:
|
|
43
|
+
if exc.code == "task_document_empty":
|
|
44
|
+
continue
|
|
45
|
+
raise ValueError(str(exc)) from exc
|
|
46
|
+
for node in project_task_nodes(document):
|
|
47
|
+
if node.task_id in seen:
|
|
48
|
+
raise ValueError(f"duplicate task id: {node.task_id}")
|
|
49
|
+
seen.add(node.task_id)
|
|
50
|
+
tasks.append((task_file, node.task_id, node.status))
|
|
51
|
+
if not tasks:
|
|
52
|
+
raise ValueError("no TASK sections found")
|
|
53
|
+
return tasks
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _head_token(root: str) -> str:
|
|
57
|
+
try:
|
|
58
|
+
return "head:" + cf_git.run_git(root, ("rev-parse", "HEAD")).strip()
|
|
59
|
+
except cf_git.GitError:
|
|
60
|
+
return "head:none"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _review_scope(root: str) -> VerificationScope:
|
|
64
|
+
files = set(cf_git.tracked_files(root))
|
|
65
|
+
try:
|
|
66
|
+
files.update(cf_git.changed_files(root))
|
|
67
|
+
except cf_git.GitError:
|
|
68
|
+
pass
|
|
69
|
+
return VerificationScope(root, tuple(sorted(files)), _head_token(root))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def collect_requirement_bindings(directory: Path,
|
|
73
|
+
stages: Sequence[str] = ("review",)) -> dict[str, dict[str, object]]:
|
|
74
|
+
"""收集需求目录下全部 task context 中指定阶段的绑定。
|
|
75
|
+
|
|
76
|
+
返回 spec_id -> {"path": spec 相对路径, "rules": {rule_ref: [(context_path, context), ...]}}。
|
|
77
|
+
读取失败 fail-closed(报告文件与原因),不允许静默跳过。
|
|
78
|
+
"""
|
|
79
|
+
allowed = set(stages)
|
|
80
|
+
targets: dict[str, dict[str, object]] = {}
|
|
81
|
+
for ctx_path in sorted(directory.rglob("spec-context.yml")):
|
|
82
|
+
try:
|
|
83
|
+
context = load_context(str(ctx_path))
|
|
84
|
+
except (ContextError, OSError, ValueError) as exc:
|
|
85
|
+
raise ValueError(f"review_context_unreadable: {ctx_path}: {exc}") from exc
|
|
86
|
+
for binding in context.bindings:
|
|
87
|
+
for rule in binding.rules:
|
|
88
|
+
if rule.verifier_stage not in allowed:
|
|
89
|
+
continue
|
|
90
|
+
entry = targets.setdefault(binding.spec_id, {"path": binding.path, "rules": {}})
|
|
91
|
+
entry["rules"].setdefault(rule.ref, []).append((ctx_path, context))
|
|
92
|
+
return targets
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect_review_bindings(directory: Path) -> dict[str, dict[str, object]]:
|
|
96
|
+
"""review 层绑定(manual 确认与待确认清单专用)。"""
|
|
97
|
+
return collect_requirement_bindings(directory, ("review",))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _confirmed_manual(spec_id: str, entries: Sequence[tuple[Path, SpecContext]],
|
|
101
|
+
rule_ref: str) -> Optional[Mapping[str, object]]:
|
|
102
|
+
"""从任意绑定该 rule 的 context 读取有效的用户确认记录(按 rule 自身 stage)。"""
|
|
103
|
+
for _, context in entries:
|
|
104
|
+
for binding in context.bindings:
|
|
105
|
+
if binding.spec_id != spec_id:
|
|
106
|
+
continue
|
|
107
|
+
for rule in binding.rules:
|
|
108
|
+
if rule.ref != rule_ref:
|
|
109
|
+
continue
|
|
110
|
+
status = rule.stage_status.get(rule.verifier_stage or "code")
|
|
111
|
+
decision = status.decision if status is not None else None
|
|
112
|
+
if (status is not None and status.status == "verified"
|
|
113
|
+
and decision is not None and decision.kind == "manual_verification"):
|
|
114
|
+
return {
|
|
115
|
+
"reason": decision.reason,
|
|
116
|
+
"confirmed_by": decision.confirmed_by,
|
|
117
|
+
"confirmed_at": decision.confirmed_at,
|
|
118
|
+
"source": decision.source,
|
|
119
|
+
}
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def pending_manual_rules(
|
|
124
|
+
root: str, targets: Mapping[str, Mapping[str, object]]
|
|
125
|
+
) -> tuple[list[dict[str, object]], dict[str, dict[str, Mapping[str, object]]]]:
|
|
126
|
+
"""收集 review 层 manual 规则:待用户确认清单 + 已确认项的 verifier 入参。"""
|
|
127
|
+
pending: list[dict[str, object]] = []
|
|
128
|
+
confirmations: dict[str, dict[str, Mapping[str, object]]] = {}
|
|
129
|
+
for spec_id in sorted(targets):
|
|
130
|
+
info = targets[spec_id]
|
|
131
|
+
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
|
|
132
|
+
manual = {item.rule: item for item in metadata.verifiers if item.type == "manual"}
|
|
133
|
+
for rule_ref, entries in sorted(info["rules"].items()):
|
|
134
|
+
confirmation = _confirmed_manual(spec_id, entries, rule_ref)
|
|
135
|
+
if confirmation is not None:
|
|
136
|
+
confirmations.setdefault(spec_id, {})[rule_ref] = confirmation
|
|
137
|
+
elif rule_ref in manual:
|
|
138
|
+
verifier = manual[rule_ref]
|
|
139
|
+
pending.append({
|
|
140
|
+
"ref": f"{spec_id}#{rule_ref}",
|
|
141
|
+
"spec_id": spec_id,
|
|
142
|
+
"checklist": verifier.config.get("checklist", ""),
|
|
143
|
+
"owner": verifier.config.get("owner", ""),
|
|
144
|
+
"bound_contexts": len(entries),
|
|
145
|
+
})
|
|
146
|
+
return pending, confirmations
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _manual_confirmations(root: str, targets: Mapping[str, Mapping[str, object]]) -> dict[str, dict[str, Mapping[str, object]]]:
|
|
150
|
+
"""收集各 manual rule 自身 stage 上已记录的用户确认(verifier 入参)。"""
|
|
151
|
+
confirmations: dict[str, dict[str, Mapping[str, object]]] = {}
|
|
152
|
+
for spec_id in sorted(targets):
|
|
153
|
+
info = targets[spec_id]
|
|
154
|
+
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
|
|
155
|
+
manual_refs = {item.rule for item in metadata.verifiers if item.type == "manual"}
|
|
156
|
+
for rule_ref, entries in sorted(info["rules"].items()):
|
|
157
|
+
if rule_ref not in manual_refs:
|
|
158
|
+
continue
|
|
159
|
+
confirmation = _confirmed_manual(spec_id, entries, rule_ref)
|
|
160
|
+
if confirmation is not None:
|
|
161
|
+
confirmations.setdefault(spec_id, {})[rule_ref] = confirmation
|
|
162
|
+
return confirmations
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _store_requirement_evidence(metadata: SpecMetadata, scope: VerificationScope,
|
|
166
|
+
result: VerificationResult, directory: str) -> None:
|
|
167
|
+
try:
|
|
168
|
+
from codeflow.evidence.cf_evidence_store import requirement_key
|
|
169
|
+
store_verification_evidence(metadata, scope, result, requirement=requirement_key(directory))
|
|
170
|
+
except (OSError, ValueError) as exc:
|
|
171
|
+
sys.stderr.write(f"cf_task_workflow: verifier evidence sidecar failed: {exc}\n")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _update_requirement_contexts(evidence_by_context: Mapping[Path, list[VerificationEvidence]],
|
|
175
|
+
scope: VerificationScope) -> list[str]:
|
|
176
|
+
# Integrate all specs before writing each context to avoid stale overwrites.
|
|
177
|
+
for ctx_path in sorted(evidence_by_context, key=str):
|
|
178
|
+
context = load_context(str(ctx_path))
|
|
179
|
+
updated, _ = integrate_verification_evidence(context, evidence_by_context[ctx_path])
|
|
180
|
+
if updated != context:
|
|
181
|
+
save_context(str(ctx_path), updated)
|
|
182
|
+
failures = []
|
|
183
|
+
for ctx_path in sorted(evidence_by_context):
|
|
184
|
+
gate = validate_stage(load_context(str(ctx_path)), "review", diff_sha256=scope.diff_sha256)
|
|
185
|
+
failures.extend(f"{issue.spec_id}#{issue.rule_ref}" for issue in gate.errors)
|
|
186
|
+
return failures
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _summarize_verifier(evidence: VerificationEvidence, summary: list[dict[str, object]]) -> str:
|
|
190
|
+
summary.append({"check_id": evidence.verifier_ref, "status": evidence.status, "error_code": evidence.error_code})
|
|
191
|
+
if evidence.status == "verified":
|
|
192
|
+
return "reused" if (evidence.details or {}).get("cache_reused") else "executed"
|
|
193
|
+
if evidence.error_code in _ENV_ERROR_CODES:
|
|
194
|
+
return "env_blocked"
|
|
195
|
+
return "incomplete" if evidence.error_code == "verifier_timeout" else "failed"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_requirement_verifiers(
|
|
199
|
+
root: str,
|
|
200
|
+
directory: str,
|
|
201
|
+
targets: dict[str, dict[str, object]],
|
|
202
|
+
confirmations: Optional[Mapping[str, Mapping[str, Mapping[str, object]]]] = None,
|
|
203
|
+
) -> dict[str, object]:
|
|
204
|
+
"""执行需求级全量 verifier(code+review 按 spec/rule 去重),证据写回全部相关 context。"""
|
|
205
|
+
executed = reused = env_blocked = incomplete = 0
|
|
206
|
+
failures: list[str] = []
|
|
207
|
+
summaries: list[dict[str, object]] = []
|
|
208
|
+
evidence_by_context: dict[Path, list[VerificationEvidence]] = {}
|
|
209
|
+
started = time.monotonic()
|
|
210
|
+
scope = _review_scope(root)
|
|
211
|
+
preflight_ms = (time.monotonic() - started) * 1000
|
|
212
|
+
planning_ms = evidence_ms = 0.0
|
|
213
|
+
for spec_id in sorted(targets):
|
|
214
|
+
info = targets[spec_id]
|
|
215
|
+
started = time.monotonic()
|
|
216
|
+
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
|
|
217
|
+
planning_ms += (time.monotonic() - started) * 1000
|
|
218
|
+
result = run_all_verifiers(metadata, scope, (confirmations or {}).get(spec_id), stage=None)
|
|
219
|
+
started = time.monotonic()
|
|
220
|
+
_store_requirement_evidence(metadata, scope, result, directory)
|
|
221
|
+
evidence_ms += (time.monotonic() - started) * 1000
|
|
222
|
+
by_rule: dict[str, VerificationEvidence] = {}
|
|
223
|
+
for evidence in result.evidence:
|
|
224
|
+
rule_ref = evidence.verifier_ref.split("#", 1)[-1]
|
|
225
|
+
by_rule[rule_ref] = evidence
|
|
226
|
+
category = _summarize_verifier(evidence, summaries)
|
|
227
|
+
executed += category == "executed"
|
|
228
|
+
reused += category == "reused"
|
|
229
|
+
env_blocked += category == "env_blocked"
|
|
230
|
+
incomplete += category == "incomplete"
|
|
231
|
+
if category == "failed":
|
|
232
|
+
failures.append(evidence.verifier_ref)
|
|
233
|
+
for rule_ref, entries in info["rules"].items():
|
|
234
|
+
evidence = by_rule.get(rule_ref)
|
|
235
|
+
if evidence is None:
|
|
236
|
+
continue
|
|
237
|
+
for ctx_path, _context in entries:
|
|
238
|
+
evidence_by_context.setdefault(ctx_path, []).append(evidence)
|
|
239
|
+
started = time.monotonic()
|
|
240
|
+
failures.extend(_update_requirement_contexts(evidence_by_context, scope))
|
|
241
|
+
evidence_ms += (time.monotonic() - started) * 1000
|
|
242
|
+
record_report({"kind": "spec_phases", "timings": {"planning_ms": planning_ms,
|
|
243
|
+
"preflight_ms": preflight_ms, "evidence_ms": evidence_ms}})
|
|
244
|
+
return {"executed": executed, "reused": reused, "failed": sorted(set(failures)),
|
|
245
|
+
"env_blocked": env_blocked, "incomplete": incomplete, "evidence": summaries}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
VERIFY_LAYERS = ("validators", "review", "e2e")
|
|
249
|
+
VERIFY_PROFILES = ("smoke", "changed", "full")
|
|
250
|
+
_ENV_ERROR_CODES = frozenset(("env_blocked", "environment_unavailable", "command_unavailable"))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_verify_layers(value: Optional[str]) -> tuple[str, ...]:
|
|
254
|
+
if value is None or not value.strip():
|
|
255
|
+
return VERIFY_LAYERS
|
|
256
|
+
chosen: set[str] = set()
|
|
257
|
+
for item in value.split(","):
|
|
258
|
+
name = item.strip()
|
|
259
|
+
if not name:
|
|
260
|
+
continue
|
|
261
|
+
if name not in VERIFY_LAYERS:
|
|
262
|
+
raise ValueError(f"unknown verify layer: {name}; allowed: {', '.join(VERIFY_LAYERS)}")
|
|
263
|
+
chosen.add(name)
|
|
264
|
+
if not chosen:
|
|
265
|
+
raise ValueError(f"--layers requires at least one of: {', '.join(VERIFY_LAYERS)}")
|
|
266
|
+
return tuple(name for name in VERIFY_LAYERS if name in chosen)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def parse_verify_profile(value: Optional[str], default: str = "changed") -> str:
|
|
270
|
+
profile = (value or default).strip()
|
|
271
|
+
if profile not in VERIFY_PROFILES:
|
|
272
|
+
raise ValueError(f"unknown verify profile: {profile}; allowed: {', '.join(VERIFY_PROFILES)}")
|
|
273
|
+
return profile
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _layer_result(decision: str, *, reason: str = "", executed: int = 0, reused: int = 0,
|
|
277
|
+
failed: Optional[Sequence[str]] = None, env_blocked: int = 0, incomplete: int = 0,
|
|
278
|
+
evidence: Optional[Sequence[Mapping[str, object]]] = None,
|
|
279
|
+
**extra: object) -> dict[str, object]:
|
|
280
|
+
result: dict[str, object] = {
|
|
281
|
+
"decision": decision,
|
|
282
|
+
"reason": reason,
|
|
283
|
+
"executed": executed,
|
|
284
|
+
"reused": reused,
|
|
285
|
+
"failed": list(failed or ()),
|
|
286
|
+
"env_blocked": env_blocked,
|
|
287
|
+
"incomplete": incomplete,
|
|
288
|
+
"evidence": list(evidence or ()),
|
|
289
|
+
}
|
|
290
|
+
result.update(extra)
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _nothing_to_verify() -> dict[str, object]:
|
|
295
|
+
return _layer_result("pass", reason="nothing_to_verify")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _validation_view(details: Mapping[str, object]) -> dict[str, object]:
|
|
299
|
+
return {
|
|
300
|
+
"decision": details.get("decision"),
|
|
301
|
+
"reason": details.get("reason", ""),
|
|
302
|
+
"reused": details.get("reused", 0),
|
|
303
|
+
"failures": details.get("failures", []),
|
|
304
|
+
"files": list(details.get("files") or ()),
|
|
305
|
+
"timings": details.get("timings"),
|
|
306
|
+
"observations": details.get("observations", []),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _validator_execution_count(root: str, selected: Sequence[str], reused: int) -> int:
|
|
311
|
+
from codeflow.core.cf_validators import resolve_validators, trigger_matches
|
|
312
|
+
|
|
313
|
+
_config, validators = resolve_validators(root)
|
|
314
|
+
applicable = 0
|
|
315
|
+
for validator in validators:
|
|
316
|
+
if not isinstance(validator, Mapping):
|
|
317
|
+
continue
|
|
318
|
+
trigger = str(validator.get("trigger", ""))
|
|
319
|
+
if any(trigger_matches(trigger, path) for path in selected):
|
|
320
|
+
applicable += 1
|
|
321
|
+
return max(0, applicable - reused)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _validators_layer(root: str, profile: str,
|
|
325
|
+
files: Optional[Sequence[str]] = None) -> dict[str, object]:
|
|
326
|
+
try:
|
|
327
|
+
from codeflow.task.cf_validation_service import validate_files
|
|
328
|
+
|
|
329
|
+
if files is None:
|
|
330
|
+
from codeflow.task.cf_validation_service import validation_scope
|
|
331
|
+
files = (tuple(sorted(cf_git.tracked_files(root))) if profile == "full"
|
|
332
|
+
else validation_scope(root))
|
|
333
|
+
details = validate_files(root, files, include_heavy=profile == "full")
|
|
334
|
+
except (OSError, ValueError) as exc:
|
|
335
|
+
return _layer_result("block", reason="validation_error", detail=str(exc))
|
|
336
|
+
view = _validation_view(details)
|
|
337
|
+
if details.get("reason") in ("no_changes", "no_validators_configured"):
|
|
338
|
+
return _layer_result("pass", reason="nothing_to_verify", validation=view)
|
|
339
|
+
failures = list(details.get("failures") or ())
|
|
340
|
+
failed = [str(item.get("name", "validator")) for item in failures if not item.get("incomplete")]
|
|
341
|
+
incomplete = sum(1 for item in failures if item.get("incomplete"))
|
|
342
|
+
reused = int(details.get("reused") or 0)
|
|
343
|
+
selected = details.get("files") or ()
|
|
344
|
+
executed = _validator_execution_count(root, selected, reused)
|
|
345
|
+
decision = "pass" if details.get("decision") == "pass" else "block"
|
|
346
|
+
return _layer_result(
|
|
347
|
+
decision,
|
|
348
|
+
reason="" if decision == "pass" else "validation_failed",
|
|
349
|
+
executed=executed,
|
|
350
|
+
reused=reused,
|
|
351
|
+
failed=failed,
|
|
352
|
+
incomplete=incomplete,
|
|
353
|
+
validation=view,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _validation_report(result: dict[str, object], layer: Mapping[str, object],
|
|
358
|
+
layers: Sequence[str]) -> dict[str, object]:
|
|
359
|
+
view = layer.get("validation") or {}
|
|
360
|
+
observations = view.get("observations") or []
|
|
361
|
+
result["scope"] = {"layers": list(layers), "files": view.get("files", [])}
|
|
362
|
+
result["selection_reason"] = ("validator_trigger_matches_selected_files" if observations
|
|
363
|
+
else "no_validator_selected")
|
|
364
|
+
result["reuse_reason"] = "matching_validation_cache" if layer.get("reused") else "no_reused_results"
|
|
365
|
+
result["execution_identities"] = [item["identity"] for item in observations]
|
|
366
|
+
result["input_change_category"] = sorted({item["input_change_category"] for item in observations})
|
|
367
|
+
result["summary"] = (f"validators: executed {int(layer.get('executed') or 0)}, "
|
|
368
|
+
f"reused {int(layer.get('reused') or 0)}")
|
|
369
|
+
if view.get("timings") is not None:
|
|
370
|
+
result["timings"] = dict(view["timings"])
|
|
371
|
+
result["timings_scope"] = ["validators"]
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _combine_acceptance_timing(result: dict[str, object], layer: Mapping[str, object],
|
|
376
|
+
started: float) -> None:
|
|
377
|
+
acceptance = layer.get("acceptance")
|
|
378
|
+
if not isinstance(acceptance, Mapping) or not isinstance(acceptance.get("timings"), Mapping):
|
|
379
|
+
return
|
|
380
|
+
incoming = acceptance["timings"]
|
|
381
|
+
current = result.get("timings")
|
|
382
|
+
if not isinstance(current, dict):
|
|
383
|
+
current = {key: 0.0 for key in ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms")}
|
|
384
|
+
result["timings"] = current
|
|
385
|
+
result["timings_scope"] = []
|
|
386
|
+
for key in ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms"):
|
|
387
|
+
current[key] = float(current.get(key, 0)) + float(incoming.get(key, 0))
|
|
388
|
+
current["elapsed_ms"] = (time.monotonic() - started) * 1000
|
|
389
|
+
result["timings_scope"].append("e2e")
|
|
390
|
+
reasons = {"e2e": acceptance.get("selection_reason", "unknown")}
|
|
391
|
+
if "selection_reason" in result:
|
|
392
|
+
reasons["validators"] = result["selection_reason"]
|
|
393
|
+
result["selection_reasons"] = reasons
|
|
394
|
+
result["selection_reason"] = "multiple_layer_policies" if len(reasons) > 1 else reasons["e2e"]
|
|
395
|
+
result["selected_check_ids"] = acceptance.get("selected_check_ids", [])
|
|
396
|
+
result.setdefault("execution_identities", []).extend(acceptance.get("execution_identities", []))
|
|
397
|
+
categories = acceptance.get("input_change_category") or {}
|
|
398
|
+
result["input_change_categories"] = {"validators": result.get("input_change_category", []), "e2e": categories}
|
|
399
|
+
result["input_change_category"] = sorted(set(result.get("input_change_category", [])) | set(categories.values()))
|
|
400
|
+
summaries = [str(value) for value in (result.get("summary"), acceptance.get("summary")) if value]
|
|
401
|
+
result["summary"] = "; ".join(summaries)
|
|
402
|
+
result["reuse_reason"] = "evidence_reused" if result.get("reused") else "no_reused_results"
|
|
403
|
+
if "scope" not in result:
|
|
404
|
+
result["scope"] = {"layers": result.get("selected_layers", []), "acceptance": acceptance.get("scope", {})}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _verify_validators(root: str, directory: str, profile: str) -> dict[str, object]:
|
|
408
|
+
"""Development checks use the active baseline without completing the TASK."""
|
|
409
|
+
started = time.monotonic()
|
|
410
|
+
files: Optional[Sequence[str]] = None
|
|
411
|
+
try:
|
|
412
|
+
if (Path(root) / ".code-flow/.active-task.json").exists():
|
|
413
|
+
active = load_active_task(root)
|
|
414
|
+
if (Path(root) / active.task_dir).resolve() != Path(directory).resolve():
|
|
415
|
+
raise ValueError("active_task_directory_mismatch")
|
|
416
|
+
context = load_context(str(Path(directory) / "spec-context.yml"))
|
|
417
|
+
if active.context_sha256 != context_sha256(context):
|
|
418
|
+
raise ValueError("active_context_hash_mismatch: run code-flow spec refresh/doctor")
|
|
419
|
+
from codeflow.task.cf_validation_service import validation_scope
|
|
420
|
+
files = validation_scope(root)
|
|
421
|
+
layer = _validators_layer(root, profile, files)
|
|
422
|
+
except (OSError, ValueError) as exc:
|
|
423
|
+
layer = _layer_result("block", reason="validation_context_invalid", detail=str(exc))
|
|
424
|
+
result = _aggregate_layers({"validators": layer}, profile, ("validators",))
|
|
425
|
+
_validation_report(result, layer, ("validators",))
|
|
426
|
+
if "timings" in result:
|
|
427
|
+
result["timings"]["elapsed_ms"] = (time.monotonic() - started) * 1000
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _review_layer(root: str, directory: str) -> dict[str, object]:
|
|
432
|
+
started = time.monotonic()
|
|
433
|
+
try:
|
|
434
|
+
review_targets = collect_review_bindings(Path(directory))
|
|
435
|
+
pending_rules, _ = pending_manual_rules(root, review_targets)
|
|
436
|
+
except ValueError as exc:
|
|
437
|
+
return _layer_result("block", reason="manual_review_context_unreadable", detail=str(exc))
|
|
438
|
+
if pending_rules:
|
|
439
|
+
return _layer_result(
|
|
440
|
+
"block",
|
|
441
|
+
reason="manual_confirmation_required",
|
|
442
|
+
manual_rules=pending_rules,
|
|
443
|
+
next=("向用户展示待确认清单并取得明确回复后,运行 confirm-manual 批量写入确认,再重跑 verify;"
|
|
444
|
+
"Agent 不得代确认。"),
|
|
445
|
+
)
|
|
446
|
+
try:
|
|
447
|
+
all_targets = collect_requirement_bindings(Path(directory), ("code", "review"))
|
|
448
|
+
except ValueError as exc:
|
|
449
|
+
return _layer_result("block", reason="review_context_unreadable", detail=str(exc))
|
|
450
|
+
if not all_targets:
|
|
451
|
+
return _nothing_to_verify()
|
|
452
|
+
confirmations = _manual_confirmations(root, all_targets)
|
|
453
|
+
record_report({"kind": "spec_phases", "timings": {"planning_ms": (time.monotonic() - started) * 1000}})
|
|
454
|
+
review = _run_requirement_verifiers(root, directory, all_targets, confirmations)
|
|
455
|
+
blocked = bool(review["failed"] or review["env_blocked"] or review["incomplete"])
|
|
456
|
+
return _layer_result(
|
|
457
|
+
"block" if blocked else "pass",
|
|
458
|
+
reason="review_failed" if blocked else "",
|
|
459
|
+
executed=int(review["executed"]),
|
|
460
|
+
reused=int(review["reused"]),
|
|
461
|
+
failed=review["failed"],
|
|
462
|
+
env_blocked=int(review["env_blocked"]),
|
|
463
|
+
incomplete=int(review["incomplete"]),
|
|
464
|
+
evidence=review["evidence"],
|
|
465
|
+
review={"executed": review["executed"], "reused": review["reused"], "failed": review["failed"]},
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _acceptance_counts(acceptance: Mapping[str, object]) -> dict[str, object]:
|
|
470
|
+
executed = reused = incomplete = env_blocked = 0
|
|
471
|
+
failed: list[str] = []
|
|
472
|
+
evidence: list[dict[str, object]] = []
|
|
473
|
+
for item in acceptance.get("results") or ():
|
|
474
|
+
if not isinstance(item, Mapping):
|
|
475
|
+
continue
|
|
476
|
+
scenario_id = str(item.get("id", ""))
|
|
477
|
+
status = str(item.get("status", ""))
|
|
478
|
+
evidence.append({"check_id": scenario_id, "status": status})
|
|
479
|
+
if status == "incomplete":
|
|
480
|
+
incomplete += 1
|
|
481
|
+
elif status == "env_blocked":
|
|
482
|
+
env_blocked += 1
|
|
483
|
+
elif status in ("failed", "not_configured"):
|
|
484
|
+
failed.append(scenario_id)
|
|
485
|
+
elif status == "passed":
|
|
486
|
+
if item.get("cache_reused"):
|
|
487
|
+
reused += 1
|
|
488
|
+
else:
|
|
489
|
+
executed += 1
|
|
490
|
+
return {"executed": executed, "reused": reused, "failed": sorted(failed),
|
|
491
|
+
"env_blocked": env_blocked, "incomplete": incomplete, "evidence": evidence}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def e2e_preflight(manifest: Path, tasks: Sequence[tuple[Path, str, str]],
|
|
495
|
+
requirement_gate: bool, profile: str = "changed",
|
|
496
|
+
root: str = "") -> tuple[Optional[dict[str, object]], Optional[dict[str, object]]]:
|
|
497
|
+
try:
|
|
498
|
+
data = load_manifest(manifest)
|
|
499
|
+
validate_execution_baseline(manifest, data)
|
|
500
|
+
except (OSError, ValueError) as exc:
|
|
501
|
+
return None, _layer_result("block", reason="acceptance_manifest_invalid", detail=str(exc))
|
|
502
|
+
bound_task = (manifest.parent / str(data.get("task_file", ""))).resolve()
|
|
503
|
+
if {task.resolve() for task, _, _ in tasks} != {bound_task}:
|
|
504
|
+
return None, _layer_result("block", reason="acceptance_manifest_task_mismatch")
|
|
505
|
+
owner = ",".join(task for _, task, _ in tasks)
|
|
506
|
+
selected_ids: Optional[set[str]] = None
|
|
507
|
+
if requirement_gate and root and profile != "full":
|
|
508
|
+
selected = select_profile(data, root, profile)["scenarios"]
|
|
509
|
+
selected_ids = {str(item.get("id")) for item in select_owned(selected, owner)}
|
|
510
|
+
data = {**data, "scenarios": select_owned(data["scenarios"], owner)}
|
|
511
|
+
if not requirement_gate:
|
|
512
|
+
if not any(row.get("kind") == "e2e" or row.get("exec") == "deferred" for row in data["scenarios"]):
|
|
513
|
+
return None, _nothing_to_verify()
|
|
514
|
+
return data, None
|
|
515
|
+
functional_required, manual_scenarios = _pending_scenario_rows(data, selected_ids)
|
|
516
|
+
if functional_required:
|
|
517
|
+
return None, _layer_result("block", reason="functional_or_manual_evidence_missing",
|
|
518
|
+
scenarios=functional_required)
|
|
519
|
+
if manual_scenarios:
|
|
520
|
+
return None, _layer_result(
|
|
521
|
+
"block", reason="manual_confirmation_required", manual_scenarios=manual_scenarios,
|
|
522
|
+
next=("向用户展示待确认清单并取得明确回复后,运行 confirm-manual 批量写入确认,再重跑 verify;"
|
|
523
|
+
"Agent 不得代确认。"))
|
|
524
|
+
return data, None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _pending_scenario_rows(data: Mapping[str, object], selected_ids: Optional[set[str]]
|
|
528
|
+
) -> tuple[list[object], list[dict[str, object]]]:
|
|
529
|
+
functional_required: list[object] = []
|
|
530
|
+
manual_scenarios: list[dict[str, object]] = []
|
|
531
|
+
checks = {
|
|
532
|
+
str(item.get("id")): item for item in data.get("checks", [])
|
|
533
|
+
if isinstance(item, Mapping) and item.get("id")
|
|
534
|
+
}
|
|
535
|
+
for row in data["scenarios"]:
|
|
536
|
+
if row.get("exec") == "aspirational":
|
|
537
|
+
continue
|
|
538
|
+
if selected_ids is not None and str(row.get("id")) not in selected_ids:
|
|
539
|
+
continue
|
|
540
|
+
if row.get("status") == "stale":
|
|
541
|
+
# Stale evidence is known-outdated, not missing. The E2E layer
|
|
542
|
+
# re-runs it; preflight must not turn it into a hard archive block.
|
|
543
|
+
continue
|
|
544
|
+
if verified_evidence(row):
|
|
545
|
+
continue
|
|
546
|
+
if row.get("status") == "accepted_risk":
|
|
547
|
+
check_ids = scenario_check_ids(row)
|
|
548
|
+
if check_ids and any(accepted_risk(checks.get(check_id), row) is not None for check_id in check_ids):
|
|
549
|
+
continue
|
|
550
|
+
if row.get("kind") == "manual" or row.get("exec") == "manual":
|
|
551
|
+
manual_scenarios.append({
|
|
552
|
+
"id": row.get("id"),
|
|
553
|
+
"owner": row.get("owner", ""),
|
|
554
|
+
"boundary": row.get("boundary", ""),
|
|
555
|
+
})
|
|
556
|
+
elif (row.get("status") == "verified" and row.get("kind", "functional") != "e2e"
|
|
557
|
+
and row.get("exec") != "deferred"):
|
|
558
|
+
functional_required.append(row["id"])
|
|
559
|
+
return functional_required, manual_scenarios
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def stale_functional_rows(data: Mapping[str, object], root: str) -> list[str]:
|
|
563
|
+
"""Functional rows claiming verified but recorded against different content."""
|
|
564
|
+
from codeflow.acceptance.cf_acceptance_evidence import stale_scenario_checks
|
|
565
|
+
fingerprint = worktree_fingerprint(root)
|
|
566
|
+
checks = {
|
|
567
|
+
str(item.get("id")): item for item in data.get("checks", [])
|
|
568
|
+
if isinstance(item, Mapping) and item.get("id")
|
|
569
|
+
}
|
|
570
|
+
from codeflow.acceptance.cf_check_inputs import check_input_hashes
|
|
571
|
+
per_check = check_input_hashes(root, checks, fingerprint)
|
|
572
|
+
stale: list[str] = []
|
|
573
|
+
for row in data.get("scenarios", []) or []:
|
|
574
|
+
if not isinstance(row, Mapping):
|
|
575
|
+
continue
|
|
576
|
+
if str(row.get("kind", "functional")) in ("manual", "e2e"):
|
|
577
|
+
continue
|
|
578
|
+
if row.get("status") not in ("verified", "stale"):
|
|
579
|
+
continue
|
|
580
|
+
if stale_scenario_checks(row, per_check, fingerprint):
|
|
581
|
+
stale.append(str(row.get("id")))
|
|
582
|
+
return stale
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _unverified_functional_rows(data: Mapping[str, object]) -> list[str]:
|
|
586
|
+
checks = {
|
|
587
|
+
str(item.get("id")): item for item in data.get("checks", [])
|
|
588
|
+
if isinstance(item, Mapping) and item.get("id")
|
|
589
|
+
}
|
|
590
|
+
rows: list[str] = []
|
|
591
|
+
for row in data.get("scenarios", []) or []:
|
|
592
|
+
if not isinstance(row, Mapping):
|
|
593
|
+
continue
|
|
594
|
+
if str(row.get("kind", "functional")) in ("manual", "e2e") or row.get("exec") in ("manual", "aspirational"):
|
|
595
|
+
continue
|
|
596
|
+
if row.get("status") == "accepted_risk":
|
|
597
|
+
check_ids = scenario_check_ids(row)
|
|
598
|
+
if any(accepted_risk(checks.get(check_id), row) is not None for check_id in check_ids):
|
|
599
|
+
continue
|
|
600
|
+
if not verified_evidence(row):
|
|
601
|
+
rows.append(str(row.get("id")))
|
|
602
|
+
return rows
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _acceptance_required(tasks: Sequence[tuple[Path, str, str]]) -> bool:
|
|
606
|
+
for task_file, task_id, _ in tasks:
|
|
607
|
+
try:
|
|
608
|
+
text = Path(task_file).read_text(encoding="utf-8")
|
|
609
|
+
except (OSError, UnicodeError):
|
|
610
|
+
continue
|
|
611
|
+
section = re.search(rf"(?ms)^##\s+{re.escape(task_id)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
612
|
+
if section is None:
|
|
613
|
+
continue
|
|
614
|
+
body = section.group(0)
|
|
615
|
+
refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", body)
|
|
616
|
+
if refs and refs.group(1).strip().upper() not in ("", "N/A"):
|
|
617
|
+
return True
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _e2e_layer(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]],
|
|
622
|
+
requirement_gate: bool, profile: str = "changed", resume: bool = True,
|
|
623
|
+
deadline: Optional[float] = None, max_failures: int = 0,
|
|
624
|
+
fail_fast: bool = False, jobs: int = 0,
|
|
625
|
+
partial_ids: Optional[Sequence[str]] = None) -> dict[str, object]:
|
|
626
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
627
|
+
if not manifest.exists():
|
|
628
|
+
if _acceptance_required(tasks):
|
|
629
|
+
return _layer_result("block", reason="acceptance_manifest_missing")
|
|
630
|
+
return _nothing_to_verify()
|
|
631
|
+
data, failure = e2e_preflight(manifest, tasks, requirement_gate, profile, root)
|
|
632
|
+
if failure is not None:
|
|
633
|
+
return failure
|
|
634
|
+
stale = stale_functional_rows(data, root) if requirement_gate else []
|
|
635
|
+
unverified = _unverified_functional_rows(data) if requirement_gate else []
|
|
636
|
+
if profile == "smoke":
|
|
637
|
+
# Smoke only needs the E2E subset; functional freshness is handled by
|
|
638
|
+
# the explicit changed/full gate, not by the batch smoke.
|
|
639
|
+
stale = []
|
|
640
|
+
unverified = []
|
|
641
|
+
try:
|
|
642
|
+
acceptance = run_manifest(
|
|
643
|
+
str(manifest), root, write_evidence=True,
|
|
644
|
+
owner=",".join(str(item[1]) for item in tasks),
|
|
645
|
+
include_e2e=bool(stale or unverified) or profile in ("full", "smoke"),
|
|
646
|
+
only_e2e=(partial_ids is not None and profile == "smoke") or (
|
|
647
|
+
not (stale or unverified) and profile not in ("full", "smoke")),
|
|
648
|
+
profile=profile, resume=resume, deadline=deadline,
|
|
649
|
+
max_failures=max_failures, fail_fast=fail_fast, jobs=jobs,
|
|
650
|
+
force_ids=tuple(partial_ids) if partial_ids is not None else tuple(stale or unverified),
|
|
651
|
+
only_scenarios=partial_ids,
|
|
652
|
+
)
|
|
653
|
+
except (OSError, ValueError) as exc:
|
|
654
|
+
return _layer_result("block", reason="acceptance_run_error", detail=str(exc))
|
|
655
|
+
counts = _acceptance_counts(acceptance)
|
|
656
|
+
decision = "pass" if acceptance.get("decision") == "pass" else "block"
|
|
657
|
+
extra: dict[str, object] = {"results": acceptance.get("results", [])}
|
|
658
|
+
for key in ("checks", "error", "env_blocked_details"):
|
|
659
|
+
if key in acceptance:
|
|
660
|
+
extra[key] = acceptance[key]
|
|
661
|
+
return _layer_result(
|
|
662
|
+
decision,
|
|
663
|
+
reason="" if decision == "pass" else "e2e_failed",
|
|
664
|
+
executed=counts["executed"],
|
|
665
|
+
reused=counts["reused"],
|
|
666
|
+
failed=counts["failed"],
|
|
667
|
+
env_blocked=counts["env_blocked"],
|
|
668
|
+
incomplete=counts["incomplete"],
|
|
669
|
+
evidence=counts["evidence"],
|
|
670
|
+
acceptance=acceptance,
|
|
671
|
+
**extra,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _aggregate_layers(layer_results: Mapping[str, Mapping[str, object]], profile: str,
|
|
676
|
+
selected: Sequence[str]) -> dict[str, object]:
|
|
677
|
+
blocking = [(name, layer) for name, layer in layer_results.items() if layer.get("decision") != "pass"]
|
|
678
|
+
every_layer_empty = bool(layer_results) and all(
|
|
679
|
+
layer.get("reason") == "nothing_to_verify" for layer in layer_results.values()
|
|
680
|
+
)
|
|
681
|
+
if blocking:
|
|
682
|
+
first = blocking[0]
|
|
683
|
+
reason = str(first[1].get("reason") or f"{first[0]}_failed")
|
|
684
|
+
elif every_layer_empty:
|
|
685
|
+
reason = "nothing_to_verify"
|
|
686
|
+
else:
|
|
687
|
+
reason = ""
|
|
688
|
+
evidence = [
|
|
689
|
+
{"layer": name, **item}
|
|
690
|
+
for name, layer in layer_results.items()
|
|
691
|
+
for item in layer.get("evidence", [])
|
|
692
|
+
if isinstance(item, Mapping)
|
|
693
|
+
]
|
|
694
|
+
result: dict[str, object] = {
|
|
695
|
+
"decision": "block" if blocking else "pass",
|
|
696
|
+
"reason": reason,
|
|
697
|
+
"profile": profile,
|
|
698
|
+
"selected_layers": list(selected),
|
|
699
|
+
"layers": dict(layer_results),
|
|
700
|
+
"executed": sum(int(layer.get("executed", 0)) for layer in layer_results.values()),
|
|
701
|
+
"reused": sum(int(layer.get("reused", 0)) for layer in layer_results.values()),
|
|
702
|
+
"failed": sorted({str(item) for layer in layer_results.values()
|
|
703
|
+
for item in layer.get("failed", [])}),
|
|
704
|
+
"env_blocked": sum(int(layer.get("env_blocked", 0)) for layer in layer_results.values()),
|
|
705
|
+
"incomplete": sum(int(layer.get("incomplete", 0)) for layer in layer_results.values()),
|
|
706
|
+
"evidence": evidence,
|
|
707
|
+
"env_blocked_details": [
|
|
708
|
+
{"layer": name, **item} for name, layer in layer_results.items()
|
|
709
|
+
for item in layer.get("env_blocked_details", []) if isinstance(item, Mapping)
|
|
710
|
+
],
|
|
711
|
+
}
|
|
712
|
+
for field in ("manual_rules", "manual_scenarios", "scenarios", "next"):
|
|
713
|
+
merged = [layer[field] for layer in layer_results.values() if field in layer]
|
|
714
|
+
if merged:
|
|
715
|
+
result[field] = merged[0] if len(merged) == 1 else merged
|
|
716
|
+
return result
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _batch_scope(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]], wanted: set[str]
|
|
720
|
+
) -> tuple[list[tuple[Path, str, str]], list[str], list[str]]:
|
|
721
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
722
|
+
if not manifest.is_file():
|
|
723
|
+
return [task for task in tasks if task[1] in wanted], [], []
|
|
724
|
+
data = load_manifest(manifest)
|
|
725
|
+
rows = {str(row["id"]): row for row in data["scenarios"]}
|
|
726
|
+
from codeflow.acceptance.cf_acceptance_selection import changed_paths
|
|
727
|
+
from codeflow.acceptance.cf_check_inputs import match_path
|
|
728
|
+
changes = changed_paths(root)
|
|
729
|
+
checks = {str(item["id"]): item for item in data.get("checks", [])}
|
|
730
|
+
has_explicit_smoke = any(row.get("kind") == "e2e" and row.get("smoke") for row in rows.values())
|
|
731
|
+
selected = {sid for sid, row in rows.items() if row.get("owner") in wanted
|
|
732
|
+
and (row.get("kind") != "e2e" or row.get("smoke") or not has_explicit_smoke)}
|
|
733
|
+
for sid, row in rows.items():
|
|
734
|
+
patterns = list(row.get("trigger_paths") or [])
|
|
735
|
+
for check_id in scenario_check_ids(row):
|
|
736
|
+
check = checks.get(check_id, {})
|
|
737
|
+
patterns.extend(check.get("inputs") or [])
|
|
738
|
+
patterns.extend(check.get("paths") or [])
|
|
739
|
+
affected = any(match_path(path, str(pattern)) for path in changes for pattern in patterns)
|
|
740
|
+
if (row.get("kind") == "e2e" and row.get("smoke")
|
|
741
|
+
and (set(row.get("depends_on", [])) & selected or affected)):
|
|
742
|
+
selected.add(sid)
|
|
743
|
+
pending = list(selected)
|
|
744
|
+
while pending:
|
|
745
|
+
for dependency in rows[pending.pop()].get("depends_on", []):
|
|
746
|
+
if dependency in rows and dependency not in selected:
|
|
747
|
+
selected.add(dependency)
|
|
748
|
+
pending.append(dependency)
|
|
749
|
+
owners = wanted | {str(rows[sid].get("owner")) for sid in selected if rows[sid].get("owner")}
|
|
750
|
+
scoped = [task for task in tasks if task[1] in owners]
|
|
751
|
+
ready = {task_id for _, task_id, status in scoped if status in FINISHED_STATUSES}
|
|
752
|
+
return scoped, sorted(selected), sorted(owners - ready)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _verify_layers(root: str, directory: str, layers: Optional[str] = None,
|
|
756
|
+
profile: Optional[str] = None,
|
|
757
|
+
only_tasks: Optional[Sequence[str]] = None,
|
|
758
|
+
resume: bool = True, deadline: Optional[float] = None,
|
|
759
|
+
max_failures: int = 0, fail_fast: bool = False,
|
|
760
|
+
jobs: int = 0) -> dict[str, object]:
|
|
761
|
+
"""Run chosen layers; only_tasks limits the batch smoke gate to merged TASKs."""
|
|
762
|
+
started = time.monotonic()
|
|
763
|
+
recover_transition(root)
|
|
764
|
+
selected = parse_verify_layers(layers)
|
|
765
|
+
resolved_profile = parse_verify_profile(profile)
|
|
766
|
+
if selected == ("validators",) and only_tasks is None:
|
|
767
|
+
return _verify_validators(root, directory, resolved_profile)
|
|
768
|
+
tasks = demand_tasks(Path(directory))
|
|
769
|
+
if only_tasks is not None:
|
|
770
|
+
wanted = {str(task_id) for task_id in only_tasks}
|
|
771
|
+
known = {task_id for _, task_id, _ in tasks}
|
|
772
|
+
if wanted - known:
|
|
773
|
+
return {"decision": "block", "reason": "unknown_tasks", "tasks": sorted(wanted - known)}
|
|
774
|
+
tasks, partial_ids, not_ready = _batch_scope(root, directory, tasks, wanted)
|
|
775
|
+
if not_ready:
|
|
776
|
+
return {"decision": "block", "reason": "not_ready", "tasks": not_ready,
|
|
777
|
+
"scope": {"type": "partial", "tasks": sorted(wanted)}, "requirement_verified": False}
|
|
778
|
+
else:
|
|
779
|
+
partial_ids = None
|
|
780
|
+
unfinished = [task_id for _, task_id, status in tasks if status not in FINISHED_STATUSES]
|
|
781
|
+
if unfinished:
|
|
782
|
+
return {"decision": "block", "reason": "unfinished_tasks", "tasks": unfinished}
|
|
783
|
+
if (Path(root) / ".code-flow/.active-task.json").exists():
|
|
784
|
+
return {"decision": "block", "reason": "finish_active_task_first"}
|
|
785
|
+
requirement_gate = only_tasks is None and set(selected) == set(VERIFY_LAYERS)
|
|
786
|
+
layer_results: dict[str, dict[str, object]] = {}
|
|
787
|
+
for name in selected:
|
|
788
|
+
if name == "validators":
|
|
789
|
+
layer_results[name] = _validators_layer(root, resolved_profile)
|
|
790
|
+
elif name == "review":
|
|
791
|
+
layer_results[name] = _review_layer(root, directory)
|
|
792
|
+
else:
|
|
793
|
+
layer_results[name] = _e2e_layer(
|
|
794
|
+
root, directory, tasks, requirement_gate,
|
|
795
|
+
profile=resolved_profile, resume=resume, deadline=deadline,
|
|
796
|
+
max_failures=max_failures, fail_fast=fail_fast, jobs=jobs, partial_ids=partial_ids,
|
|
797
|
+
)
|
|
798
|
+
result = _aggregate_layers(layer_results, resolved_profile, selected)
|
|
799
|
+
if "validators" in layer_results:
|
|
800
|
+
_validation_report(result, layer_results["validators"], selected)
|
|
801
|
+
if "e2e" in layer_results:
|
|
802
|
+
_combine_acceptance_timing(result, layer_results["e2e"], started)
|
|
803
|
+
if only_tasks is not None:
|
|
804
|
+
result["scope"] = {**result.get("scope", {}), "type": "partial", "tasks": sorted(wanted),
|
|
805
|
+
"scenarios": partial_ids}
|
|
806
|
+
result["requirement_verified"] = False
|
|
807
|
+
if (requirement_gate and resolved_profile == "full" and result["decision"] == "pass"
|
|
808
|
+
and (result.get("reason") != "nothing_to_verify" or not _acceptance_required(tasks))):
|
|
809
|
+
return complete_requirement_verification(root, directory, tasks, result)
|
|
810
|
+
return result
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _append_spec_reporting(result: dict[str, object], reports: Sequence[Mapping[str, object]],
|
|
814
|
+
started: float) -> None:
|
|
815
|
+
checks = sorted((dict(item) for item in reports if item.get("kind") == "spec_check"),
|
|
816
|
+
key=lambda item: str(item.get("check_id", "")))
|
|
817
|
+
if not checks:
|
|
818
|
+
return
|
|
819
|
+
result["spec_checks"] = checks
|
|
820
|
+
spec_executed = sum(1 for item in checks if not item.get("reused"))
|
|
821
|
+
spec_reused = len(checks) - spec_executed
|
|
822
|
+
review = result.get("layers", {}).get("review")
|
|
823
|
+
if isinstance(review, dict):
|
|
824
|
+
result["executed"] += spec_executed - int(review.get("executed", 0))
|
|
825
|
+
result["reused"] += spec_reused - int(review.get("reused", 0))
|
|
826
|
+
review["executed"], review["reused"] = spec_executed, spec_reused
|
|
827
|
+
if isinstance(review.get("review"), dict):
|
|
828
|
+
review["review"].update(executed=spec_executed, reused=spec_reused)
|
|
829
|
+
prefix = str(result.get("summary") or "")
|
|
830
|
+
result["summary"] = (prefix + "; " if prefix else "") + f"spec: executed {spec_executed}, reused {spec_reused}"
|
|
831
|
+
result.setdefault("execution_identities", []).extend(item["identity"] for item in checks if item.get("identity"))
|
|
832
|
+
timings = result.setdefault("timings", {key: 0.0 for key in
|
|
833
|
+
("planning_ms", "preflight_ms", "execution_ms", "evidence_ms")})
|
|
834
|
+
timings["execution_ms"] += sum(float(item.get("execution_ms", 0)) for item in checks)
|
|
835
|
+
for report in reports:
|
|
836
|
+
if report.get("kind") == "spec_phases":
|
|
837
|
+
phases = report.get("timings") or {}
|
|
838
|
+
for key in ("planning_ms", "preflight_ms", "evidence_ms"):
|
|
839
|
+
timings[key] += float(phases.get(key, 0))
|
|
840
|
+
timings["elapsed_ms"] = (time.monotonic() - started) * 1000
|
|
841
|
+
result.setdefault("timings_scope", []).append("spec")
|
|
842
|
+
result.setdefault("scope", {"layers": result.get("selected_layers", [])})
|
|
843
|
+
result.setdefault("input_change_category", ["unchanged" if spec_reused else "unproven"])
|
|
844
|
+
if "selection_reason" in result:
|
|
845
|
+
reasons = result.setdefault("selection_reasons", {})
|
|
846
|
+
if not reasons:
|
|
847
|
+
reasons["validators" if "validators" in result.get("timings_scope", []) else "other"] = result["selection_reason"]
|
|
848
|
+
reasons["spec"] = "required_spec_bindings"
|
|
849
|
+
result["selection_reason"] = "multiple_layer_policies"
|
|
850
|
+
else:
|
|
851
|
+
result["selection_reason"] = "required_spec_bindings"
|
|
852
|
+
if any(item.get("reused") for item in checks):
|
|
853
|
+
result["reuse_reason"] = "evidence_reused"
|
|
854
|
+
else:
|
|
855
|
+
result.setdefault("reuse_reason", "no_reused_results")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def verify_layers(root: str, directory: str, layers: Optional[str] = None,
|
|
859
|
+
profile: Optional[str] = None, only_tasks: Optional[Sequence[str]] = None,
|
|
860
|
+
resume: bool = True, deadline: Optional[float] = None, max_failures: int = 0,
|
|
861
|
+
fail_fast: bool = False, jobs: int = 0) -> dict[str, object]:
|
|
862
|
+
started = time.monotonic()
|
|
863
|
+
with report_session() as reports:
|
|
864
|
+
result = _verify_layers(root, directory, layers, profile, only_tasks, resume,
|
|
865
|
+
deadline, max_failures, fail_fast, jobs)
|
|
866
|
+
_append_spec_reporting(result, reports, started)
|
|
867
|
+
return result
|