@michaelxwb/code-flow 0.6.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -42
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /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 cf_core import _FRONTMATTER_RE, normalize_path
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
- _log(f"save_check_state failed: {exc}")
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 = _FRONTMATTER_RE.match(content)
169
- if not match or "checks:" not in match.group(1):
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.group(1)) or {}
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
- _log(f"load_spec_checks failed {spec_abs_path}: {exc}")
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}