@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,660 @@
|
|
|
1
|
+
"""Extract and validate plan Acceptance Coverage manifests.
|
|
2
|
+
|
|
3
|
+
This low-level acceptance module owns manifest extraction, immutable-field
|
|
4
|
+
hashing and drift validation. The path-frozen CLI entry
|
|
5
|
+
(`codeflow.cli.cf_acceptance_manifest`) only parses arguments and prints output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Mapping, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from codeflow.acceptance.cf_acceptance_schema import (
|
|
18
|
+
EXEC_MODES, load_manifest, scenario_check_ids, validate_checks, validate_scenarios,
|
|
19
|
+
)
|
|
20
|
+
from codeflow.core.cf_markdown import column_index, frontmatter_block, table_cells
|
|
21
|
+
from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
|
|
25
|
+
|
|
26
|
+
_MANIFEST_SCHEMA = 3
|
|
27
|
+
_LEGACY_MANIFEST_SCHEMAS = (2,)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def kind(level: str) -> str:
|
|
31
|
+
"""Map test level to execution kind.
|
|
32
|
+
|
|
33
|
+
- functional: auto-executed in Done Gate (unit, integration)
|
|
34
|
+
- e2e: deferred until explicit --verify-e2e (needs external dependencies)
|
|
35
|
+
- manual: user confirmation required, never auto-executed
|
|
36
|
+
|
|
37
|
+
层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
|
|
38
|
+
取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
|
|
39
|
+
"""
|
|
40
|
+
head = level.strip().lower()
|
|
41
|
+
for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
|
|
42
|
+
head = head.split(separator, 1)[0]
|
|
43
|
+
head = head.strip()
|
|
44
|
+
if head in ("manual", "手工", "手动"):
|
|
45
|
+
return "manual"
|
|
46
|
+
if head in ("e2e", "端到端", "end"):
|
|
47
|
+
return "e2e"
|
|
48
|
+
return "functional"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_EXEC_ALIASES = {
|
|
52
|
+
"auto": "auto",
|
|
53
|
+
"automatic": "auto",
|
|
54
|
+
"自动化": "auto",
|
|
55
|
+
"manual": "manual",
|
|
56
|
+
"手工": "manual",
|
|
57
|
+
"手动": "manual",
|
|
58
|
+
"deferred": "deferred",
|
|
59
|
+
"延期": "deferred",
|
|
60
|
+
"aspirational": "aspirational",
|
|
61
|
+
"愿望": "aspirational",
|
|
62
|
+
"意向": "aspirational",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_COLUMN_NAMES = {
|
|
66
|
+
"source": ("来源设计", "来源", "source"),
|
|
67
|
+
"level": ("测试层级", "测试级别", "level"),
|
|
68
|
+
"boundary": ("关键真实边界", "真实边界", "boundary"),
|
|
69
|
+
"owner": ("负责任务", "负责任任务", "负责人", "owner"),
|
|
70
|
+
"status": ("状态", "status"),
|
|
71
|
+
"command": ("执行命令", "命令", "command"),
|
|
72
|
+
"cwd": ("工作目录", "cwd"),
|
|
73
|
+
"timeout": ("超时", "timeout"),
|
|
74
|
+
"depends": ("依赖", "depends"),
|
|
75
|
+
"priority": ("优先级", "priority"),
|
|
76
|
+
"exec": ("执行方式", "exec"),
|
|
77
|
+
"env": ("环境", "env"),
|
|
78
|
+
"inputs": ("输入", "inputs"),
|
|
79
|
+
"why_e2e": ("E2E理由", "E2E 理由", "为什么E2E", "why_e2e"),
|
|
80
|
+
"trigger_paths": ("触发路径", "影响路径", "trigger_paths"),
|
|
81
|
+
"smoke": ("冒烟", "常驻冒烟", "smoke"),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _column_indexes(cells: Sequence[str]) -> dict[str, int]:
|
|
86
|
+
"""Map table headers to field offsets (fields omit the leading 场景ID cell)."""
|
|
87
|
+
indexes: dict[str, int] = {}
|
|
88
|
+
for key, names in _COLUMN_NAMES.items():
|
|
89
|
+
index = column_index(cells[1:], *names)
|
|
90
|
+
if index >= 0:
|
|
91
|
+
indexes[key] = index
|
|
92
|
+
return indexes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _column_value(fields: Sequence[str], columns: Mapping[str, int], key: str, fallback: int) -> str:
|
|
96
|
+
"""Prefer named headers; only headerless legacy tables use positional fallback."""
|
|
97
|
+
index = columns.get(key, -1) if columns else fallback
|
|
98
|
+
return fields[index].strip() if 0 <= index < len(fields) else ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def coverage_execution(coverage: str, scenario: str) -> tuple[str, str]:
|
|
102
|
+
"""Return (kind, exec mode) for one Acceptance Coverage table row."""
|
|
103
|
+
columns: dict[str, int] = {}
|
|
104
|
+
for line in coverage.splitlines():
|
|
105
|
+
cells = table_cells(line)
|
|
106
|
+
if not cells:
|
|
107
|
+
continue
|
|
108
|
+
header = _column_indexes(cells)
|
|
109
|
+
if "level" in header:
|
|
110
|
+
columns = header
|
|
111
|
+
if cells[0] == scenario:
|
|
112
|
+
fields = cells[1:]
|
|
113
|
+
return (kind(_column_value(fields, columns, "level", 1)),
|
|
114
|
+
_parse_exec(_column_value(fields, columns, "exec", 9)))
|
|
115
|
+
return "", ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _metadata_cell(fields: Sequence[str], columns: Mapping[str, int], key: str) -> str:
|
|
119
|
+
index = columns.get(key, -1)
|
|
120
|
+
return fields[index].strip() if 0 <= index < len(fields) else ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_priority(cell: str) -> str:
|
|
124
|
+
value = (cell or "").strip().strip("`").upper()
|
|
125
|
+
return value if value and value not in {"-", "N/A"} else ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_exec(cell: str) -> str:
|
|
129
|
+
value = (cell or "").strip().strip("`")
|
|
130
|
+
if not value or value in {"-", "N/A"}:
|
|
131
|
+
return ""
|
|
132
|
+
normalized = _EXEC_ALIASES.get(value.lower(), value.lower())
|
|
133
|
+
if normalized not in EXEC_MODES:
|
|
134
|
+
raise ValueError(f"未知执行方式: {value}(允许 {', '.join(EXEC_MODES)})")
|
|
135
|
+
return normalized
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_bool(cell: str, scenario_id: str = "") -> Optional[bool]:
|
|
139
|
+
value = (cell or "").strip().strip("`").lower()
|
|
140
|
+
if not value or value in {"-", "n/a", "none"}:
|
|
141
|
+
return None
|
|
142
|
+
if value in {"true", "yes", "y", "1", "是", "冒烟", "smoke"}:
|
|
143
|
+
return True
|
|
144
|
+
if value in {"false", "no", "n", "0", "否", "不是"}:
|
|
145
|
+
return False
|
|
146
|
+
raise ValueError(f"{scenario_id} 布尔列取值非法: {cell}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_inputs(cell: str, scenario_id: str = "") -> list[str]:
|
|
150
|
+
text = (cell or "").strip().strip("`")
|
|
151
|
+
if not text or text in {"-", "none", "N/A", "TBD", "tbd"}:
|
|
152
|
+
return []
|
|
153
|
+
if text.startswith("["):
|
|
154
|
+
try:
|
|
155
|
+
value = json.loads(text)
|
|
156
|
+
except json.JSONDecodeError as exc:
|
|
157
|
+
raise ValueError(f"{scenario_id} 输入列不是合法 JSON 数组: {exc}") from exc
|
|
158
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
159
|
+
raise ValueError(f"{scenario_id} 输入列必须是字符串数组")
|
|
160
|
+
return list(value)
|
|
161
|
+
return [item.strip() for item in re.split(r"[,,、;;]", text) if item.strip()]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _row_metadata(fields: Sequence[str], columns: Mapping[str, int], scenario_id: str) -> dict[str, object]:
|
|
165
|
+
"""Only emit optional fields when explicitly present, preserving schema2/legacy rows."""
|
|
166
|
+
metadata: dict[str, object] = {}
|
|
167
|
+
priority = _parse_priority(_metadata_cell(fields, columns, "priority"))
|
|
168
|
+
if priority:
|
|
169
|
+
metadata["priority"] = priority
|
|
170
|
+
exec_mode = _parse_exec(_metadata_cell(fields, columns, "exec"))
|
|
171
|
+
if exec_mode:
|
|
172
|
+
metadata["exec"] = exec_mode
|
|
173
|
+
env = _metadata_cell(fields, columns, "env").strip("`")
|
|
174
|
+
if env and env not in {"-", "N/A"}:
|
|
175
|
+
metadata["env"] = env
|
|
176
|
+
inputs = _parse_inputs(_metadata_cell(fields, columns, "inputs"), scenario_id)
|
|
177
|
+
if inputs:
|
|
178
|
+
metadata["inputs"] = inputs
|
|
179
|
+
why_e2e = _metadata_cell(fields, columns, "why_e2e").strip("`")
|
|
180
|
+
if why_e2e and why_e2e not in {"-", "N/A", "n/a", "TBD", "tbd"}:
|
|
181
|
+
metadata["why_e2e"] = why_e2e
|
|
182
|
+
trigger_paths = _parse_inputs(_metadata_cell(fields, columns, "trigger_paths"), scenario_id)
|
|
183
|
+
if trigger_paths:
|
|
184
|
+
metadata["trigger_paths"] = trigger_paths
|
|
185
|
+
smoke = _parse_bool(_metadata_cell(fields, columns, "smoke"), scenario_id)
|
|
186
|
+
if smoke is not None:
|
|
187
|
+
metadata["smoke"] = smoke
|
|
188
|
+
return metadata
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _merge_check_config(check: dict[str, object], env: str, inputs: Sequence[str]) -> None:
|
|
192
|
+
"""Same execution identity must not declare conflicting env/inputs."""
|
|
193
|
+
check_id = str(check.get("id", ""))
|
|
194
|
+
if env:
|
|
195
|
+
current = str(check.get("env", "none"))
|
|
196
|
+
if current != "none" and current != env:
|
|
197
|
+
raise ValueError(f"check configuration conflict: {check_id} env {current} != {env}")
|
|
198
|
+
check["env"] = env
|
|
199
|
+
if inputs:
|
|
200
|
+
current = list(check.get("inputs") or [])
|
|
201
|
+
if current and current != list(inputs):
|
|
202
|
+
raise ValueError(f"check configuration conflict: {check_id} inputs {current} != {list(inputs)}")
|
|
203
|
+
check["inputs"] = list(inputs)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_command(cell: str, scenario_id: str) -> Optional[list[str]]:
|
|
207
|
+
"""Parse the optional 命令 column: argv JSON array preferred, else shell words.
|
|
208
|
+
|
|
209
|
+
Empty / `-` / `planned` → None (not yet registered, not executable).
|
|
210
|
+
"""
|
|
211
|
+
text = (cell or "").strip()
|
|
212
|
+
if not text or text in {"-", "planned", "pending", "TBD", "tbd"}:
|
|
213
|
+
return None
|
|
214
|
+
if text.startswith("["):
|
|
215
|
+
try:
|
|
216
|
+
value = json.loads(text)
|
|
217
|
+
except json.JSONDecodeError as exc:
|
|
218
|
+
raise ValueError(f"{scenario_id} 命令列不是合法 argv JSON: {exc}") from exc
|
|
219
|
+
if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
|
|
220
|
+
raise ValueError(f"{scenario_id} 命令列必须是 argv 字符串数组")
|
|
221
|
+
return list(value)
|
|
222
|
+
return shlex.split(text)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_float(cell: str, default: float) -> float:
|
|
226
|
+
try:
|
|
227
|
+
return float((cell or "").strip() or default)
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
return default
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _manifest_hash(rows: list[dict[str, object]]) -> str:
|
|
233
|
+
immutable = []
|
|
234
|
+
for item in rows:
|
|
235
|
+
entry = {
|
|
236
|
+
key: item[key] for key in
|
|
237
|
+
("id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on")
|
|
238
|
+
}
|
|
239
|
+
# Optional E2E precision fields are hashed only when present so old
|
|
240
|
+
# manifests keep their historical baseline hash.
|
|
241
|
+
for key in ("why_e2e", "trigger_paths", "smoke", "components", "steps", "expected",
|
|
242
|
+
"expected_refs", "expected_checks", "assertions", "preconditions", "defer_reason", "defer_owner", "target_phase", "features", "tags"):
|
|
243
|
+
if key in item:
|
|
244
|
+
entry[key] = item[key]
|
|
245
|
+
if "expected_checks" in item:
|
|
246
|
+
entry["checks"] = item.get("checks", [])
|
|
247
|
+
immutable.append(entry)
|
|
248
|
+
payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
249
|
+
return hashlib.sha256(payload).hexdigest()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def legacy_kind(level: str) -> str:
|
|
253
|
+
"""v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
|
|
254
|
+
normalized = level.strip().lower()
|
|
255
|
+
if normalized == "manual":
|
|
256
|
+
return "manual"
|
|
257
|
+
if normalized == "e2e":
|
|
258
|
+
return "e2e"
|
|
259
|
+
return "functional"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def contract_commands(text: str) -> dict[str, str]:
|
|
263
|
+
"""Acceptance Contract 表的执行命令(Coverage 表无命令列时的注册回退)。
|
|
264
|
+
|
|
265
|
+
Coverage 模板长期只有 6 列,命令实际写在 TASK 段落 Contract 表;提取器只
|
|
266
|
+
读 Coverage 会让新需求 finish 必现 no_executable_scenarios。这里按场景 ID
|
|
267
|
+
回退读取 Contract 的“执行命令”列(按表头定位,兼容列序差异),与 Coverage
|
|
268
|
+
第 7 列(argv JSON)等价。
|
|
269
|
+
"""
|
|
270
|
+
commands: dict[str, str] = {}
|
|
271
|
+
try:
|
|
272
|
+
document = parse_task_document(text)
|
|
273
|
+
except TaskDocumentError as exc:
|
|
274
|
+
if exc.code == "task_document_empty":
|
|
275
|
+
return commands
|
|
276
|
+
raise
|
|
277
|
+
for section in document.tasks:
|
|
278
|
+
contract = re.search(
|
|
279
|
+
r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section.body
|
|
280
|
+
)
|
|
281
|
+
if contract is None:
|
|
282
|
+
continue
|
|
283
|
+
command_idx = -1
|
|
284
|
+
for line in contract.group(1).splitlines():
|
|
285
|
+
cells = table_cells(line)
|
|
286
|
+
if not cells:
|
|
287
|
+
continue
|
|
288
|
+
if command_idx == -1:
|
|
289
|
+
match_column = column_index(cells, "命令")
|
|
290
|
+
if match_column == -1:
|
|
291
|
+
continue
|
|
292
|
+
command_idx = match_column
|
|
293
|
+
continue
|
|
294
|
+
match = _ROW_RE.match(line)
|
|
295
|
+
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
296
|
+
continue
|
|
297
|
+
if command_idx < len(cells):
|
|
298
|
+
commands.setdefault(match.group(1), cells[command_idx])
|
|
299
|
+
return commands
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _check_id(command: list[str], cwd: str, timeout: float, config: Mapping[str, object] | None = None) -> str:
|
|
303
|
+
payload = json.dumps([command, cwd, timeout, config or {}], ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
304
|
+
return "CHK-" + hashlib.sha256(payload).hexdigest()[:12]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _derive_checks(rows: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
308
|
+
"""Derive executable checks from scenario command identity (stable IDs)."""
|
|
309
|
+
by_id: dict[str, dict[str, object]] = {}
|
|
310
|
+
by_command: dict[tuple, dict[str, object]] = {}
|
|
311
|
+
for row in rows:
|
|
312
|
+
command = row.get("command")
|
|
313
|
+
if not isinstance(command, list) or not command:
|
|
314
|
+
continue
|
|
315
|
+
cwd = str(row.get("cwd", "."))
|
|
316
|
+
timeout = float(row.get("timeout", 60))
|
|
317
|
+
trigger_paths = [
|
|
318
|
+
str(item) for item in row.get("trigger_paths", []) or [] if str(item).strip()
|
|
319
|
+
]
|
|
320
|
+
config = {
|
|
321
|
+
"env": row.get("env") if isinstance(row.get("env"), str) else "",
|
|
322
|
+
"inputs": list(row.get("inputs") or []),
|
|
323
|
+
}
|
|
324
|
+
identity = {
|
|
325
|
+
key: row.get(key) for key in
|
|
326
|
+
("env", "env_scope", "preflight", "parallel_safe", "profile", "inputs")
|
|
327
|
+
}
|
|
328
|
+
command_key = (tuple(command), cwd, timeout)
|
|
329
|
+
previous = by_command.setdefault(command_key, {"env": "", "inputs": []})
|
|
330
|
+
if config["env"] and previous["env"] and config["env"] != previous["env"]:
|
|
331
|
+
raise ValueError(f"check configuration conflict: {row.get('id')} {command_key} env")
|
|
332
|
+
if config["inputs"] and previous["inputs"] and config["inputs"] != previous["inputs"]:
|
|
333
|
+
raise ValueError(f"check configuration conflict: {row.get('id')} {command_key} inputs")
|
|
334
|
+
previous["env"] = previous["env"] or config["env"]
|
|
335
|
+
previous["inputs"] = previous["inputs"] or config["inputs"]
|
|
336
|
+
check_id = _check_id(command, cwd, timeout, identity)
|
|
337
|
+
row["check"] = check_id
|
|
338
|
+
check = by_id.get(check_id)
|
|
339
|
+
if check is None:
|
|
340
|
+
check = {
|
|
341
|
+
"id": check_id,
|
|
342
|
+
"command": list(command),
|
|
343
|
+
"cwd": cwd,
|
|
344
|
+
"timeout": timeout,
|
|
345
|
+
"env": "none",
|
|
346
|
+
"env_scope": "shared",
|
|
347
|
+
"parallel_safe": False,
|
|
348
|
+
"profile": "changed",
|
|
349
|
+
"inputs": [],
|
|
350
|
+
"preflight": [],
|
|
351
|
+
"paths": [],
|
|
352
|
+
}
|
|
353
|
+
by_id[check_id] = check
|
|
354
|
+
env = row.get("env")
|
|
355
|
+
inputs = row.get("inputs")
|
|
356
|
+
_merge_check_config(
|
|
357
|
+
check,
|
|
358
|
+
env if isinstance(env, str) else "",
|
|
359
|
+
inputs if isinstance(inputs, list) else [],
|
|
360
|
+
)
|
|
361
|
+
if trigger_paths:
|
|
362
|
+
check["paths"] = sorted(set(check.get("paths") or []) | set(trigger_paths))
|
|
363
|
+
if row.get("smoke"):
|
|
364
|
+
check["profile"] = "smoke"
|
|
365
|
+
return list(by_id.values())
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_SCENARIO_SOURCE_FIELDS = ("why_e2e", "trigger_paths", "smoke", "components", "steps", "expected", "assertions",
|
|
369
|
+
"expected_refs", "expected_checks", "checks", "preconditions", "defer_reason", "target_phase", "features", "tags")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _source_contract(scenarios_file: str,
|
|
373
|
+
text: Optional[str] = None) -> tuple[dict[str, object], dict[str, set[str]]]:
|
|
374
|
+
import yaml
|
|
375
|
+
from codeflow.acceptance.cf_scenario_source import ScenarioError, parse_scenarios
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
source = parse_scenarios(scenarios_file, text=text)
|
|
379
|
+
raw_text = Path(scenarios_file).read_text(encoding="utf-8") if text is None else text
|
|
380
|
+
block = frontmatter_block(raw_text)
|
|
381
|
+
if block is None:
|
|
382
|
+
if raw_text.startswith("---"):
|
|
383
|
+
raise ScenarioError("scenario_source_invalid_frontmatter", "frontmatter 缺少闭合 ---")
|
|
384
|
+
block = raw_text
|
|
385
|
+
raw = yaml.safe_load(block) or {}
|
|
386
|
+
except (OSError, UnicodeError, yaml.YAMLError, ScenarioError) as exc:
|
|
387
|
+
code = getattr(exc, "code", "scenario_source_unreadable")
|
|
388
|
+
message = getattr(exc, "message", str(exc))
|
|
389
|
+
raise ValueError(f"scenario_source_invalid: {code}: {message}") from exc
|
|
390
|
+
raw_scenarios = raw.get("scenarios") if isinstance(raw, Mapping) else None
|
|
391
|
+
explicit = {
|
|
392
|
+
str(item.get("id")): set(item)
|
|
393
|
+
for item in raw_scenarios or []
|
|
394
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
395
|
+
}
|
|
396
|
+
return source, explicit
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _merge_scenario_source(rows: list[dict[str, object]], scenarios_file: str,
|
|
400
|
+
text: Optional[str] = None) -> None:
|
|
401
|
+
"""Merge E2E precision fields from the canonical scenario source.
|
|
402
|
+
|
|
403
|
+
The Acceptance Coverage table is a projection. Quality fields live in the
|
|
404
|
+
scenario source so a plan cannot silently lose its E2E justification when
|
|
405
|
+
the table is regenerated. Only additive fields are merged; a populated
|
|
406
|
+
Coverage cell is never overwritten by a default value.
|
|
407
|
+
"""
|
|
408
|
+
if not scenarios_file:
|
|
409
|
+
return
|
|
410
|
+
source, explicit = _source_contract(scenarios_file, text)
|
|
411
|
+
by_id = {
|
|
412
|
+
str(item.get("id")): item for item in rows
|
|
413
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
|
414
|
+
}
|
|
415
|
+
for scenario in source["scenarios"]:
|
|
416
|
+
scenario_id = str(scenario.get("id"))
|
|
417
|
+
row = by_id.get(scenario_id)
|
|
418
|
+
if row is None:
|
|
419
|
+
continue
|
|
420
|
+
provided = explicit.get(scenario_id, set())
|
|
421
|
+
# The scenario source is canonical for fields it actually declares.
|
|
422
|
+
# Explicit false/empty values must be able to clear a stale table cell.
|
|
423
|
+
for field in ("level", "priority", "boundary", "exec",
|
|
424
|
+
"why_e2e", "trigger_paths", "smoke", "env", "components", "steps", "expected",
|
|
425
|
+
"expected_refs", "expected_checks", "checks", "assertions", "preconditions", "defer_reason", "target_phase", "features", "tags"):
|
|
426
|
+
if field in provided:
|
|
427
|
+
row[field] = scenario.get(field)
|
|
428
|
+
if "owner" in provided:
|
|
429
|
+
row["defer_owner"] = scenario.get("owner", "")
|
|
430
|
+
row["kind"] = kind(str(row.get("level", "")))
|
|
431
|
+
for field in _SCENARIO_SOURCE_FIELDS:
|
|
432
|
+
if field in provided:
|
|
433
|
+
continue
|
|
434
|
+
value = scenario.get(field)
|
|
435
|
+
if value not in (None, "", [], False):
|
|
436
|
+
row[field] = value
|
|
437
|
+
if not row.get("priority") and scenario.get("priority"):
|
|
438
|
+
row["priority"] = scenario["priority"]
|
|
439
|
+
if not row.get("boundary") and scenario.get("boundary"):
|
|
440
|
+
row["boundary"] = scenario["boundary"]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _extract_rows(task_file: str, kind_fn: Callable[[str], str], contract_fallback: bool = True,
|
|
444
|
+
scenarios_file: str = "", merge_source: bool = True, *,
|
|
445
|
+
text: Optional[str] = None, scenario_text: Optional[str] = None) -> dict[str, object]:
|
|
446
|
+
path = Path(task_file)
|
|
447
|
+
text = path.read_text(encoding="utf-8") if text is None else text
|
|
448
|
+
contract_command_cells = contract_commands(text) if contract_fallback else {}
|
|
449
|
+
rows: list[dict[str, object]] = []
|
|
450
|
+
in_table = False
|
|
451
|
+
seen: set[str] = set()
|
|
452
|
+
columns: dict[str, int] = {}
|
|
453
|
+
for line in text.splitlines():
|
|
454
|
+
if line.startswith("## Acceptance Coverage"):
|
|
455
|
+
in_table = True
|
|
456
|
+
continue
|
|
457
|
+
if in_table and line.startswith("## "):
|
|
458
|
+
break
|
|
459
|
+
if not in_table:
|
|
460
|
+
continue
|
|
461
|
+
header = table_cells(line)
|
|
462
|
+
if header and header[0].replace(" ", "") in ("场景ID", "场景编号"):
|
|
463
|
+
columns = _column_indexes(header)
|
|
464
|
+
continue
|
|
465
|
+
match = _ROW_RE.match(line)
|
|
466
|
+
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
467
|
+
continue
|
|
468
|
+
fields = [item.strip() for item in match.group(2).split("|")]
|
|
469
|
+
if match.group(1) in seen:
|
|
470
|
+
raise ValueError(f"duplicate scenario id: {match.group(1)}")
|
|
471
|
+
if len(fields) < 5:
|
|
472
|
+
raise ValueError(f"Acceptance Coverage missing columns: {match.group(1)}")
|
|
473
|
+
seen.add(match.group(1))
|
|
474
|
+
depends_cell = _column_value(fields, columns, "depends", 8)
|
|
475
|
+
depends = [item.strip() for item in depends_cell.split(",") if item.strip()]
|
|
476
|
+
level = _column_value(fields, columns, "level", 1)
|
|
477
|
+
# Legacy coverage tables sometimes append a command cell without a
|
|
478
|
+
# header; keep the positional read when the header has no command column.
|
|
479
|
+
if "command" in columns:
|
|
480
|
+
command_cell = _column_value(fields, columns, "command", 5)
|
|
481
|
+
else:
|
|
482
|
+
command_cell = fields[5].strip() if len(fields) > 5 else ""
|
|
483
|
+
if not command_cell:
|
|
484
|
+
command_cell = contract_command_cells.get(match.group(1), "")
|
|
485
|
+
row: dict[str, object] = {
|
|
486
|
+
"id": match.group(1),
|
|
487
|
+
"source": _column_value(fields, columns, "source", 0),
|
|
488
|
+
"level": level,
|
|
489
|
+
"kind": kind_fn(level),
|
|
490
|
+
"boundary": _column_value(fields, columns, "boundary", 2),
|
|
491
|
+
"owner": _column_value(fields, columns, "owner", 3),
|
|
492
|
+
"status": _column_value(fields, columns, "status", 4),
|
|
493
|
+
"command": _parse_command(command_cell, match.group(1)),
|
|
494
|
+
"cwd": _column_value(fields, columns, "cwd", 6) or ".",
|
|
495
|
+
"timeout": _parse_float(_column_value(fields, columns, "timeout", 7), 60.0),
|
|
496
|
+
"depends_on": depends,
|
|
497
|
+
}
|
|
498
|
+
row.update(_row_metadata(fields, columns, match.group(1)))
|
|
499
|
+
rows.append(row)
|
|
500
|
+
if not rows:
|
|
501
|
+
raise ValueError("Acceptance Coverage 缺失或为空")
|
|
502
|
+
if merge_source and not scenarios_file:
|
|
503
|
+
candidate = Path(task_file).with_suffix(".scenarios.md")
|
|
504
|
+
if candidate.is_file():
|
|
505
|
+
scenarios_file = str(candidate)
|
|
506
|
+
if merge_source:
|
|
507
|
+
_merge_scenario_source(rows, scenarios_file, scenario_text)
|
|
508
|
+
validate_scenarios(rows)
|
|
509
|
+
checks = _derive_checks(rows)
|
|
510
|
+
return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows),
|
|
511
|
+
"checks": checks, "scenarios": rows}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = kind,
|
|
515
|
+
contract_fallback: bool = True,
|
|
516
|
+
scenarios_file: str = "", merge_source: bool = True, *,
|
|
517
|
+
task_text: Optional[str] = None,
|
|
518
|
+
scenario_text: Optional[str] = None) -> dict[str, object]:
|
|
519
|
+
return _extract_rows(task_file, kind_fn, contract_fallback, scenarios_file, merge_source,
|
|
520
|
+
text=task_text, scenario_text=scenario_text)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
IMMUTABLE_SCENARIO_FIELDS = (
|
|
524
|
+
"id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on", "check", "checks", "expected_checks",
|
|
525
|
+
"priority", "exec", "env", "inputs", "why_e2e", "trigger_paths", "smoke",
|
|
526
|
+
"components", "steps", "expected", "expected_refs", "assertions", "preconditions", "defer_reason", "defer_owner",
|
|
527
|
+
"target_phase", "features", "tags",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
_OPTIONAL_SCENARIO_FIELDS = ("priority", "exec", "env", "inputs", "why_e2e", "trigger_paths", "smoke")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _heal_legacy_manifest(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
|
|
534
|
+
"""旧版提取规则锁定的 manifest:任务内容未变时原地升级并重锁哈希。
|
|
535
|
+
|
|
536
|
+
两种历史规则:`_kind` 精确匹配把带注解的 E2E 锁成 functional;Coverage 无
|
|
537
|
+
命令列时不回退 Contract(command=None)。两类都不改变任务事实,按当前提取
|
|
538
|
+
结果对齐不可变字段并重锁,保留 execution evidence 等运行态字段。
|
|
539
|
+
"""
|
|
540
|
+
by_id: dict[str, dict[str, object]] = {}
|
|
541
|
+
scenarios = expected.get("scenarios")
|
|
542
|
+
if isinstance(scenarios, list):
|
|
543
|
+
for item in scenarios:
|
|
544
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
545
|
+
by_id[item["id"]] = item
|
|
546
|
+
current = manifest.get("scenarios")
|
|
547
|
+
if isinstance(current, list):
|
|
548
|
+
for item in current:
|
|
549
|
+
if not isinstance(item, dict):
|
|
550
|
+
continue
|
|
551
|
+
target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
|
|
552
|
+
if target is not None:
|
|
553
|
+
for field in IMMUTABLE_SCENARIO_FIELDS:
|
|
554
|
+
if field in target:
|
|
555
|
+
item[field] = target[field]
|
|
556
|
+
manifest["task_sha256"] = expected["task_sha256"]
|
|
557
|
+
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def validate_manifest(task_file: str, manifest_file: str,
|
|
561
|
+
allow_missing_e2e_gate: bool = False, *,
|
|
562
|
+
task_text: Optional[str] = None,
|
|
563
|
+
manifest: Optional[dict[str, object]] = None,
|
|
564
|
+
scenario_file: str = "",
|
|
565
|
+
scenario_text: Optional[str] = None) -> tuple[bool, str]:
|
|
566
|
+
try:
|
|
567
|
+
manifest = load_manifest(Path(manifest_file)) if manifest is None else manifest
|
|
568
|
+
stored_source = manifest.get("scenarios_file")
|
|
569
|
+
source_path = ""
|
|
570
|
+
if isinstance(stored_source, str) and stored_source.strip():
|
|
571
|
+
candidate = Path(manifest_file).parent / stored_source
|
|
572
|
+
if not candidate.is_file():
|
|
573
|
+
return False, "acceptance_manifest_scenarios_missing"
|
|
574
|
+
source_path = str(candidate)
|
|
575
|
+
else:
|
|
576
|
+
fallback = Path(task_file).with_suffix(".scenarios.md")
|
|
577
|
+
if scenario_file and fallback.is_file() and fallback.resolve() == Path(scenario_file).resolve():
|
|
578
|
+
source_path = scenario_file
|
|
579
|
+
expected = extract_manifest(task_file, scenarios_file=source_path, task_text=task_text,
|
|
580
|
+
scenario_text=scenario_text if source_path else None)
|
|
581
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
582
|
+
return False, f"acceptance_manifest_invalid: {exc}"
|
|
583
|
+
schema = manifest.get("schema")
|
|
584
|
+
if schema != _MANIFEST_SCHEMA and schema not in _LEGACY_MANIFEST_SCHEMAS:
|
|
585
|
+
return False, "acceptance_manifest_drift"
|
|
586
|
+
if manifest.get("task_sha256") != expected["task_sha256"]:
|
|
587
|
+
# 旧版规则(无 Contract 回退 + _kind 精确匹配)锁定的 manifest:兼容升级。
|
|
588
|
+
legacy = extract_manifest(task_file, legacy_kind, contract_fallback=False, merge_source=False,
|
|
589
|
+
task_text=task_text)
|
|
590
|
+
if manifest.get("task_sha256") != legacy["task_sha256"]:
|
|
591
|
+
return False, "acceptance_manifest_drift"
|
|
592
|
+
_heal_legacy_manifest(Path(manifest_file), manifest, expected)
|
|
593
|
+
stored_task = manifest.get("task_file", "")
|
|
594
|
+
if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
|
|
595
|
+
return False, "acceptance_manifest_task_mismatch"
|
|
596
|
+
expected_quality = any(
|
|
597
|
+
any(field in scenario for field in ("why_e2e", "trigger_paths", "smoke"))
|
|
598
|
+
for scenario in expected.get("scenarios", []) if isinstance(scenario, Mapping)
|
|
599
|
+
)
|
|
600
|
+
if expected_quality and manifest.get("e2e_gate") != 1 and not allow_missing_e2e_gate:
|
|
601
|
+
return False, "acceptance_manifest_e2e_gate_missing"
|
|
602
|
+
if not isinstance(manifest.get("scenarios"), list):
|
|
603
|
+
return False, "acceptance_manifest_scenarios_changed"
|
|
604
|
+
if schema == _MANIFEST_SCHEMA:
|
|
605
|
+
checks = manifest.get("checks")
|
|
606
|
+
try:
|
|
607
|
+
validate_checks(checks)
|
|
608
|
+
except ValueError as exc:
|
|
609
|
+
return False, f"acceptance_manifest_checks_invalid: {exc}"
|
|
610
|
+
expected_checks = {item["id"]: item for item in expected.get("checks", [])}
|
|
611
|
+
if {item.get("id") for item in checks if isinstance(item, Mapping)} != set(expected_checks):
|
|
612
|
+
return False, "acceptance_manifest_checks_changed"
|
|
613
|
+
stored_checks = {
|
|
614
|
+
item.get("id"): item for item in checks
|
|
615
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
616
|
+
}
|
|
617
|
+
for check_id, expected_check in expected_checks.items():
|
|
618
|
+
expected_paths = expected_check.get("paths")
|
|
619
|
+
if expected_paths and stored_checks.get(check_id, {}).get("paths") != expected_paths:
|
|
620
|
+
return False, "acceptance_manifest_check_scope_changed"
|
|
621
|
+
stored = {
|
|
622
|
+
item.get("id"): item
|
|
623
|
+
for item in manifest["scenarios"]
|
|
624
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
625
|
+
}
|
|
626
|
+
required = {item["id"] for item in expected["scenarios"]}
|
|
627
|
+
if set(stored) != required:
|
|
628
|
+
return False, "acceptance_manifest_scenarios_changed"
|
|
629
|
+
for item in expected["scenarios"]:
|
|
630
|
+
current = stored[item["id"]]
|
|
631
|
+
for field in IMMUTABLE_SCENARIO_FIELDS:
|
|
632
|
+
if field in ("check", "checks") and "checks" not in current:
|
|
633
|
+
continue
|
|
634
|
+
if field in _OPTIONAL_SCENARIO_FIELDS and field not in current:
|
|
635
|
+
continue
|
|
636
|
+
if current.get(field) != item.get(field):
|
|
637
|
+
return False, f"acceptance_manifest_field_changed:{item['id']}:{field}"
|
|
638
|
+
text = Path(task_file).read_text(encoding="utf-8") if task_text is None else task_text
|
|
639
|
+
for scenario in expected["scenarios"]:
|
|
640
|
+
owner = scenario["owner"]
|
|
641
|
+
section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
642
|
+
refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)) if section else None
|
|
643
|
+
if not refs or scenario["id"] not in re.findall(r"\b[SEB]-\d+\b", refs.group(1)):
|
|
644
|
+
return False, "acceptance_manifest_owner_missing"
|
|
645
|
+
return True, ""
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def validate_execution_baseline(path: Path, data: Mapping[str, object]) -> None:
|
|
649
|
+
"""Legacy standalone manifests remain usable; locked plans must match."""
|
|
650
|
+
task_name = data.get("task_file")
|
|
651
|
+
if not isinstance(task_name, str) or not task_name:
|
|
652
|
+
return
|
|
653
|
+
task = Path(task_name)
|
|
654
|
+
task = task if task.is_absolute() else path.parent / task
|
|
655
|
+
if not task.is_file():
|
|
656
|
+
raise ValueError(f"acceptance task file missing: {task}")
|
|
657
|
+
if "## Acceptance Coverage" in task.read_text(encoding="utf-8"):
|
|
658
|
+
valid, reason = validate_manifest(str(task), str(path))
|
|
659
|
+
if not valid:
|
|
660
|
+
raise ValueError(reason)
|