@michaelxwb/code-flow 0.5.2 → 0.6.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 (82) hide show
  1. package/docs/migrations/spec-workflow-0.6.0.md +42 -0
  2. package/package.json +4 -2
  3. package/src/adapters/claude/CLAUDE.md +11 -17
  4. package/src/adapters/claude/commands/cf-init.md +5 -4
  5. package/src/adapters/claude/commands/cf-spec.md +63 -0
  6. package/src/adapters/claude/commands/cf-sync.md +25 -0
  7. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  8. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  9. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  10. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  11. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  12. package/src/adapters/claude/settings.local.json +1 -11
  13. package/src/adapters/codex/AGENTS.md +11 -17
  14. package/src/adapters/codex/hooks.json +6 -4
  15. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  16. package/src/adapters/codex/skills/cf-spec/SKILL.md +56 -0
  17. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  18. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  19. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  20. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  21. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  22. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  23. package/src/adapters/costrict/CLAUDE.md +11 -17
  24. package/src/adapters/costrict/commands/cf-init.md +5 -4
  25. package/src/adapters/costrict/commands/cf-spec.md +63 -0
  26. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  27. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  28. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  29. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  30. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  31. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  32. package/src/adapters/costrict/settings.local.json +1 -11
  33. package/src/adapters/opencode/AGENTS.md +11 -17
  34. package/src/adapters/opencode/commands/cf-init.md +4 -3
  35. package/src/adapters/opencode/commands/cf-spec.md +55 -0
  36. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  37. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  38. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  39. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  40. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  41. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  42. package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
  43. package/src/cli.js +51 -2
  44. package/src/core/code-flow/config.yml +14 -16
  45. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  46. package/src/core/code-flow/scripts/cf_core.py +36 -256
  47. package/src/core/code-flow/scripts/cf_log.py +8 -0
  48. package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
  49. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
  50. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  51. package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
  52. package/src/core/code-flow/scripts/cf_spec_gate.py +230 -0
  53. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  54. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  55. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  56. package/src/core/code-flow/scripts/cf_spec_router.py +149 -0
  57. package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
  58. package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
  59. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  60. package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
  61. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  62. package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
  63. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
  64. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  65. package/src/core/code-flow/specs/backend/database.md +13 -1
  66. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  67. package/src/core/code-flow/specs/backend/logging.md +13 -1
  68. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  69. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  70. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  71. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  72. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  73. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  74. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  75. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  76. package/src/migrate/spec-workflow.js +238 -0
  77. package/src/adapters/claude/commands/cf-inject.md +0 -55
  78. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  79. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  80. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  81. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  82. package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
@@ -0,0 +1,230 @@
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
+ from typing import IO, Mapping, Optional, Sequence
14
+
15
+ from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class GateIssue:
20
+ code: str
21
+ spec_id: str
22
+ rule_ref: str
23
+ stage: str
24
+ message: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class GateResult:
29
+ decision: str
30
+ errors: tuple[GateIssue, ...]
31
+ warnings: tuple[GateIssue, ...]
32
+ affected_refs: tuple[str, ...]
33
+
34
+
35
+ def _parse_time(value: str) -> Optional[datetime]:
36
+ try:
37
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
38
+ except ValueError:
39
+ return None
40
+ return parsed if parsed.tzinfo is not None else None
41
+
42
+
43
+ def _decision_valid(decision: Optional[Decision]) -> bool:
44
+ if decision is None:
45
+ return False
46
+ return all((decision.reason, decision.confirmed_by, decision.confirmed_at, decision.source))
47
+
48
+
49
+ def _waiver_issue(decision: Optional[Decision], now: datetime) -> Optional[str]:
50
+ if not _decision_valid(decision) or decision is None or not decision.expires_at:
51
+ return "waiver_invalid"
52
+ expires = _parse_time(decision.expires_at)
53
+ if expires is None:
54
+ return "waiver_invalid"
55
+ return "waiver_expired" if expires <= now else None
56
+
57
+
58
+ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Optional[str]) -> Optional[str]:
59
+ if not status.evidence:
60
+ return "evidence_missing"
61
+ for evidence in status.evidence:
62
+ if evidence.get("status") != "verified":
63
+ continue
64
+ if evidence.get("rule_text_sha256") != rule.text_sha256:
65
+ continue
66
+ evidence_diff = evidence.get("diff_sha256")
67
+ if evidence_diff is not None and diff_sha256 is not None and evidence_diff != diff_sha256:
68
+ continue
69
+ result_hash = evidence.get("result_sha256")
70
+ if isinstance(result_hash, str) and result_hash:
71
+ return None
72
+ return "stale_evidence"
73
+
74
+
75
+ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, diff_sha256: Optional[str]) -> Optional[str]:
76
+ if status.status == "applied":
77
+ return None
78
+ if status.status == "verified":
79
+ return _evidence_issue(rule, status, diff_sha256)
80
+ if status.status == "not_applicable":
81
+ return None if _decision_valid(status.decision) else "decision_invalid"
82
+ if status.status == "waived":
83
+ return _waiver_issue(status.decision, now)
84
+ return status.status
85
+
86
+
87
+ def _binding_issues(
88
+ binding: SpecBinding, stage: str, now: datetime, diff_sha256: Optional[str]
89
+ ) -> tuple[list[GateIssue], list[GateIssue]]:
90
+ errors: list[GateIssue] = []
91
+ warnings: list[GateIssue] = []
92
+ for rule in binding.rules:
93
+ status = rule.stage_status.get(stage)
94
+ if status is None:
95
+ continue
96
+ if rule.enforcement == "advisory" and status.status == "pending":
97
+ continue
98
+ code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
99
+ if code is None:
100
+ continue
101
+ issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
102
+ target = errors if rule.enforcement == "required" else warnings
103
+ target.append(issue)
104
+ return errors, warnings
105
+
106
+
107
+ def validate_stage(
108
+ context: SpecContext,
109
+ stage: str,
110
+ artifact: str = "",
111
+ task_id: str = "",
112
+ now: Optional[datetime] = None,
113
+ diff_sha256: Optional[str] = None,
114
+ ) -> GateResult:
115
+ del artifact, task_id
116
+ current = now or datetime.now(timezone.utc)
117
+ errors: list[GateIssue] = []
118
+ warnings: list[GateIssue] = []
119
+ for binding in context.bindings:
120
+ binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
121
+ errors.extend(binding_errors)
122
+ warnings.extend(binding_warnings)
123
+ refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
124
+ return GateResult("block" if errors else "pass", tuple(errors), tuple(warnings), refs)
125
+
126
+
127
+ def _task_sections(text: str) -> Mapping[str, str]:
128
+ matches = list(re.finditer(r"(?m)^## (TASK-\d+):[^\n]*$", text))
129
+ sections: dict[str, str] = {}
130
+ for index, match in enumerate(matches):
131
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
132
+ sections[match.group(1)] = text[match.start():end]
133
+ return sections
134
+
135
+
136
+ def _plan_issue(code: str, binding: SpecBinding, rule: RuleBinding, message: str) -> GateIssue:
137
+ return GateIssue(code, binding.spec_id, rule.ref, "plan", message)
138
+
139
+
140
+ def _rule_plan_issues(
141
+ binding: SpecBinding, rule: RuleBinding, sections: Mapping[str, str]
142
+ ) -> tuple[GateIssue, ...]:
143
+ full_ref = f"{binding.spec_id}#{rule.ref}"
144
+ owners = tuple(task for task, section in sections.items() if full_ref in section)
145
+ if not owners:
146
+ return (_plan_issue("plan_owner_missing", binding, rule, f"{full_ref} 没有责任 TASK"),)
147
+ if len(owners) > 1:
148
+ return (_plan_issue("plan_owner_duplicate", binding, rule, f"{full_ref} 有多个责任 TASK"),)
149
+ section = sections[owners[0]]
150
+ issues: list[GateIssue] = []
151
+ if rule.verifier_ref == "advisory:none":
152
+ issues.append(_plan_issue("plan_verifier_missing", binding, rule, f"{full_ref} 缺 verifier"))
153
+ checklist = section.partition("### Checklist")[2].partition("### Acceptance Contract")[0]
154
+ if rule.ref not in checklist or "verifier" not in checklist.lower():
155
+ issues.append(_plan_issue("plan_checklist_missing", binding, rule, f"{full_ref} 缺 verifier Checklist"))
156
+ contract = section.partition("### Acceptance Contract")[2]
157
+ if not contract.strip() or not re.search(r"\b(unit|integration|E2E|manual)\b", contract):
158
+ issues.append(_plan_issue("plan_acceptance_missing", binding, rule, f"{full_ref} 缺测试层级/真实边界"))
159
+ return tuple(issues)
160
+
161
+
162
+ def validate_plan_coverage(context: SpecContext, artifact: str) -> GateResult:
163
+ try:
164
+ text = Path(artifact).read_text(encoding="utf-8")
165
+ except OSError as exc:
166
+ issue = GateIssue("plan_read_error", "", "", "plan", str(exc))
167
+ return GateResult("block", (issue,), (), ())
168
+ sections = _task_sections(text)
169
+ errors: list[GateIssue] = []
170
+ for binding in context.bindings:
171
+ for rule in binding.rules:
172
+ if rule.enforcement == "required" and "plan" in rule.stage_status:
173
+ errors.extend(_rule_plan_issues(binding, rule, sections))
174
+ refs = tuple(sorted(f"{item.spec_id}#{item.rule_ref}" for item in errors))
175
+ return GateResult("block" if errors else "pass", tuple(errors), (), refs)
176
+
177
+
178
+ def _issue_data(issue: GateIssue) -> dict[str, str]:
179
+ return {
180
+ "code": issue.code,
181
+ "spec_id": issue.spec_id,
182
+ "rule_ref": issue.rule_ref,
183
+ "stage": issue.stage,
184
+ "message": issue.message,
185
+ }
186
+
187
+
188
+ def result_to_data(result: GateResult) -> dict[str, object]:
189
+ return {
190
+ "decision": result.decision,
191
+ "errors": [_issue_data(item) for item in result.errors],
192
+ "warnings": [_issue_data(item) for item in result.warnings],
193
+ "affected_refs": list(result.affected_refs),
194
+ }
195
+
196
+
197
+ def _merge_results(first: GateResult, second: GateResult) -> GateResult:
198
+ errors = (*first.errors, *second.errors)
199
+ warnings = (*first.warnings, *second.warnings)
200
+ refs = tuple(sorted(set((*first.affected_refs, *second.affected_refs))))
201
+ return GateResult("block" if errors else "pass", errors, warnings, refs)
202
+
203
+
204
+ def _parser() -> argparse.ArgumentParser:
205
+ parser = argparse.ArgumentParser(prog="cf_spec_gate.py")
206
+ parser.add_argument("--task-dir", required=True)
207
+ parser.add_argument("--stage", required=True)
208
+ parser.add_argument("--task", default="")
209
+ parser.add_argument("--artifact", default="")
210
+ parser.add_argument("--json", action="store_true")
211
+ return parser
212
+
213
+
214
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
215
+ try:
216
+ args = _parser().parse_args(argv)
217
+ context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
218
+ result = validate_stage(context, args.stage, task_id=args.task)
219
+ if args.stage == "plan" and args.artifact:
220
+ result = _merge_results(result, validate_plan_coverage(context, args.artifact))
221
+ stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
222
+ return 0 if result.decision == "pass" else 3
223
+ except Exception as exc:
224
+ sys.stderr.write(f"cf_spec_gate error: {exc}\n")
225
+ stdout.write(json.dumps({"decision": "block", "errors": [{"code": "gate_error"}]}, ensure_ascii=False))
226
+ return 2
227
+
228
+
229
+ if __name__ == "__main__":
230
+ raise SystemExit(main())
@@ -0,0 +1,304 @@
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
+ import yaml
14
+
15
+
16
+ ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
17
+ ALLOWED_ENFORCEMENT = frozenset(("required", "advisory"))
18
+ ALLOWED_VERIFIERS = frozenset(("document", "regex", "ast", "command", "test", "manual"))
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
+ _SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
24
+ _H2_RE = re.compile(r"^##\s+")
25
+ _BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
26
+
27
+
28
+ class SpecMetadataError(ValueError):
29
+ """A schema error with a stable field and source location."""
30
+
31
+ def __init__(self, path: str, field: str, line: int, message: str) -> None:
32
+ self.path = path
33
+ self.field = field
34
+ self.line = max(1, line)
35
+ self.message = message
36
+ super().__init__(f"{path}:{self.line}: metadata_error {field}: {message}")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SpecHashes:
41
+ file_sha256: str
42
+ metadata_sha256: str
43
+ rules_sha256: str
44
+
45
+ def values(self) -> tuple[str, str, str]:
46
+ return (self.file_sha256, self.metadata_sha256, self.rules_sha256)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class SpecVerifier:
51
+ rule: str
52
+ type: str
53
+ config: Mapping[str, object]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SpecRule:
58
+ ref: str
59
+ text: str
60
+ section: str
61
+ enforcement: str
62
+ text_sha256: str
63
+ line: int
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class SpecMetadata:
68
+ id: str
69
+ description: str
70
+ stages: tuple[str, ...]
71
+ enforcement: str
72
+ owner: Optional[str]
73
+ checks: tuple[Mapping[str, object], ...]
74
+ verifiers: tuple[SpecVerifier, ...]
75
+ rules: tuple[SpecRule, ...]
76
+ hashes: SpecHashes
77
+ path: str
78
+
79
+
80
+ def _sha256(data: bytes) -> str:
81
+ return hashlib.sha256(data).hexdigest()
82
+
83
+
84
+ def _normalized_text(text: str) -> str:
85
+ return " ".join(text.split())
86
+
87
+
88
+ def _canonical_json(value: object, path: str, field: str, line: int) -> bytes:
89
+ try:
90
+ text = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
91
+ except (TypeError, ValueError) as exc:
92
+ raise SpecMetadataError(path, field, line, f"无法规范化: {exc}") from exc
93
+ return text.encode("utf-8")
94
+
95
+
96
+ def _field_line(frontmatter: str, key: str) -> int:
97
+ pattern = re.compile(rf"^\s*{re.escape(key)}\s*:")
98
+ for number, line in enumerate(frontmatter.splitlines(), start=2):
99
+ if pattern.match(line):
100
+ return number
101
+ return 1
102
+
103
+
104
+ def _verifier_field_line(frontmatter: str, index: int, key: str) -> int:
105
+ current = -1
106
+ for number, line in enumerate(frontmatter.splitlines(), start=2):
107
+ if re.match(r"^\s*-\s+rule\s*:", line):
108
+ current += 1
109
+ if current == index and re.match(rf"^\s+{re.escape(key)}\s*:", line):
110
+ return number
111
+ return _field_line(frontmatter, "verifiers")
112
+
113
+
114
+ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], str, str]:
115
+ match = _FRONTMATTER_RE.match(content)
116
+ if not match:
117
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
118
+ raw = match.group(1)
119
+ try:
120
+ loaded = yaml.safe_load(raw) or {}
121
+ except yaml.YAMLError as exc:
122
+ mark = getattr(exc, "problem_mark", None)
123
+ line = (mark.line + 2) if mark is not None else 1
124
+ raise SpecMetadataError(path, "frontmatter", line, f"YAML 解析失败: {exc}") from exc
125
+ if not isinstance(loaded, dict):
126
+ raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
127
+ if not all(isinstance(key, str) for key in loaded):
128
+ raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
129
+ return loaded, raw, content[match.end():]
130
+
131
+
132
+ def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
133
+ value = meta.get(key)
134
+ if not isinstance(value, str) or not value.strip():
135
+ raise SpecMetadataError(path, key, _field_line(raw, key), "必须是非空字符串")
136
+ return value.strip()
137
+
138
+
139
+ def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[str, ...]:
140
+ value = meta.get("stages")
141
+ line = _field_line(raw, "stages")
142
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
143
+ raise SpecMetadataError(path, "stages", line, "必须是非空字符串列表")
144
+ stages = tuple(item.strip() for item in value)
145
+ invalid = sorted(set(stages) - ALLOWED_STAGES)
146
+ if invalid or len(set(stages)) != len(stages):
147
+ detail = f"非法或重复 stage: {invalid or list(stages)}"
148
+ raise SpecMetadataError(path, "stages", line, detail)
149
+ return stages
150
+
151
+
152
+ def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
153
+ value = meta.get("checks", [])
154
+ if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
155
+ raise SpecMetadataError(path, "checks", _field_line(raw, "checks"), "必须是 mapping 列表")
156
+ return tuple(value)
157
+
158
+
159
+ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[SpecVerifier, ...]:
160
+ value = meta.get("verifiers", [])
161
+ if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
162
+ raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), "必须是 mapping 列表")
163
+ result: list[SpecVerifier] = []
164
+ seen: set[str] = set()
165
+ for index, item in enumerate(value):
166
+ rule = item.get("rule")
167
+ kind = item.get("type")
168
+ config = item.get("config")
169
+ if not isinstance(rule, str) or not _RULE_ID_RE.fullmatch(rule):
170
+ line = _verifier_field_line(raw, index, "rule")
171
+ raise SpecMetadataError(path, f"verifiers[{index}].rule", line, "必须是稳定 RULE ID")
172
+ if kind not in ALLOWED_VERIFIERS:
173
+ line = _verifier_field_line(raw, index, "type")
174
+ raise SpecMetadataError(path, f"verifiers[{index}].type", line, f"不支持 type: {kind!r}")
175
+ if not isinstance(config, dict):
176
+ line = _verifier_field_line(raw, index, "config")
177
+ raise SpecMetadataError(path, f"verifiers[{index}].config", line, "必须是 mapping")
178
+ if rule in seen:
179
+ raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), f"重复 verifier: {rule}")
180
+ seen.add(rule)
181
+ result.append(SpecVerifier(rule=rule, type=kind, config=config))
182
+ return tuple(result)
183
+
184
+
185
+ def _domain_from_path(path: str) -> str:
186
+ source = Path(path)
187
+ parts = source.parts
188
+ if "specs" in parts:
189
+ index = parts.index("specs")
190
+ candidate = parts[index + 1] if index + 1 < len(parts) - 1 else source.stem
191
+ else:
192
+ candidate = source.parent.name or source.stem
193
+ normalized = re.sub(r"[^a-z0-9]+", "-", candidate.lower()).strip("-")
194
+ return normalized or "spec"
195
+
196
+
197
+ def _entry_details(section: str) -> tuple[str, str]:
198
+ if section in ("Rules", "Anti-Patterns"):
199
+ return "RULE", "required"
200
+ if section == "Patterns":
201
+ return "PATTERN", "advisory"
202
+ return "EXAMPLE", "informational"
203
+
204
+
205
+ def _parse_rules(body: str, path: str, line_offset: int) -> tuple[SpecRule, ...]:
206
+ domain = _domain_from_path(path)
207
+ counters = {"RULE": 0, "PATTERN": 0, "EXAMPLE": 0}
208
+ rules: list[SpecRule] = []
209
+ section = ""
210
+ fence = ""
211
+ used: set[str] = set()
212
+ for relative_line, line in enumerate(body.splitlines(), start=1):
213
+ number = relative_line + line_offset
214
+ stripped = line.strip()
215
+ if not fence and stripped.startswith(("```", "~~~")):
216
+ marker = stripped[:3]
217
+ fence = marker
218
+ continue
219
+ if fence and stripped.startswith(fence):
220
+ fence = ""
221
+ continue
222
+ heading = _SECTION_RE.match(line) if not fence else None
223
+ if heading:
224
+ section = heading.group(1)
225
+ continue
226
+ if not fence and _H2_RE.match(line):
227
+ section = ""
228
+ continue
229
+ bullet = _BULLET_RE.match(line) if section and not fence else None
230
+ if not bullet:
231
+ continue
232
+ prefix, enforcement = _entry_details(section)
233
+ counters[prefix] += 1
234
+ ref = f"{prefix}-{domain}-{counters[prefix]:03d}"
235
+ text = bullet.group(1).strip()
236
+ explicit = _EXPLICIT_RULE_RE.match(text) if prefix == "RULE" else None
237
+ if prefix == "RULE" and text.startswith("[RULE-") and not explicit:
238
+ raise SpecMetadataError(path, "rules", number, "显式 Rule ID 必须是 RULE-<domain>-NNN")
239
+ if explicit:
240
+ ref, text = explicit.group(1), explicit.group(2).strip()
241
+ while ref in used and not explicit:
242
+ counters[prefix] += 1
243
+ ref = f"{prefix}-{domain}-{counters[prefix]:03d}"
244
+ if ref in used:
245
+ raise SpecMetadataError(path, "rules", number, f"重复规则 ID: {ref}")
246
+ used.add(ref)
247
+ normalized = _normalized_text(text)
248
+ rules.append(SpecRule(ref, normalized, section, enforcement, _sha256(normalized.encode()), number))
249
+ return tuple(rules)
250
+
251
+
252
+ def _validate_links(
253
+ rules: tuple[SpecRule, ...], verifiers: tuple[SpecVerifier, ...], path: str, raw: str
254
+ ) -> None:
255
+ required = {rule.ref for rule in rules if rule.enforcement == "required"}
256
+ linked = {verifier.rule for verifier in verifiers}
257
+ missing = sorted(required - linked)
258
+ unknown = sorted(linked - required)
259
+ line = _field_line(raw, "verifiers")
260
+ if missing:
261
+ raise SpecMetadataError(path, "verifiers", line, f"required Rule 缺 verifier: {', '.join(missing)}")
262
+ if unknown:
263
+ raise SpecMetadataError(path, "verifiers", line, f"verifier 引用未知 required Rule: {', '.join(unknown)}")
264
+
265
+
266
+ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
267
+ meta, raw_frontmatter, body = _split_frontmatter(content, path)
268
+ spec_id = _required_string(meta, "id", path, raw_frontmatter)
269
+ if not _ID_RE.fullmatch(spec_id):
270
+ raise SpecMetadataError(path, "id", _field_line(raw_frontmatter, "id"), "必须是 kebab-case")
271
+ description = _required_string(meta, "description", path, raw_frontmatter)
272
+ stages = _validate_stages(meta, path, raw_frontmatter)
273
+ enforcement = _required_string(meta, "enforcement", path, raw_frontmatter)
274
+ if enforcement not in ALLOWED_ENFORCEMENT:
275
+ line = _field_line(raw_frontmatter, "enforcement")
276
+ raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
277
+ owner = meta.get("owner")
278
+ if owner is not None and (not isinstance(owner, str) or not owner.strip()):
279
+ raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
280
+ checks = _validate_checks(meta, path, raw_frontmatter)
281
+ verifiers = _parse_verifiers(meta, path, raw_frontmatter)
282
+ body_line_offset = raw_frontmatter.count("\n") + 3
283
+ rules = _parse_rules(body, path, body_line_offset)
284
+ _validate_links(rules, verifiers, path, raw_frontmatter)
285
+ required_rules = [(rule.ref, rule.text) for rule in rules if rule.enforcement == "required"]
286
+ source_bytes = raw_bytes if raw_bytes is not None else content.encode("utf-8")
287
+ hashes = SpecHashes(
288
+ file_sha256=_sha256(source_bytes),
289
+ metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
290
+ rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
291
+ )
292
+ return SpecMetadata(spec_id, description, stages, enforcement, owner, checks, verifiers, rules, hashes, path)
293
+
294
+
295
+ def load_spec_metadata(path: str) -> SpecMetadata:
296
+ try:
297
+ raw = Path(path).read_bytes()
298
+ except OSError as exc:
299
+ raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
300
+ try:
301
+ content = raw.decode("utf-8")
302
+ except UnicodeDecodeError as exc:
303
+ raise SpecMetadataError(path, "encoding", 1, f"必须是 UTF-8: {exc}") from exc
304
+ return parse_spec_metadata(content, path=path, raw_bytes=raw)