@blxzer/cursor-trellis 0.3.6 → 0.4.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/CHANGELOG.md +23 -0
- package/README.md +6 -6
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +5 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +39 -2
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/constants/paths.d.ts +4 -0
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +4 -0
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.4.0.json +9 -0
- package/dist/templates/markdown/index.d.ts +6 -0
- package/dist/templates/markdown/index.d.ts.map +1 -1
- package/dist/templates/markdown/index.js +6 -0
- package/dist/templates/markdown/index.js.map +1 -1
- package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
- package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
- package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
- package/dist/templates/markdown/spec/guides/index.md.txt +37 -0
- package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +4 -0
- package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
- package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +1 -0
- package/dist/templates/trellis/index.d.ts +12 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +26 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/pool/README.md +103 -0
- package/dist/templates/trellis/pool/items/.gitkeep +0 -0
- package/dist/templates/trellis/pool/plan.md +26 -0
- package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
- package/dist/templates/trellis/scripts/common/task_dashboard.py +8 -0
- package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
- package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
- package/dist/templates/trellis/scripts/common/task_store.py +256 -0
- package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
- package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
- package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
- package/dist/templates/trellis/scripts/pool.py +192 -0
- package/dist/templates/trellis/scripts/task.py +66 -1
- package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
- package/dist/templates/trellis/workflow.md +32 -2
- package/package.json +2 -2
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for the review-pool store (read, write, links, validation, plan)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
13
|
+
|
|
14
|
+
from common.pool_store import (
|
|
15
|
+
check_plan,
|
|
16
|
+
find_item_path,
|
|
17
|
+
get_linked_tasks,
|
|
18
|
+
get_pool_items,
|
|
19
|
+
link_item_task,
|
|
20
|
+
list_items,
|
|
21
|
+
load_item,
|
|
22
|
+
normalize_id_list,
|
|
23
|
+
parse_item_file,
|
|
24
|
+
plan_referenced_ids,
|
|
25
|
+
unlink_item_task,
|
|
26
|
+
validate_pool,
|
|
27
|
+
write_item_frontmatter,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ITEM_TEMPLATE = """\
|
|
31
|
+
---
|
|
32
|
+
id: {item_id}
|
|
33
|
+
title: {title}
|
|
34
|
+
status: {status}
|
|
35
|
+
type: mechanism
|
|
36
|
+
locale: zh
|
|
37
|
+
created: 2026-08-08
|
|
38
|
+
approved: 2026-08-08
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 意图
|
|
42
|
+
|
|
43
|
+
one line
|
|
44
|
+
|
|
45
|
+
## 动机
|
|
46
|
+
|
|
47
|
+
why
|
|
48
|
+
|
|
49
|
+
## 粗验收
|
|
50
|
+
|
|
51
|
+
- a
|
|
52
|
+
- b
|
|
53
|
+
|
|
54
|
+
## 非目标
|
|
55
|
+
|
|
56
|
+
- c
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _repo(tmp_path: Path) -> Path:
|
|
61
|
+
repo = tmp_path / "repo"
|
|
62
|
+
(repo / ".cstl" / "pool" / "items").mkdir(parents=True)
|
|
63
|
+
(repo / ".cstl" / "tasks").mkdir(parents=True)
|
|
64
|
+
return repo
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_item(repo: Path, item_id: str, *, title: str = "t", status: str = "accepted") -> Path:
|
|
68
|
+
path = repo / ".cstl" / "pool" / "items" / f"{item_id}.md"
|
|
69
|
+
path.write_text(
|
|
70
|
+
ITEM_TEMPLATE.format(item_id=item_id, title=title, status=status),
|
|
71
|
+
encoding="utf-8",
|
|
72
|
+
)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _write_task(
|
|
77
|
+
repo: Path,
|
|
78
|
+
dir_name: str,
|
|
79
|
+
status: str = "completed",
|
|
80
|
+
meta: dict | None = None,
|
|
81
|
+
) -> Path:
|
|
82
|
+
task_dir = repo / ".cstl" / "tasks" / dir_name
|
|
83
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
data: dict = {"id": dir_name, "name": dir_name, "status": status}
|
|
85
|
+
if meta is not None:
|
|
86
|
+
data["meta"] = meta
|
|
87
|
+
(task_dir / "task.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
88
|
+
return task_dir
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write_plan(repo: Path, text: str) -> Path:
|
|
92
|
+
plan_file = repo / ".cstl" / "pool" / "plan.md"
|
|
93
|
+
plan_file.write_text(text, encoding="utf-8")
|
|
94
|
+
return plan_file
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Load / parse
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
def test_parse_item_file_loads_fields(tmp_path: Path) -> None:
|
|
102
|
+
repo = _repo(tmp_path)
|
|
103
|
+
path = _write_item(repo, "P01", title="审核池状态机")
|
|
104
|
+
item = parse_item_file(path)
|
|
105
|
+
assert item.id == "P01"
|
|
106
|
+
assert item.title == "审核池状态机"
|
|
107
|
+
assert item.status == "accepted"
|
|
108
|
+
assert item.type == "mechanism"
|
|
109
|
+
assert item.linked_tasks == []
|
|
110
|
+
assert "## 动机" in item.body
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_list_items_and_load_item(tmp_path: Path) -> None:
|
|
114
|
+
repo = _repo(tmp_path)
|
|
115
|
+
_write_item(repo, "P02")
|
|
116
|
+
_write_item(repo, "P01")
|
|
117
|
+
items = list_items(repo)
|
|
118
|
+
assert [item.id for item in items] == ["P01", "P02"]
|
|
119
|
+
|
|
120
|
+
loaded = load_item(repo, "P02")
|
|
121
|
+
assert loaded is not None and loaded.id == "P02"
|
|
122
|
+
assert load_item(repo, "P99") is None
|
|
123
|
+
assert find_item_path(repo, "P01") == repo / ".cstl" / "pool" / "items" / "P01.md"
|
|
124
|
+
assert find_item_path(repo, "P99") is None
|
|
125
|
+
assert list_items(tmp_path / "empty") == [] # no pool dir -> empty
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_normalize_id_list_dedupes_preserves_order() -> None:
|
|
129
|
+
assert normalize_id_list(["b", " a ", "b", "", "c", 5]) == ["b", "a", "c"]
|
|
130
|
+
assert normalize_id_list(None) == []
|
|
131
|
+
assert normalize_id_list("single") == ["single"]
|
|
132
|
+
assert normalize_id_list(" ") == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =============================================================================
|
|
136
|
+
# Frontmatter write
|
|
137
|
+
# =============================================================================
|
|
138
|
+
|
|
139
|
+
def test_write_frontmatter_preserves_body_and_key_order(tmp_path: Path) -> None:
|
|
140
|
+
repo = _repo(tmp_path)
|
|
141
|
+
path = _write_item(repo, "P01")
|
|
142
|
+
original = path.read_text(encoding="utf-8")
|
|
143
|
+
original_body = original[original.index("---\n", 4) + 5 :]
|
|
144
|
+
|
|
145
|
+
write_item_frontmatter(path, {"status": "review", "linked_tasks": ["task-x"]})
|
|
146
|
+
|
|
147
|
+
rewritten = path.read_text(encoding="utf-8")
|
|
148
|
+
assert rewritten[rewritten.index("---\n", 4) + 5 :] == original_body # body byte-identical
|
|
149
|
+
item = parse_item_file(path)
|
|
150
|
+
assert item.status == "review"
|
|
151
|
+
assert item.linked_tasks == ["task-x"]
|
|
152
|
+
|
|
153
|
+
fm_lines = [line for line in rewritten.splitlines() if ":" in line and not line.startswith("-")]
|
|
154
|
+
keys = [line.split(":", 1)[0] for line in fm_lines]
|
|
155
|
+
assert keys == ["id", "title", "status", "type", "locale", "created", "approved", "linked_tasks"]
|
|
156
|
+
assert keys == list(item.frontmatter.keys())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_write_frontmatter_keeps_unknown_key_relative_order(tmp_path: Path) -> None:
|
|
160
|
+
repo = _repo(tmp_path)
|
|
161
|
+
path = repo / ".cstl" / "pool" / "items" / "P05.md"
|
|
162
|
+
path.write_text(
|
|
163
|
+
"---\n"
|
|
164
|
+
"id: P05\n"
|
|
165
|
+
"title: t\n"
|
|
166
|
+
"status: inbox\n"
|
|
167
|
+
"type: task\n"
|
|
168
|
+
"decision_note: keep-me\n"
|
|
169
|
+
"created: 2026-08-08\n"
|
|
170
|
+
"---\n\n## 意图\nx\n",
|
|
171
|
+
encoding="utf-8",
|
|
172
|
+
)
|
|
173
|
+
write_item_frontmatter(path, {"status": "review"})
|
|
174
|
+
item = parse_item_file(path)
|
|
175
|
+
assert item.frontmatter["decision_note"] == "keep-me"
|
|
176
|
+
# known keys first, then the unknown key keeps its original position
|
|
177
|
+
assert list(item.frontmatter.keys()) == [
|
|
178
|
+
"id", "title", "status", "type", "created", "decision_note",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_write_frontmatter_empty_list_round_trips(tmp_path: Path) -> None:
|
|
183
|
+
repo = _repo(tmp_path)
|
|
184
|
+
path = _write_item(repo, "P01")
|
|
185
|
+
write_item_frontmatter(path, {"linked_tasks": []})
|
|
186
|
+
assert parse_item_file(path).linked_tasks == []
|
|
187
|
+
assert "linked_tasks:" in path.read_text(encoding="utf-8")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# =============================================================================
|
|
191
|
+
# Bidirectional link / unlink
|
|
192
|
+
# =============================================================================
|
|
193
|
+
|
|
194
|
+
def test_link_item_task_writes_both_sides_idempotent(tmp_path: Path) -> None:
|
|
195
|
+
repo = _repo(tmp_path)
|
|
196
|
+
_write_item(repo, "P01")
|
|
197
|
+
task_dir = _write_task(repo, "08-09-some-task", status="in_progress")
|
|
198
|
+
|
|
199
|
+
first = link_item_task(repo, "P01", "some-task") # suffix ref -> canonical dir name
|
|
200
|
+
assert first.ok
|
|
201
|
+
assert first.task_ref == "08-09-some-task"
|
|
202
|
+
|
|
203
|
+
item = load_item(repo, "P01")
|
|
204
|
+
assert item is not None
|
|
205
|
+
assert get_linked_tasks(item) == ["08-09-some-task"]
|
|
206
|
+
data = json.loads((task_dir / "task.json").read_text(encoding="utf-8"))
|
|
207
|
+
assert get_pool_items(data) == ["P01"]
|
|
208
|
+
|
|
209
|
+
second = link_item_task(repo, "P01", "08-09-some-task")
|
|
210
|
+
assert second.ok
|
|
211
|
+
item = load_item(repo, "P01")
|
|
212
|
+
assert item is not None
|
|
213
|
+
assert get_linked_tasks(item) == ["08-09-some-task"] # no duplicate
|
|
214
|
+
assert get_pool_items(json.loads((task_dir / "task.json").read_text(encoding="utf-8"))) == ["P01"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_link_multiple_tasks_and_items(tmp_path: Path) -> None:
|
|
218
|
+
repo = _repo(tmp_path)
|
|
219
|
+
_write_item(repo, "P01")
|
|
220
|
+
_write_item(repo, "P02")
|
|
221
|
+
task_a = _write_task(repo, "task-a")
|
|
222
|
+
task_b = _write_task(repo, "task-b")
|
|
223
|
+
|
|
224
|
+
link_item_task(repo, "P01", "task-a")
|
|
225
|
+
link_item_task(repo, "P01", "task-b")
|
|
226
|
+
link_item_task(repo, "P02", "task-a")
|
|
227
|
+
|
|
228
|
+
item_p01 = load_item(repo, "P01")
|
|
229
|
+
assert item_p01 is not None
|
|
230
|
+
assert item_p01.linked_tasks == ["task-a", "task-b"]
|
|
231
|
+
data_a = json.loads((task_a / "task.json").read_text(encoding="utf-8"))
|
|
232
|
+
assert get_pool_items(data_a) == ["P01", "P02"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_link_missing_item_or_task_is_error(tmp_path: Path) -> None:
|
|
236
|
+
repo = _repo(tmp_path)
|
|
237
|
+
_write_item(repo, "P01")
|
|
238
|
+
result = link_item_task(repo, "P99", "task-a")
|
|
239
|
+
assert not result.ok
|
|
240
|
+
assert result.errors[0].code == "item-not-found"
|
|
241
|
+
|
|
242
|
+
result = link_item_task(repo, "P01", "ghost-task")
|
|
243
|
+
assert not result.ok
|
|
244
|
+
assert result.errors[0].code == "task-not-found"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_unlink_removes_both_sides_and_warns_on_missing_side(tmp_path: Path) -> None:
|
|
248
|
+
repo = _repo(tmp_path)
|
|
249
|
+
_write_item(repo, "P01")
|
|
250
|
+
task_dir = _write_task(repo, "task-a")
|
|
251
|
+
link_item_task(repo, "P01", "task-a")
|
|
252
|
+
|
|
253
|
+
result = unlink_item_task(repo, "P01", "task-a")
|
|
254
|
+
assert result.ok
|
|
255
|
+
assert result.warnings == []
|
|
256
|
+
item = load_item(repo, "P01")
|
|
257
|
+
assert item is not None
|
|
258
|
+
assert item.linked_tasks == []
|
|
259
|
+
data = json.loads((task_dir / "task.json").read_text(encoding="utf-8"))
|
|
260
|
+
assert get_pool_items(data) == []
|
|
261
|
+
|
|
262
|
+
# task side already clean -> warning, still ok
|
|
263
|
+
result = unlink_item_task(repo, "P01", "task-a")
|
|
264
|
+
assert result.ok
|
|
265
|
+
assert any(w.code == "no-task-side-link" for w in result.warnings)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_unlink_missing_task_still_cleans_item_side(tmp_path: Path) -> None:
|
|
269
|
+
repo = _repo(tmp_path)
|
|
270
|
+
_write_item(repo, "P01")
|
|
271
|
+
task_dir = _write_task(repo, "task-a")
|
|
272
|
+
link_item_task(repo, "P01", "task-a")
|
|
273
|
+
# task dir disappears (simulates dangling state)
|
|
274
|
+
(task_dir / "task.json").unlink()
|
|
275
|
+
|
|
276
|
+
result = unlink_item_task(repo, "P01", "task-a")
|
|
277
|
+
assert result.ok
|
|
278
|
+
assert any(w.code == "task-not-found" for w in result.warnings)
|
|
279
|
+
item = load_item(repo, "P01")
|
|
280
|
+
assert item is not None
|
|
281
|
+
assert item.linked_tasks == []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_unlink_missing_item_is_error(tmp_path: Path) -> None:
|
|
285
|
+
repo = _repo(tmp_path)
|
|
286
|
+
result = unlink_item_task(repo, "P99", "task-a")
|
|
287
|
+
assert not result.ok
|
|
288
|
+
assert result.errors[0].code == "item-not-found"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# =============================================================================
|
|
292
|
+
# validate_pool
|
|
293
|
+
# =============================================================================
|
|
294
|
+
|
|
295
|
+
def test_validate_pool_missing_sections_and_keys(tmp_path: Path) -> None:
|
|
296
|
+
repo = _repo(tmp_path)
|
|
297
|
+
path = repo / ".cstl" / "pool" / "items" / "P03.md"
|
|
298
|
+
path.write_text(
|
|
299
|
+
"---\nid: P03\nstatus: accepted\n---\n\n## 意图\nx\n\n## 动机\nx\n",
|
|
300
|
+
encoding="utf-8",
|
|
301
|
+
)
|
|
302
|
+
issues = validate_pool(repo)
|
|
303
|
+
codes = [issue.code for issue in issues]
|
|
304
|
+
assert "missing-section" in codes # 粗验收 / 非目标 missing
|
|
305
|
+
assert "missing-frontmatter-key" in codes # type missing
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_validate_pool_inbox_missing_section_is_warning(tmp_path: Path) -> None:
|
|
309
|
+
repo = _repo(tmp_path)
|
|
310
|
+
path = repo / ".cstl" / "pool" / "items" / "P11.md"
|
|
311
|
+
path.write_text(
|
|
312
|
+
"---\nid: P11\nstatus: inbox\ntype: mechanism\n---\n\n## 意图\nx\n\n## 动机\nx\n",
|
|
313
|
+
encoding="utf-8",
|
|
314
|
+
)
|
|
315
|
+
issues = validate_pool(repo)
|
|
316
|
+
missing = [issue for issue in issues if issue.code == "missing-section"]
|
|
317
|
+
assert len(missing) == 2 # 粗验收 / 非目标 missing
|
|
318
|
+
assert all(not issue.is_error for issue in missing)
|
|
319
|
+
assert not any(issue.is_error for issue in issues) # 纯 WARN → CLI exit 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_validate_pool_accepted_missing_section_is_error(tmp_path: Path) -> None:
|
|
323
|
+
repo = _repo(tmp_path)
|
|
324
|
+
path = repo / ".cstl" / "pool" / "items" / "P12.md"
|
|
325
|
+
path.write_text(
|
|
326
|
+
"---\nid: P12\nstatus: accepted\ntype: mechanism\n---\n\n## 意图\nx\n\n## 动机\nx\n",
|
|
327
|
+
encoding="utf-8",
|
|
328
|
+
)
|
|
329
|
+
issues = validate_pool(repo)
|
|
330
|
+
missing = [issue for issue in issues if issue.code == "missing-section"]
|
|
331
|
+
assert len(missing) == 2
|
|
332
|
+
assert all(issue.is_error for issue in missing)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_validate_pool_invalid_status_and_duplicate_id(tmp_path: Path) -> None:
|
|
336
|
+
repo = _repo(tmp_path)
|
|
337
|
+
_write_item(repo, "P01", status="accepted")
|
|
338
|
+
dup = repo / ".cstl" / "pool" / "items" / "P01-dup.md"
|
|
339
|
+
dup.write_text(
|
|
340
|
+
ITEM_TEMPLATE.format(item_id="P01", title="dup", status="weird"),
|
|
341
|
+
encoding="utf-8",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
issues = validate_pool(repo)
|
|
345
|
+
codes = [issue.code for issue in issues]
|
|
346
|
+
assert "duplicate-id" in codes
|
|
347
|
+
assert "invalid-status" in codes
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_validate_pool_dangling_link_is_error(tmp_path: Path) -> None:
|
|
351
|
+
repo = _repo(tmp_path)
|
|
352
|
+
path = _write_item(repo, "P01")
|
|
353
|
+
write_item_frontmatter(path, {"linked_tasks": ["ghost-task"]})
|
|
354
|
+
|
|
355
|
+
issues = validate_pool(repo)
|
|
356
|
+
assert any(issue.code == "dangling-link" for issue in issues)
|
|
357
|
+
assert all(issue.is_error for issue in issues if issue.code == "dangling-link")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_validate_pool_one_sided_link_is_error(tmp_path: Path) -> None:
|
|
361
|
+
repo = _repo(tmp_path)
|
|
362
|
+
path = _write_item(repo, "P01")
|
|
363
|
+
_write_task(repo, "task-a")
|
|
364
|
+
write_item_frontmatter(path, {"linked_tasks": ["task-a"]}) # item side only
|
|
365
|
+
|
|
366
|
+
issues = validate_pool(repo)
|
|
367
|
+
assert any(issue.code == "link-missing-task-side" for issue in issues)
|
|
368
|
+
|
|
369
|
+
# task side only, item side missing
|
|
370
|
+
write_item_frontmatter(path, {"linked_tasks": []})
|
|
371
|
+
_write_task(repo, "task-b", meta={"pool_items": ["P01"]})
|
|
372
|
+
issues = validate_pool(repo)
|
|
373
|
+
assert any(issue.code == "link-missing-item-side" for issue in issues)
|
|
374
|
+
|
|
375
|
+
# task side references an unknown item
|
|
376
|
+
_write_task(repo, "task-c", meta={"pool_items": ["P99"]})
|
|
377
|
+
issues = validate_pool(repo)
|
|
378
|
+
assert any(issue.code == "link-item-missing" for issue in issues)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_validate_pool_clean_pool_has_no_issues(tmp_path: Path) -> None:
|
|
382
|
+
repo = _repo(tmp_path)
|
|
383
|
+
_write_item(repo, "P01")
|
|
384
|
+
task_dir = _write_task(repo, "task-a")
|
|
385
|
+
link_item_task(repo, "P01", "task-a")
|
|
386
|
+
assert validate_pool(repo) == []
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# =============================================================================
|
|
390
|
+
# check_plan
|
|
391
|
+
# =============================================================================
|
|
392
|
+
|
|
393
|
+
def test_check_plan_ghost_id_is_error(tmp_path: Path) -> None:
|
|
394
|
+
repo = _repo(tmp_path)
|
|
395
|
+
_write_item(repo, "P01")
|
|
396
|
+
_write_plan(repo, "# 计划\n\n- P01 已落地\n- P99 幽灵引用\n")
|
|
397
|
+
issues = check_plan(repo)
|
|
398
|
+
assert any(issue.code == "plan-ghost-id" for issue in issues)
|
|
399
|
+
ghost = next(issue for issue in issues if issue.code == "plan-ghost-id")
|
|
400
|
+
assert "P99" in ghost.message
|
|
401
|
+
assert ghost.is_error
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_check_plan_ignores_code_blocks(tmp_path: Path) -> None:
|
|
405
|
+
repo = _repo(tmp_path)
|
|
406
|
+
_write_item(repo, "P01")
|
|
407
|
+
_write_plan(
|
|
408
|
+
repo,
|
|
409
|
+
"# 计划\n\n```\nP99 inside code block\n```\n\nP01 outside\n",
|
|
410
|
+
)
|
|
411
|
+
issues = check_plan(repo)
|
|
412
|
+
assert not any(issue.code == "plan-ghost-id" for issue in issues)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_plan_referenced_ids_intersects_known() -> None:
|
|
416
|
+
text = "P01 done, P02 later, P99 unknown"
|
|
417
|
+
assert plan_referenced_ids(text, {"P01", "P02"}) == {"P01", "P02"}
|
|
418
|
+
assert plan_referenced_ids(text, {"P01"}) == {"P01"}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_check_plan_accepted_item_not_in_plan_warns(tmp_path: Path) -> None:
|
|
422
|
+
repo = _repo(tmp_path)
|
|
423
|
+
_write_item(repo, "P01")
|
|
424
|
+
_write_item(repo, "P07")
|
|
425
|
+
_write_plan(repo, "# 计划\n\nP01 落地\n")
|
|
426
|
+
issues = check_plan(repo)
|
|
427
|
+
assert any(issue.code == "accepted-not-in-plan" for issue in issues)
|
|
428
|
+
assert not any(issue.code == "plan-ghost-id" for issue in issues)
|