@michaelxwb/code-flow 0.6.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,264 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Fail-closed Stage Gate for required Spec Context rules."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- from dataclasses import dataclass
8
- from datetime import datetime, timezone
9
- import json
10
- from pathlib import Path
11
- import re
12
- import sys
13
- import time
14
- from typing import IO, Mapping, Optional, Sequence
15
-
16
- from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
17
- from cf_core import phase_timing
18
-
19
-
20
- @dataclass(frozen=True)
21
- class GateIssue:
22
- code: str
23
- spec_id: str
24
- rule_ref: str
25
- stage: str
26
- message: str
27
-
28
-
29
- @dataclass(frozen=True)
30
- class GateResult:
31
- decision: str
32
- errors: tuple[GateIssue, ...]
33
- warnings: tuple[GateIssue, ...]
34
- affected_refs: tuple[str, ...]
35
-
36
-
37
- def _parse_time(value: str) -> Optional[datetime]:
38
- try:
39
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
40
- except ValueError:
41
- return None
42
- return parsed if parsed.tzinfo is not None else None
43
-
44
-
45
- def _decision_valid(decision: Optional[Decision]) -> bool:
46
- if decision is None:
47
- return False
48
- return all((decision.reason, decision.confirmed_by, decision.confirmed_at, decision.source))
49
-
50
-
51
- def _waiver_issue(decision: Optional[Decision], now: datetime) -> Optional[str]:
52
- if not _decision_valid(decision) or decision is None or not decision.expires_at:
53
- return "waiver_invalid"
54
- expires = _parse_time(decision.expires_at)
55
- if expires is None:
56
- return "waiver_invalid"
57
- return "waiver_expired" if expires <= now else None
58
-
59
-
60
- def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Optional[str]) -> Optional[str]:
61
- if not status.evidence:
62
- return "evidence_missing"
63
- for evidence in status.evidence:
64
- if evidence.get("status") != "verified":
65
- continue
66
- if evidence.get("rule_text_sha256") != rule.text_sha256:
67
- continue
68
- evidence_diff = evidence.get("diff_sha256")
69
- if evidence_diff is not None and diff_sha256 is not None and evidence_diff != diff_sha256:
70
- continue
71
- result_hash = evidence.get("result_sha256")
72
- if isinstance(result_hash, str) and result_hash:
73
- return None
74
- return "stale_evidence"
75
-
76
-
77
- def _cheap_skip(status: RuleStageStatus) -> bool:
78
- """Latest evidence is a cheap-gate skip (command/test verifier deferred)."""
79
- if not status.evidence:
80
- return False
81
- latest = status.evidence[-1]
82
- return isinstance(latest, Mapping) and latest.get("error_code") == "skipped_in_cheap_gate"
83
-
84
-
85
- def _status_issue(
86
- rule: RuleBinding,
87
- status: RuleStageStatus,
88
- now: datetime,
89
- diff_sha256: Optional[str],
90
- allow_cheap_skips: bool = False,
91
- ) -> Optional[str]:
92
- if status.status == "applied":
93
- return None
94
- if status.status == "verified":
95
- return _evidence_issue(rule, status, diff_sha256)
96
- if allow_cheap_skips and status.status == "unverified" and _cheap_skip(status):
97
- # 轻量门禁(stop 阶段):命令类 verifier 未执行属预期,留给 finish 全量门禁。
98
- return None
99
- if status.status == "not_applicable":
100
- return None if _decision_valid(status.decision) else "decision_invalid"
101
- if status.status == "waived":
102
- return _waiver_issue(status.decision, now)
103
- return status.status
104
-
105
-
106
- def _binding_issues(
107
- binding: SpecBinding,
108
- stage: str,
109
- now: datetime,
110
- diff_sha256: Optional[str],
111
- allow_cheap_skips: bool = False,
112
- ) -> tuple[list[GateIssue], list[GateIssue]]:
113
- errors: list[GateIssue] = []
114
- warnings: list[GateIssue] = []
115
- for rule in binding.rules:
116
- status = rule.stage_status.get(stage)
117
- if status is None:
118
- continue
119
- if stage in ("code", "review") and getattr(rule, "verifier_stage", "code") != stage:
120
- continue
121
- if rule.enforcement == "advisory" and status.status == "pending":
122
- continue
123
- code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256, allow_cheap_skips)
124
- if code is None:
125
- continue
126
- issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
127
- target = errors if rule.enforcement == "required" else warnings
128
- target.append(issue)
129
- return errors, warnings
130
-
131
-
132
- def validate_stage(
133
- context: SpecContext,
134
- stage: str,
135
- artifact: str = "",
136
- task_id: str = "",
137
- now: Optional[datetime] = None,
138
- diff_sha256: Optional[str] = None,
139
- allow_cheap_skips: bool = False,
140
- ) -> GateResult:
141
- del artifact, task_id
142
- current = now or datetime.now(timezone.utc)
143
- errors: list[GateIssue] = []
144
- warnings: list[GateIssue] = []
145
- for binding in context.bindings:
146
- binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256, allow_cheap_skips)
147
- errors.extend(binding_errors)
148
- warnings.extend(binding_warnings)
149
- refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
150
- return GateResult("block" if errors else "pass", tuple(errors), tuple(warnings), refs)
151
-
152
-
153
- def _task_sections(text: str) -> Mapping[str, str]:
154
- matches = list(re.finditer(r"(?m)^## (TASK-\d+):[^\n]*$", text))
155
- sections: dict[str, str] = {}
156
- for index, match in enumerate(matches):
157
- end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
158
- sections[match.group(1)] = text[match.start():end]
159
- return sections
160
-
161
-
162
- def _plan_issue(code: str, binding: SpecBinding, rule: RuleBinding, message: str) -> GateIssue:
163
- return GateIssue(code, binding.spec_id, rule.ref, "plan", message)
164
-
165
-
166
- def _rule_plan_issues(
167
- binding: SpecBinding, rule: RuleBinding, sections: Mapping[str, str]
168
- ) -> tuple[GateIssue, ...]:
169
- full_ref = f"{binding.spec_id}#{rule.ref}"
170
- owners = tuple(task for task, section in sections.items() if full_ref in section)
171
- if not owners:
172
- return (_plan_issue("plan_owner_missing", binding, rule, f"{full_ref} 没有责任 TASK"),)
173
- if len(owners) > 1:
174
- return (_plan_issue("plan_owner_duplicate", binding, rule, f"{full_ref} 有多个责任 TASK"),)
175
- section = sections[owners[0]]
176
- issues: list[GateIssue] = []
177
- if rule.verifier_ref == "advisory:none":
178
- issues.append(_plan_issue("plan_verifier_missing", binding, rule, f"{full_ref} 缺 verifier"))
179
- checklist = section.partition("### Checklist")[2].partition("### Acceptance Contract")[0]
180
- if rule.ref not in checklist or "verifier" not in checklist.lower():
181
- issues.append(_plan_issue("plan_checklist_missing", binding, rule, f"{full_ref} 缺 verifier Checklist"))
182
- contract = section.partition("### Acceptance Contract")[2]
183
- if not contract.strip() or not re.search(r"\b(unit|integration|E2E|manual)\b", contract):
184
- issues.append(_plan_issue("plan_acceptance_missing", binding, rule, f"{full_ref} 缺测试层级/真实边界"))
185
- return tuple(issues)
186
-
187
-
188
- def validate_plan_coverage(context: SpecContext, artifact: str) -> GateResult:
189
- try:
190
- text = Path(artifact).read_text(encoding="utf-8")
191
- except OSError as exc:
192
- issue = GateIssue("plan_read_error", "", "", "plan", str(exc))
193
- return GateResult("block", (issue,), (), ())
194
- sections = _task_sections(text)
195
- errors: list[GateIssue] = []
196
- for binding in context.bindings:
197
- for rule in binding.rules:
198
- if rule.enforcement == "required" and "plan" in rule.stage_status:
199
- errors.extend(_rule_plan_issues(binding, rule, sections))
200
- refs = tuple(sorted(f"{item.spec_id}#{item.rule_ref}" for item in errors))
201
- return GateResult("block" if errors else "pass", tuple(errors), (), refs)
202
-
203
-
204
- def _issue_data(issue: GateIssue) -> dict[str, str]:
205
- return {
206
- "code": issue.code,
207
- "spec_id": issue.spec_id,
208
- "rule_ref": issue.rule_ref,
209
- "stage": issue.stage,
210
- "message": issue.message,
211
- }
212
-
213
-
214
- def result_to_data(result: GateResult) -> dict[str, object]:
215
- return {
216
- "decision": result.decision,
217
- "errors": [_issue_data(item) for item in result.errors],
218
- "warnings": [_issue_data(item) for item in result.warnings],
219
- "affected_refs": list(result.affected_refs),
220
- }
221
-
222
-
223
- def _merge_results(first: GateResult, second: GateResult) -> GateResult:
224
- errors = (*first.errors, *second.errors)
225
- warnings = (*first.warnings, *second.warnings)
226
- refs = tuple(sorted(set((*first.affected_refs, *second.affected_refs))))
227
- return GateResult("block" if errors else "pass", errors, warnings, refs)
228
-
229
-
230
- def _parser() -> argparse.ArgumentParser:
231
- parser = argparse.ArgumentParser(prog="cf_spec_gate.py")
232
- parser.add_argument("--task-dir", required=True)
233
- parser.add_argument("--stage", required=True)
234
- parser.add_argument("--task", default="")
235
- parser.add_argument("--artifact", default="")
236
- parser.add_argument("--json", action="store_true")
237
- return parser
238
-
239
-
240
- def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
241
- started = time.monotonic()
242
- try:
243
- args = _parser().parse_args(argv)
244
- phase_started = time.monotonic()
245
- context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
246
- phase_timing("gate.load_context", phase_started)
247
- phase_started = time.monotonic()
248
- result = validate_stage(context, args.stage, task_id=args.task)
249
- phase_timing("gate.validate_stage", phase_started)
250
- if args.stage == "plan" and args.artifact:
251
- phase_started = time.monotonic()
252
- result = _merge_results(result, validate_plan_coverage(context, args.artifact))
253
- phase_timing("gate.validate_plan", phase_started)
254
- stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
255
- phase_timing("gate.total", started)
256
- return 0 if result.decision == "pass" else 3
257
- except Exception as exc:
258
- sys.stderr.write(f"cf_spec_gate error: {exc}\n")
259
- stdout.write(json.dumps({"decision": "block", "errors": [{"code": "gate_error"}]}, ensure_ascii=False))
260
- return 2
261
-
262
-
263
- if __name__ == "__main__":
264
- raise SystemExit(main())
@@ -1,384 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Schema-v1 Spec metadata parser and deterministic content hashes."""
3
-
4
- from __future__ import annotations
5
-
6
- from dataclasses import dataclass
7
- import hashlib
8
- import json
9
- from pathlib import Path
10
- import re
11
- from typing import Mapping, Optional
12
-
13
-
14
-
15
- ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
16
- ALLOWED_ENFORCEMENT = frozenset(("required", "advisory"))
17
- ALLOWED_VERIFIERS = frozenset(("document", "regex", "ast", "command", "test", "manual"))
18
- ALLOWED_VERIFIER_STAGES = frozenset(("code", "review"))
19
- _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
20
- _RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
21
- _EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
22
- _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
23
- _FRONTMATTER_OPEN_RE = re.compile(br"---[ \t]*\r?\n")
24
- _FRONTMATTER_CLOSE_RE = re.compile(br"---[ \t]*(?:\r?\n)?")
25
- _SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
26
- _H2_RE = re.compile(r"^##\s+")
27
- _BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
28
-
29
-
30
- class SpecMetadataError(ValueError):
31
- """A schema error with a stable field and source location."""
32
-
33
- def __init__(self, path: str, field: str, line: int, message: str) -> None:
34
- self.path = path
35
- self.field = field
36
- self.line = max(1, line)
37
- self.message = message
38
- super().__init__(f"{path}:{self.line}: metadata_error {field}: {message}")
39
-
40
-
41
- @dataclass(frozen=True)
42
- class SpecHashes:
43
- file_sha256: str
44
- metadata_sha256: str
45
- rules_sha256: str
46
-
47
- def values(self) -> tuple[str, str, str]:
48
- return (self.file_sha256, self.metadata_sha256, self.rules_sha256)
49
-
50
-
51
- @dataclass(frozen=True)
52
- class SpecVerifier:
53
- rule: str
54
- type: str
55
- config: Mapping[str, object]
56
- stage: str = "code"
57
- files: tuple[str, ...] = ()
58
-
59
-
60
- @dataclass(frozen=True)
61
- class SpecRule:
62
- ref: str
63
- text: str
64
- section: str
65
- enforcement: str
66
- text_sha256: str
67
- line: int
68
-
69
-
70
- @dataclass(frozen=True)
71
- class SpecHeader:
72
- id: str
73
- stages: tuple[str, ...]
74
- enforcement: str
75
-
76
-
77
- @dataclass(frozen=True)
78
- class SpecMetadata:
79
- id: str
80
- description: str
81
- stages: tuple[str, ...]
82
- enforcement: str
83
- owner: Optional[str]
84
- checks: tuple[Mapping[str, object], ...]
85
- verifiers: tuple[SpecVerifier, ...]
86
- rules: tuple[SpecRule, ...]
87
- hashes: SpecHashes
88
- path: str
89
-
90
-
91
- def _sha256(data: bytes) -> str:
92
- return hashlib.sha256(data).hexdigest()
93
-
94
-
95
- def _normalized_text(text: str) -> str:
96
- return " ".join(text.split())
97
-
98
-
99
- def _canonical_json(value: object, path: str, field: str, line: int) -> bytes:
100
- try:
101
- text = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
102
- except (TypeError, ValueError) as exc:
103
- raise SpecMetadataError(path, field, line, f"无法规范化: {exc}") from exc
104
- return text.encode("utf-8")
105
-
106
-
107
- def _field_line(frontmatter: str, key: str) -> int:
108
- pattern = re.compile(rf"^\s*{re.escape(key)}\s*:")
109
- for number, line in enumerate(frontmatter.splitlines(), start=2):
110
- if pattern.match(line):
111
- return number
112
- return 1
113
-
114
-
115
- def _verifier_field_line(frontmatter: str, index: int, key: str) -> int:
116
- current = -1
117
- for number, line in enumerate(frontmatter.splitlines(), start=2):
118
- if re.match(r"^\s*-\s+rule\s*:", line):
119
- current += 1
120
- if current == index and re.match(rf"^\s+{re.escape(key)}\s*:", line):
121
- return number
122
- return _field_line(frontmatter, "verifiers")
123
-
124
-
125
- def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], str, str]:
126
- match = _FRONTMATTER_RE.match(content)
127
- if not match:
128
- raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
129
- raw = match.group(1)
130
- return _parse_frontmatter(raw, path), raw, content[match.end():]
131
-
132
-
133
- def _parse_frontmatter(raw: str, path: str) -> Mapping[str, object]:
134
- import yaml
135
- try:
136
- loaded = yaml.safe_load(raw) or {}
137
- except yaml.YAMLError as exc:
138
- mark = getattr(exc, "problem_mark", None)
139
- line = (mark.line + 2) if mark is not None else 1
140
- raise SpecMetadataError(path, "frontmatter", line, f"YAML 解析失败: {exc}") from exc
141
- if not isinstance(loaded, dict):
142
- raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
143
- if not all(isinstance(key, str) for key in loaded):
144
- raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
145
- return loaded
146
-
147
-
148
- def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
149
- value = meta.get(key)
150
- if not isinstance(value, str) or not value.strip():
151
- raise SpecMetadataError(path, key, _field_line(raw, key), "必须是非空字符串")
152
- return value.strip()
153
-
154
-
155
- def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[str, ...]:
156
- value = meta.get("stages")
157
- line = _field_line(raw, "stages")
158
- if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
159
- raise SpecMetadataError(path, "stages", line, "必须是非空字符串列表")
160
- stages = tuple(item.strip() for item in value)
161
- invalid = sorted(set(stages) - ALLOWED_STAGES)
162
- if invalid or len(set(stages)) != len(stages):
163
- detail = f"非法或重复 stage: {invalid or list(stages)}"
164
- raise SpecMetadataError(path, "stages", line, detail)
165
- return stages
166
-
167
-
168
- def _spec_header(meta: Mapping[str, object], raw: str, path: str) -> SpecHeader:
169
- spec_id = _required_string(meta, "id", path, raw)
170
- if not _ID_RE.fullmatch(spec_id):
171
- raise SpecMetadataError(path, "id", _field_line(raw, "id"), "必须是 kebab-case")
172
- stages = _validate_stages(meta, path, raw)
173
- enforcement = _required_string(meta, "enforcement", path, raw)
174
- if enforcement not in ALLOWED_ENFORCEMENT:
175
- line = _field_line(raw, "enforcement")
176
- raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
177
- return SpecHeader(spec_id, stages, enforcement)
178
-
179
-
180
- def _read_frontmatter(path: str) -> str:
181
- lines: list[bytes] = []
182
- try:
183
- with Path(path).open("rb") as source:
184
- if not _FRONTMATTER_OPEN_RE.fullmatch(source.readline()):
185
- raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
186
- for line in source:
187
- if _FRONTMATTER_CLOSE_RE.fullmatch(line):
188
- break
189
- lines.append(line)
190
- else:
191
- raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
192
- except OSError as exc:
193
- raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
194
- raw = b"".join(lines)
195
- try:
196
- return raw.decode("utf-8")
197
- except UnicodeDecodeError as exc:
198
- line = raw[:exc.start].count(b"\n") + 2
199
- raise SpecMetadataError(path, "encoding", line, f"必须是 UTF-8: {exc}") from exc
200
-
201
-
202
- def load_spec_header(path: str) -> SpecHeader:
203
- """Load only routing fields, stopping at the frontmatter delimiter."""
204
- raw = _read_frontmatter(path)
205
- return _spec_header(_parse_frontmatter(raw, path), raw, path)
206
-
207
-
208
- def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
209
- value = meta.get("checks", [])
210
- if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
211
- raise SpecMetadataError(path, "checks", _field_line(raw, "checks"), "必须是 mapping 列表")
212
- return tuple(value)
213
-
214
-
215
- def _parse_verifier_files(value: object, path: str, raw: str, index: int) -> tuple[str, ...]:
216
- line = _verifier_field_line(raw, index, "files")
217
- if not isinstance(value, list) or not value:
218
- raise SpecMetadataError(path, f"verifiers[{index}].files", line, "必须是非空 glob 字符串数组")
219
- files: list[str] = []
220
- for item in value:
221
- if not isinstance(item, str) or not item.strip():
222
- raise SpecMetadataError(path, f"verifiers[{index}].files", line, "glob 必须是非空字符串")
223
- text = item.strip().replace("\\", "/")
224
- if text.startswith("/") or re.match(r"^[A-Za-z]:/", text):
225
- raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止绝对路径: {item!r}")
226
- if ".." in text.split("/"):
227
- raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止 .. 跳出仓库: {item!r}")
228
- files.append(text)
229
- return tuple(files)
230
-
231
-
232
- def _parse_verifiers(
233
- meta: Mapping[str, object], path: str, raw: str, stages: tuple[str, ...]
234
- ) -> tuple[SpecVerifier, ...]:
235
- value = meta.get("verifiers", [])
236
- if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
237
- raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), "必须是 mapping 列表")
238
- result: list[SpecVerifier] = []
239
- seen: set[str] = set()
240
- for index, item in enumerate(value):
241
- rule = item.get("rule")
242
- kind = item.get("type")
243
- config = item.get("config")
244
- explicit_stage = "stage" in item
245
- stage = item.get("stage", "code")
246
- if not isinstance(rule, str) or not _RULE_ID_RE.fullmatch(rule):
247
- line = _verifier_field_line(raw, index, "rule")
248
- raise SpecMetadataError(path, f"verifiers[{index}].rule", line, "必须是稳定 RULE ID")
249
- if kind not in ALLOWED_VERIFIERS:
250
- line = _verifier_field_line(raw, index, "type")
251
- raise SpecMetadataError(path, f"verifiers[{index}].type", line, f"不支持 type: {kind!r}")
252
- if not isinstance(config, dict):
253
- line = _verifier_field_line(raw, index, "config")
254
- raise SpecMetadataError(path, f"verifiers[{index}].config", line, "必须是 mapping")
255
- if explicit_stage and (not isinstance(stage, str) or stage not in ALLOWED_VERIFIER_STAGES):
256
- line = _verifier_field_line(raw, index, "stage")
257
- raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"必须是 {sorted(ALLOWED_VERIFIER_STAGES)} 之一")
258
- if explicit_stage and stage not in stages:
259
- line = _verifier_field_line(raw, index, "stage")
260
- raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"stage {stage!r} 未在 stages 声明: {', '.join(stages)}")
261
- files = _parse_verifier_files(item["files"], path, raw, index) if "files" in item else ()
262
- if rule in seen:
263
- raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), f"重复 verifier: {rule}")
264
- seen.add(rule)
265
- result.append(SpecVerifier(rule=rule, type=kind, config=config, stage=stage, files=files))
266
- return tuple(result)
267
-
268
-
269
- def _domain_from_path(path: str) -> str:
270
- source = Path(path)
271
- parts = source.parts
272
- if "specs" in parts:
273
- index = parts.index("specs")
274
- candidate = parts[index + 1] if index + 1 < len(parts) - 1 else source.stem
275
- else:
276
- candidate = source.parent.name or source.stem
277
- normalized = re.sub(r"[^a-z0-9]+", "-", candidate.lower()).strip("-")
278
- return normalized or "spec"
279
-
280
-
281
- def _entry_details(section: str) -> tuple[str, str]:
282
- if section in ("Rules", "Anti-Patterns"):
283
- return "RULE", "required"
284
- if section == "Patterns":
285
- return "PATTERN", "advisory"
286
- return "EXAMPLE", "informational"
287
-
288
-
289
- def _parse_rules(body: str, path: str, line_offset: int) -> tuple[SpecRule, ...]:
290
- domain = _domain_from_path(path)
291
- counters = {"RULE": 0, "PATTERN": 0, "EXAMPLE": 0}
292
- rules: list[SpecRule] = []
293
- section = ""
294
- fence = ""
295
- used: set[str] = set()
296
- for relative_line, line in enumerate(body.splitlines(), start=1):
297
- number = relative_line + line_offset
298
- stripped = line.strip()
299
- if not fence and stripped.startswith(("```", "~~~")):
300
- marker = stripped[:3]
301
- fence = marker
302
- continue
303
- if fence and stripped.startswith(fence):
304
- fence = ""
305
- continue
306
- heading = _SECTION_RE.match(line) if not fence else None
307
- if heading:
308
- section = heading.group(1)
309
- continue
310
- if not fence and _H2_RE.match(line):
311
- section = ""
312
- continue
313
- bullet = _BULLET_RE.match(line) if section and not fence else None
314
- if not bullet:
315
- continue
316
- prefix, enforcement = _entry_details(section)
317
- counters[prefix] += 1
318
- ref = f"{prefix}-{domain}-{counters[prefix]:03d}"
319
- text = bullet.group(1).strip()
320
- explicit = _EXPLICIT_RULE_RE.match(text) if prefix == "RULE" else None
321
- if prefix == "RULE" and text.startswith("[RULE-") and not explicit:
322
- raise SpecMetadataError(path, "rules", number, "显式 Rule ID 必须是 RULE-<domain>-NNN")
323
- if explicit:
324
- ref, text = explicit.group(1), explicit.group(2).strip()
325
- while ref in used and not explicit:
326
- counters[prefix] += 1
327
- ref = f"{prefix}-{domain}-{counters[prefix]:03d}"
328
- if ref in used:
329
- raise SpecMetadataError(path, "rules", number, f"重复规则 ID: {ref}")
330
- used.add(ref)
331
- normalized = _normalized_text(text)
332
- rules.append(SpecRule(ref, normalized, section, enforcement, _sha256(normalized.encode()), number))
333
- return tuple(rules)
334
-
335
-
336
- def _validate_links(
337
- rules: tuple[SpecRule, ...], verifiers: tuple[SpecVerifier, ...], path: str, raw: str
338
- ) -> None:
339
- required = {rule.ref for rule in rules if rule.enforcement == "required"}
340
- linked = {verifier.rule for verifier in verifiers}
341
- missing = sorted(required - linked)
342
- unknown = sorted(linked - required)
343
- line = _field_line(raw, "verifiers")
344
- if missing:
345
- raise SpecMetadataError(path, "verifiers", line, f"required Rule 缺 verifier: {', '.join(missing)}")
346
- if unknown:
347
- raise SpecMetadataError(path, "verifiers", line, f"verifier 引用未知 required Rule: {', '.join(unknown)}")
348
-
349
-
350
- def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
351
- meta, raw_frontmatter, body = _split_frontmatter(content, path)
352
- header = _spec_header(meta, raw_frontmatter, path)
353
- description = _required_string(meta, "description", path, raw_frontmatter)
354
- owner = meta.get("owner")
355
- if owner is not None and (not isinstance(owner, str) or not owner.strip()):
356
- raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
357
- checks = _validate_checks(meta, path, raw_frontmatter)
358
- verifiers = _parse_verifiers(meta, path, raw_frontmatter, header.stages)
359
- body_line_offset = raw_frontmatter.count("\n") + 3
360
- rules = _parse_rules(body, path, body_line_offset)
361
- _validate_links(rules, verifiers, path, raw_frontmatter)
362
- required_rules = [(rule.ref, rule.text) for rule in rules if rule.enforcement == "required"]
363
- source_bytes = raw_bytes if raw_bytes is not None else content.encode("utf-8")
364
- hashes = SpecHashes(
365
- file_sha256=_sha256(source_bytes),
366
- metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
367
- rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
368
- )
369
- return SpecMetadata(
370
- header.id, description, header.stages, header.enforcement,
371
- owner, checks, verifiers, rules, hashes, path,
372
- )
373
-
374
-
375
- def load_spec_metadata(path: str) -> SpecMetadata:
376
- try:
377
- raw = Path(path).read_bytes()
378
- except OSError as exc:
379
- raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
380
- try:
381
- content = raw.decode("utf-8")
382
- except UnicodeDecodeError as exc:
383
- raise SpecMetadataError(path, "encoding", 1, f"必须是 UTF-8: {exc}") from exc
384
- return parse_spec_metadata(content, path=path, raw_bytes=raw)