@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,301 @@
1
+ """Bind and refresh Spec Context against real Specs and task artifacts."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ import time
9
+ from typing import Optional, Sequence
10
+
11
+ from codeflow.core.cf_core import phase_timing
12
+ from codeflow.core.cf_log import warn
13
+ from codeflow.spec.cf_spec_context_model import (
14
+ BindingInput, ContextError, DriftChange, DriftResult, RuleBinding, RuleStageStatus,
15
+ SpecBinding, SpecContext, now_iso,
16
+ )
17
+ from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecRule, load_spec_metadata
18
+
19
+
20
+ FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
21
+ _PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
22
+ _PERSISTENT_HASH_PATHS: set[str] = set()
23
+ _MAX_HASH_CACHE_ENTRIES = 4096
24
+
25
+
26
+ def _binding_from_input(selection: BindingInput) -> SpecBinding:
27
+ if not selection.selected_by.strip() or not selection.reason.strip():
28
+ raise ContextError("invalid_binding", "selection", "selected_by/reason 必须非空")
29
+ candidate = selection.candidate
30
+ verifier_by_rule = {item.rule: item for item in candidate.metadata.verifiers}
31
+ rules: list[RuleBinding] = []
32
+ for rule in candidate.metadata.rules:
33
+ if rule.enforcement == "informational":
34
+ continue
35
+ enforcement = "advisory" if candidate.metadata.enforcement == "advisory" else rule.enforcement
36
+ statuses = {
37
+ stage: RuleStageStatus("pending", (), None, ()) for stage in candidate.metadata.stages
38
+ }
39
+ verifier = verifier_by_rule.get(rule.ref)
40
+ verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
41
+ rules.append(
42
+ RuleBinding(
43
+ rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
44
+ verifier.stage if verifier is not None else "code",
45
+ )
46
+ )
47
+ return SpecBinding(
48
+ candidate.spec_id,
49
+ candidate.path,
50
+ candidate.metadata.hashes,
51
+ selection.selected_by.strip(),
52
+ selection.reason.strip(),
53
+ candidate.metadata.enforcement,
54
+ candidate.metadata.stages,
55
+ tuple(rules),
56
+ )
57
+
58
+
59
+ def _with_declared_stages(rule: RuleBinding, stages: Sequence[str]) -> RuleBinding:
60
+ """兼容补齐:绑定旧版本时声明的 stages 缺状态,按声明补 pending。"""
61
+ missing = [stage for stage in stages if stage not in rule.stage_status]
62
+ if not missing:
63
+ return rule
64
+ statuses = dict(rule.stage_status)
65
+ for stage in missing:
66
+ statuses[stage] = RuleStageStatus("pending", (), None, ())
67
+ return replace(rule, stage_status=statuses)
68
+
69
+
70
+ def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
71
+ enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
72
+ statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
73
+ verifier = next((item for item in metadata.verifiers if item.rule == rule.ref), None)
74
+ verifier_ref = f"{spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
75
+ return RuleBinding(
76
+ rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
77
+ verifier.stage if verifier is not None else "code",
78
+ )
79
+
80
+
81
+ def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
82
+ bindings = {binding.spec_id: binding for binding in context.bindings}
83
+ for selection in selections:
84
+ incoming = _binding_from_input(selection)
85
+ existing = bindings.get(incoming.spec_id)
86
+ if existing is not None and existing.hashes != incoming.hashes:
87
+ raise ContextError(
88
+ "stale_binding",
89
+ "selection",
90
+ f"{incoming.spec_id} 已绑定版本发生变化,请先 refresh",
91
+ )
92
+ if existing is not None:
93
+ incoming = replace(
94
+ existing,
95
+ selected_by=incoming.selected_by,
96
+ reason=incoming.reason,
97
+ enforcement=incoming.enforcement,
98
+ stages=incoming.stages,
99
+ )
100
+ bindings[incoming.spec_id] = incoming
101
+ ordered = tuple(bindings[key] for key in sorted(bindings))
102
+ if ordered == context.bindings:
103
+ return context
104
+ return replace(context, updated_at=now_iso(), bindings=ordered)
105
+
106
+
107
+ def refresh_missing_specs(context: SpecContext, root: str) -> SpecContext:
108
+ spec_root = Path(root) / ".code-flow" / "specs"
109
+ bindings: list[SpecBinding] = []
110
+ for binding in context.bindings:
111
+ if (spec_root / binding.path).is_file():
112
+ bindings.append(binding)
113
+ continue
114
+ rules = []
115
+ for rule in binding.rules:
116
+ stages = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
117
+ rules.append(replace(rule, stage_status=stages))
118
+ bindings.append(replace(binding, status="missing", rules=tuple(rules)))
119
+ return replace(context, updated_at=now_iso(), bindings=tuple(bindings))
120
+
121
+
122
+ def _stale_rule(rule: RuleBinding) -> RuleBinding:
123
+ statuses = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
124
+ return replace(rule, stage_status=statuses)
125
+
126
+
127
+ def _removed_rule(rule: RuleBinding) -> RuleBinding:
128
+ statuses = {
129
+ stage: status
130
+ if status.status in ("not_applicable", "waived") and status.decision is not None
131
+ else replace(status, status="stale")
132
+ for stage, status in rule.stage_status.items()
133
+ }
134
+ return replace(rule, stage_status=statuses)
135
+
136
+
137
+ def _load_hash_cache(cache_path: Optional[Path]) -> None:
138
+ if cache_path is None or str(cache_path) in _PERSISTENT_HASH_PATHS:
139
+ return
140
+ try:
141
+ loaded = json.loads(cache_path.read_text(encoding="utf-8"))
142
+ if not isinstance(loaded, dict):
143
+ raise ValueError("expected an object")
144
+ _PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
145
+ except FileNotFoundError:
146
+ loaded = {}
147
+ except (OSError, ValueError) as exc:
148
+ warn(f"artifact hash cache unavailable; rebuilding: {exc}")
149
+ _PERSISTENT_HASH_PATHS.add(str(cache_path))
150
+
151
+
152
+ def _store_hash_cache(cache_path: Optional[Path], key: str, signature: tuple[int, int, int], digest: str) -> None:
153
+ if cache_path is None:
154
+ return
155
+ _PERSISTENT_HASH_CACHE[key] = dict(zip(("mtime_ns", "size", "ino", "sha256"), (*signature, digest)))
156
+ while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
157
+ _PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
158
+ try:
159
+ temporary = cache_path.with_suffix(".json.tmp")
160
+ temporary.write_text(json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True), encoding="utf-8")
161
+ temporary.replace(cache_path)
162
+ except OSError as exc:
163
+ warn(f"artifact hash cache could not be saved: {exc}")
164
+
165
+
166
+ def file_sha256(path: Path) -> str:
167
+ try:
168
+ stat = path.stat()
169
+ key = str(path)
170
+ signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
171
+ cached = FILE_HASH_CACHE.get(key)
172
+ if cached is not None and cached[:3] == signature:
173
+ return cached[3]
174
+ cache_path = next((parent / ".artifact-hash-cache.json" for parent in path.parents
175
+ if parent.name == ".code-flow"), None)
176
+ _load_hash_cache(cache_path)
177
+ persistent = _PERSISTENT_HASH_CACHE.get(key)
178
+ if isinstance(persistent, dict) and tuple(persistent.get(k) for k in ("mtime_ns", "size", "ino")) == signature:
179
+ digest = persistent.get("sha256")
180
+ if isinstance(digest, str) and digest:
181
+ FILE_HASH_CACHE[key] = (*signature, digest)
182
+ return digest
183
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
184
+ FILE_HASH_CACHE[key] = (*signature, digest)
185
+ _store_hash_cache(cache_path, key, signature, digest)
186
+ return digest
187
+ except OSError as exc:
188
+ raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
189
+
190
+
191
+ def _refresh_artifacts(
192
+ spec_id: str, rule: RuleBinding, artifact_root: Path
193
+ ) -> tuple[RuleBinding, tuple[DriftChange, ...]]:
194
+ statuses = dict(rule.stage_status)
195
+ changes: list[DriftChange] = []
196
+ for stage, status in rule.stage_status.items():
197
+ for reference in status.refs:
198
+ path = artifact_root / reference.artifact
199
+ current = file_sha256(path) if path.is_file() else "missing"
200
+ if current == reference.artifact_sha256:
201
+ continue
202
+ if status.status == "applied" and current != "missing":
203
+ statuses[stage] = replace(
204
+ status,
205
+ refs=tuple(
206
+ replace(item, artifact_sha256=current) if item is reference else item
207
+ for item in status.refs
208
+ ),
209
+ )
210
+ else:
211
+ statuses[stage] = replace(status, status="stale")
212
+ changes.append(
213
+ DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
214
+ )
215
+ break
216
+ return replace(rule, stage_status=statuses), tuple(changes)
217
+
218
+
219
+ def _refresh_existing_rule(
220
+ binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
221
+ ) -> tuple[RuleBinding, Optional[DriftChange]]:
222
+ enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
223
+ verifier = next((item for item in metadata.verifiers if item.rule == current.ref), None)
224
+ verifier_ref = f"{binding.spec_id}#{current.ref}" if verifier is not None else "advisory:none"
225
+ updated = replace(
226
+ existing,
227
+ summary=current.text,
228
+ text_sha256=current.text_sha256,
229
+ enforcement=enforcement,
230
+ verifier_ref=verifier_ref,
231
+ verifier_stage=verifier.stage if verifier is not None else "code",
232
+ )
233
+ if existing.text_sha256 == current.text_sha256:
234
+ return updated, None
235
+ change = DriftChange(
236
+ "rule_changed", binding.spec_id, current.ref, existing.text_sha256, current.text_sha256, tuple(existing.stage_status)
237
+ )
238
+ return _stale_rule(updated), change
239
+
240
+
241
+ def _refresh_binding(
242
+ binding: SpecBinding, spec_root: Path, artifact_root: Path
243
+ ) -> tuple[SpecBinding, tuple[DriftChange, ...]]:
244
+ path = spec_root / binding.path
245
+ if not path.is_file():
246
+ stale = replace(binding, status="missing", rules=tuple(_stale_rule(rule) for rule in binding.rules))
247
+ change = DriftChange("spec_missing", binding.spec_id, "", binding.hashes.file_sha256, "missing", binding.stages)
248
+ return stale, (change,)
249
+ metadata = load_spec_metadata(str(path))
250
+ existing = {rule.ref: rule for rule in binding.rules}
251
+ rules: list[RuleBinding] = []
252
+ changes: list[DriftChange] = []
253
+ if metadata.hashes.metadata_sha256 != binding.hashes.metadata_sha256:
254
+ changes.append(
255
+ DriftChange("metadata_changed", binding.spec_id, "", binding.hashes.metadata_sha256, metadata.hashes.metadata_sha256, ())
256
+ )
257
+ for current in metadata.rules:
258
+ if current.enforcement == "informational":
259
+ continue
260
+ prior = existing.pop(current.ref, None)
261
+ if prior is None:
262
+ rule = _rule_from_metadata(binding.spec_id, metadata, current)
263
+ changes.append(DriftChange("rule_added", binding.spec_id, current.ref, "", current.text_sha256, metadata.stages))
264
+ else:
265
+ rule, change = _refresh_existing_rule(binding, prior, current, metadata)
266
+ if change is not None:
267
+ changes.append(change)
268
+ rule = _with_declared_stages(rule, metadata.stages)
269
+ rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
270
+ rules.append(rule)
271
+ changes.extend(artifact_changes)
272
+ for removed in existing.values():
273
+ rules.append(_removed_rule(removed))
274
+ changes.append(DriftChange("rule_removed", binding.spec_id, removed.ref, removed.text_sha256, "missing", tuple(removed.stage_status)))
275
+ updated = replace(binding, hashes=metadata.hashes, enforcement=metadata.enforcement, stages=metadata.stages, rules=tuple(rules), status="active")
276
+ return updated, tuple(changes)
277
+
278
+
279
+ def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
280
+ started = time.monotonic()
281
+ specs = Path(root) / ".code-flow" / "specs"
282
+ artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
283
+ bindings: list[SpecBinding] = []
284
+ changes: list[DriftChange] = []
285
+ for binding in context.bindings:
286
+ phase_started = time.monotonic()
287
+ updated, binding_changes = _refresh_binding(binding, specs, artifacts)
288
+ phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
289
+ bindings.append(updated)
290
+ changes.extend(binding_changes)
291
+ refreshed = replace(context, updated_at=now_iso(), bindings=tuple(bindings))
292
+ phase_timing("context.refresh.total", started)
293
+ if refreshed.bindings == context.bindings:
294
+ return DriftResult(context, tuple(changes))
295
+ return DriftResult(refreshed, tuple(changes))
296
+
297
+
298
+ def diff_spec_hashes(context: SpecContext, root: str) -> DriftResult:
299
+ return refresh_context(context, root)
300
+
301
+
@@ -0,0 +1,149 @@
1
+ """Platform-neutral edit routing and compliance services."""
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Sequence
5
+
6
+ from codeflow.core import cf_log
7
+ from codeflow.acceptance.cf_checks import load_check_state, load_spec_checks, record_hits, run_checks, save_check_state
8
+ from codeflow.core.cf_core import (build_effective_mapping, effective_project_root, is_code_file, load_config,
9
+ match_domains, normalize_path, normalize_spec_entry, resolve_enforcement, resolve_quality_loop)
10
+ from codeflow.spec.cf_injection import injection_text
11
+ from codeflow.spec.cf_spec_router import RouterError, route_prompt
12
+
13
+ FEEDBACK_HINT = '如认为误报,直接告诉我"这是误报",我会标记忽略。'
14
+ _REPORTED_KEY = "_reported"
15
+
16
+
17
+ def _collect_checks(project_root: str, domains: list[str], mapping: dict[str, object]) -> list[dict[str, object]]:
18
+ """Aggregate checks of all constraint specs in matched domains.
19
+
20
+ Each check is annotated with its source spec path. Checks apply whenever
21
+ their own `files` glob matches — independent of injection tag gating.
22
+ """
23
+ specs_root = os.path.join(project_root, ".code-flow", "specs")
24
+ collected = []
25
+ seen_specs = set()
26
+ for domain in domains:
27
+ for entry in (mapping.get(domain) or {}).get("specs") or []:
28
+ cfg = normalize_spec_entry(entry)
29
+ rel = cfg.get("path")
30
+ if not rel or rel in seen_specs or not cfg.get("tags"):
31
+ continue
32
+ seen_specs.add(rel)
33
+ checks, _ = load_spec_checks(os.path.join(specs_root, rel))
34
+ for check in checks:
35
+ item = dict(check)
36
+ item["spec"] = rel
37
+ collected.append(item)
38
+ return collected
39
+
40
+
41
+ def _session_reported(state: dict[str, object], sid: str) -> set[str]:
42
+ session = state.get(_REPORTED_KEY) or {}
43
+ if session.get("sid") != sid:
44
+ return set()
45
+ return set(session.get("keys") or [])
46
+
47
+
48
+ def _save_reported(project_root: str, state: dict[str, object], sid: str, keys: set[str]) -> None:
49
+ state[_REPORTED_KEY] = {"sid": sid, "keys": sorted(keys)}
50
+ save_check_state(project_root, state)
51
+
52
+
53
+ def _feedback_text(violations: list[dict[str, object]]) -> str:
54
+ lines = ["## Spec 合规反馈 (auto-check)", ""]
55
+ for v in violations:
56
+ lines.append(f"⚠ {v['message']}(规则: {v['spec']}#{v['check_id']})")
57
+ lines.append(f" 违规行 {v['line_no']}: {v['line']}")
58
+ lines.append("")
59
+ lines.append("请修正以上违规后再继续。" + FEEDBACK_HINT)
60
+ return "\n".join(lines)
61
+
62
+
63
+
64
+ def _routing_result(root: str, relative: str, sid: str) -> tuple[str, tuple[str, ...], bool]:
65
+ return _route_edits(root, (relative,), sid)
66
+
67
+
68
+ def _route_edits(root: str, paths: Sequence[str], sid: str) -> tuple[str, tuple[str, ...], bool]:
69
+ try:
70
+ result = route_prompt(root, paths, sid, check_scope=True)
71
+ except RouterError as exc:
72
+ return f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing.", (), True
73
+ text = injection_text(root, sid, result)
74
+ emitted = tuple(result.specs) if text else ()
75
+ if result.expanded_specs:
76
+ warning = f"⚠ 该路径引入未绑定 required Spec:{', '.join(result.expanded_specs)} — 建议先 refresh Context / Plan 再继续编辑。"
77
+ text = "\n\n".join(part for part in (warning, text) if part)
78
+ return text, emitted, False
79
+
80
+
81
+ def prepare_edit(session_root: str, path: str, sid: str, tool: str) -> tuple[str, bool]:
82
+ return prepare_edits(session_root, (path,), sid, tool)
83
+
84
+
85
+ def prepare_edits(session_root: str, paths: Sequence[str], sid: str, tool: str) -> tuple[str, bool]:
86
+ groups: dict[str, list[str]] = {}
87
+ for path in paths:
88
+ absolute = path if os.path.isabs(path) else os.path.join(session_root, path)
89
+ root = effective_project_root(session_root, absolute)
90
+ relative = normalize_path(os.path.relpath(absolute, root))
91
+ if relative not in groups.setdefault(root, []):
92
+ groups[root].append(relative)
93
+ feedback = []
94
+ rejected = False
95
+ for root, relative_paths in groups.items():
96
+ text, specs, blocked = _route_edits(root, relative_paths, sid)
97
+ rejected = rejected or blocked
98
+ if text:
99
+ feedback.append(text)
100
+ for relative in relative_paths:
101
+ cf_log.append_event(root, "edit_intent", {"file": relative, "tool": tool,
102
+ "specs": list(specs), "blocked": blocked}, sid)
103
+ return "\n\n".join(dict.fromkeys(feedback)), rejected
104
+
105
+
106
+ def _fresh_violations(root: str, path: str, content: str, checks: list[dict[str, object]], sid: str) -> list[dict[str, object]]:
107
+ state = load_check_state(root)
108
+ violations, skipped = run_checks(checks, path, content, state)
109
+ for item in skipped:
110
+ cf_log.degrade(root, "post_check", f"{item['check_id']}:{item['reason']}", sid)
111
+ spec_by_check = {check["id"]: check["spec"] for check in checks}
112
+ reported = _session_reported(state, sid)
113
+ fresh = []
114
+ for violation in violations:
115
+ key = f"{violation['check_id']}|{violation['file']}"
116
+ if key not in reported:
117
+ violation["spec"] = spec_by_check.get(violation["check_id"], "")
118
+ fresh.append(violation)
119
+ reported.add(key)
120
+ if fresh:
121
+ record_hits(root, sorted({v["check_id"] for v in fresh}))
122
+ _save_reported(root, load_check_state(root), sid, reported)
123
+ return fresh
124
+
125
+
126
+ def after_edit(session_root: str, path: str, sid: str, tool: str, deleted: bool = False) -> str:
127
+ absolute = path if os.path.isabs(path) else os.path.join(session_root, path)
128
+ root = effective_project_root(session_root, absolute)
129
+ config = load_config(root)
130
+ if not config or resolve_enforcement(config) == "inject" or not resolve_quality_loop(config)["post_check"]:
131
+ return ""
132
+ relative = normalize_path(os.path.relpath(absolute, root))
133
+ cf_log.append_event(root, "edit", {"file": relative, "tool": tool}, sid)
134
+ if deleted or not is_code_file(relative, config.get("quality_loop") or {}):
135
+ return ""
136
+ mapping = build_effective_mapping(root, config.get("path_mapping") or {})
137
+ checks = _collect_checks(root, match_domains(relative, mapping), mapping)
138
+ if not checks:
139
+ return ""
140
+ try:
141
+ content = Path(absolute).read_text(encoding="utf-8")
142
+ except (OSError, UnicodeError) as exc:
143
+ cf_log.degrade(root, "post_check", f"read_failed:{relative}:{exc}", sid)
144
+ return ""
145
+ fresh = _fresh_violations(root, relative, content, checks, sid)
146
+ for violation in fresh:
147
+ cf_log.append_event(root, "violation", {"check_id": violation["check_id"], "spec": violation["spec"],
148
+ "file": violation["file"], "severity": violation["severity"]}, sid)
149
+ return _feedback_text(fresh) if fresh else ""
@@ -0,0 +1,111 @@
1
+ """Session-isolated injection deduplication, committed after successful output."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Callable, Iterator, TYPE_CHECKING
11
+
12
+ from codeflow.core import cf_log
13
+
14
+ if TYPE_CHECKING:
15
+ from codeflow.spec.cf_spec_router import RouteResult
16
+
17
+ _PENDING: ContextVar[list[Callable[[], None]] | None] = ContextVar("injection_output", default=None)
18
+ _INTERVAL = 25
19
+
20
+
21
+ def defer_commit(action: Callable[[], None]) -> None:
22
+ pending = _PENDING.get()
23
+ if pending is None:
24
+ action()
25
+ else:
26
+ pending.append(action)
27
+
28
+
29
+ @contextmanager
30
+ def injection_output() -> Iterator[None]:
31
+ """Discard pending dedup state if building or writing the output fails."""
32
+ if _PENDING.get() is not None:
33
+ yield
34
+ return
35
+ actions: list[Callable[[], None]] = []
36
+ token = _PENDING.set(actions)
37
+ try:
38
+ yield
39
+ except BaseException:
40
+ raise
41
+ else:
42
+ for action in actions:
43
+ action()
44
+ finally:
45
+ _PENDING.reset(token)
46
+
47
+
48
+ def _state_path(root: str, sid: str) -> Path:
49
+ name = hashlib.sha256(sid.encode("utf-8")).hexdigest()
50
+ return Path(root) / ".code-flow/sessions/injection" / f"{name}.json"
51
+
52
+
53
+ def _load_state(path: Path) -> dict[str, object]:
54
+ try:
55
+ state = json.loads(path.read_text(encoding="utf-8"))
56
+ return state if isinstance(state, dict) and state.get("version") == 1 else {}
57
+ except (OSError, UnicodeError, json.JSONDecodeError):
58
+ return {}
59
+
60
+
61
+ def _number(state: dict[str, object], key: str) -> int:
62
+ value = state.get(key, 0)
63
+ return value if type(value) is int and value >= 0 else 0
64
+
65
+
66
+ def _save_state(path: Path, state: dict[str, object]) -> None:
67
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
68
+ try:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ temporary.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
71
+ os.replace(temporary, path)
72
+ except (OSError, UnicodeError, TypeError) as exc:
73
+ cf_log.warn(f"cf_injection: dedup state unavailable: {exc}")
74
+ finally:
75
+ try:
76
+ temporary.unlink(missing_ok=True)
77
+ except OSError as exc:
78
+ cf_log.warn(f"cf_injection: cannot remove temporary dedup state: {exc}")
79
+
80
+
81
+ def _commit(root: str, sid: str, result: RouteResult, count: int, fingerprint: str, emitted: bool) -> None:
82
+ path = _state_path(root, sid)
83
+ state = _load_state(path)
84
+ state.update(version=1, prompt_count=count)
85
+ if emitted:
86
+ state[f"{result.mode}_fingerprint"] = fingerprint
87
+ state[f"{result.mode}_last_prompt"] = count
88
+ cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode,
89
+ "source": result.mode}, sid)
90
+ _save_state(path, state)
91
+
92
+
93
+ def injection_text(root: str, sid: str, result: RouteResult, *, prompt: bool = False) -> str:
94
+ if result.mode not in {"task", "path"} or not result.text:
95
+ return result.text
96
+ state = _load_state(_state_path(root, sid))
97
+ count = _number(state, "prompt_count") + int(prompt)
98
+ fingerprint = hashlib.sha256(
99
+ (result.mode + "\n" + result.text).encode("utf-8")
100
+ ).hexdigest()
101
+ emitted = (state.get(f"{result.mode}_fingerprint") != fingerprint
102
+ or count - _number(state, f"{result.mode}_last_prompt") >= _INTERVAL)
103
+ defer_commit(lambda: _commit(root, sid, result, count, fingerprint, emitted))
104
+ return result.text if emitted else ""
105
+
106
+
107
+ def pre_tool_payload(text: str, blocked: bool) -> dict[str, object]:
108
+ output: dict[str, object] = {"hookEventName": "PreToolUse", "additionalContext": text}
109
+ if blocked:
110
+ output.update(permissionDecision="deny", permissionDecisionReason=text)
111
+ return {"hookSpecificOutput": output}
@@ -0,0 +1,114 @@
1
+ """Resolve artifact references and report unfinished rule applications."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Mapping, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from codeflow.spec.cf_spec_context_model import SpecContext
11
+
12
+ _UNRESOLVED = re.compile(r"\b(?:TBD|TODO)\b|待同步|待确认|待定|需同步\s*spec\s*文本", re.I)
13
+
14
+
15
+ def _heading_matches(title: str, section: str, pattern: re.Pattern) -> bool:
16
+ if pattern.search(title):
17
+ return True
18
+ plain = re.sub(r"^\d+(?:\.\d+)*[.)、]?\s+", "", title.strip())
19
+ slug = re.sub(r"\s+", "-", re.sub(r"[^\w\s-]", "", plain).strip()).casefold()
20
+ return slug == section.casefold()
21
+
22
+
23
+ def referenced_item(text: str, section_id: str, item_id: str) -> str:
24
+ """Find the item inside its declared section, excluding fenced examples."""
25
+ clean = re.sub(r"```[^\n]*\n[\s\S]*?```|~~~[^\n]*\n[\s\S]*?~~~", "", text)
26
+ headings = list(re.finditer(r"(?m)^(#{1,6})[ \t]+([^\n]+)$", clean))
27
+ section = section_id.lstrip("#§ ").strip()
28
+ pattern = re.compile(rf"(?<![\w.-]){re.escape(section)}(?![\w.-])", re.I)
29
+ item_pattern = re.compile(rf"(?<![\w-]){re.escape(item_id)}(?![\w-])", re.I)
30
+ if not headings:
31
+ return next((part for part in clean.split("\n\n") if pattern.search(part) and item_pattern.search(part)), "")
32
+ start = next((entry for entry in headings if _heading_matches(entry.group(2), section, pattern)), None)
33
+ if start is None:
34
+ return ""
35
+ end = next((entry.start() for entry in headings
36
+ if entry.start() > start.start() and len(entry.group(1)) <= len(start.group(1))), len(clean))
37
+ lines = clean[start.end():end].splitlines()
38
+ if re.fullmatch(r"TASK-\d+", section, re.I) and item_id.casefold() == section.casefold():
39
+ return "\n".join(lines).strip()
40
+ selected: list[str] = []
41
+ for line in lines:
42
+ if item_pattern.search(line):
43
+ selected.append(line)
44
+ elif selected and (re.match(r"\s*(?:#{1,6}\s|\|?\s*\[?RULE-|[-*]\s+\[?RULE-)", line)):
45
+ break
46
+ elif selected:
47
+ selected.append(line)
48
+ return "\n".join(selected).strip()
49
+
50
+
51
+ def unresolved_text(text: str) -> bool:
52
+ # A prohibition mentioning TBD is a valid rule, not an unfinished result.
53
+ without_prohibitions = re.sub(
54
+ r"(?:禁止|不得|不能)[^,。;\n]*(?:TBD|TODO|待同步|待确认)[^,。;\n]*", "", text, flags=re.I)
55
+ return bool(_UNRESOLVED.search(without_prohibitions))
56
+
57
+
58
+ def artifact_problem(root: str, artifact: str, section: str, item: str, expected_hash: str = "",
59
+ *, task_dir: str = "") -> str:
60
+ project = Path(root).resolve()
61
+ base = Path(task_dir).resolve() if task_dir else project
62
+ target = (base / artifact).resolve()
63
+ if base not in target.parents or project not in target.parents:
64
+ return "rule_artifact_outside_project"
65
+ try:
66
+ source = target.read_bytes()
67
+ content = source.decode("utf-8")
68
+ except (OSError, UnicodeError):
69
+ return "rule_artifact_missing"
70
+ if expected_hash and hashlib.sha256(source).hexdigest() != expected_hash:
71
+ return "rule_artifact_stale"
72
+ selected = referenced_item(content, section, item)
73
+ if not selected:
74
+ return "rule_artifact_reference_missing"
75
+ return "rule_applied_unresolved" if unresolved_text(selected) else ""
76
+
77
+
78
+ def applied_findings(context: SpecContext, root: str, only_stage: str = "",
79
+ *, task_dir: str = "") -> list[dict[str, str]]:
80
+ findings: list[dict[str, str]] = []
81
+ for binding in context.bindings:
82
+ for rule in binding.rules:
83
+ if rule.enforcement != "required":
84
+ continue
85
+ for stage, status in rule.stage_status.items():
86
+ if only_stage and stage != only_stage:
87
+ continue
88
+ if status.status != "applied":
89
+ continue
90
+ for ref in status.refs:
91
+ code = artifact_problem(root, ref.artifact, ref.section_id, ref.item_id,
92
+ ref.artifact_sha256, task_dir=task_dir)
93
+ if code:
94
+ findings.append({"code": code, "severity": "block", "path": ref.artifact,
95
+ "message": f"{binding.spec_id}#{rule.ref} ({stage}): {code}",
96
+ "fix": "完成引用条目并运行 code-flow spec refresh,再执行对应阶段验证"})
97
+ return findings
98
+
99
+
100
+ def convention_findings(root: str, paths: list[str]) -> list[dict[str, str]]:
101
+ findings: list[dict[str, str]] = []
102
+ for relative in paths:
103
+ target = Path(root) / ".code-flow/specs" / relative
104
+ try:
105
+ text = target.read_text(encoding="utf-8")
106
+ except (OSError, UnicodeError) as exc:
107
+ findings.append({"code": "convention_review_unreadable", "severity": "block", "path": str(target),
108
+ "message": str(exc), "fix": "修复绑定 Spec 文件后重跑 doctor"})
109
+ continue
110
+ if re.search(r"(?mi)^##\s+(?:Conventions|约定)\s*$", text):
111
+ findings.append({"code": "convention_review_required", "severity": "warn", "path": str(target),
112
+ "message": "Conventions 尚未成为可验证约束;关键条目应提升为有稳定 ID 的 Rules",
113
+ "fix": "审查约定与本次设计的一致性;关键约束补充 Rule 和 verifier"})
114
+ return findings