@seanyao/roll 0.5.0 → 2.602.1
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 +717 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +14897 -815
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -0
- package/lib/i18n/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +186 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +727 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +578 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
US-DECK-002: deck.md schema + grounding validator.
|
|
4
|
+
|
|
5
|
+
Reads a `deck.md` file, parses it with the same parser used by the renderer
|
|
6
|
+
(`lib/slides-render.py`), and verifies:
|
|
7
|
+
|
|
8
|
+
1. Required frontmatter fields are present:
|
|
9
|
+
template, slug, title_en, title_zh, total_slides, created
|
|
10
|
+
2. frontmatter.total_slides matches the actual `## Slide N` section count.
|
|
11
|
+
3. Each slide has non-empty title_en / title_zh / body_en / body_zh.
|
|
12
|
+
4. Grounding threshold: at least ceil(N/3) evidence citations across all
|
|
13
|
+
slides (i.e. >= 1 per 3 slides). If the deck has fewer, the validator
|
|
14
|
+
exits non-zero with a ⚠️ grounding warning so callers (e.g.
|
|
15
|
+
`roll slides build`) can flag it.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 slides-validate.py <deck.md>
|
|
19
|
+
|
|
20
|
+
Exit codes:
|
|
21
|
+
0 valid (schema OK + grounding threshold met)
|
|
22
|
+
1 schema error (missing field, mismatch, missing slide body, etc.)
|
|
23
|
+
2 grounding warning (schema OK but evidence below threshold)
|
|
24
|
+
3 file not found / unreadable / parse error
|
|
25
|
+
|
|
26
|
+
Error messages are written to stderr in English + Chinese (Roll bilingual
|
|
27
|
+
convention).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import importlib.util
|
|
33
|
+
import math
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
REQUIRED_FRONTMATTER = (
|
|
39
|
+
"template",
|
|
40
|
+
"slug",
|
|
41
|
+
"title_en",
|
|
42
|
+
"title_zh",
|
|
43
|
+
"total_slides",
|
|
44
|
+
"created",
|
|
45
|
+
)
|
|
46
|
+
REQUIRED_SLIDE_KEYS = ("title_en", "title_zh", "body_en", "body_zh")
|
|
47
|
+
|
|
48
|
+
# Every slide must always carry a bilingual title.
|
|
49
|
+
REQUIRED_TITLE_KEYS = ("title_en", "title_zh")
|
|
50
|
+
|
|
51
|
+
# ── Layout schema (US-DECK-017) ──────────────────────────────────────────────
|
|
52
|
+
#
|
|
53
|
+
# The default layout (and the implicit layout for slides with no `layout:`
|
|
54
|
+
# field) is `plain`, which keeps the Phase 1.5 `body_en` / `body_zh` contract.
|
|
55
|
+
# Every other layout declares its required fields so the validator can flag a
|
|
56
|
+
# missing field with a concrete line number + an example snippet.
|
|
57
|
+
#
|
|
58
|
+
# Field contracts mirror the Mustache partials shipped by US-DECK-016 in
|
|
59
|
+
# lib/slides/components/<layout>.html — keep the two in sync.
|
|
60
|
+
|
|
61
|
+
DEFAULT_LAYOUT = "plain"
|
|
62
|
+
|
|
63
|
+
# scalar required fields per layout (besides the always-required title_en/zh).
|
|
64
|
+
_LAYOUT_SCALAR_FIELDS = {
|
|
65
|
+
"plain": ("body_en", "body_zh"),
|
|
66
|
+
"cards-2": (),
|
|
67
|
+
"cards-3": (),
|
|
68
|
+
"cards-4": (),
|
|
69
|
+
"compare": (
|
|
70
|
+
"left_title_en",
|
|
71
|
+
"left_title_zh",
|
|
72
|
+
"right_title_en",
|
|
73
|
+
"right_title_zh",
|
|
74
|
+
),
|
|
75
|
+
"pipeline": (),
|
|
76
|
+
"timeline": (),
|
|
77
|
+
"quote": ("text_en", "text_zh"),
|
|
78
|
+
"highlight": ("body_en", "body_zh"),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# list-of-mapping required fields: layout -> (list_key, (item_field, ...)).
|
|
82
|
+
_LAYOUT_LIST_FIELDS = {
|
|
83
|
+
"cards-2": ("cards", ("title_en", "title_zh", "body_en", "body_zh")),
|
|
84
|
+
"cards-3": ("cards", ("title_en", "title_zh", "body_en", "body_zh")),
|
|
85
|
+
"cards-4": ("cards", ("title_en", "title_zh", "body_en", "body_zh")),
|
|
86
|
+
"pipeline": ("stages", ("title_en", "title_zh", "desc_en", "desc_zh")),
|
|
87
|
+
"timeline": ("items", ("title_en", "title_zh", "body_en", "body_zh")),
|
|
88
|
+
"compare": ("left_items", ("text_en", "text_zh")),
|
|
89
|
+
}
|
|
90
|
+
# compare also requires a right_items list with the same item shape.
|
|
91
|
+
_LAYOUT_EXTRA_LISTS = {
|
|
92
|
+
"compare": (("right_items", ("text_en", "text_zh")),),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
LAYOUT_WHITELIST = tuple(_LAYOUT_SCALAR_FIELDS.keys())
|
|
96
|
+
|
|
97
|
+
# Minimal example snippet per layout, shown when a required field is missing.
|
|
98
|
+
_LAYOUT_EXAMPLES = {
|
|
99
|
+
"cards-2": 'cards:\n - title_en: "..."\n title_zh: "..."\n '
|
|
100
|
+
'body_en: "..."\n body_zh: "..."',
|
|
101
|
+
"cards-3": 'cards:\n - title_en: "..."\n title_zh: "..."\n '
|
|
102
|
+
'body_en: "..."\n body_zh: "..."',
|
|
103
|
+
"cards-4": 'cards:\n - title_en: "..."\n title_zh: "..."\n '
|
|
104
|
+
'body_en: "..."\n body_zh: "..."',
|
|
105
|
+
"compare": 'left_title_en: "..."\nleft_title_zh: "..."\n'
|
|
106
|
+
'right_title_en: "..."\nright_title_zh: "..."\n'
|
|
107
|
+
'left_items:\n - text_en: "..."\n text_zh: "..."\n'
|
|
108
|
+
'right_items:\n - text_en: "..."\n text_zh: "..."',
|
|
109
|
+
"pipeline": 'stages:\n - title_en: "..."\n title_zh: "..."\n '
|
|
110
|
+
'desc_en: "..."\n desc_zh: "..."',
|
|
111
|
+
"timeline": 'items:\n - title_en: "..."\n title_zh: "..."\n '
|
|
112
|
+
'body_en: "..."\n body_zh: "..."',
|
|
113
|
+
"quote": 'text_en: "..."\ntext_zh: "..."',
|
|
114
|
+
"highlight": 'body_en: |\n ...\nbody_zh: |\n ...',
|
|
115
|
+
"plain": 'body_en: |\n ...\nbody_zh: |\n ...',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_renderer():
|
|
120
|
+
"""Import lib/slides-render.py as a module (hyphenated filename, so we
|
|
121
|
+
can't `import slides_render` directly)."""
|
|
122
|
+
here = Path(__file__).resolve().parent
|
|
123
|
+
spec = importlib.util.spec_from_file_location(
|
|
124
|
+
"slides_render", str(here / "slides-render.py")
|
|
125
|
+
)
|
|
126
|
+
if spec is None or spec.loader is None:
|
|
127
|
+
raise ImportError("could not load slides-render.py")
|
|
128
|
+
mod = importlib.util.module_from_spec(spec)
|
|
129
|
+
spec.loader.exec_module(mod)
|
|
130
|
+
return mod
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def err(msg_en: str, msg_zh: str = "") -> None:
|
|
134
|
+
print(f"[slides-validate] {msg_en}", file=sys.stderr)
|
|
135
|
+
if msg_zh:
|
|
136
|
+
print(f"[slides-validate] {msg_zh}", file=sys.stderr)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_frontmatter(fm: dict) -> list[str]:
|
|
140
|
+
errors: list[str] = []
|
|
141
|
+
for key in REQUIRED_FRONTMATTER:
|
|
142
|
+
if key not in fm or fm[key] == "" or fm[key] is None:
|
|
143
|
+
errors.append(f"missing required frontmatter field: {key}")
|
|
144
|
+
if "total_slides" in fm and not isinstance(fm["total_slides"], int):
|
|
145
|
+
errors.append(
|
|
146
|
+
f"total_slides must be an integer, got "
|
|
147
|
+
f"{type(fm['total_slides']).__name__}: {fm['total_slides']!r}"
|
|
148
|
+
)
|
|
149
|
+
return errors
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _slide_header_lines(src: str) -> dict:
|
|
153
|
+
"""Map slide number -> 1-based source line of its `## Slide N` header."""
|
|
154
|
+
import re
|
|
155
|
+
|
|
156
|
+
header_re = re.compile(r"^##\s+Slide\s+(\d+)\s*$")
|
|
157
|
+
out: dict = {}
|
|
158
|
+
for idx, line in enumerate(src.splitlines(), start=1):
|
|
159
|
+
m = header_re.match(line)
|
|
160
|
+
if m:
|
|
161
|
+
out[int(m.group(1))] = idx
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def slide_layout(slide: dict) -> str:
|
|
166
|
+
"""Return a slide's declared layout, defaulting to `plain` when absent."""
|
|
167
|
+
layout = slide.get("layout")
|
|
168
|
+
if not layout:
|
|
169
|
+
return DEFAULT_LAYOUT
|
|
170
|
+
return str(layout)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_empty(v) -> bool:
|
|
174
|
+
return v is None or (isinstance(v, str) and v.strip() == "")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def validate_slides(fm: dict, slides: list[dict], line_of: dict | None = None) -> list[str]:
|
|
178
|
+
errors: list[str] = []
|
|
179
|
+
line_of = line_of or {}
|
|
180
|
+
actual = len(slides)
|
|
181
|
+
declared = fm.get("total_slides")
|
|
182
|
+
if isinstance(declared, int) and declared != actual:
|
|
183
|
+
errors.append(
|
|
184
|
+
f"total_slides mismatch: frontmatter declares {declared} but "
|
|
185
|
+
f"found {actual} `## Slide N` sections"
|
|
186
|
+
)
|
|
187
|
+
for slide in slides:
|
|
188
|
+
errors += validate_slide_layout(slide, line_of)
|
|
189
|
+
return errors
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def validate_slide_layout(slide: dict, line_of: dict | None = None) -> list[str]:
|
|
193
|
+
"""
|
|
194
|
+
Validate a single slide's required fields for its declared layout.
|
|
195
|
+
|
|
196
|
+
- Title fields are always required.
|
|
197
|
+
- A missing `layout:` is treated as `plain` (no error — backward compat).
|
|
198
|
+
- An unknown layout name is rejected against the whitelist.
|
|
199
|
+
- Per-layout scalar + list-of-mapping required fields are checked, with the
|
|
200
|
+
concrete `deck.md:<line>` location (header line of the slide) and a field
|
|
201
|
+
example for the layout.
|
|
202
|
+
"""
|
|
203
|
+
line_of = line_of or {}
|
|
204
|
+
errors: list[str] = []
|
|
205
|
+
n = slide.get("number", "?")
|
|
206
|
+
line = line_of.get(n)
|
|
207
|
+
loc = f"deck.md:{line}" if line else f"slide {n}"
|
|
208
|
+
|
|
209
|
+
# Title is always required regardless of layout.
|
|
210
|
+
for key in REQUIRED_TITLE_KEYS:
|
|
211
|
+
if _is_empty(slide.get(key)):
|
|
212
|
+
errors.append(f"slide {n} ({loc}): missing or empty {key}")
|
|
213
|
+
|
|
214
|
+
layout = slide_layout(slide)
|
|
215
|
+
if layout not in LAYOUT_WHITELIST:
|
|
216
|
+
errors.append(
|
|
217
|
+
f"slide {n} ({loc}): unknown layout {layout!r}; "
|
|
218
|
+
f"allowed: {', '.join(LAYOUT_WHITELIST)}"
|
|
219
|
+
)
|
|
220
|
+
return errors
|
|
221
|
+
|
|
222
|
+
example = _LAYOUT_EXAMPLES.get(layout, "")
|
|
223
|
+
|
|
224
|
+
def missing(field: str) -> None:
|
|
225
|
+
msg = f"slide {n} ({loc}): layout {layout!r} requires field {field!r}"
|
|
226
|
+
if example:
|
|
227
|
+
msg += f"\nHint: example for {layout}:\n{example}"
|
|
228
|
+
errors.append(msg)
|
|
229
|
+
|
|
230
|
+
# Scalar required fields.
|
|
231
|
+
for field in _LAYOUT_SCALAR_FIELDS.get(layout, ()):
|
|
232
|
+
if _is_empty(slide.get(field)):
|
|
233
|
+
missing(field)
|
|
234
|
+
|
|
235
|
+
# List-of-mapping required fields.
|
|
236
|
+
list_specs: list = []
|
|
237
|
+
if layout in _LAYOUT_LIST_FIELDS:
|
|
238
|
+
list_specs.append(_LAYOUT_LIST_FIELDS[layout])
|
|
239
|
+
list_specs += list(_LAYOUT_EXTRA_LISTS.get(layout, ()))
|
|
240
|
+
for list_key, item_fields in list_specs:
|
|
241
|
+
items = slide.get(list_key)
|
|
242
|
+
if not isinstance(items, list) or not items:
|
|
243
|
+
missing(list_key)
|
|
244
|
+
continue
|
|
245
|
+
for idx, item in enumerate(items, start=1):
|
|
246
|
+
if not isinstance(item, dict):
|
|
247
|
+
errors.append(
|
|
248
|
+
f"slide {n} ({loc}): {list_key}[{idx}] must be a mapping "
|
|
249
|
+
f"with {', '.join(item_fields)}"
|
|
250
|
+
)
|
|
251
|
+
continue
|
|
252
|
+
for f in item_fields:
|
|
253
|
+
if _is_empty(item.get(f)):
|
|
254
|
+
errors.append(
|
|
255
|
+
f"slide {n} ({loc}): {list_key}[{idx}] missing {f!r}"
|
|
256
|
+
)
|
|
257
|
+
return errors
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def lint_slide_layout(slide: dict) -> list[str]:
|
|
261
|
+
"""
|
|
262
|
+
Non-fatal layout warnings (returned separately so the caller can print but
|
|
263
|
+
not fail). Currently: declaring a rich layout while also carrying a stray
|
|
264
|
+
`body_en` / `body_zh` that the layout will not consume (possible waste).
|
|
265
|
+
"""
|
|
266
|
+
warnings: list[str] = []
|
|
267
|
+
layout = slide_layout(slide)
|
|
268
|
+
if layout in ("plain", "highlight"):
|
|
269
|
+
return warnings # these layouts legitimately consume body_en/zh
|
|
270
|
+
n = slide.get("number", "?")
|
|
271
|
+
for f in ("body_en", "body_zh"):
|
|
272
|
+
if not _is_empty(slide.get(f)):
|
|
273
|
+
warnings.append(
|
|
274
|
+
f"slide {n}: layout {layout!r} does not use {f!r}; "
|
|
275
|
+
f"the field will be ignored (possible waste)"
|
|
276
|
+
)
|
|
277
|
+
return warnings
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def evaluate_grounding(slides: list[dict]) -> tuple[int, int, bool]:
|
|
281
|
+
"""
|
|
282
|
+
Return (citations, threshold, meets_threshold).
|
|
283
|
+
|
|
284
|
+
The threshold is `ceil(len(slides) / 3)` — i.e. at least one evidence
|
|
285
|
+
citation per three slides. An empty deck trivially meets the threshold
|
|
286
|
+
(threshold = 0).
|
|
287
|
+
"""
|
|
288
|
+
citations = 0
|
|
289
|
+
for slide in slides:
|
|
290
|
+
ev = slide.get("evidence")
|
|
291
|
+
if isinstance(ev, list):
|
|
292
|
+
citations += len(ev)
|
|
293
|
+
threshold = math.ceil(len(slides) / 3) if slides else 0
|
|
294
|
+
return citations, threshold, citations >= threshold
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main(argv: list[str]) -> int:
|
|
298
|
+
if len(argv) < 2:
|
|
299
|
+
err(
|
|
300
|
+
"usage: slides-validate.py <deck.md>",
|
|
301
|
+
"用法: slides-validate.py <deck.md>",
|
|
302
|
+
)
|
|
303
|
+
return 3
|
|
304
|
+
|
|
305
|
+
path = Path(argv[1])
|
|
306
|
+
if not path.is_file():
|
|
307
|
+
err(f"deck file not found: {path}", f"未找到 deck 文件:{path}")
|
|
308
|
+
return 3
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
renderer = _load_renderer()
|
|
312
|
+
except Exception as e:
|
|
313
|
+
err(f"could not load renderer module: {e}")
|
|
314
|
+
return 3
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
src = path.read_text(encoding="utf-8")
|
|
318
|
+
fm, body = renderer.parse_frontmatter(src)
|
|
319
|
+
slides = renderer.parse_slides(body)
|
|
320
|
+
except (ValueError, OSError) as e:
|
|
321
|
+
err(f"failed to parse deck.md: {e}", "解析 deck.md 失败")
|
|
322
|
+
return 3
|
|
323
|
+
|
|
324
|
+
line_of = _slide_header_lines(src)
|
|
325
|
+
|
|
326
|
+
schema_errors: list[str] = []
|
|
327
|
+
schema_errors += validate_frontmatter(fm)
|
|
328
|
+
schema_errors += validate_slides(fm, slides, line_of)
|
|
329
|
+
|
|
330
|
+
if schema_errors:
|
|
331
|
+
for e in schema_errors:
|
|
332
|
+
err(e)
|
|
333
|
+
return 1
|
|
334
|
+
|
|
335
|
+
# Non-fatal layout lint warnings (e.g. a rich layout carrying a stray body
|
|
336
|
+
# that it will not consume). These print but do not change the exit code.
|
|
337
|
+
for slide in slides:
|
|
338
|
+
for w in lint_slide_layout(slide):
|
|
339
|
+
err(f"⚠️ {w}")
|
|
340
|
+
|
|
341
|
+
citations, threshold, ok = evaluate_grounding(slides)
|
|
342
|
+
if not ok:
|
|
343
|
+
err(
|
|
344
|
+
f"⚠️ grounding below threshold: {citations} evidence citation(s) "
|
|
345
|
+
f"for {len(slides)} slides (need >= {threshold}). "
|
|
346
|
+
f"Each slide group of 3 must include at least one evidence entry.",
|
|
347
|
+
f"⚠️ 证据引用不足:{len(slides)} 张幻灯片仅有 {citations} 条 evidence,"
|
|
348
|
+
f"至少需要 {threshold} 条(每 3 张 ≥ 1 条)。",
|
|
349
|
+
)
|
|
350
|
+
return 2
|
|
351
|
+
|
|
352
|
+
# Valid — silent success.
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test quality merge gate (US-QA-012).
|
|
3
|
+
|
|
4
|
+
Scan bats test files for rubric ❼ (inline external-tool behaviour) and ❽
|
|
5
|
+
(file outside this repo) violations. Loop's auto-merge runs this between
|
|
6
|
+
CI green and merge; non-zero exit holds the PR until either the test is
|
|
7
|
+
fixed or PR description carries `[skip-test-quality]` (US-QA-013).
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
test_quality_gate.py [--skip] <bats-file> [<bats-file> …]
|
|
11
|
+
|
|
12
|
+
Exit:
|
|
13
|
+
0 — clean OR --skip flag set
|
|
14
|
+
1 — one or more violations
|
|
15
|
+
2 — usage error
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Tuple
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ❼ — inline external-tool patterns. We flag when a single line contains
|
|
26
|
+
# TWO OR MORE of these distinct tool markers, which signals a hand-rolled
|
|
27
|
+
# pipeline duplicating what a project helper should own. A lone `grep -q`
|
|
28
|
+
# or `awk` (no pipe-chain) is fine.
|
|
29
|
+
INLINE_TOOL_PATTERNS = [
|
|
30
|
+
re.compile(r"\bsed\s+[^|]*[s/]"), # sed with substitution / address
|
|
31
|
+
re.compile(r"\bawk\s+'"), # awk with script
|
|
32
|
+
re.compile(r"\bgrep\s+-[a-zA-Z]*o"), # grep -o / -oE (extraction)
|
|
33
|
+
re.compile(r"\bfind\s+[^|]*-name"), # find -name (path scanning)
|
|
34
|
+
re.compile(r"\bcut\s+-f"), # cut -f (column extraction)
|
|
35
|
+
re.compile(r"\btr\s+-d"), # tr -d (char deletion)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# ❽ — paths outside this repo. We flag `~/.<name>` (dotfile dirs) and
|
|
39
|
+
# absolute system paths. `BATS_TMPDIR` is the sandbox marker and is fine.
|
|
40
|
+
OUTSIDE_PATTERNS = [
|
|
41
|
+
re.compile(r"~/\.[A-Za-z]"), # ~/.codex, ~/.kimi, ~/.roll, etc.
|
|
42
|
+
re.compile(r"(?<![A-Za-z0-9])/etc/[A-Za-z]"),
|
|
43
|
+
re.compile(r"(?<![A-Za-z0-9])/usr/[A-Za-z]"),
|
|
44
|
+
re.compile(r"(?<![A-Za-z0-9])/var/[A-Za-z]"),
|
|
45
|
+
]
|
|
46
|
+
OUTSIDE_ALLOW = re.compile(r"BATS_TMPDIR")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scan_lines(text: str) -> List[Tuple[int, str, str]]:
|
|
50
|
+
"""Return list of (line_no, kind, snippet). kind is "❼" or "❽"."""
|
|
51
|
+
findings: List[Tuple[int, str, str]] = []
|
|
52
|
+
in_heredoc = False
|
|
53
|
+
heredoc_terminator: str = ""
|
|
54
|
+
lines = text.splitlines()
|
|
55
|
+
for idx, raw_line in enumerate(lines, start=1):
|
|
56
|
+
line = raw_line.rstrip("\n")
|
|
57
|
+
stripped = line.lstrip()
|
|
58
|
+
|
|
59
|
+
if in_heredoc:
|
|
60
|
+
if line.strip() == heredoc_terminator:
|
|
61
|
+
in_heredoc = False
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Skip comments — comments can legitimately discuss sed/awk in prose.
|
|
65
|
+
if stripped.startswith("#"):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Skip @test header lines — bats decorators carry the test name
|
|
69
|
+
# which often quotes the patterns the test exercises (false positive).
|
|
70
|
+
if stripped.startswith("@test "):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Explicit allow marker for lines that legitimately exercise the
|
|
74
|
+
# gate itself (test fixture content), or for project doc-validation
|
|
75
|
+
# awks that don't test production code.
|
|
76
|
+
if "test-quality:allow" in line:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Heredoc start: << 'EOF' or <<EOF (optional quotes).
|
|
80
|
+
# After the heredoc terminator on this line, subsequent lines are
|
|
81
|
+
# data until the terminator appears alone on a line.
|
|
82
|
+
m = re.search(r"<<\s*['\"]?([A-Z_]+)['\"]?", line)
|
|
83
|
+
if m:
|
|
84
|
+
heredoc_terminator = m.group(1)
|
|
85
|
+
in_heredoc = True
|
|
86
|
+
# Don't scan this declarator line further — the leading code
|
|
87
|
+
# before "<<" might still contain tool patterns, but we'd be
|
|
88
|
+
# double-flagging here vs the line that actually executes.
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# ❼: any inline extraction/parsing tool on this line flags. Each
|
|
92
|
+
# pattern intentionally describes parsing intent (sed substitution,
|
|
93
|
+
# awk script, grep -o / -oE, find -name, cut -f, tr -d) — single
|
|
94
|
+
# grep -q without -o doesn't match and stays untouched.
|
|
95
|
+
if any(pat.search(line) for pat in INLINE_TOOL_PATTERNS):
|
|
96
|
+
findings.append((idx, "❼", line.strip()))
|
|
97
|
+
|
|
98
|
+
# ❽: any outside-path hit unless BATS_TMPDIR appears (sandbox marker).
|
|
99
|
+
if OUTSIDE_ALLOW.search(line):
|
|
100
|
+
continue
|
|
101
|
+
for pat in OUTSIDE_PATTERNS:
|
|
102
|
+
if pat.search(line):
|
|
103
|
+
findings.append((idx, "❽", line.strip()))
|
|
104
|
+
break # one ❽ finding per line is enough
|
|
105
|
+
|
|
106
|
+
return findings
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def scan_file(path: Path) -> List[Tuple[int, str, str]]:
|
|
110
|
+
try:
|
|
111
|
+
text = path.read_text(errors="ignore")
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
return [(0, "?", f"file not found: {path}")]
|
|
114
|
+
return _scan_lines(text)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> int:
|
|
118
|
+
args = sys.argv[1:]
|
|
119
|
+
skip = False
|
|
120
|
+
files: List[str] = []
|
|
121
|
+
for a in args:
|
|
122
|
+
if a in ("--skip", "--skip-test-quality"):
|
|
123
|
+
skip = True
|
|
124
|
+
else:
|
|
125
|
+
files.append(a)
|
|
126
|
+
if not files:
|
|
127
|
+
print("usage: test_quality_gate.py [--skip] <bats-file> [<bats-file> …]",
|
|
128
|
+
file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
if skip:
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
total = 0
|
|
134
|
+
for f in files:
|
|
135
|
+
findings = scan_file(Path(f))
|
|
136
|
+
for line_no, kind, snippet in findings:
|
|
137
|
+
print(f"{f}:{line_no}: {kind} {snippet}")
|
|
138
|
+
total += 1
|
|
139
|
+
return 1 if total > 0 else 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanyao/roll",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.602.1",
|
|
4
4
|
"description": "Roll — Roll out features with AI agents",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"test": "
|
|
6
|
+
"test": "bash tests/run.sh"
|
|
7
7
|
},
|
|
8
8
|
"keywords": [
|
|
9
9
|
"ai",
|
|
@@ -15,19 +15,20 @@
|
|
|
15
15
|
"homepage": "https://github.com/seanyao/roll",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/seanyao/roll.git"
|
|
18
|
+
"url": "git+https://github.com/seanyao/roll.git"
|
|
19
19
|
},
|
|
20
20
|
"bin": {
|
|
21
|
-
"roll": "
|
|
21
|
+
"roll": "bin/roll"
|
|
22
22
|
},
|
|
23
23
|
"license": "MIT",
|
|
24
|
-
"author": "Sean Yao <
|
|
24
|
+
"author": "Sean Yao <sean.dlut@gmail.com>",
|
|
25
25
|
"files": [
|
|
26
26
|
"bin/",
|
|
27
27
|
"conventions/",
|
|
28
|
+
"lib/",
|
|
28
29
|
"skills/",
|
|
29
|
-
"tools/",
|
|
30
30
|
"template/",
|
|
31
|
-
"README.md"
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGELOG.md"
|
|
32
33
|
]
|
|
33
34
|
}
|