@michaelxwb/code-flow 0.6.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Validate test registration against a bounded, real collection command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import time
|
|
9
|
+
from typing import Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids, validate_checks
|
|
12
|
+
from codeflow.core.cf_exec_base import run_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
REGISTRATION_FIELDS = ("suite", "test_ids", "markers", "collect", "level", "environment_revision", "evidence_ttl_seconds")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CollectionError(ValueError):
|
|
19
|
+
def __init__(self, code: str, details: Mapping[str, object]) -> None:
|
|
20
|
+
super().__init__(code)
|
|
21
|
+
self.code = code
|
|
22
|
+
self.details = dict(details)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pytest_markers(argv: Sequence[str]) -> list[str]:
|
|
26
|
+
positions = [index for index, value in enumerate(argv[:-1]) if value == "-m" and argv[index + 1] != "pytest"]
|
|
27
|
+
expression = argv[positions[-1] + 1] if positions else ""
|
|
28
|
+
markers = [value.strip() for value in expression.split(" and ")] if expression else []
|
|
29
|
+
if any(not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", value) for value in markers):
|
|
30
|
+
raise ValueError("check_collection_marker_expression_invalid")
|
|
31
|
+
return markers
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pytest_collection(output: str, argv: Sequence[str]) -> list[dict[str, object]]:
|
|
35
|
+
markers = _pytest_markers(argv)
|
|
36
|
+
return [{"id": line.strip(), "markers": markers,
|
|
37
|
+
"capabilities": ["browser"] if "browser" in markers else []}
|
|
38
|
+
for line in output.splitlines() if re.fullmatch(r"[^\s]+\.py::[^\s]+", line.strip())]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _objects(value: object) -> list[Mapping[str, object]]:
|
|
42
|
+
if not isinstance(value, list) or not all(isinstance(item, Mapping) for item in value):
|
|
43
|
+
raise ValueError("check_collection_invalid_playwright_structure")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _browser_projects(config: Mapping[str, object]) -> set[str]:
|
|
48
|
+
projects: set[str] = set()
|
|
49
|
+
for project in _objects(config.get("projects", [])):
|
|
50
|
+
metadata = project.get("metadata", {})
|
|
51
|
+
capabilities = metadata.get("capabilities", []) if isinstance(metadata, Mapping) else None
|
|
52
|
+
if not isinstance(capabilities, list) or not all(isinstance(value, str) for value in capabilities):
|
|
53
|
+
raise ValueError("check_collection_invalid_project_capabilities")
|
|
54
|
+
if "browser" in capabilities:
|
|
55
|
+
projects.add(str(project.get("name", "")).lower())
|
|
56
|
+
return projects | {"chromium", "firefox", "webkit", "browser"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _playwright_spec(spec: Mapping[str, object], suite: Mapping[str, object], report_root: Path,
|
|
60
|
+
root: str, projects: set[str]) -> list[dict[str, object]]:
|
|
61
|
+
file = spec.get("file", suite.get("file", ""))
|
|
62
|
+
tags = spec.get("tags", [])
|
|
63
|
+
if not isinstance(file, str) or not isinstance(tags, list) or not all(isinstance(value, str) for value in tags):
|
|
64
|
+
raise ValueError("check_collection_invalid_playwright_spec")
|
|
65
|
+
if type(spec.get("line")) is not int:
|
|
66
|
+
raise ValueError("check_collection_invalid_playwright_location")
|
|
67
|
+
file = (report_root / file).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
68
|
+
tests: list[dict[str, object]] = []
|
|
69
|
+
for case in _objects(spec.get("tests", [])):
|
|
70
|
+
project = case.get("projectName", "")
|
|
71
|
+
if not isinstance(project, str):
|
|
72
|
+
raise ValueError("check_collection_invalid_playwright_project")
|
|
73
|
+
tests.append({"id": f"{file}:{spec['line']}@{project}", "markers": [tag.lstrip("@") for tag in tags],
|
|
74
|
+
"capabilities": ["browser"] if project.lower() in projects else []})
|
|
75
|
+
return tests
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _playwright_collection(data: Mapping[str, object], root: str) -> list[dict[str, object]]:
|
|
79
|
+
tests: list[dict[str, object]] = []
|
|
80
|
+
config = data.get("config", {})
|
|
81
|
+
if not isinstance(config, Mapping) or not isinstance(config.get("rootDir", root), str):
|
|
82
|
+
raise ValueError("check_collection_invalid_playwright_config")
|
|
83
|
+
report_root = Path(str(config.get("rootDir", root)))
|
|
84
|
+
projects = _browser_projects(config)
|
|
85
|
+
pending = list(_objects(data.get("suites", [])))
|
|
86
|
+
while pending:
|
|
87
|
+
suite = pending.pop()
|
|
88
|
+
if not isinstance(suite, Mapping):
|
|
89
|
+
raise ValueError("check_collection_invalid_playwright_suite")
|
|
90
|
+
pending.extend(_objects(suite.get("suites", [])))
|
|
91
|
+
for spec in _objects(suite.get("specs", [])):
|
|
92
|
+
tests.extend(_playwright_spec(spec, suite, report_root, root, projects))
|
|
93
|
+
return tests
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_collection(output: str, config: Mapping[str, object], root: str) -> list[Mapping[str, object]]:
|
|
97
|
+
fmt = config.get("format", "json")
|
|
98
|
+
if fmt == "pytest":
|
|
99
|
+
return _pytest_collection(output, config["argv"])
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(output)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise ValueError("check_collection_invalid_json") from exc
|
|
104
|
+
if not isinstance(data, Mapping):
|
|
105
|
+
raise ValueError("check_collection_invalid_tests")
|
|
106
|
+
tests = _playwright_collection(data, root) if fmt == "playwright" else data.get("tests")
|
|
107
|
+
if fmt not in ("json", "pytest", "playwright") or not isinstance(tests, list):
|
|
108
|
+
raise ValueError("check_collection_invalid_format")
|
|
109
|
+
for test in tests:
|
|
110
|
+
if not isinstance(test, Mapping) or not isinstance(test.get("id"), str):
|
|
111
|
+
raise ValueError("check_collection_invalid_tests")
|
|
112
|
+
for field in ("capabilities", "markers"):
|
|
113
|
+
values = test.get(field, [])
|
|
114
|
+
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
|
|
115
|
+
raise ValueError("check_collection_invalid_tests")
|
|
116
|
+
return tests
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _finding(code: str, scenario: str, check: str, message: str) -> dict[str, str]:
|
|
120
|
+
return {"code": code, "scenario": scenario, "check_id": check, "message": message}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _execution_argv(root: str, check: Mapping[str, object]) -> list[str]:
|
|
124
|
+
command = check.get("command")
|
|
125
|
+
if not isinstance(command, list) or not all(isinstance(value, str) for value in command):
|
|
126
|
+
return []
|
|
127
|
+
expanded = list(command)
|
|
128
|
+
for _ in range(4):
|
|
129
|
+
if not expanded or Path(expanded[0]).name not in ("npm", "pnpm", "yarn"):
|
|
130
|
+
return expanded
|
|
131
|
+
offset = 2 if len(expanded) > 1 and expanded[1] == "run" else 1
|
|
132
|
+
if len(expanded) <= offset:
|
|
133
|
+
return []
|
|
134
|
+
package = Path(root) / str(check.get("cwd", ".")) / "package.json"
|
|
135
|
+
data = json.loads(package.read_text(encoding="utf-8"))
|
|
136
|
+
script = data.get("scripts", {}).get(expanded[offset])
|
|
137
|
+
if not isinstance(script, str):
|
|
138
|
+
return []
|
|
139
|
+
lexer = shlex.shlex(script, posix=True, punctuation_chars=True)
|
|
140
|
+
lexer.whitespace_split = True
|
|
141
|
+
tokens = list(lexer)
|
|
142
|
+
if any(value in ("&&", "||", ";", "|", ">", ">>", "&") for value in tokens):
|
|
143
|
+
raise ValueError("check_execution_script_requires_direct_command")
|
|
144
|
+
expanded = tokens + [value for value in expanded[offset + 1:] if value != "--"]
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _execution_matches(root: str, check: Mapping[str, object], selected: Sequence[Mapping[str, object]]) -> bool:
|
|
149
|
+
from codeflow.acceptance.cf_check_inputs import match_path
|
|
150
|
+
argv = _execution_argv(root, check)
|
|
151
|
+
if not argv or not selected or any(value in argv for value in ("-k", "-g", "--grep", "--grep-invert")):
|
|
152
|
+
return False
|
|
153
|
+
pytest = any(Path(value).name in ("pytest", "py.test") for value in argv)
|
|
154
|
+
markers = _pytest_markers(argv) if pytest else []
|
|
155
|
+
if markers and any(not set(markers) <= set(item.get("markers", [])) for item in selected):
|
|
156
|
+
return False
|
|
157
|
+
selectors: list[str] = []
|
|
158
|
+
for value in argv:
|
|
159
|
+
normalized = value.replace("\\", "/").removeprefix("./")
|
|
160
|
+
if normalized.startswith(str(Path(root).resolve()) + "/"):
|
|
161
|
+
normalized = normalized[len(str(Path(root).resolve())) + 1:]
|
|
162
|
+
if re.fullmatch(r"[\w./*?:-]+\.(?:py|[cm]?[jt]sx?)(?::[\w:\[\].-]+)?", normalized):
|
|
163
|
+
selectors.append(normalized)
|
|
164
|
+
if selectors:
|
|
165
|
+
return all(any(match_path(str(item["id"]).split("@", 1)[0], selector)
|
|
166
|
+
or match_path(str(item["id"]).split("@", 1)[0].split(":", 1)[0], selector)
|
|
167
|
+
for selector in selectors) for item in selected)
|
|
168
|
+
if pytest:
|
|
169
|
+
return bool(markers)
|
|
170
|
+
return any(Path(value).name in ("playwright", "cypress") for value in argv)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _collected(root: str, check: Mapping[str, object], deadline: float | None = None) -> dict[str, Mapping[str, object]]:
|
|
174
|
+
config = check.get("collect")
|
|
175
|
+
if not isinstance(config, Mapping):
|
|
176
|
+
raise ValueError("check_collection_missing")
|
|
177
|
+
argv = config.get("argv")
|
|
178
|
+
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) for item in argv):
|
|
179
|
+
raise ValueError("check_collection_invalid")
|
|
180
|
+
timeout = config.get("timeout", 10)
|
|
181
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (float, int)) or not 0 < timeout <= 60:
|
|
182
|
+
raise ValueError("check_collection_timeout_invalid")
|
|
183
|
+
directory = (Path(root) / str(check.get("cwd", "."))).resolve()
|
|
184
|
+
if directory != Path(root).resolve() and Path(root).resolve() not in directory.parents:
|
|
185
|
+
raise ValueError("check_collection_outside_project")
|
|
186
|
+
result = run_command(argv, str(directory), float(timeout), deadline)
|
|
187
|
+
if result.get("status") != "ok" or result.get("returncode") != 0:
|
|
188
|
+
raise CollectionError("check_collection_failed", {"command": argv, "cwd": str(directory),
|
|
189
|
+
"exit_code": result.get("returncode"), "status": result.get("status"),
|
|
190
|
+
"output": (str(result.get("stdout", "")) + str(result.get("stderr", "")))[-4000:]})
|
|
191
|
+
output = str(result.get("stdout", ""))
|
|
192
|
+
if len(output.encode()) > 2_000_000:
|
|
193
|
+
raise ValueError("check_collection_too_large")
|
|
194
|
+
tests = _parse_collection(output, config, root)
|
|
195
|
+
if len({item["id"] for item in tests}) != len(tests):
|
|
196
|
+
raise ValueError("check_collection_duplicate_test")
|
|
197
|
+
return {str(item["id"]): item for item in tests}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _requirements(row: Mapping[str, object]) -> tuple[bool, set[str]]:
|
|
201
|
+
browser = str(row.get("level", row.get("kind", ""))).lower() == "e2e"
|
|
202
|
+
markers: set[str] = set()
|
|
203
|
+
if row.get("smoke"):
|
|
204
|
+
markers.add("smoke")
|
|
205
|
+
if row.get("level") == "capacity" or "capacity" in row.get("tags", []):
|
|
206
|
+
markers.add("capacity")
|
|
207
|
+
return browser, markers
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def registration_required(row: Mapping[str, object]) -> bool:
|
|
211
|
+
"""Return whether a scenario needs collection-backed registration."""
|
|
212
|
+
browser, markers = _requirements(row)
|
|
213
|
+
return bool(browser or markers)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _collection_config(root: str, check: Mapping[str, object], markers: set[str]) -> dict[str, object]:
|
|
217
|
+
argv = _execution_argv(root, check)
|
|
218
|
+
if any(Path(value).name in ("pytest", "py.test") for value in argv):
|
|
219
|
+
if "-k" in argv:
|
|
220
|
+
raise ValueError("check_execution_selection_mismatch: use concrete pytest node IDs")
|
|
221
|
+
selected_markers = set(_pytest_markers(argv)) | markers
|
|
222
|
+
argv = [value for value in argv if not re.fullmatch(r"-q+|--quiet", value)]
|
|
223
|
+
argv += ["--collect-only", "--verbosity=-1"]
|
|
224
|
+
if selected_markers:
|
|
225
|
+
argv += ["-m", " and ".join(sorted(selected_markers))]
|
|
226
|
+
return {"argv": argv, "format": "pytest", "timeout": 10}
|
|
227
|
+
if any(Path(value).name == "playwright" for value in argv):
|
|
228
|
+
return {"argv": argv + ["--list", "--reporter=json"], "format": "playwright", "timeout": 10}
|
|
229
|
+
raise ValueError("check_collection_inference_unsupported: supply --registration-file")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def register_check(root: str, check: dict[str, object], rows: Sequence[Mapping[str, object]],
|
|
233
|
+
deadline: float) -> list[dict[str, str]]:
|
|
234
|
+
"""Collect, populate and validate one check against all of its scenarios."""
|
|
235
|
+
validate_checks([check])
|
|
236
|
+
effective_rows = [{**row, "level": check.get("level", row.get("level"))} for row in rows]
|
|
237
|
+
requirements = [_requirements(row) for row in effective_rows]
|
|
238
|
+
browser = any(item[0] for item in requirements)
|
|
239
|
+
markers = set().union(*(item[1] for item in requirements))
|
|
240
|
+
if "collect" not in check:
|
|
241
|
+
check["collect"] = _collection_config(root, check, markers | ({"browser"} if browser else set()))
|
|
242
|
+
collected = _collected(root, check, deadline)
|
|
243
|
+
if not collected:
|
|
244
|
+
raise ValueError("check_collection_empty")
|
|
245
|
+
check.setdefault("test_ids", sorted(collected))
|
|
246
|
+
selected = [collected[value] for value in check["test_ids"] if value in collected]
|
|
247
|
+
actual_markers = set.intersection(*(set(item.get("markers", [])) for item in selected)) if selected else set()
|
|
248
|
+
check.setdefault("markers", sorted(actual_markers))
|
|
249
|
+
if browser and selected and all("browser" in item.get("capabilities", []) for item in selected):
|
|
250
|
+
check.setdefault("suite", "browser")
|
|
251
|
+
validate_checks([check])
|
|
252
|
+
return [issue for row in effective_rows for issue in _registration_issues(root, row, check, collected)]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _registration_issues(root: str, row: Mapping[str, object], check: Mapping[str, object],
|
|
256
|
+
collected: Mapping[str, Mapping[str, object]]) -> list[dict[str, str]]:
|
|
257
|
+
scenario, check_id = str(row.get("id", "")), str(check.get("id", ""))
|
|
258
|
+
browser, markers = _requirements(row)
|
|
259
|
+
errors: list[dict[str, str]] = []
|
|
260
|
+
test_ids = check.get("test_ids", [])
|
|
261
|
+
if not isinstance(test_ids, list) or not test_ids or not all(isinstance(value, str) for value in test_ids):
|
|
262
|
+
return [_finding("check_test_ids_missing", scenario, check_id, "check 必须登记具体测试 ID")]
|
|
263
|
+
selected = [collected[value] for value in test_ids if value in collected]
|
|
264
|
+
if len(selected) != len(test_ids):
|
|
265
|
+
errors.append(_finding("check_test_not_collected", scenario, check_id, "登记的测试 ID 未全部被真实收集"))
|
|
266
|
+
try:
|
|
267
|
+
matches = _execution_matches(root, check, selected)
|
|
268
|
+
except (OSError, ValueError) as exc:
|
|
269
|
+
errors.append(_finding("check_execution_config_invalid", scenario, check_id, str(exc)))
|
|
270
|
+
matches = False
|
|
271
|
+
if not matches:
|
|
272
|
+
errors.append(_finding("check_execution_selection_mismatch", scenario, check_id,
|
|
273
|
+
"实际执行命令必须选择登记用例;不可验证的 grep/-k 过滤请改用具体测试 ID"))
|
|
274
|
+
if browser and (check.get("suite") != "browser" or any("browser" not in item.get("capabilities", []) for item in selected)):
|
|
275
|
+
errors.append(_finding("e2e_check_not_browser", scenario, check_id, "E2E check 必须属于 browser 套件并收集到浏览器能力用例"))
|
|
276
|
+
if check.get("level") and check.get("level") != "e2e" and any(
|
|
277
|
+
"browser" in item.get("capabilities", []) for item in selected
|
|
278
|
+
):
|
|
279
|
+
errors.append(_finding("check_level_browser_mismatch", scenario, check_id, "浏览器用例必须声明 e2e 层级"))
|
|
280
|
+
for marker in sorted(markers):
|
|
281
|
+
if marker not in check.get("markers", []) or any(marker not in item.get("markers", []) for item in selected):
|
|
282
|
+
code = "capacity_check_not_marked" if marker == "capacity" else "smoke_check_not_registered"
|
|
283
|
+
errors.append(_finding(code, scenario, check_id, f"check 与真实收集用例必须包含 {marker} 标记"))
|
|
284
|
+
return errors
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def validate_registration(root: str, scenarios: Sequence[Mapping[str, object]],
|
|
288
|
+
checks: Sequence[Mapping[str, object]]) -> dict[str, object]:
|
|
289
|
+
by_id = {str(check["id"]): check for check in checks}
|
|
290
|
+
cache: dict[str, dict[str, Mapping[str, object]] | Exception] = {}
|
|
291
|
+
errors: list[dict[str, object]] = []
|
|
292
|
+
deadline = time.monotonic() + 60
|
|
293
|
+
runnable_smoke = runnable_browser = 0
|
|
294
|
+
for row in scenarios:
|
|
295
|
+
browser, markers = _requirements(row)
|
|
296
|
+
ids = scenario_check_ids(row)
|
|
297
|
+
has_browser_check = any(by_id.get(check_id, {}).get("level") == "e2e" for check_id in ids)
|
|
298
|
+
if not browser and not markers and not has_browser_check:
|
|
299
|
+
continue
|
|
300
|
+
row_errors: list[dict[str, object]] = []
|
|
301
|
+
if not ids:
|
|
302
|
+
row_errors.append(_finding("scenario_check_missing", str(row["id"]), "", "需要可执行 check 绑定"))
|
|
303
|
+
for check_id in ids:
|
|
304
|
+
check = by_id.get(check_id)
|
|
305
|
+
if check is None:
|
|
306
|
+
row_errors.append(_finding("unknown_check", str(row["id"]), check_id, "引用的 check 不存在"))
|
|
307
|
+
continue
|
|
308
|
+
effective = {**row, "level": check.get("level", row.get("level"))}
|
|
309
|
+
if not registration_required(effective):
|
|
310
|
+
continue
|
|
311
|
+
if check_id not in cache:
|
|
312
|
+
try:
|
|
313
|
+
cache[check_id] = _collected(root, check, deadline)
|
|
314
|
+
except (OSError, ValueError) as exc:
|
|
315
|
+
cache[check_id] = exc
|
|
316
|
+
collected = cache[check_id]
|
|
317
|
+
if isinstance(collected, Exception):
|
|
318
|
+
detail = collected.details if isinstance(collected, CollectionError) else {}
|
|
319
|
+
row_errors.append({**_finding(str(collected), str(row["id"]), check_id, "无法验证测试收集与套件登记"), **detail})
|
|
320
|
+
else:
|
|
321
|
+
row_errors.extend(_registration_issues(root, effective, check, collected))
|
|
322
|
+
errors.extend(row_errors)
|
|
323
|
+
runnable_smoke += int(bool(row.get("smoke")) and not row_errors)
|
|
324
|
+
runnable_browser += int(browser and not row_errors)
|
|
325
|
+
return {"ok": not errors, "errors": errors, "runnable_smoke_count": runnable_smoke,
|
|
326
|
+
"runnable_browser_count": runnable_browser, "collected_checks": len(cache)}
|
|
@@ -15,12 +15,13 @@ Contract (spec-quality-loop.design.md §2.3.2 / §3.4 / §3.5):
|
|
|
15
15
|
import fnmatch
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
|
-
import sys
|
|
19
18
|
from typing import Mapping
|
|
20
19
|
from concurrent.futures import ThreadPoolExecutor
|
|
21
20
|
from concurrent.futures import TimeoutError as FutureTimeout
|
|
22
21
|
|
|
23
|
-
from
|
|
22
|
+
from codeflow.core import cf_log
|
|
23
|
+
from codeflow.core.cf_core import normalize_path
|
|
24
|
+
from codeflow.core.cf_markdown import frontmatter_block
|
|
24
25
|
|
|
25
26
|
CHECK_TIMEOUT_SECONDS = 2.0
|
|
26
27
|
MAX_CONTENT_BYTES = 256 * 1024
|
|
@@ -39,10 +40,6 @@ FP_DISABLE_COUNT = 3
|
|
|
39
40
|
FP_DISABLE_RATE = 0.10
|
|
40
41
|
|
|
41
42
|
|
|
42
|
-
def _log(msg: str) -> None:
|
|
43
|
-
sys.stderr.write(f"[cf-checks] {msg}\n")
|
|
44
|
-
|
|
45
|
-
|
|
46
43
|
def state_path(project_root: str) -> str:
|
|
47
44
|
return os.path.join(project_root, ".code-flow", STATE_NAME)
|
|
48
45
|
|
|
@@ -67,7 +64,7 @@ def save_check_state(project_root: str, state: dict) -> bool:
|
|
|
67
64
|
json.dump(state, f, ensure_ascii=False)
|
|
68
65
|
return True
|
|
69
66
|
except Exception as exc:
|
|
70
|
-
|
|
67
|
+
cf_log.warn(f"save_check_state failed: {exc}")
|
|
71
68
|
return False
|
|
72
69
|
|
|
73
70
|
|
|
@@ -165,12 +162,12 @@ def parse_spec_checks(content: str) -> tuple:
|
|
|
165
162
|
"""Parse frontmatter checks → (checks, errors). Both lists, never raises."""
|
|
166
163
|
if not isinstance(content, str):
|
|
167
164
|
return [], []
|
|
168
|
-
match =
|
|
169
|
-
if
|
|
165
|
+
match = frontmatter_block(content)
|
|
166
|
+
if match is None or "checks:" not in match:
|
|
170
167
|
return [], []
|
|
171
168
|
try:
|
|
172
169
|
import yaml
|
|
173
|
-
meta = yaml.safe_load(match
|
|
170
|
+
meta = yaml.safe_load(match) or {}
|
|
174
171
|
except Exception as exc:
|
|
175
172
|
return [], [f"frontmatter YAML 解析失败: {exc}"]
|
|
176
173
|
raw = meta.get("checks")
|
|
@@ -204,7 +201,7 @@ def load_spec_checks(spec_abs_path: str) -> tuple:
|
|
|
204
201
|
with open(spec_abs_path, "r", encoding="utf-8") as f:
|
|
205
202
|
checks, errors = parse_spec_checks(f.read())
|
|
206
203
|
except Exception as exc:
|
|
207
|
-
|
|
204
|
+
cf_log.warn(f"load_spec_checks failed {spec_abs_path}: {exc}")
|
|
208
205
|
return [], []
|
|
209
206
|
_checks_cache[spec_abs_path] = {
|
|
210
207
|
"mtime": mtime, "checks": checks, "errors": errors,
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""One executable plan gate for manifest identity, semantics and design closure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from shlex import quote
|
|
9
|
+
from typing import Mapping, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FileSnapshot:
|
|
14
|
+
"""One file read once, with a stat fingerprint for drift detection."""
|
|
15
|
+
|
|
16
|
+
path: str
|
|
17
|
+
text: str
|
|
18
|
+
size: int
|
|
19
|
+
mtime_ns: int
|
|
20
|
+
|
|
21
|
+
def drift(self) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
stat = Path(self.path).stat()
|
|
24
|
+
except OSError:
|
|
25
|
+
return True
|
|
26
|
+
return stat.st_size != self.size or stat.st_mtime_ns != self.mtime_ns
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PlanSnapshot:
|
|
31
|
+
"""Verified inputs of one verify_plan operation; no persistent cache."""
|
|
32
|
+
|
|
33
|
+
task: FileSnapshot
|
|
34
|
+
manifest: FileSnapshot
|
|
35
|
+
scenario_file: str
|
|
36
|
+
scenario: Optional[FileSnapshot]
|
|
37
|
+
|
|
38
|
+
def drift(self) -> bool:
|
|
39
|
+
if self.task.drift() or self.manifest.drift():
|
|
40
|
+
return True
|
|
41
|
+
return self.scenario is not None and self.scenario.drift()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_snapshot(path: Path) -> FileSnapshot:
|
|
45
|
+
stat = path.stat()
|
|
46
|
+
return FileSnapshot(str(path), path.read_text(encoding="utf-8"), stat.st_size, stat.st_mtime_ns)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_plan_snapshot(directory: str, task_file: str,
|
|
50
|
+
require_scenarios: bool = False) -> PlanSnapshot:
|
|
51
|
+
"""Read TASK, manifest and scenario source at most once for one operation."""
|
|
52
|
+
from codeflow.acceptance.cf_scenario_source import ScenarioError
|
|
53
|
+
base = Path(directory)
|
|
54
|
+
manifest = _read_snapshot(base / ".acceptance-manifest.json")
|
|
55
|
+
task = _read_snapshot(Path(task_file))
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(manifest.text)
|
|
58
|
+
except ValueError:
|
|
59
|
+
data = {}
|
|
60
|
+
try:
|
|
61
|
+
source_file = _source(base, data if isinstance(data, Mapping) else {}, require_scenarios)
|
|
62
|
+
except ScenarioError:
|
|
63
|
+
source_file = ""
|
|
64
|
+
source = _read_snapshot(Path(source_file)) if source_file and Path(source_file).is_file() else None
|
|
65
|
+
return PlanSnapshot(task, manifest, source_file, source)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _root(directory: Path) -> Path:
|
|
69
|
+
return next((parent.parent for parent in (directory, *directory.parents)
|
|
70
|
+
if parent.name == ".code-flow"), directory)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _source(directory: Path, manifest: Mapping[str, object], required: bool) -> str:
|
|
74
|
+
from codeflow.acceptance.cf_scenario_source import require_source
|
|
75
|
+
named = manifest.get("scenarios_file")
|
|
76
|
+
if isinstance(named, str) and named:
|
|
77
|
+
return str(directory / named)
|
|
78
|
+
candidates = sorted(directory.glob("*.scenarios.md"))
|
|
79
|
+
if required or candidates:
|
|
80
|
+
return str(require_source(directory))
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _projection(source: Mapping[str, object], manifest: Mapping[str, object]) -> list[dict[str, object]]:
|
|
85
|
+
from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
|
|
86
|
+
rows = {str(row["id"]): row for row in manifest.get("scenarios", [])}
|
|
87
|
+
checks = {str(check["id"]) for check in manifest.get("checks", [])}
|
|
88
|
+
findings: list[dict[str, object]] = []
|
|
89
|
+
for row in source["scenarios"]:
|
|
90
|
+
required = scenario_check_ids(row)
|
|
91
|
+
current = rows.get(str(row["id"]), {})
|
|
92
|
+
unknown = set(required) - checks
|
|
93
|
+
if unknown:
|
|
94
|
+
findings.append({"code": "unknown_check", "scenario": row["id"], "checks": sorted(unknown)})
|
|
95
|
+
if required and set(required) != set(scenario_check_ids(current)):
|
|
96
|
+
findings.append({"code": "scenario_checks_not_projected", "scenario": row["id"]})
|
|
97
|
+
return findings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _scenario_snapshot(snapshot: PlanSnapshot, source_file: str) -> Optional[FileSnapshot]:
|
|
101
|
+
scenario = snapshot.scenario
|
|
102
|
+
if not source_file:
|
|
103
|
+
return None
|
|
104
|
+
if scenario is not None and Path(scenario.path).resolve() == Path(source_file).resolve():
|
|
105
|
+
return scenario
|
|
106
|
+
return _read_snapshot(Path(source_file)) if Path(source_file).is_file() else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _scenario_ids(text: str) -> set[str]:
|
|
110
|
+
"""Expand explicit IDs and `S-01~04` / `S-01~S-04` ranges."""
|
|
111
|
+
ids = {item.upper() for item in re.findall(r"\b(?:S|E|B)-\d+\b", text, re.IGNORECASE)}
|
|
112
|
+
for match in re.finditer(r"\b([SEB])-(\d+)\s*~\s*(?:\1-)?(\d+)\b", text, re.IGNORECASE):
|
|
113
|
+
prefix, start, end = match.group(1).upper(), int(match.group(2)), int(match.group(3))
|
|
114
|
+
width = max(len(match.group(2)), len(match.group(3)))
|
|
115
|
+
for value in range(start, end + 1):
|
|
116
|
+
ids.add(f"{prefix}-{value:0{width}d}")
|
|
117
|
+
return ids
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _checklist_issues(text: str) -> list[dict[str, str]]:
|
|
121
|
+
"""Flag scenario IDs used in a Checklist but not declared in Acceptance-Refs.
|
|
122
|
+
|
|
123
|
+
Cross-task boundaries start as this mismatch: a task writes another task's
|
|
124
|
+
scenario into its checklist, then the plan assigns the scenario to only one
|
|
125
|
+
owner. Reported as a non-blocking warning for existing plans.
|
|
126
|
+
"""
|
|
127
|
+
from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
|
|
128
|
+
try:
|
|
129
|
+
document = parse_task_document(text)
|
|
130
|
+
except TaskDocumentError:
|
|
131
|
+
return []
|
|
132
|
+
issues: list[dict[str, str]] = []
|
|
133
|
+
for task in document.tasks:
|
|
134
|
+
declared = _scenario_ids(str(task.fields.get("Acceptance-Refs", "")))
|
|
135
|
+
checklist = task.body.partition("### Checklist")[2].partition("### Acceptance Contract")[0]
|
|
136
|
+
used = _scenario_ids(checklist)
|
|
137
|
+
for scenario in sorted(used - declared):
|
|
138
|
+
issues.append({"code": "checklist_scenario_not_in_refs", "task": task.id, "scenario": scenario})
|
|
139
|
+
for scenario in sorted(declared - used):
|
|
140
|
+
issues.append({"code": "acceptance_ref_not_in_checklist", "task": task.id, "scenario": scenario})
|
|
141
|
+
return issues
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def verify_plan(directory: str, task_file: str, require_scenarios: bool = False,
|
|
145
|
+
*, snapshot: Optional[PlanSnapshot] = None) -> dict[str, object]:
|
|
146
|
+
from codeflow.acceptance.cf_acceptance_manifest_core import validate_manifest
|
|
147
|
+
from codeflow.acceptance.cf_acceptance_schema import load_manifest
|
|
148
|
+
from codeflow.acceptance.cf_check_registration import validate_registration
|
|
149
|
+
from codeflow.cli.cf_design_lint import lint_requirement
|
|
150
|
+
from codeflow.acceptance.cf_scenario_source import parse_scenarios, validate_e2e_quality
|
|
151
|
+
from codeflow.spec.cf_spec_context_model import load_context
|
|
152
|
+
from codeflow.spec.cf_stage_gate import result_to_data, validate_plan_coverage, validate_stage
|
|
153
|
+
base = Path(directory)
|
|
154
|
+
path = base / ".acceptance-manifest.json"
|
|
155
|
+
if not path.is_file():
|
|
156
|
+
return {"decision": "block", "reason": "acceptance_manifest_missing"}
|
|
157
|
+
plan = snapshot if snapshot is not None else load_plan_snapshot(directory, task_file, require_scenarios)
|
|
158
|
+
if plan.drift():
|
|
159
|
+
return {"decision": "block", "reason": "plan_snapshot_drift"}
|
|
160
|
+
try:
|
|
161
|
+
manifest = load_manifest(path, text=plan.manifest.text)
|
|
162
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
163
|
+
return {"decision": "block", "reason": f"acceptance_manifest_invalid: {exc}"}
|
|
164
|
+
source_file = _source(base, manifest, require_scenarios)
|
|
165
|
+
scenario = _scenario_snapshot(plan, source_file)
|
|
166
|
+
valid, reason = validate_manifest(task_file, str(path), task_text=plan.task.text,
|
|
167
|
+
manifest=manifest, scenario_file=source_file,
|
|
168
|
+
scenario_text=scenario.text if scenario is not None else None)
|
|
169
|
+
if not valid:
|
|
170
|
+
return {"decision": "block", "reason": reason}
|
|
171
|
+
if scenario is not None:
|
|
172
|
+
source = parse_scenarios(scenario.path, text=scenario.text)
|
|
173
|
+
elif source_file:
|
|
174
|
+
source = parse_scenarios(source_file)
|
|
175
|
+
else:
|
|
176
|
+
source = {"scenarios": manifest["scenarios"]}
|
|
177
|
+
quality = validate_e2e_quality(source["scenarios"])
|
|
178
|
+
projection = _projection(source, manifest)
|
|
179
|
+
registration = validate_registration(str(_root(base)), manifest["scenarios"], manifest.get("checks", []))
|
|
180
|
+
quality.update({"runnable_smoke_count": registration["runnable_smoke_count"],
|
|
181
|
+
"runnable_browser_count": registration["runnable_browser_count"],
|
|
182
|
+
"registration_status": "passed" if registration["ok"] else "failed"})
|
|
183
|
+
design = lint_requirement(task_file, source_file, task_text=plan.task.text,
|
|
184
|
+
scenarios=source["scenarios"] if source_file else None)
|
|
185
|
+
context = load_context(str(base / "spec-context.yml"))
|
|
186
|
+
gate = validate_stage(context, "plan", root=str(_root(base)), task_dir=directory)
|
|
187
|
+
coverage = validate_plan_coverage(context, task_file, text=plan.task.text)
|
|
188
|
+
checklist = _checklist_issues(plan.task.text)
|
|
189
|
+
blocked = (not quality["ok"] or projection or not registration["ok"] or
|
|
190
|
+
design["decision"] != "pass" or gate.decision != "pass" or coverage.decision != "pass")
|
|
191
|
+
recovery = (["code-flow spec refresh --task-dir " + quote(str(base)) +
|
|
192
|
+
" --root " + quote(str(_root(base))) + " --json"]
|
|
193
|
+
if any(issue.code == "rule_artifact_stale" for issue in gate.errors) else [])
|
|
194
|
+
return {"decision": "block" if blocked else "pass", "gate": result_to_data(gate), "next": recovery,
|
|
195
|
+
"coverage": result_to_data(coverage), "e2e_quality": quality,
|
|
196
|
+
"registration": registration, "design_lint": design, "projection": projection,
|
|
197
|
+
"checklist": checklist}
|