@devcxl/opencode-cabbage 1.3.0 → 2.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/.codex-plugin/plugin.json +1 -1
- package/README.md +35 -23
- package/README.zh.md +22 -15
- package/assets/agents/dev-lifecycle.md +6 -0
- package/assets/prompts/bootstrap.md +4 -0
- package/assets/skills/flow-change/SKILL.md +109 -0
- package/assets/skills/flow-design/SKILL.md +15 -0
- package/assets/skills/flow-docs/SKILL.md +112 -0
- package/assets/skills/flow-incident/SKILL.md +98 -0
- package/assets/skills/flow-research/SKILL.md +9 -0
- package/assets/skills/flow-setup/SKILL.md +13 -0
- package/codex-skills/agent-dev-lifecycle/SKILL.md +2 -0
- package/codex-skills/flow-change/SKILL.md +109 -0
- package/codex-skills/flow-design/SKILL.md +18 -3
- package/codex-skills/flow-docs/SKILL.md +112 -0
- package/codex-skills/flow-incident/SKILL.md +98 -0
- package/codex-skills/flow-research/SKILL.md +12 -3
- package/codex-skills/flow-setup/SKILL.md +13 -0
- package/dist/index.d.ts +2 -5
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/plugin/agents.d.ts +20 -0
- package/dist/plugin/agents.js +79 -0
- package/dist/plugin/agents.js.map +1 -1
- package/dist/plugin/cabbage-cli.d.ts +28 -0
- package/dist/plugin/cabbage-cli.js +92 -0
- package/dist/plugin/cabbage-cli.js.map +1 -0
- package/dist/plugin/cabbage-tool.d.ts +103 -0
- package/dist/plugin/cabbage-tool.js +169 -0
- package/dist/plugin/cabbage-tool.js.map +1 -0
- package/dist/plugin/commands.d.ts +10 -0
- package/dist/plugin/commands.js +1 -1
- package/dist/plugin/commands.js.map +1 -1
- package/dist/plugin/continuation.d.ts +22 -0
- package/dist/plugin/continuation.js +233 -0
- package/dist/plugin/continuation.js.map +1 -0
- package/dist/plugin/goal-store.d.ts +42 -49
- package/dist/plugin/goal-store.js +35 -32
- package/dist/plugin/goal-store.js.map +1 -1
- package/dist/plugin/goal.d.ts +92 -28
- package/dist/plugin/goal.js +201 -158
- package/dist/plugin/goal.js.map +1 -1
- package/dist/plugin/install-dir.d.ts +13 -0
- package/dist/plugin/install-dir.js +46 -0
- package/dist/plugin/install-dir.js.map +1 -0
- package/dist/plugin/server.d.ts +4 -13
- package/dist/plugin/server.js +278 -423
- package/dist/plugin/server.js.map +1 -1
- package/dist/plugin/session-abort.d.ts +5 -0
- package/dist/plugin/session-abort.js +26 -0
- package/dist/plugin/session-abort.js.map +1 -0
- package/dist/plugin/skills.d.ts +15 -0
- package/dist/plugin/skills.js +47 -50
- package/dist/plugin/skills.js.map +1 -1
- package/dist/plugin.d.ts +2 -2
- package/dist/plugin.js +6 -2
- package/dist/plugin.js.map +1 -1
- package/dist/util/paths.d.ts +0 -6
- package/dist/util/paths.js +0 -15
- package/dist/util/paths.js.map +1 -1
- package/package.json +4 -7
- package/vendor/cabbage/MANIFEST.json +8 -0
- package/vendor/cabbage/cabbage_cli/__init__.py +1 -0
- package/vendor/cabbage/cabbage_cli/__main__.py +2 -0
- package/vendor/cabbage/cabbage_cli/assets/docs-site/.vitepress/config.ts +78 -0
- package/vendor/cabbage/cabbage_cli/assets/docs-site/README.md +31 -0
- package/vendor/cabbage/cabbage_cli/assets/docs-site/package.json +16 -0
- package/vendor/cabbage/cabbage_cli/assets/docs-site/pnpm-lock.yaml +2504 -0
- package/vendor/cabbage/cabbage_cli/assets/integrations/cabbage.yml +127 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/adr.md +47 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/api-design.md +45 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/change-record.md +21 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/database-design.md +37 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/generic.md +20 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/impact.md +41 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/incident.md +48 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/postmortem.md +43 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/prd.md +71 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/release-plan.md +45 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/rfc.md +44 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/security-review.md +35 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/tasks.md +62 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/tech-spec.md +87 -0
- package/vendor/cabbage/cabbage_cli/assets/templates/test-plan.md +44 -0
- package/vendor/cabbage/cabbage_cli/assets/workflows/change.yaml +11 -0
- package/vendor/cabbage/cabbage_cli/assets/workflows/incident.yaml +4 -0
- package/vendor/cabbage/cabbage_cli/cli.py +283 -0
- package/vendor/cabbage/cabbage_cli/core.py +606 -0
- package/vendor/cabbage/cabbage_cli/scaffold.py +313 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import hashlib, json, os, re, subprocess, tempfile
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
CABBAGE_DIR = ".cabbage"
|
|
8
|
+
LEGACY_PLACEHOLDERS = (
|
|
9
|
+
"Describe the architectural decision context.",
|
|
10
|
+
"Record the chosen decision.",
|
|
11
|
+
"Record positive and negative consequences.",
|
|
12
|
+
"Describe endpoints/events, inputs, outputs and errors.",
|
|
13
|
+
"Describe versioning and backward compatibility.",
|
|
14
|
+
"Describe schema changes.",
|
|
15
|
+
"Describe forward migration.",
|
|
16
|
+
"Describe rollback and data-safety constraints.",
|
|
17
|
+
"Describe the artifact.",
|
|
18
|
+
"Describe material risks and mitigations.",
|
|
19
|
+
"Describe user/business impact.",
|
|
20
|
+
"Record key timestamps and actions.",
|
|
21
|
+
"Record containment and recovery.",
|
|
22
|
+
"Describe the root cause.",
|
|
23
|
+
"Describe systemic contributing factors.",
|
|
24
|
+
"List owned corrective actions.",
|
|
25
|
+
"Replace this text with the product goal.",
|
|
26
|
+
"Describe in-scope and out-of-scope behavior.",
|
|
27
|
+
"Define observable acceptance criteria.",
|
|
28
|
+
"Describe deployment steps and ordering.",
|
|
29
|
+
"Describe rollback triggers and steps.",
|
|
30
|
+
"Describe post-deploy verification.",
|
|
31
|
+
"Describe the problem.",
|
|
32
|
+
"Describe the proposal.",
|
|
33
|
+
"Describe alternatives and trade-offs.",
|
|
34
|
+
"Describe trust boundaries, abuse cases and sensitive assets.",
|
|
35
|
+
"Describe authorization, validation, secrets, audit and mitigations.",
|
|
36
|
+
"Describe current state and constraints.",
|
|
37
|
+
"Describe the proposed design. Use Mermaid for reviewable diagrams.",
|
|
38
|
+
"Describe failure handling and recovery.",
|
|
39
|
+
"Describe incremental rollout and compatibility.",
|
|
40
|
+
"Describe unit, integration, E2E, regression and non-functional coverage.",
|
|
41
|
+
"Define the critical test cases.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
class CabbageError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def write_text_atomic(path: Path, text: str, encoding: str = "utf-8") -> None:
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, encoding=encoding) as tf:
|
|
50
|
+
tf.write(text)
|
|
51
|
+
temp_name = tf.name
|
|
52
|
+
os.replace(temp_name, path)
|
|
53
|
+
|
|
54
|
+
def load_yaml(path: Path) -> dict:
|
|
55
|
+
if not path.exists():
|
|
56
|
+
raise CabbageError(f"missing file: {path}")
|
|
57
|
+
try:
|
|
58
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
59
|
+
except yaml.YAMLError as e:
|
|
60
|
+
raise CabbageError(f"invalid YAML in {path}: {e}")
|
|
61
|
+
return data or {}
|
|
62
|
+
|
|
63
|
+
def dump_yaml(path: Path, data: dict) -> None:
|
|
64
|
+
content = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
65
|
+
write_text_atomic(path, content, encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
def dump_json(path: Path, data: dict) -> None:
|
|
68
|
+
content = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
69
|
+
write_text_atomic(path, content, encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
def sha256_bytes(data: bytes) -> str:
|
|
72
|
+
return hashlib.sha256(data).hexdigest()
|
|
73
|
+
|
|
74
|
+
def sha256_file(path: Path) -> str:
|
|
75
|
+
return sha256_bytes(path.read_bytes()) if path.exists() else "MISSING"
|
|
76
|
+
|
|
77
|
+
def now_iso() -> str:
|
|
78
|
+
return datetime.now(timezone.utc).isoformat()
|
|
79
|
+
|
|
80
|
+
def project_root(start: Path | None = None) -> Path:
|
|
81
|
+
p = (start or Path.cwd()).resolve()
|
|
82
|
+
for cur in [p, *p.parents]:
|
|
83
|
+
if (cur / CABBAGE_DIR / "config.yaml").exists():
|
|
84
|
+
return cur
|
|
85
|
+
raise CabbageError("not a cabbage project; run `cabbage init`")
|
|
86
|
+
|
|
87
|
+
def load_config(root: Path) -> dict:
|
|
88
|
+
return load_yaml(root / CABBAGE_DIR / "config.yaml")
|
|
89
|
+
|
|
90
|
+
def change_dir(root: Path, change_id: str) -> Path:
|
|
91
|
+
return changes_root(root) / change_id
|
|
92
|
+
|
|
93
|
+
def changes_root(root: Path) -> Path:
|
|
94
|
+
return root / CABBAGE_DIR / "changes"
|
|
95
|
+
|
|
96
|
+
def archive_root(root: Path) -> Path:
|
|
97
|
+
return root / CABBAGE_DIR / "archive"
|
|
98
|
+
|
|
99
|
+
def change_spec(root: Path, change_id: str) -> dict:
|
|
100
|
+
return load_yaml(change_dir(root, change_id) / "change.yaml")
|
|
101
|
+
|
|
102
|
+
def workflow(root: Path, change_type: str) -> tuple[dict, Path]:
|
|
103
|
+
p = root / CABBAGE_DIR / "workflows" / f"{change_type}.yaml"
|
|
104
|
+
if not p.exists():
|
|
105
|
+
available = sorted(x.stem for x in p.parent.glob("*.yaml")) if p.parent.exists() else []
|
|
106
|
+
hint = f"; available: {', '.join(available)}" if available else ""
|
|
107
|
+
raise CabbageError(f"unknown change type: {change_type}{hint}")
|
|
108
|
+
return load_yaml(p), p
|
|
109
|
+
|
|
110
|
+
def state_path(root: Path, change_id: str) -> Path:
|
|
111
|
+
return change_dir(root, change_id) / "state.json"
|
|
112
|
+
|
|
113
|
+
def load_state(root: Path, change_id: str) -> dict:
|
|
114
|
+
p = state_path(root, change_id)
|
|
115
|
+
if not p.exists():
|
|
116
|
+
return {"version": 1, "completed": {}}
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
119
|
+
except json.JSONDecodeError as e:
|
|
120
|
+
raise CabbageError(f"invalid state.json: {e}")
|
|
121
|
+
data.setdefault("completed", {})
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
def save_state(root: Path, change_id: str, state: dict) -> None:
|
|
125
|
+
dump_json(state_path(root, change_id), state)
|
|
126
|
+
|
|
127
|
+
def stage_context(spec: dict, stage: dict) -> dict:
|
|
128
|
+
ctx={"type": spec.get("type")}
|
|
129
|
+
cond=stage.get("when")
|
|
130
|
+
if isinstance(cond,str) and cond.startswith("impact."):
|
|
131
|
+
key=cond.split(".",1)[1]
|
|
132
|
+
ctx["condition"]={key: bool(spec.get("impact",{}).get(key,False))}
|
|
133
|
+
return ctx
|
|
134
|
+
|
|
135
|
+
def condition_enabled(stage: dict, spec: dict) -> bool:
|
|
136
|
+
cond = stage.get("when")
|
|
137
|
+
if not cond or cond == "always":
|
|
138
|
+
return True
|
|
139
|
+
if cond.startswith("impact."):
|
|
140
|
+
return bool(spec.get("impact", {}).get(cond.split(".", 1)[1], False))
|
|
141
|
+
raise CabbageError(f"unsupported condition: {cond}")
|
|
142
|
+
|
|
143
|
+
def stage_map(wf: dict) -> dict[str, dict]:
|
|
144
|
+
return {s["id"]: s for s in wf.get("stages", [])}
|
|
145
|
+
|
|
146
|
+
def artifact_path(root: Path, change_id: str, stage: dict) -> Path | None:
|
|
147
|
+
artifact = stage.get("artifact")
|
|
148
|
+
return change_dir(root, change_id) / artifact if artifact else None
|
|
149
|
+
|
|
150
|
+
def current_signature(root: Path, change_id: str, stage_id: str, memo: dict | None = None, visiting: set[str] | None = None) -> str:
|
|
151
|
+
memo = memo if memo is not None else {}
|
|
152
|
+
if stage_id in memo:
|
|
153
|
+
return memo[stage_id]
|
|
154
|
+
visiting = visiting if visiting is not None else set()
|
|
155
|
+
if stage_id in visiting:
|
|
156
|
+
raise CabbageError(f"dependency cycle detected involving stage: `{stage_id}`")
|
|
157
|
+
visiting.add(stage_id)
|
|
158
|
+
|
|
159
|
+
spec = change_spec(root, change_id)
|
|
160
|
+
wf, wf_path = workflow(root, spec["type"])
|
|
161
|
+
smap = stage_map(wf)
|
|
162
|
+
if stage_id not in smap:
|
|
163
|
+
raise CabbageError(f"unknown stage: {stage_id}")
|
|
164
|
+
stage = smap[stage_id]
|
|
165
|
+
ap = artifact_path(root, change_id, stage)
|
|
166
|
+
payload = {
|
|
167
|
+
"workflow": sha256_file(wf_path),
|
|
168
|
+
"context": stage_context(spec, stage),
|
|
169
|
+
"stage": stage,
|
|
170
|
+
"artifact": sha256_file(ap) if ap else None,
|
|
171
|
+
"dependencies": {d: current_signature(root, change_id, d, memo, visiting.copy()) for d in stage.get("depends_on", []) if d in smap and condition_enabled(smap[d], spec)},
|
|
172
|
+
}
|
|
173
|
+
sig = sha256_bytes(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode())
|
|
174
|
+
memo[stage_id] = sig
|
|
175
|
+
visiting.remove(stage_id)
|
|
176
|
+
return sig
|
|
177
|
+
|
|
178
|
+
def stage_statuses(root: Path, change_id: str) -> list[dict]:
|
|
179
|
+
spec = change_spec(root, change_id)
|
|
180
|
+
wf, _ = workflow(root, spec["type"])
|
|
181
|
+
state = load_state(root, change_id)
|
|
182
|
+
completed = state.get("completed", {})
|
|
183
|
+
out=[]
|
|
184
|
+
memo={}
|
|
185
|
+
for stage in wf.get("stages", []):
|
|
186
|
+
sid=stage["id"]
|
|
187
|
+
enabled=condition_enabled(stage, spec)
|
|
188
|
+
if not enabled:
|
|
189
|
+
status="skipped"
|
|
190
|
+
elif sid not in completed:
|
|
191
|
+
status="pending"
|
|
192
|
+
else:
|
|
193
|
+
status="done" if completed[sid].get("signature") == current_signature(root, change_id, sid, memo) else "stale"
|
|
194
|
+
out.append({"id": sid, "status": status, "artifact": stage.get("artifact"), "description": stage.get("description", "")})
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
198
|
+
if not text.startswith("---\n"):
|
|
199
|
+
return {}, text
|
|
200
|
+
end=text.find("\n---\n", 4)
|
|
201
|
+
if end < 0:
|
|
202
|
+
return {}, text
|
|
203
|
+
try:
|
|
204
|
+
meta=yaml.safe_load(text[4:end]) or {}
|
|
205
|
+
except yaml.YAMLError as e:
|
|
206
|
+
raise CabbageError(f"invalid frontmatter YAML: {e}")
|
|
207
|
+
return meta, text[end+5:]
|
|
208
|
+
|
|
209
|
+
def extract_headings_and_slugs(markdown_text: str) -> set[str]:
|
|
210
|
+
slugs = set()
|
|
211
|
+
for line in markdown_text.splitlines():
|
|
212
|
+
m = re.match(r"^#+\s+(.+)$", line)
|
|
213
|
+
if m:
|
|
214
|
+
title = m.group(1).strip()
|
|
215
|
+
slug = re.sub(r"[^\w\s-]", "", title).strip().lower()
|
|
216
|
+
slug = re.sub(r"[\s_]+", "-", slug)
|
|
217
|
+
if slug:
|
|
218
|
+
slugs.add(slug)
|
|
219
|
+
slugs.add(title.lower())
|
|
220
|
+
return slugs
|
|
221
|
+
|
|
222
|
+
def validate_markdown(root: Path, change_id: str, stage: dict, verification: bool=False) -> list[str]:
|
|
223
|
+
errors=[]
|
|
224
|
+
ap=artifact_path(root, change_id, stage)
|
|
225
|
+
if not ap:
|
|
226
|
+
return errors
|
|
227
|
+
if not ap.exists():
|
|
228
|
+
return [f"{stage['id']}: missing artifact {ap.relative_to(root)}"]
|
|
229
|
+
text=ap.read_text(encoding="utf-8")
|
|
230
|
+
meta, body=parse_frontmatter(text)
|
|
231
|
+
if meta.get("change") != change_id:
|
|
232
|
+
errors.append(f"{stage['id']}: frontmatter `change` must be `{change_id}`")
|
|
233
|
+
if meta.get("cabbage_stage") != stage["id"]:
|
|
234
|
+
errors.append(f"{stage['id']}: frontmatter `cabbage_stage` must be `{stage['id']}`")
|
|
235
|
+
for heading in stage.get("required_headings", []):
|
|
236
|
+
if not re.search(rf"^#+\s+{re.escape(heading)}\s*$", body, flags=re.M|re.I):
|
|
237
|
+
errors.append(f"{stage['id']}: missing heading `{heading}`")
|
|
238
|
+
if verification:
|
|
239
|
+
if re.search(r"\b(TODO|TBD|FIXME)\b", body, flags=re.I):
|
|
240
|
+
errors.append(f"{stage['id']}: unresolved TODO/TBD/FIXME")
|
|
241
|
+
body_lines = {
|
|
242
|
+
re.sub(r"^\s*[-*]\s+", "", line).strip() for line in body.splitlines()
|
|
243
|
+
}
|
|
244
|
+
has_placeholder = re.search(r"<!--\s*CABBAGE:", body, flags=re.I) or any(
|
|
245
|
+
placeholder in body_lines for placeholder in LEGACY_PLACEHOLDERS
|
|
246
|
+
)
|
|
247
|
+
if has_placeholder:
|
|
248
|
+
errors.append(
|
|
249
|
+
f"{stage['id']}: placeholder content remains; replace all template prompts"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Mermaid verification
|
|
253
|
+
in_mermaid = False
|
|
254
|
+
mermaid_blocks = []
|
|
255
|
+
cur_block = []
|
|
256
|
+
for line in text.splitlines():
|
|
257
|
+
stripped = line.strip()
|
|
258
|
+
if not in_mermaid and stripped.startswith("```mermaid"):
|
|
259
|
+
in_mermaid = True
|
|
260
|
+
cur_block = []
|
|
261
|
+
elif in_mermaid and stripped == "```":
|
|
262
|
+
in_mermaid = False
|
|
263
|
+
mermaid_blocks.append("\n".join(cur_block))
|
|
264
|
+
elif in_mermaid:
|
|
265
|
+
cur_block.append(line)
|
|
266
|
+
if in_mermaid:
|
|
267
|
+
errors.append(f"{stage['id']}: unclosed Mermaid block")
|
|
268
|
+
|
|
269
|
+
MERMAID_TYPES = (
|
|
270
|
+
"graph", "flowchart", "sequenceDiagram", "classDiagram", "stateDiagram",
|
|
271
|
+
"stateDiagram-v2", "erDiagram", "journey", "gantt", "pie", "quadrantChart",
|
|
272
|
+
"requirementDiagram", "gitGraph", "c4Context", "mindmap", "timeline", "sankey-beta", "xychart-beta"
|
|
273
|
+
)
|
|
274
|
+
for block in mermaid_blocks:
|
|
275
|
+
clean_lines = [l.strip() for l in block.splitlines() if l.strip() and not l.strip().startswith("%%")]
|
|
276
|
+
if not clean_lines:
|
|
277
|
+
errors.append(f"{stage['id']}: empty Mermaid diagram block")
|
|
278
|
+
elif not any(clean_lines[0].startswith(mtype) for mtype in MERMAID_TYPES):
|
|
279
|
+
errors.append(f"{stage['id']}: Mermaid block missing recognized diagram type (e.g. flowchart, sequenceDiagram)")
|
|
280
|
+
|
|
281
|
+
# Local markdown links and anchors
|
|
282
|
+
for target in re.findall(r"\[[^\]]*\]\(([^)]+)\)", text):
|
|
283
|
+
target=target.strip().split()[0].strip("<>")
|
|
284
|
+
if not target or target.startswith(("http://", "https://", "mailto:")):
|
|
285
|
+
continue
|
|
286
|
+
raw, *anchor = target.split("#", 1)
|
|
287
|
+
anchor_name = anchor[0].strip().lower() if anchor else None
|
|
288
|
+
|
|
289
|
+
if raw:
|
|
290
|
+
p=(ap.parent/raw).resolve()
|
|
291
|
+
try:
|
|
292
|
+
p.relative_to(root.resolve())
|
|
293
|
+
except ValueError:
|
|
294
|
+
errors.append(f"{stage['id']}: link escapes project root: {target}")
|
|
295
|
+
continue
|
|
296
|
+
if not p.exists():
|
|
297
|
+
errors.append(f"{stage['id']}: broken local link: {target}")
|
|
298
|
+
continue
|
|
299
|
+
if p.is_dir():
|
|
300
|
+
if not (p / "README.md").exists() and not (p / "index.md").exists():
|
|
301
|
+
errors.append(f"{stage['id']}: directory link missing README.md/index.md: {target}")
|
|
302
|
+
elif anchor_name and p.suffix.lower() in {".md", ".mdx"}:
|
|
303
|
+
target_slugs = extract_headings_and_slugs(p.read_text(encoding="utf-8"))
|
|
304
|
+
if anchor_name not in target_slugs:
|
|
305
|
+
errors.append(f"{stage['id']}: broken anchor in link: {target}")
|
|
306
|
+
elif anchor_name:
|
|
307
|
+
current_slugs = extract_headings_and_slugs(text)
|
|
308
|
+
if anchor_name not in current_slugs:
|
|
309
|
+
errors.append(f"{stage['id']}: broken internal anchor: #{anchor_name}")
|
|
310
|
+
|
|
311
|
+
if stage.get("validator") == "checklist":
|
|
312
|
+
boxes=re.findall(r"^\s*[-*]\s+\[([ xX\-\/])\]", body, flags=re.M)
|
|
313
|
+
if not boxes:
|
|
314
|
+
errors.append(f"{stage['id']}: expected at least one task checkbox")
|
|
315
|
+
if verification and any(x.strip() == "" for x in boxes):
|
|
316
|
+
errors.append(f"{stage['id']}: unchecked implementation tasks remain")
|
|
317
|
+
return errors
|
|
318
|
+
|
|
319
|
+
def dependency_errors(root: Path, change_id: str, stage: dict) -> list[str]:
|
|
320
|
+
spec=change_spec(root, change_id); wf,_=workflow(root,spec["type"]); smap=stage_map(wf)
|
|
321
|
+
statuses={x["id"]:x["status"] for x in stage_statuses(root,change_id)}
|
|
322
|
+
errors=[]
|
|
323
|
+
for dep in stage.get("depends_on", []):
|
|
324
|
+
if dep in smap and condition_enabled(smap[dep], spec) and statuses.get(dep) != "done":
|
|
325
|
+
errors.append(f"{stage['id']}: dependency `{dep}` is {statuses.get(dep)}")
|
|
326
|
+
return errors
|
|
327
|
+
|
|
328
|
+
def verify_stage(root: Path, change_id: str, stage_id: str) -> None:
|
|
329
|
+
spec=change_spec(root,change_id); wf,_=workflow(root,spec["type"]); smap=stage_map(wf)
|
|
330
|
+
if stage_id not in smap: raise CabbageError(f"unknown stage: {stage_id}")
|
|
331
|
+
st=smap[stage_id]
|
|
332
|
+
if not condition_enabled(st,spec): raise CabbageError(f"stage `{stage_id}` is skipped by current impact")
|
|
333
|
+
errors=dependency_errors(root,change_id,st)+validate_markdown(root,change_id,st,verification=True)
|
|
334
|
+
if errors: raise CabbageError("\n".join(errors))
|
|
335
|
+
state=load_state(root,change_id)
|
|
336
|
+
state["completed"][stage_id]={"signature":current_signature(root,change_id,stage_id),"verified_at":now_iso(),"completed_at":now_iso()}
|
|
337
|
+
save_state(root,change_id,state)
|
|
338
|
+
|
|
339
|
+
DEFAULT_STAGE_DOCS_MAPPING = {
|
|
340
|
+
"requirement": "01-product/{change_id}.md",
|
|
341
|
+
"design": "03-architecture/system-design/{change_id}.md",
|
|
342
|
+
"adr": "03-architecture/adr/{change_id}.md",
|
|
343
|
+
"rfc": "03-architecture/rfc/{change_id}.md",
|
|
344
|
+
"api": "05-api/{change_id}.md",
|
|
345
|
+
"database": "04-domain/{change_id}.md",
|
|
346
|
+
"security": "09-security/{change_id}.md",
|
|
347
|
+
"tests": "08-testing/{change_id}.md",
|
|
348
|
+
"release": "12-release/{change_id}.md",
|
|
349
|
+
"incident": "15-incidents/{change_id}.md",
|
|
350
|
+
"postmortem": "15-incidents/{change_id}-postmortem.md",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def stage_docs_mapping(root: Path) -> dict[str, str]:
|
|
354
|
+
cfg = load_config(root)
|
|
355
|
+
custom = cfg.get("docs", {}).get("mapping", {})
|
|
356
|
+
mapping = dict(DEFAULT_STAGE_DOCS_MAPPING)
|
|
357
|
+
mapping.update(custom)
|
|
358
|
+
return mapping
|
|
359
|
+
|
|
360
|
+
def sync_change_to_docs(root: Path, change_id: str) -> list[str]:
|
|
361
|
+
spec=change_spec(root, change_id); wf,_=workflow(root, spec["type"])
|
|
362
|
+
docs_name=load_config(root).get("docs",{}).get("dir","docs")
|
|
363
|
+
docs_root=root/docs_name
|
|
364
|
+
mapping=stage_docs_mapping(root)
|
|
365
|
+
synced=[]
|
|
366
|
+
for st in wf.get("stages",[]):
|
|
367
|
+
sid=st["id"]
|
|
368
|
+
if not condition_enabled(st, spec):
|
|
369
|
+
continue
|
|
370
|
+
rel_target_tmpl=mapping.get(sid)
|
|
371
|
+
if not rel_target_tmpl:
|
|
372
|
+
continue
|
|
373
|
+
ap=artifact_path(root, change_id, st)
|
|
374
|
+
if not ap or not ap.exists():
|
|
375
|
+
continue
|
|
376
|
+
text=ap.read_text(encoding="utf-8")
|
|
377
|
+
meta, body=parse_frontmatter(text)
|
|
378
|
+
dest_meta={
|
|
379
|
+
"origin_change": change_id,
|
|
380
|
+
"change_type": spec.get("type", "feature"),
|
|
381
|
+
"cabbage_stage": sid,
|
|
382
|
+
"synced_at": now_iso(),
|
|
383
|
+
}
|
|
384
|
+
dest_text=f"---\n{yaml.safe_dump(dest_meta, sort_keys=False, allow_unicode=True)}---\n\n{body.lstrip()}"
|
|
385
|
+
dest_path=docs_root/rel_target_tmpl.format(change_id=change_id)
|
|
386
|
+
write_text_atomic(dest_path, dest_text, encoding="utf-8")
|
|
387
|
+
synced.append(str(dest_path.relative_to(root)).replace("\\","/"))
|
|
388
|
+
return synced
|
|
389
|
+
|
|
390
|
+
def validate_change(root: Path, change_id: str, verification: bool=False) -> list[str]:
|
|
391
|
+
errors=[]
|
|
392
|
+
spec=change_spec(root,change_id)
|
|
393
|
+
if spec.get("id") != change_id: errors.append(f"change.yaml id must be `{change_id}`")
|
|
394
|
+
if spec.get("status") not in {"active","archived"}: errors.append("change.yaml status must be active or archived")
|
|
395
|
+
cfg=load_config(root); known=set(cfg.get("impact_fields",[])); actual=set(spec.get("impact",{}))
|
|
396
|
+
missing=known-actual
|
|
397
|
+
if missing: errors.append("missing impact fields: "+", ".join(sorted(missing)))
|
|
398
|
+
wf,_=workflow(root,spec["type"])
|
|
399
|
+
for st in wf.get("stages",[]):
|
|
400
|
+
if condition_enabled(st,spec):
|
|
401
|
+
errors.extend(validate_markdown(root,change_id,st,verification=verification))
|
|
402
|
+
return errors
|
|
403
|
+
|
|
404
|
+
def gate(root: Path, change_id: str, target: str) -> list[str]:
|
|
405
|
+
spec=change_spec(root,change_id); wf,_=workflow(root,spec["type"])
|
|
406
|
+
statuses=stage_statuses(root,change_id)
|
|
407
|
+
order=[x["id"] for x in statuses]
|
|
408
|
+
if target in {"merge","archive"}:
|
|
409
|
+
required=statuses
|
|
410
|
+
elif target in order:
|
|
411
|
+
# 阶段 id 作为目标:校验其全部前置阶段(条件未启用的阶段为 skipped,不阻塞)
|
|
412
|
+
required=statuses[:order.index(target)]
|
|
413
|
+
else:
|
|
414
|
+
return [f"unknown gate target: {target} (use a stage id, merge, or archive)"]
|
|
415
|
+
return [f"{x['id']} is {x['status']}" for x in required if x["status"] not in {"done","skipped"}]
|
|
416
|
+
|
|
417
|
+
def ready_to_archive(statuses: list[dict]) -> bool:
|
|
418
|
+
"""merge/archive 判定:全部门禁阶段均为 done/skipped(status 提示与门禁共用同一语义)。"""
|
|
419
|
+
return bool(statuses) and not [x for x in statuses if x["status"] not in {"done","skipped"}]
|
|
420
|
+
|
|
421
|
+
def git_changed_files(root: Path, base: str) -> list[str]:
|
|
422
|
+
proc=subprocess.run(["git","diff","--name-only",f"{base}...HEAD"],cwd=root,text=True,capture_output=True)
|
|
423
|
+
if proc.returncode != 0:
|
|
424
|
+
raise CabbageError(proc.stderr.strip() or f"git diff failed against {base}")
|
|
425
|
+
return [x.strip() for x in proc.stdout.splitlines() if x.strip()]
|
|
426
|
+
|
|
427
|
+
KNOWN_EXCLUDE_FILES = {
|
|
428
|
+
"README.md", "README_zh.md", "LICENSE", "LICENSE.md", "LICENSE.txt",
|
|
429
|
+
".gitignore", ".gitattributes", ".editorconfig", ".prettierrc", ".prettierignore",
|
|
430
|
+
".eslintignore", ".eslintrc", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
|
431
|
+
"requirements.txt", "pyproject.toml", "Makefile", "Dockerfile", "docker-compose.yml"
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
def is_code_change(path: str, cfg: dict) -> bool:
|
|
435
|
+
normalized=path.replace("\\","/")
|
|
436
|
+
excludes=tuple(cfg.get("ci",{}).get("exclude_prefixes",["docs/",".cabbage/",".github/"]))
|
|
437
|
+
if any(normalized.startswith(x) for x in excludes): return False
|
|
438
|
+
filename = Path(normalized).name
|
|
439
|
+
if filename in KNOWN_EXCLUDE_FILES: return False
|
|
440
|
+
if normalized.endswith((".md",".mdx",".txt")): return False
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
def ci_check(root: Path, base: str) -> list[str]:
|
|
444
|
+
cfg=load_config(root); files=git_changed_files(root,base); errors=[]
|
|
445
|
+
changed_changes=set(); archived_changes=set()
|
|
446
|
+
rx=re.compile(r"^\.cabbage/changes/([^/]+)/")
|
|
447
|
+
arx=re.compile(r"^\.cabbage/archive/[^/]+/([^/]+)/")
|
|
448
|
+
for f in files:
|
|
449
|
+
m=rx.match(f)
|
|
450
|
+
if m: changed_changes.add(m.group(1))
|
|
451
|
+
am=arx.match(f)
|
|
452
|
+
if am: archived_changes.add(am.group(1))
|
|
453
|
+
code=[f for f in files if is_code_change(f,cfg)]
|
|
454
|
+
existing_changes={cid for cid in changed_changes if change_dir(root,cid).exists()}
|
|
455
|
+
if code and cfg.get("ci",{}).get("require_change_for_code",True) and not existing_changes:
|
|
456
|
+
errors.append("code changed but no active .cabbage/changes/<id>/ entry changed")
|
|
457
|
+
for cid in sorted(changed_changes-existing_changes):
|
|
458
|
+
if cid not in archived_changes:
|
|
459
|
+
errors.append(f"change record `{cid}` was removed from active changes; use `cabbage archive` rather than deleting workflow history")
|
|
460
|
+
for cid in sorted(existing_changes):
|
|
461
|
+
errors.extend(f"{cid}: {e}" for e in validate_change(root,cid))
|
|
462
|
+
errors.extend(f"{cid}: merge gate: {e}" for e in gate(root,cid,"merge"))
|
|
463
|
+
if cfg.get("ci",{}).get("require_current_state_docs",True):
|
|
464
|
+
spec=change_spec(root,cid)
|
|
465
|
+
wf,_=workflow(root,spec["type"])
|
|
466
|
+
record_covers=wf.get("record_covers",[])
|
|
467
|
+
rules=cfg.get("ci",{}).get("current_state_rules",{})
|
|
468
|
+
for area,prefixes in rules.items():
|
|
469
|
+
if area in record_covers:
|
|
470
|
+
continue
|
|
471
|
+
if spec.get("impact",{}).get(area,False) and not any(any(f.startswith(prefix) for prefix in prefixes) for f in files):
|
|
472
|
+
errors.append(f"{cid}: impact `{area}=true` requires a current-state docs change under: {', '.join(prefixes)}")
|
|
473
|
+
return errors
|
|
474
|
+
|
|
475
|
+
def parse_tasks_markdown(text: str) -> dict:
|
|
476
|
+
# 1. Parse mermaid diagram
|
|
477
|
+
mermaid_match = re.search(r"```mermaid\s*\n(flowchart\s+[\s\S]*?)\n```", text)
|
|
478
|
+
mermaid_code = mermaid_match.group(1).strip() if mermaid_match else None
|
|
479
|
+
|
|
480
|
+
# 2. Parse Task sections (## Task <slug>)
|
|
481
|
+
task_sections = re.findall(r"(?m)^##\s+(Task\b[^\n]*)\n([\s\S]*?)(?=(?:^##\s+|\Z))", text)
|
|
482
|
+
tasks = []
|
|
483
|
+
structured = True
|
|
484
|
+
|
|
485
|
+
if task_sections:
|
|
486
|
+
for header, body in task_sections:
|
|
487
|
+
header_clean = header.strip()
|
|
488
|
+
# Extract id and title
|
|
489
|
+
m = re.match(r"^Task\s*([0-9A-Za-z_-]+)?(?::\s*(.*))?$", header_clean, flags=re.I)
|
|
490
|
+
raw_id = m.group(1) if m and m.group(1) else ""
|
|
491
|
+
task_id = f"Task {raw_id}".strip() if raw_id else header_clean
|
|
492
|
+
title = (m.group(2).strip() if m and m.group(2) else header_clean)
|
|
493
|
+
|
|
494
|
+
# Builds
|
|
495
|
+
builds_match = re.search(r"-\s+\*\*Builds\*\*:\s*([^\n]+)", body)
|
|
496
|
+
builds = builds_match.group(1).strip() if builds_match else title
|
|
497
|
+
|
|
498
|
+
# Blocked By
|
|
499
|
+
blocked_match = re.search(r"-\s+\*\*Blocked By\*\*:\s*([^\n]+)", body)
|
|
500
|
+
blocked_raw = blocked_match.group(1).strip() if blocked_match else "None"
|
|
501
|
+
if blocked_raw.lower() in {"none", "n/a", "nil", ""}:
|
|
502
|
+
blocked_by = []
|
|
503
|
+
else:
|
|
504
|
+
# Split by comma or semicolon
|
|
505
|
+
blocked_by = [x.strip() for x in re.split(r"[,;]+", blocked_raw) if x.strip()]
|
|
506
|
+
|
|
507
|
+
# Parallel Group
|
|
508
|
+
group_match = re.search(r"-\s+\*\*Parallel Group\*\*:\s*([^\n]+)", body)
|
|
509
|
+
group = group_match.group(1).strip() if group_match else "Default"
|
|
510
|
+
|
|
511
|
+
# Verification command
|
|
512
|
+
verif_match = re.search(r"-\s+\*\*Verification\*\*:\s*`?([^`\n]+)`?", body)
|
|
513
|
+
verification = verif_match.group(1).strip() if verif_match else "N/A"
|
|
514
|
+
|
|
515
|
+
# Standard Operating Procedure (SOP)
|
|
516
|
+
sop_match = re.search(r"-\s+\*\*Standard Operating Procedure[^\n]*\*\*:\s*\n((?:\s*(?:\d+\.|\*|-)\s+(?!\[[ xX\-\/]\])[^\n]+\n?)+)", body)
|
|
517
|
+
sop_steps = []
|
|
518
|
+
if sop_match:
|
|
519
|
+
sop_steps = [re.sub(r"^\s*(?:\d+\.|\*|-)\s+", "", line).strip() for line in sop_match.group(1).splitlines() if line.strip()]
|
|
520
|
+
|
|
521
|
+
# Checkboxes
|
|
522
|
+
cb_matches = re.findall(r"^\s*[-*]\s+\[([ xX\-\/])\]\s*([^\n]+)", body, flags=re.M)
|
|
523
|
+
checkboxes = [{"checked": val.lower() == "x", "text": desc.strip()} for val, desc in cb_matches]
|
|
524
|
+
is_completed = bool(checkboxes) and all(cb["checked"] for cb in checkboxes)
|
|
525
|
+
|
|
526
|
+
tasks.append({
|
|
527
|
+
"task_id": task_id,
|
|
528
|
+
"title": title,
|
|
529
|
+
"builds": builds,
|
|
530
|
+
"blocked_by": blocked_by,
|
|
531
|
+
"parallel_group": group,
|
|
532
|
+
"verification": verification,
|
|
533
|
+
"sop": sop_steps,
|
|
534
|
+
"checkboxes": checkboxes,
|
|
535
|
+
"is_completed": is_completed,
|
|
536
|
+
})
|
|
537
|
+
else:
|
|
538
|
+
# Fallback: scan checkboxes in # Tasks section. These tasks declare no
|
|
539
|
+
# dependencies or verification, so they are not dispatchable.
|
|
540
|
+
structured = False
|
|
541
|
+
tasks_block_match = re.search(r"(?m)^#\s+Tasks\b([\s\S]*?)(?=(?:^#\s+|\Z))", text)
|
|
542
|
+
block = tasks_block_match.group(1) if tasks_block_match else text
|
|
543
|
+
cb_matches = re.findall(r"^\s*[-*]\s+\[([ xX\-\/])\]\s*([^\n]+)", block, flags=re.M)
|
|
544
|
+
for idx, (val, desc) in enumerate(cb_matches, start=1):
|
|
545
|
+
is_done = val.lower() == "x"
|
|
546
|
+
tasks.append({
|
|
547
|
+
"task_id": f"Task {idx}",
|
|
548
|
+
"title": desc.strip(),
|
|
549
|
+
"builds": desc.strip(),
|
|
550
|
+
"blocked_by": [],
|
|
551
|
+
"parallel_group": "Default",
|
|
552
|
+
"verification": "N/A",
|
|
553
|
+
"sop": [],
|
|
554
|
+
"checkboxes": [{"checked": is_done, "text": desc.strip()}],
|
|
555
|
+
"is_completed": is_done,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
# 3. Calculate status, topological readiness
|
|
559
|
+
completed_ids = {t["task_id"] for t in tasks if t["is_completed"]}
|
|
560
|
+
|
|
561
|
+
for t in tasks:
|
|
562
|
+
if t["is_completed"]:
|
|
563
|
+
t["status"] = "done"
|
|
564
|
+
t["is_ready"] = False
|
|
565
|
+
else:
|
|
566
|
+
unresolved = [d for d in t["blocked_by"] if d not in completed_ids and d.lower() not in {"none", "preparation", ""}]
|
|
567
|
+
if not unresolved:
|
|
568
|
+
t["status"] = "ready"
|
|
569
|
+
t["is_ready"] = True
|
|
570
|
+
t["unresolved_deps"] = []
|
|
571
|
+
else:
|
|
572
|
+
t["status"] = "blocked"
|
|
573
|
+
t["is_ready"] = False
|
|
574
|
+
t["unresolved_deps"] = unresolved
|
|
575
|
+
|
|
576
|
+
# 4. Group by parallel_group
|
|
577
|
+
parallel_groups: dict[str, list[dict]] = {}
|
|
578
|
+
for t in tasks:
|
|
579
|
+
grp = t.get("parallel_group", "Default")
|
|
580
|
+
parallel_groups.setdefault(grp, []).append(t)
|
|
581
|
+
|
|
582
|
+
total_tasks = len(tasks)
|
|
583
|
+
completed_count = sum(1 for t in tasks if t["is_completed"])
|
|
584
|
+
ready_count = sum(1 for t in tasks if t["is_ready"])
|
|
585
|
+
blocked_count = total_tasks - completed_count - ready_count
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
"mermaid": mermaid_code,
|
|
589
|
+
"structured": structured,
|
|
590
|
+
"total_tasks": total_tasks,
|
|
591
|
+
"completed_tasks": completed_count,
|
|
592
|
+
"ready_tasks": ready_count,
|
|
593
|
+
"blocked_tasks": blocked_count,
|
|
594
|
+
"tasks": tasks,
|
|
595
|
+
"parallel_groups": parallel_groups,
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
def get_change_tasks_dag(root: Path, change_id: str) -> dict:
|
|
599
|
+
tasks_file = change_dir(root, change_id) / "tasks.md"
|
|
600
|
+
if not tasks_file.exists():
|
|
601
|
+
raise CabbageError(f"tasks.md not found for change `{change_id}`")
|
|
602
|
+
content = tasks_file.read_text(encoding="utf-8")
|
|
603
|
+
body = parse_frontmatter(content)[1]
|
|
604
|
+
result = parse_tasks_markdown(body)
|
|
605
|
+
result["change"] = change_id
|
|
606
|
+
return result
|