@michaelxwb/code-flow 0.5.2 → 0.6.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.
- package/docs/migrations/spec-workflow-0.6.0.md +42 -0
- package/package.json +4 -2
- package/src/adapters/claude/CLAUDE.md +11 -17
- package/src/adapters/claude/commands/cf-init.md +5 -4
- package/src/adapters/claude/commands/cf-spec.md +50 -0
- package/src/adapters/claude/commands/cf-task/align.md +23 -1
- package/src/adapters/claude/commands/cf-task/archive.md +7 -0
- package/src/adapters/claude/commands/cf-task/plan.md +16 -0
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +13 -10
- package/src/adapters/claude/settings.local.json +1 -11
- package/src/adapters/codex/AGENTS.md +11 -17
- package/src/adapters/codex/hooks.json +6 -4
- package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
- package/src/adapters/costrict/CLAUDE.md +11 -17
- package/src/adapters/costrict/commands/cf-init.md +5 -4
- package/src/adapters/costrict/commands/cf-spec.md +50 -0
- package/src/adapters/costrict/commands/cf-task/align.md +23 -1
- package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +13 -10
- package/src/adapters/costrict/settings.local.json +1 -11
- package/src/adapters/opencode/AGENTS.md +11 -17
- package/src/adapters/opencode/commands/cf-init.md +4 -3
- package/src/adapters/opencode/commands/cf-spec.md +54 -0
- package/src/adapters/opencode/commands/cf-task/align.md +24 -1
- package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +14 -10
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +13 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +12 -255
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
- package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
- package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
- package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +22 -2
- package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
- package/src/core/code-flow/specs/backend/database.md +13 -1
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/backend/logging.md +13 -1
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
- package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
- package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
- package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
- package/src/core/code-flow/specs/shared/prd-template.md +14 -3
- package/src/migrate/spec-workflow.js +238 -0
- package/src/adapters/claude/commands/cf-inject.md +0 -55
- package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
- package/src/adapters/costrict/commands/cf-inject.md +0 -55
- package/src/adapters/opencode/commands/cf-inject.md +0 -59
- package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
|
@@ -0,0 +1,224 @@
|
|
|
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) -> 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
|
+
result_hash = evidence.get("result_sha256")
|
|
67
|
+
if isinstance(result_hash, str) and result_hash:
|
|
68
|
+
return None
|
|
69
|
+
return "stale_evidence"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) -> Optional[str]:
|
|
73
|
+
if status.status == "applied":
|
|
74
|
+
return None
|
|
75
|
+
if status.status == "verified":
|
|
76
|
+
return _evidence_issue(rule, status)
|
|
77
|
+
if status.status == "not_applicable":
|
|
78
|
+
return None if _decision_valid(status.decision) else "decision_invalid"
|
|
79
|
+
if status.status == "waived":
|
|
80
|
+
return _waiver_issue(status.decision, now)
|
|
81
|
+
return status.status
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _binding_issues(binding: SpecBinding, stage: str, now: datetime) -> tuple[list[GateIssue], list[GateIssue]]:
|
|
85
|
+
errors: list[GateIssue] = []
|
|
86
|
+
warnings: list[GateIssue] = []
|
|
87
|
+
for rule in binding.rules:
|
|
88
|
+
status = rule.stage_status.get(stage)
|
|
89
|
+
if status is None:
|
|
90
|
+
continue
|
|
91
|
+
if rule.enforcement == "advisory" and status.status == "pending":
|
|
92
|
+
continue
|
|
93
|
+
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now)
|
|
94
|
+
if code is None:
|
|
95
|
+
continue
|
|
96
|
+
issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
|
|
97
|
+
target = errors if rule.enforcement == "required" else warnings
|
|
98
|
+
target.append(issue)
|
|
99
|
+
return errors, warnings
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_stage(
|
|
103
|
+
context: SpecContext,
|
|
104
|
+
stage: str,
|
|
105
|
+
artifact: str = "",
|
|
106
|
+
task_id: str = "",
|
|
107
|
+
now: Optional[datetime] = None,
|
|
108
|
+
) -> GateResult:
|
|
109
|
+
del artifact, task_id
|
|
110
|
+
current = now or datetime.now(timezone.utc)
|
|
111
|
+
errors: list[GateIssue] = []
|
|
112
|
+
warnings: list[GateIssue] = []
|
|
113
|
+
for binding in context.bindings:
|
|
114
|
+
binding_errors, binding_warnings = _binding_issues(binding, stage, current)
|
|
115
|
+
errors.extend(binding_errors)
|
|
116
|
+
warnings.extend(binding_warnings)
|
|
117
|
+
refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
|
|
118
|
+
return GateResult("block" if errors else "pass", tuple(errors), tuple(warnings), refs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _task_sections(text: str) -> Mapping[str, str]:
|
|
122
|
+
matches = list(re.finditer(r"(?m)^## (TASK-\d+):[^\n]*$", text))
|
|
123
|
+
sections: dict[str, str] = {}
|
|
124
|
+
for index, match in enumerate(matches):
|
|
125
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
126
|
+
sections[match.group(1)] = text[match.start():end]
|
|
127
|
+
return sections
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _plan_issue(code: str, binding: SpecBinding, rule: RuleBinding, message: str) -> GateIssue:
|
|
131
|
+
return GateIssue(code, binding.spec_id, rule.ref, "plan", message)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _rule_plan_issues(
|
|
135
|
+
binding: SpecBinding, rule: RuleBinding, sections: Mapping[str, str]
|
|
136
|
+
) -> tuple[GateIssue, ...]:
|
|
137
|
+
full_ref = f"{binding.spec_id}#{rule.ref}"
|
|
138
|
+
owners = tuple(task for task, section in sections.items() if full_ref in section)
|
|
139
|
+
if not owners:
|
|
140
|
+
return (_plan_issue("plan_owner_missing", binding, rule, f"{full_ref} 没有责任 TASK"),)
|
|
141
|
+
if len(owners) > 1:
|
|
142
|
+
return (_plan_issue("plan_owner_duplicate", binding, rule, f"{full_ref} 有多个责任 TASK"),)
|
|
143
|
+
section = sections[owners[0]]
|
|
144
|
+
issues: list[GateIssue] = []
|
|
145
|
+
if rule.verifier_ref == "advisory:none":
|
|
146
|
+
issues.append(_plan_issue("plan_verifier_missing", binding, rule, f"{full_ref} 缺 verifier"))
|
|
147
|
+
checklist = section.partition("### Checklist")[2].partition("### Acceptance Contract")[0]
|
|
148
|
+
if rule.ref not in checklist or "verifier" not in checklist.lower():
|
|
149
|
+
issues.append(_plan_issue("plan_checklist_missing", binding, rule, f"{full_ref} 缺 verifier Checklist"))
|
|
150
|
+
contract = section.partition("### Acceptance Contract")[2]
|
|
151
|
+
if not contract.strip() or not re.search(r"\b(unit|integration|E2E|manual)\b", contract):
|
|
152
|
+
issues.append(_plan_issue("plan_acceptance_missing", binding, rule, f"{full_ref} 缺测试层级/真实边界"))
|
|
153
|
+
return tuple(issues)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_plan_coverage(context: SpecContext, artifact: str) -> GateResult:
|
|
157
|
+
try:
|
|
158
|
+
text = Path(artifact).read_text(encoding="utf-8")
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
issue = GateIssue("plan_read_error", "", "", "plan", str(exc))
|
|
161
|
+
return GateResult("block", (issue,), (), ())
|
|
162
|
+
sections = _task_sections(text)
|
|
163
|
+
errors: list[GateIssue] = []
|
|
164
|
+
for binding in context.bindings:
|
|
165
|
+
for rule in binding.rules:
|
|
166
|
+
if rule.enforcement == "required" and "plan" in rule.stage_status:
|
|
167
|
+
errors.extend(_rule_plan_issues(binding, rule, sections))
|
|
168
|
+
refs = tuple(sorted(f"{item.spec_id}#{item.rule_ref}" for item in errors))
|
|
169
|
+
return GateResult("block" if errors else "pass", tuple(errors), (), refs)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _issue_data(issue: GateIssue) -> dict[str, str]:
|
|
173
|
+
return {
|
|
174
|
+
"code": issue.code,
|
|
175
|
+
"spec_id": issue.spec_id,
|
|
176
|
+
"rule_ref": issue.rule_ref,
|
|
177
|
+
"stage": issue.stage,
|
|
178
|
+
"message": issue.message,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def result_to_data(result: GateResult) -> dict[str, object]:
|
|
183
|
+
return {
|
|
184
|
+
"decision": result.decision,
|
|
185
|
+
"errors": [_issue_data(item) for item in result.errors],
|
|
186
|
+
"warnings": [_issue_data(item) for item in result.warnings],
|
|
187
|
+
"affected_refs": list(result.affected_refs),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _merge_results(first: GateResult, second: GateResult) -> GateResult:
|
|
192
|
+
errors = (*first.errors, *second.errors)
|
|
193
|
+
warnings = (*first.warnings, *second.warnings)
|
|
194
|
+
refs = tuple(sorted(set((*first.affected_refs, *second.affected_refs))))
|
|
195
|
+
return GateResult("block" if errors else "pass", errors, warnings, refs)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parser() -> argparse.ArgumentParser:
|
|
199
|
+
parser = argparse.ArgumentParser(prog="cf_spec_gate.py")
|
|
200
|
+
parser.add_argument("--task-dir", required=True)
|
|
201
|
+
parser.add_argument("--stage", required=True)
|
|
202
|
+
parser.add_argument("--task", default="")
|
|
203
|
+
parser.add_argument("--artifact", default="")
|
|
204
|
+
parser.add_argument("--json", action="store_true")
|
|
205
|
+
return parser
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
209
|
+
try:
|
|
210
|
+
args = _parser().parse_args(argv)
|
|
211
|
+
context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
|
|
212
|
+
result = validate_stage(context, args.stage, task_id=args.task)
|
|
213
|
+
if args.stage == "plan" and args.artifact:
|
|
214
|
+
result = _merge_results(result, validate_plan_coverage(context, args.artifact))
|
|
215
|
+
stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
|
|
216
|
+
return 0 if result.decision == "pass" else 3
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
sys.stderr.write(f"cf_spec_gate error: {exc}\n")
|
|
219
|
+
stdout.write(json.dumps({"decision": "block", "errors": [{"code": "gate_error"}]}, ensure_ascii=False))
|
|
220
|
+
return 2
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
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)
|