@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.
- package/README.md +75 -110
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +268 -0
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +21 -9
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -264
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -67
- package/src/adapters/claude/commands/cf-stats.md +0 -86
- package/src/adapters/claude/commands/cf-sync.md +0 -25
- package/src/adapters/claude/commands/cf-task/align.md +0 -309
- package/src/adapters/claude/commands/cf-task/archive.md +0 -161
- package/src/adapters/claude/commands/cf-task/block.md +0 -36
- package/src/adapters/claude/commands/cf-task/graph.md +0 -80
- package/src/adapters/claude/commands/cf-task/note.md +0 -236
- package/src/adapters/claude/commands/cf-task/plan.md +0 -363
- package/src/adapters/claude/commands/cf-task/prd.md +0 -162
- package/src/adapters/claude/commands/cf-task/start.md +0 -304
- package/src/adapters/claude/commands/cf-task/status.md +0 -59
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/claude/commands/cf-validate.md +0 -75
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -50
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -264
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -67
- package/src/adapters/costrict/commands/cf-stats.md +0 -86
- package/src/adapters/costrict/commands/cf-sync.md +0 -25
- package/src/adapters/costrict/commands/cf-task/align.md +0 -309
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
- package/src/adapters/costrict/commands/cf-task/block.md +0 -36
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
- package/src/adapters/costrict/commands/cf-task/note.md +0 -236
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
- package/src/adapters/costrict/commands/cf-task/start.md +0 -304
- package/src/adapters/costrict/commands/cf-task/status.md +0 -59
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/costrict/commands/cf-validate.md +0 -75
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -59
- package/src/adapters/opencode/commands/cf-stats.md +0 -90
- package/src/adapters/opencode/commands/cf-sync.md +0 -29
- package/src/adapters/opencode/commands/cf-task/align.md +0 -314
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
- package/src/adapters/opencode/commands/cf-task/block.md +0 -40
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
- package/src/adapters/opencode/commands/cf-task/note.md +0 -240
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
- package/src/adapters/opencode/commands/cf-task/start.md +0 -309
- package/src/adapters/opencode/commands/cf-task/status.md +0 -63
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/opencode/commands/cf-validate.md +0 -79
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/cli.js +0 -1023
- package/src/core/code-flow/config.yml +0 -158
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_checks.py +0 -336
- package/src/core/code-flow/scripts/cf_core.py +0 -787
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_feedback.py +0 -56
- package/src/core/code-flow/scripts/cf_log.py +0 -154
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
- package/src/core/code-flow/scripts/cf_scan.py +0 -295
- package/src/core/code-flow/scripts/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
- package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
- package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
- package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
- package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
- package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
- package/src/core/code-flow/scripts/cf_stats.py +0 -506
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
- package/src/core/code-flow/scripts/cf_sync.py +0 -140
- package/src/core/code-flow/scripts/cf_task_index.py +0 -148
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_validation.py +0 -81
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -53
- 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)
|