@seanyao/roll 3.609.2 → 3.610.2
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 +44 -1
- package/README.md +5 -6
- package/dist/roll.mjs +16146 -16186
- package/package.json +3 -2
- package/skills/README.md +14 -1
- package/skills/docs/skill-authoring.md +66 -0
- package/skills/reports/skill-audit-summary.md +53 -0
- package/skills/roll-.changelog/SKILL.md +25 -443
- package/skills/roll-.changelog/references/full-contract.md +462 -0
- package/skills/roll-.clarify/SKILL.md +6 -4
- package/skills/roll-.dream/SKILL.md +26 -353
- package/skills/roll-.dream/references/full-contract.md +365 -0
- package/skills/roll-.echo/SKILL.md +6 -4
- package/skills/roll-.qa/SKILL.md +25 -236
- package/skills/roll-.qa/references/full-contract.md +256 -0
- package/skills/roll-.review/SKILL.md +6 -2
- package/skills/roll-brief/SKILL.md +6 -8
- package/skills/roll-build/SKILL.md +28 -864
- package/skills/roll-build/references/full-contract.md +883 -0
- package/skills/roll-debug/SKILL.md +26 -585
- package/skills/roll-debug/references/full-contract.md +607 -0
- package/skills/roll-design/SKILL.md +28 -903
- package/skills/roll-design/references/full-contract.md +923 -0
- package/skills/roll-doc/SKILL.md +25 -574
- package/skills/roll-doc/references/full-contract.md +594 -0
- package/skills/roll-doctor/SKILL.md +21 -2
- package/skills/roll-fix/SKILL.md +28 -621
- package/skills/roll-fix/references/full-contract.md +640 -0
- package/skills/roll-idea/SKILL.md +6 -2
- package/skills/roll-loop/SKILL.md +27 -543
- package/skills/roll-loop/references/full-contract.md +555 -0
- package/skills/roll-notes/SKILL.md +6 -2
- package/skills/roll-onboard/SKILL.md +6 -2
- package/skills/roll-peer/SKILL.md +27 -316
- package/skills/roll-peer/references/full-contract.md +329 -0
- package/skills/roll-propose/SKILL.md +6 -8
- package/skills/roll-review-pr/SKILL.md +6 -2
- package/skills/roll-sentinel/SKILL.md +26 -344
- package/skills/roll-sentinel/references/full-contract.md +363 -0
- package/skills/roll-spar/SKILL.md +27 -269
- package/skills/roll-spar/references/full-contract.md +288 -0
- package/skills/route-cases/skills.json +235 -0
- package/skills/scripts/audit-skills.mjs +272 -0
- package/skills/scripts/test-audit-skills.mjs +39 -0
- package/skills/tests/fixtures/skill-audit/block-skill/SKILL.md +12 -0
- package/skills/tests/fixtures/skill-audit/minimal-skill/SKILL.md +8 -0
- package/skills/tests/fixtures/skill-audit/quoted-skill/SKILL.md +10 -0
- package/skills/tests/fixtures/skill-audit/route-cases.json +21 -0
- package/skills/tests/fixtures/skill-audit/spoke-skill/SKILL.md +12 -0
- package/skills/tests/fixtures/skill-audit/spoke-skill/references/runbook.md +3 -0
- package/bin/roll +0 -15361
- package/lib/backfill-pi-usage.py +0 -243
- package/lib/changelog_audit.py +0 -149
- package/lib/changelog_generate.py +0 -470
- package/lib/consistency_check.py +0 -409
- package/lib/context_feed_budget.sh +0 -194
- package/lib/github_sync.py +0 -876
- package/lib/i18n/slides.sh +0 -3
- package/lib/i18n/slides_build.sh +0 -38
- package/lib/i18n/slides_delete.sh +0 -19
- package/lib/i18n/slides_list.sh +0 -14
- package/lib/i18n/slides_logs.sh +0 -12
- package/lib/i18n/slides_new.sh +0 -15
- package/lib/i18n/slides_preview.sh +0 -14
- package/lib/i18n/slides_templates.sh +0 -7
- package/lib/i18n.sh +0 -211
- package/lib/loop-exit-summary.py +0 -393
- package/lib/loop-fmt.py +0 -589
- package/lib/loop_pick_agent.py +0 -316
- package/lib/loop_result_eval.py +0 -469
- package/lib/loop_unstick.py +0 -180
- package/lib/model_prices.py +0 -194
- package/lib/prices_fetcher.py +0 -534
- package/lib/roll-backlog.py +0 -225
- package/lib/roll-brief.py +0 -286
- package/lib/roll-help.py +0 -158
- package/lib/roll-home.py +0 -556
- package/lib/roll-init.py +0 -156
- package/lib/roll-loop-status.py +0 -1691
- package/lib/roll-loop-story.py +0 -191
- package/lib/roll-peer.py +0 -252
- package/lib/roll-setup.py +0 -102
- package/lib/roll-status.py +0 -367
- package/lib/roll_git.py +0 -41
- package/lib/roll_render.py +0 -414
- package/lib/slides/components/README.md +0 -123
- package/lib/slides/components/cards-2.html +0 -9
- package/lib/slides/components/cards-3.html +0 -9
- package/lib/slides/components/cards-4.html +0 -9
- package/lib/slides/components/compare.html +0 -22
- package/lib/slides/components/highlight.html +0 -9
- package/lib/slides/components/pipeline.html +0 -12
- package/lib/slides/components/plain.html +0 -7
- package/lib/slides/components/quote.html +0 -4
- package/lib/slides/components/timeline.html +0 -9
- package/lib/slides/templates/introduction-v3.html +0 -571
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +0 -778
- package/lib/slides-validate.py +0 -357
- package/lib/test_quality_gate.py +0 -143
- package/skills/roll-deck/SKILL.md +0 -296
- /package/skills/roll-debug/{injectable-bb.js → assets/injectable-bb.js} +0 -0
- /package/skills/roll-design/{ENGINEERING_CHECKLIST.md → references/engineering-checklist.md} +0 -0
package/lib/backfill-pi-usage.py
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
backfill-pi-usage — one-time, idempotent recovery of pi/deepseek token+cost
|
|
4
|
-
into an existing loop events file.
|
|
5
|
-
|
|
6
|
-
Why this exists
|
|
7
|
-
---------------
|
|
8
|
-
Before US-LOOP-026 the loop ran pi via ``pi -p`` (text mode), which prints no
|
|
9
|
-
usage. loop-fmt's old passthrough still appended a ``stage=="usage"`` event on
|
|
10
|
-
every retry attempt, each with ``model=="pi"`` and null tokens — so a single
|
|
11
|
-
cycle accumulated up to ~180 empty usage events. The dashboard SUMS token
|
|
12
|
-
fields across same-label usage events; with all-null tokens the SUM was 0
|
|
13
|
-
(harmless), but it means every affected cycle shows ``—/—``.
|
|
14
|
-
|
|
15
|
-
pi persists every session to ``~/.pi/agent/sessions/<enc-cwd>/<ts>_<uuid>.jsonl``
|
|
16
|
-
with real per-message usage. This script recovers that, and rewrites the events
|
|
17
|
-
file so each affected cycle is left with **exactly one** authoritative usage
|
|
18
|
-
event (real tokens, cost frozen in native CNY) — collapsing the N null events to
|
|
19
|
-
avoid the dashboard ×N inflation.
|
|
20
|
-
|
|
21
|
-
Safety / idempotency
|
|
22
|
-
--------------------
|
|
23
|
-
- Backs up the events file to ``<file>.bak-<UTC>`` first; aborts if backup fails.
|
|
24
|
-
- Only touches labels whose usage events are all pi-vendor (``model`` in
|
|
25
|
-
{"pi", "deepseek-v4-pro"}) AND carry null tokens AND match a pi session.
|
|
26
|
-
claude cycles, already-real cycles, and unmatched-null cycles are passed
|
|
27
|
-
through untouched.
|
|
28
|
-
- Re-runnable: once a label has a real-token usage event it is no longer a
|
|
29
|
-
candidate, so a second run is a no-op.
|
|
30
|
-
- FIX-065 tripwire: refuses to rewrite a production ``~/.shared/roll`` events
|
|
31
|
-
file from a test context (BATS / temp cwd) unless HOME itself is sandboxed.
|
|
32
|
-
|
|
33
|
-
Usage
|
|
34
|
-
-----
|
|
35
|
-
python3 lib/backfill-pi-usage.py --slug roll-ecf079
|
|
36
|
-
python3 lib/backfill-pi-usage.py --events /path/to/events.ndjson --dry-run
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
import argparse
|
|
40
|
-
import importlib.util
|
|
41
|
-
import json
|
|
42
|
-
import os
|
|
43
|
-
import shutil
|
|
44
|
-
import sys
|
|
45
|
-
from datetime import datetime, timezone
|
|
46
|
-
|
|
47
|
-
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
-
|
|
49
|
-
PI_VENDOR_MODELS = ("pi", "deepseek-v4-pro")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _load_pi_emit():
|
|
53
|
-
spec = importlib.util.spec_from_file_location(
|
|
54
|
-
"pi_emit", os.path.join(_THIS_DIR, "agent_usage", "pi_emit.py")
|
|
55
|
-
)
|
|
56
|
-
m = importlib.util.module_from_spec(spec)
|
|
57
|
-
spec.loader.exec_module(m)
|
|
58
|
-
return m
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _default_events_path(slug, shared=None):
|
|
62
|
-
base = shared or os.environ.get("LOOP_SHARED_ROOT") \
|
|
63
|
-
or os.path.expanduser("~/.shared/roll")
|
|
64
|
-
return os.path.join(base, "loop", "events-%s.ndjson" % slug)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _is_test_context():
|
|
68
|
-
return bool(os.environ.get("BATS_TEST_FILENAME")) or _cwd_is_temp()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _cwd_is_temp():
|
|
72
|
-
p = os.environ.get("PWD") or os.getcwd()
|
|
73
|
-
return any(seg in p for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _home_is_sandbox():
|
|
77
|
-
home = os.environ.get("HOME") or ""
|
|
78
|
-
return any(seg in home for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _tripwire(evfile):
|
|
82
|
-
"""FIX-065: refuse a prod write from a test context."""
|
|
83
|
-
home = os.environ.get("HOME") or ""
|
|
84
|
-
if not home or _home_is_sandbox():
|
|
85
|
-
return
|
|
86
|
-
prod = os.path.join(home, ".shared", "roll") + os.sep
|
|
87
|
-
if os.path.abspath(evfile).startswith(os.path.abspath(prod)) and _is_test_context():
|
|
88
|
-
raise SystemExit(
|
|
89
|
-
"[FIX-065] refusing to rewrite prod events file from test context: %s" % evfile
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _scan(lines):
|
|
94
|
-
"""Parse lines → (events_or_None list, per-label usage summary).
|
|
95
|
-
|
|
96
|
-
Returns (parsed, labels) where parsed is a list of (raw_line, obj_or_None)
|
|
97
|
-
preserving order, and labels maps label → {"pi": bool, "real": bool}.
|
|
98
|
-
"""
|
|
99
|
-
parsed = []
|
|
100
|
-
labels = {}
|
|
101
|
-
for raw in lines:
|
|
102
|
-
obj = None
|
|
103
|
-
try:
|
|
104
|
-
obj = json.loads(raw)
|
|
105
|
-
except (ValueError, TypeError):
|
|
106
|
-
obj = None
|
|
107
|
-
parsed.append((raw, obj))
|
|
108
|
-
if not obj or obj.get("stage") != "usage":
|
|
109
|
-
continue
|
|
110
|
-
lab = obj.get("label")
|
|
111
|
-
d = obj.get("detail") or {}
|
|
112
|
-
rec = labels.setdefault(lab, {"pi": False, "real": False})
|
|
113
|
-
if d.get("model") in PI_VENDOR_MODELS:
|
|
114
|
-
rec["pi"] = True
|
|
115
|
-
if d.get("input_tokens"):
|
|
116
|
-
rec["real"] = True
|
|
117
|
-
return parsed, labels
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def backfill(evfile, slug=None, shared=None, base_dir=None, dry_run=False):
|
|
121
|
-
"""Rewrite evfile so each recoverable pi cycle nets one real usage event.
|
|
122
|
-
|
|
123
|
-
Returns a stats dict.
|
|
124
|
-
"""
|
|
125
|
-
_tripwire(evfile)
|
|
126
|
-
pi_emit = _load_pi_emit()
|
|
127
|
-
|
|
128
|
-
with open(evfile) as f:
|
|
129
|
-
lines = f.readlines()
|
|
130
|
-
|
|
131
|
-
parsed, labels = _scan(lines)
|
|
132
|
-
|
|
133
|
-
# Candidate = pi-vendor, all-null, and a session match yields real usage.
|
|
134
|
-
candidates = [l for l, r in labels.items() if r["pi"] and not r["real"]]
|
|
135
|
-
replacement = {} # label -> detail payload
|
|
136
|
-
matched, unmatched = [], []
|
|
137
|
-
for lab in candidates:
|
|
138
|
-
cwd = os.path.join(
|
|
139
|
-
(shared or os.environ.get("LOOP_SHARED_ROOT")
|
|
140
|
-
or os.path.expanduser("~/.shared/roll")),
|
|
141
|
-
"worktrees", "%s-cycle-%s" % (slug, lab),
|
|
142
|
-
)
|
|
143
|
-
ev = pi_emit.build_event(cwd=cwd, cycle_id=lab, slug=slug, base_dir=base_dir)
|
|
144
|
-
if ev is None:
|
|
145
|
-
unmatched.append(lab)
|
|
146
|
-
continue
|
|
147
|
-
replacement[lab] = ev["detail"]
|
|
148
|
-
matched.append(lab)
|
|
149
|
-
|
|
150
|
-
if dry_run or not matched:
|
|
151
|
-
# Nothing recoverable to rewrite → no backup, no write (keeps re-runs
|
|
152
|
-
# a true no-op instead of spawning empty .bak files).
|
|
153
|
-
return {
|
|
154
|
-
"candidates": len(candidates),
|
|
155
|
-
"matched": len(matched),
|
|
156
|
-
"unmatched": len(unmatched),
|
|
157
|
-
"matched_labels": sorted(matched),
|
|
158
|
-
"unmatched_labels": sorted(unmatched),
|
|
159
|
-
"written": False,
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
# Backup before any write; abort the whole run if backup fails.
|
|
163
|
-
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
164
|
-
bak = "%s.bak-%s" % (evfile, stamp)
|
|
165
|
-
shutil.copy2(evfile, bak)
|
|
166
|
-
|
|
167
|
-
# Stream rewrite: for an affected label, the FIRST usage line becomes the
|
|
168
|
-
# real event (original ts preserved so it stays in its day bucket), every
|
|
169
|
-
# subsequent same-label usage line is dropped → exactly one per label.
|
|
170
|
-
emitted = set()
|
|
171
|
-
out = []
|
|
172
|
-
for raw, obj in parsed:
|
|
173
|
-
if not obj or obj.get("stage") != "usage":
|
|
174
|
-
out.append(raw)
|
|
175
|
-
continue
|
|
176
|
-
lab = obj.get("label")
|
|
177
|
-
if lab not in replacement:
|
|
178
|
-
out.append(raw) # claude / already-real / unmatched-null: untouched
|
|
179
|
-
continue
|
|
180
|
-
if lab in emitted:
|
|
181
|
-
continue # collapse the remaining null duplicates away
|
|
182
|
-
new_ev = {
|
|
183
|
-
"ts": obj.get("ts"),
|
|
184
|
-
"stage": "usage",
|
|
185
|
-
"label": lab,
|
|
186
|
-
"detail": replacement[lab],
|
|
187
|
-
"outcome": "ok",
|
|
188
|
-
}
|
|
189
|
-
out.append(json.dumps(new_ev) + "\n")
|
|
190
|
-
emitted.add(lab)
|
|
191
|
-
|
|
192
|
-
tmp = evfile + ".tmp-%s" % stamp
|
|
193
|
-
with open(tmp, "w") as f:
|
|
194
|
-
f.writelines(out)
|
|
195
|
-
os.replace(tmp, evfile)
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
"candidates": len(candidates),
|
|
199
|
-
"matched": len(matched),
|
|
200
|
-
"unmatched": len(unmatched),
|
|
201
|
-
"matched_labels": sorted(matched),
|
|
202
|
-
"unmatched_labels": sorted(unmatched),
|
|
203
|
-
"backup": bak,
|
|
204
|
-
"written": True,
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def main(argv=None):
|
|
209
|
-
ap = argparse.ArgumentParser(description="backfill pi/deepseek usage into events file")
|
|
210
|
-
ap.add_argument("--slug", help="project slug (resolves default events path + session cwd)")
|
|
211
|
-
ap.add_argument("--events", help="explicit events file path (overrides --slug default)")
|
|
212
|
-
ap.add_argument("--shared", help="shared root (default ~/.shared/roll)")
|
|
213
|
-
ap.add_argument("--base-dir", help="pi sessions root override (tests)")
|
|
214
|
-
ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
|
|
215
|
-
args = ap.parse_args(argv)
|
|
216
|
-
|
|
217
|
-
evfile = args.events or _default_events_path(args.slug, args.shared)
|
|
218
|
-
if not os.path.isfile(evfile):
|
|
219
|
-
print("[backfill] no events file: %s" % evfile, file=sys.stderr)
|
|
220
|
-
return 1
|
|
221
|
-
if not args.slug:
|
|
222
|
-
# slug is needed to reconstruct session cwd; derive from filename.
|
|
223
|
-
base = os.path.basename(evfile)
|
|
224
|
-
if base.startswith("events-") and base.endswith(".ndjson"):
|
|
225
|
-
args.slug = base[len("events-"):-len(".ndjson")]
|
|
226
|
-
|
|
227
|
-
stats = backfill(
|
|
228
|
-
evfile, slug=args.slug, shared=args.shared,
|
|
229
|
-
base_dir=args.base_dir, dry_run=args.dry_run,
|
|
230
|
-
)
|
|
231
|
-
mode = "DRY-RUN" if args.dry_run else "WROTE"
|
|
232
|
-
print("[backfill] %s %s" % (mode, evfile))
|
|
233
|
-
print(" candidates=%d matched=%d unmatched=%d"
|
|
234
|
-
% (stats["candidates"], stats["matched"], stats["unmatched"]))
|
|
235
|
-
if stats.get("backup"):
|
|
236
|
-
print(" backup=%s" % stats["backup"])
|
|
237
|
-
if stats["unmatched_labels"]:
|
|
238
|
-
print(" unmatched (left as null): %s" % ", ".join(stats["unmatched_labels"]))
|
|
239
|
-
return 0
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if __name__ == "__main__":
|
|
243
|
-
sys.exit(main())
|
package/lib/changelog_audit.py
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Changelog audit module (US-CONSIST-002).
|
|
3
|
-
|
|
4
|
-
Checks that ✅ Done backlog stories are reflected in CHANGELOG.md.
|
|
5
|
-
Provides both coverage checking and gap reporting.
|
|
6
|
-
|
|
7
|
-
Usage as library:
|
|
8
|
-
from lib.changelog_audit import check_changelog_coverage
|
|
9
|
-
result = check_changelog_coverage(done_stories, changelog_path)
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Any
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _read_changelog_text(changelog_path: Path) -> str:
|
|
19
|
-
"""Read changelog text, returning empty string if file is missing."""
|
|
20
|
-
if not changelog_path.exists():
|
|
21
|
-
return ""
|
|
22
|
-
return changelog_path.read_text(encoding="utf-8")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def check_changelog_coverage(
|
|
26
|
-
done_stories: dict[str, list[str]],
|
|
27
|
-
changelog_path: Path,
|
|
28
|
-
) -> dict[str, Any]:
|
|
29
|
-
"""Check that Done stories appear in CHANGELOG.md.
|
|
30
|
-
|
|
31
|
-
Args:
|
|
32
|
-
done_stories: {feature_name: [story_id, ...]} from backlog
|
|
33
|
-
changelog_path: Path to CHANGELOG.md
|
|
34
|
-
|
|
35
|
-
Returns:
|
|
36
|
-
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
37
|
-
"""
|
|
38
|
-
if not changelog_path.exists():
|
|
39
|
-
return {"status": "pass", "gaps": []}
|
|
40
|
-
|
|
41
|
-
changelog_text = _read_changelog_text(changelog_path)
|
|
42
|
-
gaps: list[str] = []
|
|
43
|
-
|
|
44
|
-
for feature_name, story_ids in done_stories.items():
|
|
45
|
-
for story_id in story_ids:
|
|
46
|
-
if story_id not in changelog_text:
|
|
47
|
-
gaps.append(
|
|
48
|
-
f"Story '{story_id}' (feature '{feature_name}') is Done "
|
|
49
|
-
"but not referenced in CHANGELOG.md"
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
"status": "pass" if not gaps else "fail",
|
|
54
|
-
"gaps": gaps,
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def check_features_md_coverage(
|
|
59
|
-
done_features: dict[str, list[str]],
|
|
60
|
-
features_md_path: Path,
|
|
61
|
-
) -> dict[str, Any]:
|
|
62
|
-
"""Check that features with Done stories are listed in features.md.
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
done_features: {feature_name: [story_id, ...]} from backlog
|
|
66
|
-
features_md_path: Path to .roll/features.md
|
|
67
|
-
|
|
68
|
-
Returns:
|
|
69
|
-
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
70
|
-
"""
|
|
71
|
-
import re
|
|
72
|
-
|
|
73
|
-
if not features_md_path.exists():
|
|
74
|
-
return {"status": "pass", "gaps": []}
|
|
75
|
-
|
|
76
|
-
features_text = features_md_path.read_text(encoding="utf-8")
|
|
77
|
-
gaps: list[str] = []
|
|
78
|
-
|
|
79
|
-
for feature_name in done_features:
|
|
80
|
-
escaped = re.escape(feature_name)
|
|
81
|
-
if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
|
|
82
|
-
gaps.append(
|
|
83
|
-
f"Feature '{feature_name}' has Done stories but is missing "
|
|
84
|
-
"from features.md catalog"
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
"status": "pass" if not gaps else "fail",
|
|
89
|
-
"gaps": gaps,
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def check_guide_doc_coverage(
|
|
94
|
-
done_features: dict[str, list[str]],
|
|
95
|
-
guide_en_dir: Path,
|
|
96
|
-
) -> dict[str, Any]:
|
|
97
|
-
"""Check that features with Done stories have guide documentation.
|
|
98
|
-
|
|
99
|
-
Heuristic: checks whether a .md file in guide/en/ references the feature
|
|
100
|
-
name or its story IDs.
|
|
101
|
-
|
|
102
|
-
Args:
|
|
103
|
-
done_features: {feature_name: [story_id, ...]} from backlog
|
|
104
|
-
guide_en_dir: Path to guide/en/
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
108
|
-
"""
|
|
109
|
-
if not guide_en_dir.exists() or not guide_en_dir.is_dir():
|
|
110
|
-
return {"status": "pass", "gaps": []}
|
|
111
|
-
|
|
112
|
-
# Collect all guide text
|
|
113
|
-
all_guide_text = ""
|
|
114
|
-
for md_file in sorted(guide_en_dir.glob("*.md")):
|
|
115
|
-
try:
|
|
116
|
-
all_guide_text += md_file.read_text(encoding="utf-8")
|
|
117
|
-
except (OSError, UnicodeDecodeError):
|
|
118
|
-
continue
|
|
119
|
-
|
|
120
|
-
# Also read practices/ subdirectory
|
|
121
|
-
practices_dir = guide_en_dir / "practices"
|
|
122
|
-
if practices_dir.exists() and practices_dir.is_dir():
|
|
123
|
-
for md_file in sorted(practices_dir.glob("*.md")):
|
|
124
|
-
try:
|
|
125
|
-
all_guide_text += md_file.read_text(encoding="utf-8")
|
|
126
|
-
except (OSError, UnicodeDecodeError):
|
|
127
|
-
continue
|
|
128
|
-
|
|
129
|
-
gaps: list[str] = []
|
|
130
|
-
for feature_name, story_ids in done_features.items():
|
|
131
|
-
found = False
|
|
132
|
-
# Check if feature name appears in guide text
|
|
133
|
-
if feature_name.lower() in all_guide_text.lower():
|
|
134
|
-
found = True
|
|
135
|
-
# Check if any story ID appears
|
|
136
|
-
for sid in story_ids:
|
|
137
|
-
if sid in all_guide_text:
|
|
138
|
-
found = True
|
|
139
|
-
break
|
|
140
|
-
if not found:
|
|
141
|
-
gaps.append(
|
|
142
|
-
f"Feature '{feature_name}' has Done stories but no guide "
|
|
143
|
-
"documentation found in guide/en/"
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
"status": "pass" if not gaps else "fail",
|
|
148
|
-
"gaps": gaps,
|
|
149
|
-
}
|