@michaelxwb/code-flow 0.8.1 → 1.0.0
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 +73 -152
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +156 -1417
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +18 -21
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/acceptance-contract.md +0 -217
- package/docs/ci/acceptance-evidence.md +0 -77
- package/docs/migrations/runtime-0.7.0.md +0 -41
- package/docs/migrations/runtime-0.8.0.md +0 -154
- package/docs/migrations/runtime-0.9.0.md +0 -40
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/docs/spec-injection-hardening.md +0 -54
- package/docs/spec-injection-performance.json +0 -73
- package/docs/spec-optimization.md +0 -12
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -262
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -87
- package/src/adapters/claude/commands/cf-stats.md +0 -96
- package/src/adapters/claude/commands/cf-sync.md +0 -39
- package/src/adapters/claude/commands/cf-task/align.md +0 -331
- package/src/adapters/claude/commands/cf-task/archive.md +0 -155
- package/src/adapters/claude/commands/cf-task/block.md +0 -49
- package/src/adapters/claude/commands/cf-task/graph.md +0 -92
- package/src/adapters/claude/commands/cf-task/note.md +0 -248
- package/src/adapters/claude/commands/cf-task/plan.md +0 -477
- package/src/adapters/claude/commands/cf-task/prd.md +0 -173
- package/src/adapters/claude/commands/cf-task/start.md +0 -317
- package/src/adapters/claude/commands/cf-task/status.md +0 -71
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/claude/commands/cf-validate.md +0 -100
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -54
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
- package/src/adapters/commands.json +0 -115
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -262
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -87
- package/src/adapters/costrict/commands/cf-stats.md +0 -96
- package/src/adapters/costrict/commands/cf-sync.md +0 -39
- package/src/adapters/costrict/commands/cf-task/align.md +0 -331
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
- package/src/adapters/costrict/commands/cf-task/block.md +0 -49
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
- package/src/adapters/costrict/commands/cf-task/note.md +0 -248
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
- package/src/adapters/costrict/commands/cf-task/start.md +0 -317
- package/src/adapters/costrict/commands/cf-task/status.md +0 -71
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/costrict/commands/cf-validate.md +0 -100
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/install-plan.json +0 -100
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -91
- package/src/adapters/opencode/commands/cf-stats.md +0 -100
- package/src/adapters/opencode/commands/cf-sync.md +0 -44
- package/src/adapters/opencode/commands/cf-task/align.md +0 -331
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
- package/src/adapters/opencode/commands/cf-task/block.md +0 -54
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
- package/src/adapters/opencode/commands/cf-task/note.md +0 -253
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
- package/src/adapters/opencode/commands/cf-task/start.md +0 -317
- package/src/adapters/opencode/commands/cf-task/status.md +0 -76
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/opencode/commands/cf-validate.md +0 -105
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/adapters/platform-bindings.json +0 -370
- package/src/cli.js +0 -1076
- package/src/core/code-flow/config.yml +0 -161
- package/src/core/code-flow/runtime-commands.json +0 -259
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
- package/src/core/code-flow/scripts/cf_audit.py +0 -10
- package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
- package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
- package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
- package/src/core/code-flow/scripts/cf_feedback.py +0 -10
- package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
- package/src/core/code-flow/scripts/cf_stats.py +0 -10
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_sync.py +0 -10
- package/src/core/code-flow/scripts/cf_task_index.py +0 -10
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_validation.py +0 -10
- 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 +0 -347
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
- package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
- package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
- package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
- package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
- package/src/core/code-flow/scripts/runtime-resources.json +0 -29
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -59
- package/src/migrate/runtime.js +0 -213
- package/src/migrate/spec-workflow.js +0 -253
- package/src/runtime.js +0 -219
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
"""E2E precision and scope for a TASK start.
|
|
2
|
-
|
|
3
|
-
Plan locks E2E quality fields; Start must turn them into a concrete execution
|
|
4
|
-
scope. A TASK writes tests only for its owned E2E scenarios and runs its owned
|
|
5
|
-
scenarios plus the global smoke set. Unrelated E2E journeys stay deferred
|
|
6
|
-
instead of turning every small change into a full-suite run.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import re
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Mapping
|
|
14
|
-
|
|
15
|
-
from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
|
|
16
|
-
from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids
|
|
17
|
-
|
|
18
|
-
_PLACEHOLDERS = frozenset(("", "-", "n/a", "na", "none", "无", "todo", "tbd", "待定"))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _meaningful(value: object) -> bool:
|
|
22
|
-
return str(value or "").strip().lower() not in _PLACEHOLDERS
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _task_scenario_ids(task_file: str, task_id: str) -> set[str]:
|
|
26
|
-
text = Path(task_file).read_text(encoding="utf-8")
|
|
27
|
-
section = re.search(
|
|
28
|
-
rf"(?ms)^##\s+{re.escape(task_id)}:.*?(?=^##\s+TASK-|\Z)", text
|
|
29
|
-
)
|
|
30
|
-
if section is None:
|
|
31
|
-
raise ValueError(f"task_not_found: {task_id}")
|
|
32
|
-
refs = re.search(
|
|
33
|
-
r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)
|
|
34
|
-
)
|
|
35
|
-
if refs is None:
|
|
36
|
-
return set()
|
|
37
|
-
return set(re.findall(r"\b(?:S|E|B)-\d+\b", refs.group(1)))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _item(row: Mapping[str, object]) -> dict[str, object]:
|
|
41
|
-
return {
|
|
42
|
-
**{field: row[field] for field in ("level", "env", "components", "steps", "expected", "expected_refs", "assertions",
|
|
43
|
-
"preconditions", "defer_reason", "defer_owner", "target_phase", "features", "tags")
|
|
44
|
-
if field in row},
|
|
45
|
-
"id": str(row.get("id", "")),
|
|
46
|
-
"title": str(row.get("title", "")),
|
|
47
|
-
"owner": row.get("owner") if isinstance(row.get("owner"), str) else "",
|
|
48
|
-
"priority": row.get("priority") if isinstance(row.get("priority"), str) else "",
|
|
49
|
-
"exec": row.get("exec") if isinstance(row.get("exec"), str) else "",
|
|
50
|
-
"boundary": str(row.get("boundary", "")),
|
|
51
|
-
"why_e2e": str(row.get("why_e2e", "")),
|
|
52
|
-
"trigger_paths": list(row.get("trigger_paths") or []),
|
|
53
|
-
"smoke": bool(row.get("smoke")),
|
|
54
|
-
"check": row.get("check") if isinstance(row.get("check"), str) else "",
|
|
55
|
-
"checks": scenario_check_ids(row),
|
|
56
|
-
"command": list(row.get("command") or []) if isinstance(row.get("command"), list) else [],
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _quality_errors(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
61
|
-
errors: list[dict[str, object]] = []
|
|
62
|
-
for item in items:
|
|
63
|
-
scenario_id = str(item.get("id", ""))
|
|
64
|
-
priority = str(item.get("priority", "")).upper()
|
|
65
|
-
if priority not in ("P0", "P1"):
|
|
66
|
-
errors.append({
|
|
67
|
-
"code": "e2e_priority_not_critical",
|
|
68
|
-
"scenario": scenario_id,
|
|
69
|
-
"message": "E2E 只允许 P0/P1 关键旅程;请改为 component/contract/integration。",
|
|
70
|
-
})
|
|
71
|
-
if not _meaningful(item.get("why_e2e")):
|
|
72
|
-
errors.append({
|
|
73
|
-
"code": "e2e_justification_missing",
|
|
74
|
-
"scenario": scenario_id,
|
|
75
|
-
"message": "E2E 必须说明为什么低层测试无法覆盖。",
|
|
76
|
-
})
|
|
77
|
-
if not _meaningful(item.get("boundary")):
|
|
78
|
-
errors.append({
|
|
79
|
-
"code": "e2e_boundary_missing",
|
|
80
|
-
"scenario": scenario_id,
|
|
81
|
-
"message": "E2E 必须声明真实边界。",
|
|
82
|
-
})
|
|
83
|
-
if str(item.get("exec", "")).lower() == "aspirational":
|
|
84
|
-
errors.append({
|
|
85
|
-
"code": "e2e_aspirational",
|
|
86
|
-
"scenario": scenario_id,
|
|
87
|
-
"message": "aspirational E2E 不能进入 TASK 执行范围。",
|
|
88
|
-
})
|
|
89
|
-
trigger_paths = [
|
|
90
|
-
str(path) for path in item.get("trigger_paths", []) or [] if _meaningful(path)
|
|
91
|
-
]
|
|
92
|
-
if not item.get("smoke") and not trigger_paths:
|
|
93
|
-
errors.append({
|
|
94
|
-
"code": "e2e_trigger_paths_missing",
|
|
95
|
-
"scenario": scenario_id,
|
|
96
|
-
"message": "非 smoke E2E 必须声明触发路径,避免小改动跑全量。",
|
|
97
|
-
})
|
|
98
|
-
return errors
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _group_scenarios(data: Mapping[str, object], owned_ids: set[str], task_id: str
|
|
102
|
-
) -> tuple[list[dict[str, object]], list[dict[str, object]], list[dict[str, object]]]:
|
|
103
|
-
owned: list[dict[str, object]] = []
|
|
104
|
-
smoke: list[dict[str, object]] = []
|
|
105
|
-
deferred: list[dict[str, object]] = []
|
|
106
|
-
for row in data.get("scenarios", []) or []:
|
|
107
|
-
if not isinstance(row, Mapping):
|
|
108
|
-
continue
|
|
109
|
-
kind = str(row.get("kind") or row.get("level") or "").lower()
|
|
110
|
-
if kind != "e2e":
|
|
111
|
-
continue
|
|
112
|
-
item = _item(row)
|
|
113
|
-
scenario_id = str(item["id"])
|
|
114
|
-
scenario_owner = str(item.get("owner") or "")
|
|
115
|
-
if scenario_id in owned_ids and (not scenario_owner or scenario_owner == task_id):
|
|
116
|
-
owned.append(item)
|
|
117
|
-
elif item["smoke"]:
|
|
118
|
-
smoke.append(item)
|
|
119
|
-
else:
|
|
120
|
-
deferred.append(item)
|
|
121
|
-
return owned, smoke, deferred
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _scoped_quality(data: Mapping[str, object], scheduled: list[dict[str, object]], root: Path
|
|
125
|
-
) -> tuple[list[dict[str, object]], list[dict[str, object]], bool]:
|
|
126
|
-
errors = _quality_errors(scheduled)
|
|
127
|
-
warnings: list[dict[str, object]] = []
|
|
128
|
-
if data.get("scenarios_file"):
|
|
129
|
-
from codeflow.acceptance.cf_scenario_quality import quality_report
|
|
130
|
-
strict = [{**item, "level": "e2e", "owner": item.get("defer_owner") or item.get("owner", "")}
|
|
131
|
-
for item in scheduled]
|
|
132
|
-
quality_errors = quality_report(strict)["errors"]
|
|
133
|
-
if data.get("quality_schema", 1) >= 2:
|
|
134
|
-
from codeflow.acceptance.cf_check_registration import validate_registration
|
|
135
|
-
errors = quality_errors + validate_registration(str(root), scheduled, data.get("checks", []))["errors"]
|
|
136
|
-
elif scheduled:
|
|
137
|
-
warnings.extend({**item, "code": f"legacy_{item['code']}"} for item in quality_errors)
|
|
138
|
-
warnings.append({"code": "legacy_quality_schema", "message":
|
|
139
|
-
"旧计划保留原 E2E 门禁;补齐 expected_refs/延期信息,运行 acceptance manifest --register-checks 重锁,"
|
|
140
|
-
"再用 --verify-plan 检查质量和测试登记。新质量检查尚未通过。"})
|
|
141
|
-
gate_required = data.get("e2e_gate") in (1, True, "required")
|
|
142
|
-
if errors and not gate_required:
|
|
143
|
-
# Existing projects upgraded mid-flight remain startable, but every
|
|
144
|
-
# legacy E2E is surfaced for re-planning instead of being silently
|
|
145
|
-
# treated as a scoped critical journey.
|
|
146
|
-
warnings.extend({**item, "code": f"legacy_{item['code']}"} for item in errors)
|
|
147
|
-
warnings.append({
|
|
148
|
-
"code": "legacy_e2e_plan",
|
|
149
|
-
"message": "旧 manifest 未声明 why_e2e/trigger_paths;先用 plan --verify-plan 重新锁定。",
|
|
150
|
-
})
|
|
151
|
-
errors = []
|
|
152
|
-
return errors, warnings, gate_required
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def build_e2e_plan(
|
|
156
|
-
manifest_file: str,
|
|
157
|
-
task_file: str,
|
|
158
|
-
task_id: str,
|
|
159
|
-
validate_baseline: bool = True,
|
|
160
|
-
) -> dict[str, object]:
|
|
161
|
-
path = Path(manifest_file)
|
|
162
|
-
owned_ids = _task_scenario_ids(task_file, task_id)
|
|
163
|
-
if not path.is_file():
|
|
164
|
-
return {
|
|
165
|
-
"task": task_id,
|
|
166
|
-
"manifest": str(path),
|
|
167
|
-
"owned": [],
|
|
168
|
-
"smoke": [],
|
|
169
|
-
"scheduled": [],
|
|
170
|
-
"deferred_count": 0,
|
|
171
|
-
"deferred_ids": [],
|
|
172
|
-
"quality": {"ok": True, "errors": [], "warnings": [], "gate": "none"},
|
|
173
|
-
}
|
|
174
|
-
data = load_manifest(path)
|
|
175
|
-
if validate_baseline:
|
|
176
|
-
validate_execution_baseline(path, data)
|
|
177
|
-
owned, smoke, deferred = _group_scenarios(data, owned_ids, task_id)
|
|
178
|
-
# 延后场景不在编码阶段调度:由需求级 verify-e2e/人工门禁执行。
|
|
179
|
-
scheduled = [item for item in [*owned, *smoke] if item.get("exec") != "deferred"]
|
|
180
|
-
deferred.extend(item for item in [*owned, *smoke] if item.get("exec") == "deferred")
|
|
181
|
-
root = next((parent.parent for parent in path.parents if parent.name == ".code-flow"), path.parent)
|
|
182
|
-
errors, warnings, gate_required = _scoped_quality(data, scheduled, root)
|
|
183
|
-
if not scheduled:
|
|
184
|
-
warnings.append({
|
|
185
|
-
"code": "no_scheduled_e2e",
|
|
186
|
-
"message": "本 TASK 没有需要编写或执行的 E2E;不要把其他场景补进来。",
|
|
187
|
-
})
|
|
188
|
-
return {
|
|
189
|
-
"task": task_id,
|
|
190
|
-
"manifest": str(path),
|
|
191
|
-
"owned": owned,
|
|
192
|
-
"smoke": smoke,
|
|
193
|
-
"scheduled": scheduled,
|
|
194
|
-
"deferred_count": len(deferred),
|
|
195
|
-
"deferred_ids": [str(item["id"]) for item in deferred],
|
|
196
|
-
"quality": {
|
|
197
|
-
"ok": not errors,
|
|
198
|
-
"errors": errors,
|
|
199
|
-
"warnings": warnings,
|
|
200
|
-
"gate": "required" if gate_required else "legacy",
|
|
201
|
-
},
|
|
202
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
"""Author check registration through real collection before locking a plan."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import time
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Mapping
|
|
8
|
-
|
|
9
|
-
from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
|
|
10
|
-
from codeflow.acceptance.cf_check_registration import (
|
|
11
|
-
REGISTRATION_FIELDS, CollectionError, register_check, registration_required,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class RegistrationError(ValueError):
|
|
16
|
-
def __init__(self, report: Mapping[str, object]) -> None:
|
|
17
|
-
self.report = dict(report)
|
|
18
|
-
super().__init__("check_registration_failed")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _overrides(file: str, ids: set[str]) -> dict[str, Mapping[str, object]]:
|
|
22
|
-
if not file:
|
|
23
|
-
return {}
|
|
24
|
-
data = json.loads(Path(file).read_text(encoding="utf-8"))
|
|
25
|
-
checks = data.get("checks") if isinstance(data, dict) else None
|
|
26
|
-
if not isinstance(checks, dict) or set(checks) - ids:
|
|
27
|
-
raise ValueError("registration_file_invalid_check_ids")
|
|
28
|
-
for check_id, config in checks.items():
|
|
29
|
-
if not isinstance(config, dict) or set(config) - set(REGISTRATION_FIELDS):
|
|
30
|
-
raise ValueError(f"registration_file_invalid_fields:{check_id}")
|
|
31
|
-
return checks
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def register_checks(manifest: dict[str, object], root: str, file: str = "") -> dict[str, object]:
|
|
35
|
-
checks = manifest.get("checks", [])
|
|
36
|
-
overrides = _overrides(file, {str(check["id"]) for check in checks})
|
|
37
|
-
report: dict[str, object] = {"ok": True, "registered": [], "errors": []}
|
|
38
|
-
deadline = time.monotonic() + 60
|
|
39
|
-
for check in checks:
|
|
40
|
-
check_id = str(check["id"])
|
|
41
|
-
rows = [row for row in manifest["scenarios"] if check_id in scenario_check_ids(row)
|
|
42
|
-
and (registration_required({**row, "level": check.get("level", row.get("level"))})
|
|
43
|
-
or registration_required(row) or check_id in overrides)]
|
|
44
|
-
if not rows:
|
|
45
|
-
continue
|
|
46
|
-
check.update(overrides.get(check_id, {}))
|
|
47
|
-
try:
|
|
48
|
-
issues = register_check(root, check, rows, deadline)
|
|
49
|
-
if issues:
|
|
50
|
-
raise RegistrationError({"ok": False, "errors": issues})
|
|
51
|
-
except (OSError, ValueError) as exc:
|
|
52
|
-
details = exc.report if isinstance(exc, RegistrationError) else (
|
|
53
|
-
exc.details if isinstance(exc, CollectionError) else {})
|
|
54
|
-
report["errors"].append({"check_id": check_id, "error": str(exc), **details})
|
|
55
|
-
else:
|
|
56
|
-
report["registered"].append(check_id)
|
|
57
|
-
report["ok"] = not report["errors"]
|
|
58
|
-
if not report["ok"]:
|
|
59
|
-
raise RegistrationError(report)
|
|
60
|
-
return report
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"""User-confirmed risk acceptance, scoped to the owning TASK and scenarios."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from datetime import datetime, timezone
|
|
5
|
-
import re
|
|
6
|
-
from typing import Mapping, Optional
|
|
7
|
-
|
|
8
|
-
from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
|
|
9
|
-
|
|
10
|
-
AGENT_IDENTITIES = frozenset(("agent", "assistant", "claude", "claude code", "codex",
|
|
11
|
-
"opencode", "costrict", "ai", "bot"))
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def risk_scenario_ids(data: Mapping[str, object], check_id: str, owner: str) -> list[str]:
|
|
15
|
-
"""Resolve the locked plan's ownership; never accept a free-form owner."""
|
|
16
|
-
rows = data.get("scenarios", [])
|
|
17
|
-
if not isinstance(rows, list) or re.fullmatch(r"TASK-\d+", owner) is None:
|
|
18
|
-
return []
|
|
19
|
-
return sorted(str(row["id"]) for row in rows if isinstance(row, Mapping)
|
|
20
|
-
and row.get("owner") == owner and row.get("id")
|
|
21
|
-
and check_id in scenario_check_ids(row))
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _matches_scenario(risk: Mapping[str, object], check: Mapping[str, object],
|
|
25
|
-
scenario: Mapping[str, object]) -> bool:
|
|
26
|
-
if risk.get("owner") != scenario.get("owner"):
|
|
27
|
-
return False
|
|
28
|
-
if check.get("id") not in scenario_check_ids(scenario):
|
|
29
|
-
return False
|
|
30
|
-
ids = risk.get("scenario_ids")
|
|
31
|
-
scenario_id = scenario.get("scenario_ref") or scenario.get("id")
|
|
32
|
-
# Legacy records remain valid only for scenarios owned by the recorded TASK.
|
|
33
|
-
return ids is None or (isinstance(ids, list) and scenario_id in ids)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def accepted_risk(check: Optional[Mapping[str, object]],
|
|
37
|
-
scenario: Optional[Mapping[str, object]] = None) -> Optional[dict[str, object]]:
|
|
38
|
-
if not isinstance(check, Mapping) or not isinstance(check.get("accepted_risk"), Mapping):
|
|
39
|
-
return None
|
|
40
|
-
risk = check["accepted_risk"]
|
|
41
|
-
fields = ("owner", "reason", "confirmed_by", "confirmed_at", "source", "expires_at")
|
|
42
|
-
if any(not isinstance(risk.get(key), str) or not str(risk[key]).strip() for key in fields):
|
|
43
|
-
return None
|
|
44
|
-
if re.fullmatch(r"TASK-\d+", str(risk["owner"])) is None:
|
|
45
|
-
return None
|
|
46
|
-
if str(risk["confirmed_by"]).strip().lower().split(":", 1)[0] in AGENT_IDENTITIES:
|
|
47
|
-
return None
|
|
48
|
-
expires = str(risk["expires_at"]).strip()
|
|
49
|
-
try:
|
|
50
|
-
deadline = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
51
|
-
except ValueError:
|
|
52
|
-
return None
|
|
53
|
-
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", expires):
|
|
54
|
-
deadline = deadline.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
55
|
-
if deadline.tzinfo is None:
|
|
56
|
-
deadline = deadline.replace(tzinfo=timezone.utc)
|
|
57
|
-
if deadline <= datetime.now(timezone.utc):
|
|
58
|
-
return None
|
|
59
|
-
if scenario is not None and not _matches_scenario(risk, check, scenario):
|
|
60
|
-
return None
|
|
61
|
-
return dict(risk)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def risk_counts(data: Mapping[str, object]) -> dict[str, int]:
|
|
65
|
-
checks, rows = data.get("checks", []), data.get("scenarios", [])
|
|
66
|
-
checks = checks if isinstance(checks, list) else []
|
|
67
|
-
rows = rows if isinstance(rows, list) else []
|
|
68
|
-
recorded = [check for check in checks if isinstance(check, Mapping)
|
|
69
|
-
and isinstance(check.get("accepted_risk"), Mapping)]
|
|
70
|
-
active = sum(any(accepted_risk(check, row) is not None for row in rows
|
|
71
|
-
if isinstance(row, Mapping)) for check in recorded)
|
|
72
|
-
return {"accepted_risks": active, "recorded_risks": len(recorded),
|
|
73
|
-
"inactive_risks": len(recorded) - active}
|
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
"""Validated acceptance records shared by execution and lifecycle gates."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import math
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Mapping, Optional
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
EXEC_MODES = ("auto", "manual", "deferred", "aspirational")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def scenario_check_ids(row: Mapping[str, object]) -> list[str]:
|
|
14
|
-
"""The plural mapping is authoritative when present and non-empty."""
|
|
15
|
-
checks = row.get("checks")
|
|
16
|
-
if isinstance(checks, list) and checks:
|
|
17
|
-
return list(dict.fromkeys(str(value) for value in checks))
|
|
18
|
-
check = row.get("check")
|
|
19
|
-
return [check] if isinstance(check, str) and check else []
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def recorded_check_hash(row: Mapping[str, object], check_id: str) -> str:
|
|
23
|
-
"""Read one check's proof; never reuse an aggregate hash for many checks."""
|
|
24
|
-
evidence = row.get("evidence")
|
|
25
|
-
if not isinstance(evidence, Mapping):
|
|
26
|
-
return ""
|
|
27
|
-
hashes = evidence.get("check_hashes")
|
|
28
|
-
if isinstance(hashes, Mapping):
|
|
29
|
-
value = hashes.get(check_id)
|
|
30
|
-
elif scenario_check_ids(row) == [check_id]:
|
|
31
|
-
value = evidence.get("input_hash") or evidence.get("fingerprint")
|
|
32
|
-
else:
|
|
33
|
-
return ""
|
|
34
|
-
return value if isinstance(value, str) else ""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def check_outcome(result: Mapping[str, object], check_id: str) -> Mapping[str, object]:
|
|
38
|
-
"""Prefer the executed check's outcome over the enclosing scenario verdict."""
|
|
39
|
-
if "check_results" not in result:
|
|
40
|
-
return result # Compatibility with evidence written before per-check outcomes.
|
|
41
|
-
outcomes = result.get("check_results")
|
|
42
|
-
outcome = outcomes.get(check_id) if isinstance(outcomes, Mapping) else None
|
|
43
|
-
shared = {key: value for key, value in result.items()
|
|
44
|
-
if key not in ("status", "exit_code", "risk", "error")}
|
|
45
|
-
return {**shared, "status": "incomplete", "exit_code": 1,
|
|
46
|
-
**(outcome if isinstance(outcome, Mapping) else {})}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def verified_evidence(row: Mapping[str, object], fingerprint: str = "") -> bool:
|
|
50
|
-
evidence = row.get("evidence")
|
|
51
|
-
if row.get("status") != "verified" or not isinstance(evidence, dict):
|
|
52
|
-
return False
|
|
53
|
-
if row.get("kind") != "manual" and row.get("exec") != "manual":
|
|
54
|
-
if fingerprint and evidence.get("fingerprint") != fingerprint:
|
|
55
|
-
return False
|
|
56
|
-
return evidence.get("status") == "passed" and evidence.get("exit_code") == 0
|
|
57
|
-
author, detail = evidence.get("confirmed_by"), evidence.get("evidence")
|
|
58
|
-
agents = {"agent", "assistant", "codex", "claude", "opencode", "costrict"}
|
|
59
|
-
return (isinstance(author, str) and bool(author.strip())
|
|
60
|
-
and author.strip().lower().split(":", 1)[0] not in agents
|
|
61
|
-
and isinstance(detail, str) and bool(detail.strip()))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def validate_scenarios(value: object) -> list[dict[str, object]]:
|
|
65
|
-
if not isinstance(value, list):
|
|
66
|
-
raise ValueError("invalid manifest scenarios: expected list")
|
|
67
|
-
seen: set[str] = set()
|
|
68
|
-
for item in value:
|
|
69
|
-
if not isinstance(item, dict):
|
|
70
|
-
raise ValueError("invalid scenario: expected object")
|
|
71
|
-
scenario_id = item.get("id")
|
|
72
|
-
if not isinstance(scenario_id, str) or not scenario_id.strip():
|
|
73
|
-
raise ValueError("scenario id missing")
|
|
74
|
-
if scenario_id in seen:
|
|
75
|
-
raise ValueError(f"duplicate scenario id: {scenario_id}")
|
|
76
|
-
seen.add(scenario_id)
|
|
77
|
-
for field in ("kind", "owner", "cwd"):
|
|
78
|
-
if field in item and not isinstance(item[field], str):
|
|
79
|
-
raise ValueError(f"invalid scenario {scenario_id}:{field}")
|
|
80
|
-
command = item.get("command")
|
|
81
|
-
if command is not None and (not isinstance(command, list) or not all(isinstance(x, str) for x in command)):
|
|
82
|
-
raise ValueError(f"invalid scenario {scenario_id}:command")
|
|
83
|
-
check = item.get("check")
|
|
84
|
-
if check is not None and not isinstance(check, str):
|
|
85
|
-
raise ValueError(f"invalid scenario {scenario_id}:check")
|
|
86
|
-
checks = item.get("checks")
|
|
87
|
-
if checks is not None and (not isinstance(checks, list) or not all(isinstance(x, str) for x in checks)):
|
|
88
|
-
raise ValueError(f"invalid scenario {scenario_id}:checks")
|
|
89
|
-
timeout = item.get("timeout", 60)
|
|
90
|
-
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
|
|
91
|
-
raise ValueError(f"invalid scenario {scenario_id}:timeout")
|
|
92
|
-
deps = item.get("depends_on", [])
|
|
93
|
-
if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
|
|
94
|
-
raise ValueError(f"invalid scenario {scenario_id}:depends_on")
|
|
95
|
-
if "runs" in item and not isinstance(item["runs"], list):
|
|
96
|
-
raise ValueError(f"invalid scenario {scenario_id}:runs")
|
|
97
|
-
revision = item.get("revision", 0)
|
|
98
|
-
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
|
|
99
|
-
raise ValueError(f"invalid scenario {scenario_id}:revision")
|
|
100
|
-
_validate_scenario_metadata(scenario_id, item)
|
|
101
|
-
for item in value:
|
|
102
|
-
unknown = set(item.get("depends_on", [])) - seen
|
|
103
|
-
if unknown:
|
|
104
|
-
raise ValueError(f"acceptance scenario dependency missing: {', '.join(sorted(unknown))}")
|
|
105
|
-
return value
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _validate_scenario_metadata(scenario_id: str, item: Mapping[str, object]) -> None:
|
|
109
|
-
"""Execution metadata mirrors design §2.5 and manifest v3 checks."""
|
|
110
|
-
priority = item.get("priority")
|
|
111
|
-
if priority is not None and (not isinstance(priority, str) or not priority.strip()):
|
|
112
|
-
raise ValueError(f"invalid scenario {scenario_id}:priority")
|
|
113
|
-
exec_mode = item.get("exec")
|
|
114
|
-
if exec_mode is not None and (not isinstance(exec_mode, str) or exec_mode not in EXEC_MODES):
|
|
115
|
-
raise ValueError(f"invalid scenario {scenario_id}:exec")
|
|
116
|
-
env = item.get("env")
|
|
117
|
-
if env is not None and (not isinstance(env, str) or not env.strip()):
|
|
118
|
-
raise ValueError(f"invalid scenario {scenario_id}:env")
|
|
119
|
-
inputs = item.get("inputs")
|
|
120
|
-
if inputs is not None and (
|
|
121
|
-
not isinstance(inputs, list) or not all(isinstance(value, str) and value for value in inputs)
|
|
122
|
-
):
|
|
123
|
-
raise ValueError(f"invalid scenario {scenario_id}:inputs")
|
|
124
|
-
why_e2e = item.get("why_e2e")
|
|
125
|
-
if why_e2e is not None and not isinstance(why_e2e, str):
|
|
126
|
-
raise ValueError(f"invalid scenario {scenario_id}:why_e2e")
|
|
127
|
-
trigger_paths = item.get("trigger_paths")
|
|
128
|
-
if trigger_paths is not None and (
|
|
129
|
-
not isinstance(trigger_paths, list)
|
|
130
|
-
or not all(isinstance(value, str) and value for value in trigger_paths)
|
|
131
|
-
):
|
|
132
|
-
raise ValueError(f"invalid scenario {scenario_id}:trigger_paths")
|
|
133
|
-
smoke = item.get("smoke")
|
|
134
|
-
if smoke is not None and not isinstance(smoke, bool):
|
|
135
|
-
raise ValueError(f"invalid scenario {scenario_id}:smoke")
|
|
136
|
-
_validate_contract_metadata(scenario_id, item)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _validate_contract_metadata(scenario_id: str, item: Mapping[str, object]) -> None:
|
|
140
|
-
if "expected_checks" in item:
|
|
141
|
-
from codeflow.acceptance.cf_scenario_quality import validate_expected_checks
|
|
142
|
-
validate_expected_checks(item)
|
|
143
|
-
for field in ("components", "steps", "expected", "assertions", "preconditions", "features", "tags"):
|
|
144
|
-
if field in item and (not isinstance(item[field], list) or not all(isinstance(value, str) for value in item[field])):
|
|
145
|
-
raise ValueError(f"invalid scenario {scenario_id}:{field}")
|
|
146
|
-
for field in ("defer_reason", "defer_owner", "target_phase"):
|
|
147
|
-
if field in item and not isinstance(item[field], str):
|
|
148
|
-
raise ValueError(f"invalid scenario {scenario_id}:{field}")
|
|
149
|
-
refs = item.get("expected_refs", [])
|
|
150
|
-
if not isinstance(refs, list) or not all(isinstance(value, Mapping) for value in refs):
|
|
151
|
-
raise ValueError(f"invalid scenario {scenario_id}:expected_refs")
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def validate_checks(value: object) -> list[dict[str, object]]:
|
|
155
|
-
"""Validate the manifest v3 checks array (execution identity)."""
|
|
156
|
-
if value is None:
|
|
157
|
-
return []
|
|
158
|
-
if not isinstance(value, list):
|
|
159
|
-
raise ValueError("invalid manifest checks: expected list")
|
|
160
|
-
seen: set[str] = set()
|
|
161
|
-
for item in value:
|
|
162
|
-
if not isinstance(item, dict):
|
|
163
|
-
raise ValueError("invalid check: expected object")
|
|
164
|
-
check_id = item.get("id")
|
|
165
|
-
if not isinstance(check_id, str) or not check_id.strip():
|
|
166
|
-
raise ValueError("check id missing")
|
|
167
|
-
if check_id in seen:
|
|
168
|
-
raise ValueError(f"duplicate check id: {check_id}")
|
|
169
|
-
seen.add(check_id)
|
|
170
|
-
command = item.get("command")
|
|
171
|
-
if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
|
|
172
|
-
raise ValueError(f"invalid check {check_id}:command")
|
|
173
|
-
cwd = item.get("cwd", ".")
|
|
174
|
-
if not isinstance(cwd, str):
|
|
175
|
-
raise ValueError(f"invalid check {check_id}:cwd")
|
|
176
|
-
timeout = item.get("timeout", 60)
|
|
177
|
-
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
|
|
178
|
-
raise ValueError(f"invalid check {check_id}:timeout")
|
|
179
|
-
_validate_check_environment(check_id, item)
|
|
180
|
-
_validate_check_registration(check_id, item)
|
|
181
|
-
return value
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def validate_check_levels(scenarios: list[dict[str, object]], checks: list[dict[str, object]]) -> None:
|
|
185
|
-
"""A mixed-level composition must declare every member's level."""
|
|
186
|
-
by_id = {str(check["id"]): check for check in checks}
|
|
187
|
-
for row in scenarios:
|
|
188
|
-
members = [by_id[check_id] for check_id in scenario_check_ids(row) if check_id in by_id]
|
|
189
|
-
levels = {str(check.get("level") or row.get("level") or "") for check in members}
|
|
190
|
-
if len(levels) > 1 and any("level" not in check for check in members):
|
|
191
|
-
raise ValueError(f"scenario {row.get('id')} mixed checks require explicit levels")
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _validate_check_registration(check_id: str, item: Mapping[str, object]) -> None:
|
|
195
|
-
from codeflow.acceptance.cf_scenario_source import LEVELS
|
|
196
|
-
if "level" in item and (not isinstance(item["level"], str) or item["level"] not in LEVELS):
|
|
197
|
-
raise ValueError(f"invalid check {check_id}:level")
|
|
198
|
-
if "suite" in item and item["suite"] not in ("unit", "component", "contract", "integration", "system", "browser", "capacity"):
|
|
199
|
-
raise ValueError(f"invalid check {check_id}:suite")
|
|
200
|
-
for field in ("test_ids", "markers"):
|
|
201
|
-
if field not in item:
|
|
202
|
-
continue
|
|
203
|
-
values = item[field]
|
|
204
|
-
if not isinstance(values, list) or not all(isinstance(value, str) and value.strip() for value in values):
|
|
205
|
-
raise ValueError(f"invalid check {check_id}:{field}")
|
|
206
|
-
if len(values) != len(set(values)):
|
|
207
|
-
raise ValueError(f"duplicate check {check_id}:{field}")
|
|
208
|
-
if "collect" in item and not isinstance(item["collect"], Mapping):
|
|
209
|
-
raise ValueError(f"invalid check {check_id}:collect")
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
_ENV_SCOPES = ("shared", "isolated")
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def _validate_check_environment(check_id: str, item: Mapping[str, object]) -> None:
|
|
216
|
-
"""env/env_scope/preflight/parallel_safe describe how a check may run."""
|
|
217
|
-
env = item.get("env", "none")
|
|
218
|
-
if not isinstance(env, str) or not env.strip():
|
|
219
|
-
raise ValueError(f"invalid check {check_id}:env")
|
|
220
|
-
revision = item.get("environment_revision")
|
|
221
|
-
if revision is not None and (not isinstance(revision, str) or not revision.strip()):
|
|
222
|
-
raise ValueError(f"invalid check {check_id}:environment_revision")
|
|
223
|
-
ttl = item.get("evidence_ttl_seconds")
|
|
224
|
-
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, (int, float))
|
|
225
|
-
or not math.isfinite(ttl) or ttl <= 0):
|
|
226
|
-
raise ValueError(f"invalid check {check_id}:evidence_ttl_seconds")
|
|
227
|
-
scope = item.get("env_scope", "shared")
|
|
228
|
-
if not isinstance(scope, str) or scope not in _ENV_SCOPES:
|
|
229
|
-
raise ValueError(f"invalid check {check_id}:env_scope")
|
|
230
|
-
if "parallel_safe" in item and not isinstance(item["parallel_safe"], bool):
|
|
231
|
-
raise ValueError(f"invalid check {check_id}:parallel_safe")
|
|
232
|
-
preflight = item.get("preflight", [])
|
|
233
|
-
if not isinstance(preflight, list):
|
|
234
|
-
raise ValueError(f"invalid check {check_id}:preflight")
|
|
235
|
-
for entry in preflight:
|
|
236
|
-
if isinstance(entry, str):
|
|
237
|
-
if not entry.strip():
|
|
238
|
-
raise ValueError(f"invalid check {check_id}:preflight")
|
|
239
|
-
elif isinstance(entry, list):
|
|
240
|
-
if not entry or not all(isinstance(token, str) and token for token in entry):
|
|
241
|
-
raise ValueError(f"invalid check {check_id}:preflight")
|
|
242
|
-
else:
|
|
243
|
-
raise ValueError(f"invalid check {check_id}:preflight")
|
|
244
|
-
paths = item.get("paths")
|
|
245
|
-
if paths is not None and (
|
|
246
|
-
not isinstance(paths, list)
|
|
247
|
-
or not all(isinstance(path, str) and path.strip() for path in paths)
|
|
248
|
-
):
|
|
249
|
-
raise ValueError(f"invalid check {check_id}:paths")
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def load_manifest(path: Path, *, text: Optional[str] = None) -> dict[str, object]:
|
|
253
|
-
data = json.loads(path.read_text(encoding="utf-8") if text is None else text)
|
|
254
|
-
if not isinstance(data, dict):
|
|
255
|
-
raise ValueError("invalid acceptance manifest: expected object")
|
|
256
|
-
quality_schema = data.get("quality_schema", 1)
|
|
257
|
-
if type(quality_schema) is not int or quality_schema not in (1, 2):
|
|
258
|
-
raise ValueError(f"unsupported acceptance quality_schema: {quality_schema!r}")
|
|
259
|
-
validate_scenarios(data.get("scenarios"))
|
|
260
|
-
if "checks" in data:
|
|
261
|
-
validate_checks(data.get("checks"))
|
|
262
|
-
validate_check_levels(data["scenarios"], data["checks"])
|
|
263
|
-
check_ids = {
|
|
264
|
-
item.get("id") for item in data.get("checks", [])
|
|
265
|
-
if isinstance(item, dict)
|
|
266
|
-
} if isinstance(data.get("checks"), list) else set()
|
|
267
|
-
for scenario in data.get("scenarios", []):
|
|
268
|
-
if not isinstance(scenario, dict):
|
|
269
|
-
continue
|
|
270
|
-
single = scenario.get("check")
|
|
271
|
-
if isinstance(single, str) and single and single not in check_ids:
|
|
272
|
-
raise ValueError(f"scenario {scenario.get('id')} references missing check: {single}")
|
|
273
|
-
multiple = scenario.get("checks")
|
|
274
|
-
if isinstance(multiple, list):
|
|
275
|
-
unknown = [str(value) for value in multiple if str(value) not in check_ids]
|
|
276
|
-
if unknown:
|
|
277
|
-
raise ValueError(
|
|
278
|
-
f"scenario {scenario.get('id')} references missing checks: {', '.join(unknown)}"
|
|
279
|
-
)
|
|
280
|
-
if "task_file" in data and not isinstance(data["task_file"], str):
|
|
281
|
-
raise ValueError("invalid acceptance manifest: task_file")
|
|
282
|
-
return data
|