@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.
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 -40
  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 +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +67 -35
  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 +73 -34
  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 -40
  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 +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  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 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  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 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  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} +37 -3
  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} +252 -26
  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 -299
  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,32 @@
1
+ """Integrity check for the installed runtime contract and managed artifacts."""
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def verify_install(root: str) -> None:
8
+ directory = Path(root)
9
+ if (directory / ".code-flow/.runtime-migration.lock").exists():
10
+ raise ValueError("runtime migration is in progress or needs recovery")
11
+ manifest = json.loads((directory / ".code-flow/.runtime-install.json").read_text(encoding="utf-8"))
12
+ version = (directory / ".code-flow/.version").read_text(encoding="utf-8").strip()
13
+ if manifest.get("schema_version") != 1 or manifest.get("version") != version:
14
+ raise ValueError("runtime installation version is inconsistent")
15
+ files = manifest.get("files")
16
+ if not isinstance(files, dict) or not files:
17
+ raise ValueError("runtime installation manifest has no artifacts")
18
+ for relative, expected in files.items():
19
+ path = directory / relative
20
+ if not path.resolve().is_relative_to(directory.resolve()):
21
+ raise ValueError(f"installation artifact escapes project: {relative}")
22
+ if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
23
+ raise ValueError(f"managed runtime artifact has drifted: {relative}")
24
+ for relative, expected in manifest.get("hook_contracts", {}).items():
25
+ actual = json.loads((directory / relative).read_text(encoding="utf-8")).get("hooks", {})
26
+ for event, groups in expected.items():
27
+ for group in groups:
28
+ candidates = [item for item in actual.get(event, [])
29
+ if item.get("matcher", "") == group.get("matcher", "")]
30
+ for hook in group["hooks"]:
31
+ if not any(hook in item.get("hooks", []) for item in candidates):
32
+ raise ValueError(f"managed hook has drifted: {relative}:{event}")
@@ -0,0 +1,53 @@
1
+ """Nonblocking process lock for Stop gates; the OS releases abandoned locks."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import contextmanager
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import IO, Iterator
9
+
10
+
11
+ class StopAlreadyRunning(ValueError):
12
+ pass
13
+
14
+
15
+ def _lock(handle: IO[str]) -> None:
16
+ if os.name == "nt":
17
+ import msvcrt
18
+ handle.seek(0)
19
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
20
+ else:
21
+ import fcntl
22
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
23
+
24
+
25
+ def _unlock(handle: IO[str]) -> None:
26
+ if os.name == "nt":
27
+ import msvcrt
28
+ handle.seek(0)
29
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
30
+ else:
31
+ import fcntl
32
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
33
+
34
+
35
+ @contextmanager
36
+ def stop_guard(root: str, sid: str) -> Iterator[None]:
37
+ path = Path(root) / ".code-flow/.stop.lock"
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ with path.open("a+", encoding="utf-8") as handle:
40
+ try:
41
+ _lock(handle)
42
+ except OSError as exc:
43
+ handle.seek(0)
44
+ owner = handle.read(512).strip() or "owner unavailable"
45
+ raise StopAlreadyRunning(f"stop_already_running: {owner}; 等待该进程结束后重试 Stop") from exc
46
+ try:
47
+ handle.seek(0)
48
+ handle.truncate()
49
+ json.dump({"pid": os.getpid(), "session_id": sid}, handle)
50
+ handle.flush()
51
+ yield
52
+ finally:
53
+ _unlock(handle)
@@ -0,0 +1,9 @@
1
+ """Shared lifecycle predicate without loading the workflow runtime.
2
+
3
+ 0.8 adds an explicit provisional state: a TASK whose required code checks were
4
+ not actually executed must never look finished. Keep the finished set narrow so
5
+ dependency resolution, parallel collect and archive cannot treat provisional
6
+ work as complete.
7
+ """
8
+
9
+ FINISHED_STATUSES = ("done", "verified")
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env python3
2
+ """Validator engine: parse validation.yml, match triggers, execute commands.
3
+
4
+ L1 domain module. It owns the single interpretation of `.code-flow/validation.yml`
5
+ (four states: missing / empty / valid / parse_error), trigger matching,
6
+ deadline-bound execution, and the content-fingerprint result cache. Consumers:
7
+ `cf_validation` (validate), `cf_task_runtime` (finish), `cf_stop_hook` (Stop),
8
+ and `cf_task_workflow` (layer counts). Dependencies stay in L0/L1: `cf_core`,
9
+ `cf_exec_base`, and `cf_log`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, replace
15
+ from datetime import datetime, timezone
16
+ import fnmatch
17
+ import hashlib
18
+ import json
19
+ import math
20
+ import os
21
+ from pathlib import Path
22
+ import re
23
+ import time
24
+ from typing import Mapping, NamedTuple, Optional, Sequence
25
+
26
+ from codeflow.core import cf_log
27
+ from codeflow.core.cf_core import normalize_path
28
+ from codeflow.core.cf_exec_base import (build_argv, record_report, redact_sensitive_text, report_requested,
29
+ remaining_seconds, run_command, worktree_fingerprint)
30
+
31
+ MISSING = "missing"
32
+ EMPTY = "empty"
33
+ VALID = "valid"
34
+ PARSE_ERROR = "parse_error"
35
+
36
+ VALIDATION_CACHE_FILE = ".validation-cache.json"
37
+ MAX_VALIDATION_CACHE_ENTRIES = 256
38
+ _BRACE_RE = re.compile(r"\{([^{}]+)\}")
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ValidatorConfig:
43
+ """Single interpretation of .code-flow/validation.yml."""
44
+
45
+ state: str
46
+ validators: tuple[Mapping[str, object], ...] = ()
47
+ error: str = ""
48
+ path: str = ""
49
+
50
+ def without_heavy(self) -> "ValidatorConfig":
51
+ """Return a config with only lightweight validators (Stop policy)."""
52
+ return replace(self, validators=tuple(item for item in self.validators if not is_heavy(item)))
53
+
54
+
55
+ class ValidatorRun(NamedTuple):
56
+ failures: list[dict[str, object]]
57
+ truncated: bool
58
+ reused: int
59
+
60
+
61
+ def expand_braces(pattern: str) -> list:
62
+ """Expand one level of `{a,b}` alternation → list of fnmatch patterns."""
63
+ match = _BRACE_RE.search(pattern)
64
+ if not match:
65
+ return [pattern]
66
+ head, tail = pattern[: match.start()], pattern[match.end():]
67
+ out = []
68
+ for option in match.group(1).split(","):
69
+ out.extend(expand_braces(head + option + tail))
70
+ return out
71
+
72
+
73
+ def trigger_matches(trigger: str, rel_path: str) -> bool:
74
+ """fnmatch with brace expansion; `**/` prefix also matches root files."""
75
+ rel_path = normalize_path(rel_path)
76
+ for pattern in expand_braces(trigger or ""):
77
+ candidates = [pattern]
78
+ if pattern.startswith("**/"):
79
+ candidates.append(pattern[3:])
80
+ if any(fnmatch.fnmatch(rel_path, candidate) for candidate in candidates):
81
+ return True
82
+ return False
83
+
84
+
85
+ def _parse_error(path: str, message: str) -> ValidatorConfig:
86
+ cf_log.warn(f"{path}: {message}")
87
+ return ValidatorConfig(PARSE_ERROR, error=message, path=path)
88
+
89
+
90
+ def _classify(path: str, data: object) -> ValidatorConfig:
91
+ if data is None or data == {}:
92
+ return ValidatorConfig(EMPTY, path=path)
93
+ if not isinstance(data, dict):
94
+ return _parse_error(path, "validation.yml must be a YAML mapping")
95
+ validators = data.get("validators")
96
+ if validators is None or validators == []:
97
+ return ValidatorConfig(EMPTY, path=path)
98
+ if not isinstance(validators, list):
99
+ return _parse_error(path, "validators must be a list")
100
+ for index, item in enumerate(validators, 1):
101
+ if not isinstance(item, Mapping):
102
+ return _parse_error(path, f"validator #{index} must be a mapping")
103
+ return ValidatorConfig(VALID, tuple(validators), path=path)
104
+
105
+
106
+ def load_validators(project_root: str) -> ValidatorConfig:
107
+ """Read validation.yml into one of four explicit states (API-01)."""
108
+ path = os.path.join(project_root, ".code-flow", "validation.yml")
109
+ if not os.path.exists(path):
110
+ return ValidatorConfig(MISSING, path=path)
111
+ try:
112
+ import yaml
113
+ except ImportError as exc:
114
+ return _parse_error(path, f"PyYAML unavailable: {exc}")
115
+ try:
116
+ with open(path, "r", encoding="utf-8") as file:
117
+ data = yaml.safe_load(file)
118
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
119
+ return _parse_error(path, str(exc))
120
+ return _classify(path, data)
121
+
122
+
123
+ def package_script_validators(project_root: str) -> tuple[Mapping[str, object], ...]:
124
+ """Existing product policy for a missing/empty validation.yml."""
125
+ package = Path(project_root) / "package.json"
126
+ if not package.is_file():
127
+ return ()
128
+ data = json.loads(package.read_text(encoding="utf-8"))
129
+ scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
130
+ if not isinstance(scripts, dict):
131
+ raise ValueError("invalid package scripts")
132
+ return tuple(
133
+ {"name": name, "trigger": "*", "command": f"npm run {name}", "timeout": 60000}
134
+ for name in ("test", "lint") if isinstance(scripts.get(name), str)
135
+ )
136
+
137
+
138
+ def resolve_validators(
139
+ project_root: str,
140
+ ) -> tuple[ValidatorConfig, tuple[Mapping[str, object], ...]]:
141
+ """Config state plus the effective validators for this project.
142
+
143
+ missing/empty keep the product policy (package.json test/lint fallback);
144
+ parse_error returns no validators so callers must fail closed on state.
145
+ """
146
+ config = load_validators(project_root)
147
+ if config.state == VALID:
148
+ return config, config.validators
149
+ if config.state == PARSE_ERROR:
150
+ return config, ()
151
+ return config, package_script_validators(project_root)
152
+
153
+
154
+ def is_heavy(validator: object) -> bool:
155
+ return isinstance(validator, Mapping) and validator.get("heavy") is True
156
+
157
+
158
+ def _validator_failure(validator: Mapping[str, object], detail: str,
159
+ incomplete: bool = False) -> dict[str, object]:
160
+ result: dict[str, object] = {
161
+ "name": validator.get("name", "validator"),
162
+ "on_fail": validator.get("on_fail", ""),
163
+ "detail": detail,
164
+ }
165
+ if incomplete:
166
+ result["incomplete"] = True
167
+ return result
168
+
169
+
170
+ def _validator_argv(root: str, validator: Mapping[str, object], matched: list,
171
+ strict: bool) -> list:
172
+ existing = [name for name in matched if os.path.isfile(os.path.join(root, name))]
173
+ template = str(validator.get("command", ""))
174
+ if "{files}" in template and not existing:
175
+ return []
176
+ argv = build_argv(template, existing)
177
+ if not argv and strict:
178
+ raise ValueError("validator command missing")
179
+ return argv
180
+
181
+
182
+ def _tool_missing(detail: str) -> bool:
183
+ """Recognize 'tool not installed' output so template validators (mypy, npx …)
184
+ do not block projects that have not installed the optional tool yet."""
185
+ lowered = detail.lower()
186
+ markers = (
187
+ "no module named",
188
+ "modulenotfounderror",
189
+ "command not found",
190
+ "is not recognized as an internal or external command",
191
+ "could not determine executable to run",
192
+ "not found in $path",
193
+ )
194
+ return any(marker in lowered for marker in markers)
195
+
196
+
197
+ def _validator_result(root: str, validator: Mapping[str, object], argv: list, sid: str,
198
+ deadline: Optional[float], strict: bool,
199
+ timings: Optional[dict[str, float]] = None) -> tuple[list, bool, bool]:
200
+ try:
201
+ timeout = float(validator.get("timeout", 30000)) / 1000.0
202
+ except (ValueError, TypeError):
203
+ if strict:
204
+ return [_validator_failure(validator, "invalid validator timeout")], False, False
205
+ timeout = 30.0
206
+ if not math.isfinite(timeout) or timeout <= 0:
207
+ return [_validator_failure(validator, "invalid validator timeout")], False, False
208
+ outcome = run_command(argv, root, timeout, deadline)
209
+ if timings is not None:
210
+ timings["execution_ms"] += float(outcome.get("execution_ms", 0))
211
+ reused = bool(outcome.get("execution_reused"))
212
+ passed = outcome["status"] == "ok" and outcome["returncode"] == 0
213
+ if outcome["status"] == "deadline_exceeded":
214
+ return [_validator_failure(validator, "预算耗尽未执行", True)], True, reused
215
+ timed_out = outcome["status"] == "timeout"
216
+ detail = (f"超时(>{timeout:.0f}s),未完成" if timed_out else
217
+ (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-400:])
218
+ if outcome["status"] == "spawn_error":
219
+ if validator.get("skip_if_missing") is True:
220
+ cf_log.degrade(root, "stop_check", f"{validator.get('name')}:tool_missing", sid)
221
+ return [], False, reused
222
+ cf_log.degrade(root, "stop_check", f"{validator.get('name')}:{outcome['stderr']}", sid)
223
+ if not strict:
224
+ return [], False, reused
225
+ elif not passed and not timed_out and validator.get("skip_if_missing") is True and _tool_missing(detail):
226
+ cf_log.degrade(root, "stop_check", f"{validator.get('name')}:tool_missing", sid)
227
+ return [], False, reused
228
+ cf_log.append_event(root, "stop_check",
229
+ {"trigger": validator.get("trigger", ""),
230
+ "cmd": Path(argv[0]).name, "passed": passed}, sid)
231
+ return ([] if passed else [_validator_failure(validator, redact_sensitive_text(detail))]), timed_out, reused
232
+
233
+
234
+ def _tree_fingerprint(project_root: str, validators: Sequence[object]) -> str:
235
+ """工作树内容指纹(内容寻址,忽略 .code-flow),与提交状态无关。
236
+
237
+ 已提交内容取 blob SHA,未提交/未跟踪内容用工作区文件哈希补齐;未跟踪文件
238
+ 只纳入命中任一 validator trigger 的路径(构建/测试产物不使缓存失效)。
239
+ 同一内容在提交前后得到相同指纹;返回 "" 表示无法判定(禁用缓存)。
240
+ """
241
+ triggers = [str(item.get("trigger", "")) for item in validators if isinstance(item, Mapping)]
242
+ if triggers:
243
+ untracked_filter = lambda path: any(trigger_matches(trigger, path) for trigger in triggers)
244
+ else:
245
+ untracked_filter = None
246
+ return worktree_fingerprint(project_root, untracked_filter)
247
+
248
+
249
+ def _validation_cache_path(project_root: str) -> Path:
250
+ return Path(project_root) / ".code-flow" / VALIDATION_CACHE_FILE
251
+
252
+
253
+ def _load_validation_cache(project_root: str) -> dict:
254
+ try:
255
+ data = json.loads(_validation_cache_path(project_root).read_text(encoding="utf-8"))
256
+ except (OSError, ValueError):
257
+ return {}
258
+ return data if isinstance(data, dict) else {}
259
+
260
+
261
+ def _save_validation_cache(project_root: str, cache: dict) -> None:
262
+ if len(cache) > MAX_VALIDATION_CACHE_ENTRIES:
263
+ ordered = sorted(
264
+ cache.items(),
265
+ key=lambda item: str(item[1].get("at", "")) if isinstance(item[1], dict) else "",
266
+ )
267
+ cache = dict(ordered[-MAX_VALIDATION_CACHE_ENTRIES:])
268
+ try:
269
+ path = _validation_cache_path(project_root)
270
+ path.parent.mkdir(parents=True, exist_ok=True)
271
+ path.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
272
+ except OSError:
273
+ return # 缓存写入失败不影响验证结论
274
+
275
+
276
+ def _cache_key(argv: Sequence[str]) -> str:
277
+ return hashlib.sha256(json.dumps({"argv": list(argv)}, ensure_ascii=False).encode()).hexdigest()
278
+
279
+
280
+ _CACHE_ENV_KEYS = frozenset({
281
+ "DATABASE_URL", "REDIS_URL", "INTERNAL_SERVICE_TOKEN", "ENV", "LOG_DIR", "LOG_LEVEL",
282
+ "ARTIFACT_ROOT", "SKILL_CACHE_ROOT", "MIGRATIONS_DIR", "DEFAULT_TENANT_ID", "HOME",
283
+ "TMPDIR", "PYTHONPATH", "PYTHONHASHSEED", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "CI",
284
+ })
285
+
286
+ _CACHE_ENV_PREFIXES = (
287
+ "MUAD_", "DATABASE_", "REDIS_", "POSTGRES", "INTERNAL_", "ARTIFACT_", "SKILL_",
288
+ "MODEL_", "CAPACITY_", "WECOM_", "CHANNEL_", "GATEWAY_", "RUNTIME_", "WORKER_",
289
+ )
290
+
291
+
292
+ def _execution_environment() -> dict[str, str]:
293
+ """Stable, test-relevant environment fingerprint.
294
+
295
+ Shell/terminal bookkeeping (SHLVL, PATH, UV_*, PWD, ...) differs between a
296
+ direct invocation and the `bash -lc` wrapper used by spec verifiers without
297
+ changing test inputs, so only known test-relevant keys/prefixes participate.
298
+ """
299
+ return {
300
+ key: value for key, value in os.environ.items()
301
+ if key in _CACHE_ENV_KEYS or key.startswith(_CACHE_ENV_PREFIXES)
302
+ }
303
+
304
+
305
+ def _execution_observation(root: str, validator: Mapping[str, object], argv: Sequence[str],
306
+ fingerprint: str, entry: object) -> dict[str, str]:
307
+ """Describe this execution without exposing commands or environment values."""
308
+ payload = {"argv": list(argv), "cwd": str(Path(root).resolve()), "validator": dict(validator),
309
+ "environment": _execution_environment(), "input_fingerprint": fingerprint}
310
+ identity = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False,
311
+ default=str).encode()).hexdigest()
312
+ prior = entry.get("fingerprint") if isinstance(entry, dict) else None
313
+ previous_identity = entry.get("execution_identity") if isinstance(entry, dict) else None
314
+ category = ("unproven" if not fingerprint else "no_baseline" if prior is None else
315
+ "worktree_changed" if prior != fingerprint else
316
+ "execution_context_changed" if previous_identity != identity else "unchanged")
317
+ return {"identity": identity, "input_change_category": category}
318
+
319
+
320
+ def _run_one(project_root: str, validator: object, files: Sequence[str], sid: str,
321
+ deadline: Optional[float], strict: bool, fingerprint: str,
322
+ cache_data: dict, dirty: list[bool], timings: Optional[dict[str, float]] = None,
323
+ observations: Optional[list[dict[str, str]]] = None) -> ValidatorRun:
324
+ if not isinstance(validator, Mapping):
325
+ failures = [_validator_failure({}, "invalid validator record")] if strict else []
326
+ return ValidatorRun(failures, False, 0)
327
+ matched = [path for path in files if trigger_matches(validator.get("trigger", ""), path)]
328
+ if not matched:
329
+ return ValidatorRun([], False, 0)
330
+ remaining = remaining_seconds(deadline)
331
+ if remaining is not None and remaining <= 0:
332
+ return ValidatorRun([_validator_failure(validator, "预算耗尽未执行", True)], True, 0)
333
+ try:
334
+ argv = _validator_argv(project_root, validator, matched, strict)
335
+ except ValueError as exc:
336
+ return ValidatorRun([_validator_failure(validator, f"validator 配置错误: {exc}")], False, 0)
337
+ if not argv:
338
+ return ValidatorRun([], False, 0)
339
+ key = _cache_key(argv)
340
+ entry = cache_data.get(key)
341
+ observation = _execution_observation(project_root, validator, argv, fingerprint, entry)
342
+ if observations is not None:
343
+ observations.append(observation)
344
+ if fingerprint:
345
+ if (isinstance(entry, dict) and entry.get("fingerprint") == fingerprint
346
+ and entry.get("execution_identity") == observation["identity"]):
347
+ return ValidatorRun([], False, 1)
348
+ errors, incomplete, reused = _validator_result(project_root, validator, argv, sid, deadline, strict, timings)
349
+ if fingerprint and not errors and not incomplete:
350
+ cache_data[key] = {
351
+ "fingerprint": fingerprint,
352
+ "at": datetime.now(timezone.utc).isoformat(),
353
+ }
354
+ cache_data[key]["execution_identity"] = observation["identity"]
355
+ dirty[0] = True
356
+ return ValidatorRun(errors, incomplete, int(reused))
357
+
358
+
359
+ def run_validators(
360
+ project_root: str,
361
+ config: ValidatorConfig,
362
+ files: Sequence[str],
363
+ sid: str,
364
+ deadline: Optional[float] = None,
365
+ *,
366
+ strict: bool = False,
367
+ cache: bool = True,
368
+ timings: Optional[dict[str, float]] = None,
369
+ observations: Optional[list[dict[str, str]]] = None,
370
+ ) -> ValidatorRun:
371
+ """Match validators, execute argv under one deadline, report unrun work.
372
+
373
+ `cache` reuses passing results when the worktree content fingerprint and
374
+ argv are unchanged (failures are never cached). Returns (failures,
375
+ truncated, reused).
376
+ """
377
+ validators = list(config.validators)
378
+ if report_requested():
379
+ if timings is None:
380
+ timings = {"planning_ms": 0.0, "preflight_ms": 0.0, "execution_ms": 0.0, "evidence_ms": 0.0}
381
+ if observations is None:
382
+ observations = []
383
+ started = time.monotonic()
384
+ fingerprint = _tree_fingerprint(project_root, validators) if cache and validators else ""
385
+ cache_data = _load_validation_cache(project_root) if fingerprint else {}
386
+ if timings is not None:
387
+ timings["preflight_ms"] += (time.monotonic() - started) * 1000
388
+ dirty = [False]
389
+ failures: list[dict[str, object]] = []
390
+ truncated = False
391
+ reused = 0
392
+ for validator in validators:
393
+ outcome = _run_one(project_root, validator, files, sid, deadline, strict,
394
+ fingerprint, cache_data, dirty, timings, observations)
395
+ failures.extend(outcome.failures)
396
+ truncated = truncated or outcome.truncated
397
+ reused += outcome.reused
398
+ if dirty[0]:
399
+ started = time.monotonic()
400
+ _save_validation_cache(project_root, cache_data)
401
+ if timings is not None:
402
+ timings["evidence_ms"] += (time.monotonic() - started) * 1000
403
+ if report_requested():
404
+ record_report({"kind": "validators", "scope": {"files": list(files)}, "timings": dict(timings or {}),
405
+ "execution_identities": [item["identity"] for item in observations or []],
406
+ "input_change_category": [item["input_change_category"] for item in observations or []],
407
+ "reused": reused, "selected": len(observations or [])})
408
+ return ValidatorRun(failures, truncated, reused)