@seanyao/roll 2026.529.5 → 2026.601.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 +57 -25
- package/README.md +10 -7
- package/bin/roll +3952 -317
- package/conventions/config.yaml +7 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.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__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__init__.py +4 -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__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +127 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/agent.sh +54 -0
- package/lib/i18n/init.sh +22 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +93 -75
- package/lib/loop_pick_agent.py +241 -170
- package/lib/loop_result_eval.py +469 -0
- package/lib/model_prices.py +0 -10
- package/lib/roll-home.py +1 -28
- package/lib/roll-loop-status.py +330 -40
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +1 -1
- package/lib/roll-plan-validate.py +165 -0
- package/lib/roll_git.py +41 -0
- package/lib/slides/components/README.md +8 -2
- package/lib/slides/templates/introduction-v3.html +1 -6
- package/lib/slides-render.py +305 -15
- package/lib/slides-validate.py +195 -7
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +67 -56
- package/skills/roll-brief/SKILL.md +1 -1
- package/skills/roll-build/SKILL.md +14 -12
- package/skills/roll-deck/SKILL.md +152 -0
- package/skills/roll-design/SKILL.md +13 -6
- package/skills/roll-doc/SKILL.md +269 -6
- package/skills/roll-fix/SKILL.md +15 -9
- package/skills/roll-loop/SKILL.md +9 -7
- package/skills/roll-notes/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +85 -0
- package/skills/roll-peer/SKILL.md +6 -5
- package/lib/agent_routes_lint.py +0 -203
- package/skills/roll-research/SKILL.md +0 -316
- package/skills/roll-research/references/schema.json +0 -166
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
package/lib/slides-render.py
CHANGED
|
@@ -99,6 +99,12 @@ def _coerce_scalar(v: str):
|
|
|
99
99
|
return v
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
# Keys whose `- item` children are always flat scalar strings, never coerced
|
|
103
|
+
# into mappings even when an item contains a colon (e.g. `evidence:` citations
|
|
104
|
+
# like `- TODO: see README.md:1`).
|
|
105
|
+
_SCALAR_LIST_KEYS = ("evidence",)
|
|
106
|
+
|
|
107
|
+
|
|
102
108
|
# A slide section starts at a line matching `^## Slide \d+` and continues
|
|
103
109
|
# until the next such line or EOF.
|
|
104
110
|
_SLIDE_HEADER_RE = re.compile(r"^##\s+Slide\s+(\d+)\s*$")
|
|
@@ -188,21 +194,21 @@ def _populate_slide(slide: dict, content_lines: list[str]) -> None:
|
|
|
188
194
|
block.pop()
|
|
189
195
|
slide[key] = "\n".join(block) + "\n" if block else ""
|
|
190
196
|
elif val == "":
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
197
|
+
# A bare `key:` introduces an indented child block, which may be:
|
|
198
|
+
# - a list of scalars (`- one.md:1`) -> list[str]
|
|
199
|
+
# - a list of mappings (`- title_en: "..."`) -> list[dict]
|
|
200
|
+
# - a nested mapping (` left_title_en: …`) -> dict
|
|
201
|
+
# or, if no indented child follows, an empty scalar.
|
|
202
|
+
key_indent = len(raw) - len(raw.lstrip(" "))
|
|
203
|
+
block, j = _collect_indented_block(content_lines, i + 1, key_indent)
|
|
204
|
+
if block:
|
|
205
|
+
# `evidence` is always a flat list of free-form citation
|
|
206
|
+
# strings (e.g. `- TODO: see README.md:1`), so it must never be
|
|
207
|
+
# coerced into a mapping by the generic block parser.
|
|
208
|
+
if key in _SCALAR_LIST_KEYS:
|
|
209
|
+
slide[key] = _parse_scalar_list(block)
|
|
210
|
+
else:
|
|
211
|
+
slide[key] = _parse_block(block)
|
|
206
212
|
i = j
|
|
207
213
|
else:
|
|
208
214
|
slide[key] = ""
|
|
@@ -212,6 +218,207 @@ def _populate_slide(slide: dict, content_lines: list[str]) -> None:
|
|
|
212
218
|
i += 1
|
|
213
219
|
|
|
214
220
|
|
|
221
|
+
def _collect_indented_block(
|
|
222
|
+
lines: list[str], start: int, parent_indent: int
|
|
223
|
+
) -> tuple[list[str], int]:
|
|
224
|
+
"""
|
|
225
|
+
Gather lines that belong to the indented child block of a `key:` at
|
|
226
|
+
`parent_indent`. A line belongs to the block if it is blank or indented
|
|
227
|
+
strictly deeper than the parent key. Returns (block_lines, next_index).
|
|
228
|
+
|
|
229
|
+
Trailing blank lines are dropped so an all-blank block reads as empty.
|
|
230
|
+
"""
|
|
231
|
+
block: list[str] = []
|
|
232
|
+
j = start
|
|
233
|
+
n = len(lines)
|
|
234
|
+
while j < n:
|
|
235
|
+
bl = lines[j]
|
|
236
|
+
if bl.strip() == "":
|
|
237
|
+
block.append("")
|
|
238
|
+
j += 1
|
|
239
|
+
continue
|
|
240
|
+
indent = len(bl) - len(bl.lstrip(" "))
|
|
241
|
+
if indent <= parent_indent:
|
|
242
|
+
break
|
|
243
|
+
block.append(bl)
|
|
244
|
+
j += 1
|
|
245
|
+
while block and block[-1] == "":
|
|
246
|
+
block.pop()
|
|
247
|
+
return block, j
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_block(block: list[str]):
|
|
251
|
+
"""
|
|
252
|
+
Parse an indented YAML-subset block into a list or a dict.
|
|
253
|
+
|
|
254
|
+
- If the first non-blank line starts with `- `, the block is a sequence.
|
|
255
|
+
Each `- ` opens a new item; a scalar follows `- ` directly (list of
|
|
256
|
+
scalars) or a `key: value` does (list of mappings, with subsequent
|
|
257
|
+
deeper-indented `key: value` lines folded into the same item).
|
|
258
|
+
- Otherwise the block is a mapping of `key: value` / `key:` (nested)
|
|
259
|
+
entries.
|
|
260
|
+
|
|
261
|
+
Scalars are coerced via `_coerce_scalar`. Nested `key:` with no inline
|
|
262
|
+
value recurse into another block (supports `compare`'s `left_items:`).
|
|
263
|
+
"""
|
|
264
|
+
# Find the indentation of the block's own top level (first non-blank line).
|
|
265
|
+
first = next((b for b in block if b.strip() != ""), "")
|
|
266
|
+
base_indent = len(first) - len(first.lstrip(" "))
|
|
267
|
+
|
|
268
|
+
if first.lstrip().startswith("- "):
|
|
269
|
+
return _parse_sequence(block, base_indent)
|
|
270
|
+
return _parse_mapping(block, base_indent)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_sequence(block: list[str], base_indent: int) -> list:
|
|
274
|
+
"""Parse a `- ...` sequence at `base_indent` into list[str] | list[dict]."""
|
|
275
|
+
items: list = []
|
|
276
|
+
i = 0
|
|
277
|
+
n = len(block)
|
|
278
|
+
while i < n:
|
|
279
|
+
line = block[i]
|
|
280
|
+
if line.strip() == "":
|
|
281
|
+
i += 1
|
|
282
|
+
continue
|
|
283
|
+
stripped = line.lstrip()
|
|
284
|
+
# Only treat a `- ` at the sequence's own indent as an item start.
|
|
285
|
+
indent = len(line) - len(stripped)
|
|
286
|
+
if indent == base_indent and stripped.startswith("- "):
|
|
287
|
+
# Strip the dash and ALL following spaces; `content_col` is the
|
|
288
|
+
# column where the item's first key actually begins, which equals
|
|
289
|
+
# the indent of the item's continuation lines in well-formed YAML.
|
|
290
|
+
after_dash = stripped[1:]
|
|
291
|
+
rest = after_dash.lstrip(" ")
|
|
292
|
+
content_col = indent + 1 + (len(after_dash) - len(rest))
|
|
293
|
+
if ":" in rest and not _looks_like_scalar(rest):
|
|
294
|
+
# List of mappings. Re-indent the inline key to `content_col`
|
|
295
|
+
# so the sub-block has one consistent base indent regardless of
|
|
296
|
+
# how the author spaced the `- ` (fixes hardcoded-indent
|
|
297
|
+
# mis-parse).
|
|
298
|
+
sub = [(" " * content_col) + rest]
|
|
299
|
+
i += 1
|
|
300
|
+
while i < n:
|
|
301
|
+
bl = block[i]
|
|
302
|
+
if bl.strip() == "":
|
|
303
|
+
sub.append("")
|
|
304
|
+
i += 1
|
|
305
|
+
continue
|
|
306
|
+
bi = len(bl) - len(bl.lstrip(" "))
|
|
307
|
+
if bi <= base_indent:
|
|
308
|
+
break
|
|
309
|
+
sub.append(bl)
|
|
310
|
+
i += 1
|
|
311
|
+
items.append(_parse_mapping(sub, content_col))
|
|
312
|
+
else:
|
|
313
|
+
items.append(_coerce_scalar(rest.strip()))
|
|
314
|
+
i += 1
|
|
315
|
+
else:
|
|
316
|
+
# Defensive: ignore stray lines that don't open an item.
|
|
317
|
+
i += 1
|
|
318
|
+
return items
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_mapping(block: list[str], base_indent: int | None = None) -> dict:
|
|
322
|
+
"""Parse `key: value` / `key:` (nested / block-literal) lines into a dict.
|
|
323
|
+
|
|
324
|
+
`base_indent` defaults to the indent of the block's first non-blank line so
|
|
325
|
+
callers need not compute it; only top-level keys at that indent are read,
|
|
326
|
+
deeper lines are folded into the value of the key they belong to.
|
|
327
|
+
"""
|
|
328
|
+
out: dict = {}
|
|
329
|
+
i = 0
|
|
330
|
+
n = len(block)
|
|
331
|
+
if base_indent is None:
|
|
332
|
+
first = next((b for b in block if b.strip() != ""), "")
|
|
333
|
+
base_indent = len(first) - len(first.lstrip(" "))
|
|
334
|
+
while i < n:
|
|
335
|
+
line = block[i]
|
|
336
|
+
if line.strip() == "" or ":" not in line:
|
|
337
|
+
i += 1
|
|
338
|
+
continue
|
|
339
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
340
|
+
if indent != base_indent:
|
|
341
|
+
# Belongs to a deeper structure handled by recursion; skip.
|
|
342
|
+
i += 1
|
|
343
|
+
continue
|
|
344
|
+
key, _, val = line.partition(":")
|
|
345
|
+
key = key.strip()
|
|
346
|
+
val = val.strip()
|
|
347
|
+
if val == "|":
|
|
348
|
+
block_lines, j = _collect_indented_block(block, i + 1, base_indent)
|
|
349
|
+
out[key] = _dedent_block_literal(block_lines)
|
|
350
|
+
i = j
|
|
351
|
+
elif val == "":
|
|
352
|
+
child, j = _collect_indented_block(block, i + 1, base_indent)
|
|
353
|
+
if child:
|
|
354
|
+
if key in _SCALAR_LIST_KEYS:
|
|
355
|
+
out[key] = _parse_scalar_list(child)
|
|
356
|
+
else:
|
|
357
|
+
out[key] = _parse_block(child)
|
|
358
|
+
i = j
|
|
359
|
+
else:
|
|
360
|
+
out[key] = ""
|
|
361
|
+
i += 1
|
|
362
|
+
else:
|
|
363
|
+
out[key] = _coerce_scalar(val)
|
|
364
|
+
i += 1
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _dedent_block_literal(block_lines: list[str]) -> str:
|
|
369
|
+
"""Strip the common leading indent of a `key: |` block literal and join the
|
|
370
|
+
lines, matching the top-level block-literal handling in `_populate_slide`."""
|
|
371
|
+
out: list[str] = []
|
|
372
|
+
common_indent: int | None = None
|
|
373
|
+
for bl in block_lines:
|
|
374
|
+
if bl.strip() == "":
|
|
375
|
+
out.append("")
|
|
376
|
+
continue
|
|
377
|
+
indent = len(bl) - len(bl.lstrip(" "))
|
|
378
|
+
if common_indent is None:
|
|
379
|
+
common_indent = indent
|
|
380
|
+
elif indent < common_indent:
|
|
381
|
+
common_indent = indent
|
|
382
|
+
if common_indent is None:
|
|
383
|
+
return ""
|
|
384
|
+
for bl in block_lines:
|
|
385
|
+
out.append(bl[common_indent:] if bl.strip() != "" else "")
|
|
386
|
+
while out and out[-1] == "":
|
|
387
|
+
out.pop()
|
|
388
|
+
return "\n".join(out) + "\n" if out else ""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _parse_scalar_list(block: list[str]) -> list:
|
|
392
|
+
"""Parse a `- item` sequence as a flat list of scalar strings, regardless of
|
|
393
|
+
whether an item contains a colon (used for `evidence:` citations)."""
|
|
394
|
+
items: list = []
|
|
395
|
+
for line in block:
|
|
396
|
+
s = line.strip()
|
|
397
|
+
if s.startswith("- "):
|
|
398
|
+
items.append(_coerce_scalar(s[2:].strip()))
|
|
399
|
+
return items
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _looks_like_scalar(rest: str) -> bool:
|
|
403
|
+
"""A `- ` item is a scalar (not a mapping) when the text before the first
|
|
404
|
+
colon looks like a value rather than a bare key — e.g. a path `README.md:42`
|
|
405
|
+
where the colon is part of the value. Heuristic: it's a mapping only when
|
|
406
|
+
the segment before the colon is a bare identifier (word chars / underscore)
|
|
407
|
+
and is immediately followed by a space or end-of-string."""
|
|
408
|
+
head, sep, tail = rest.partition(":")
|
|
409
|
+
if not sep:
|
|
410
|
+
return True
|
|
411
|
+
head = head.strip()
|
|
412
|
+
if not head or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", head):
|
|
413
|
+
return True
|
|
414
|
+
# `key:value` with no space is treated as a scalar (e.g. `README.md:42`
|
|
415
|
+
# never reaches here since `.` breaks the identifier rule, but a bare
|
|
416
|
+
# `word:7` would — require a space after the colon for a mapping).
|
|
417
|
+
if tail and not tail.startswith(" ") and not tail == "":
|
|
418
|
+
return True
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
|
|
215
422
|
# ─────────────────────────── Mustache subset ─────────────────────────────────
|
|
216
423
|
|
|
217
424
|
|
|
@@ -418,6 +625,84 @@ def _inline(text: str) -> str:
|
|
|
418
625
|
return text
|
|
419
626
|
|
|
420
627
|
|
|
628
|
+
# ──────────────────────────── layout routing ────────────────────────────────
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# The default layout when a slide omits `layout:` — preserves the pre-layout
|
|
632
|
+
# behaviour (a plain lang-en / lang-zh body block).
|
|
633
|
+
DEFAULT_LAYOUT = "plain"
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class LayoutResolver:
|
|
637
|
+
"""
|
|
638
|
+
Map a slide's `layout` name to its component partial path.
|
|
639
|
+
|
|
640
|
+
Partials live in `lib/slides/components/<layout>.html` next to this module.
|
|
641
|
+
The resolver does not read the file — it only computes and validates the
|
|
642
|
+
path so callers get a clear `Unknown layout: ...` error before any I/O.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
def __init__(self, components_dir: Path | None = None) -> None:
|
|
646
|
+
if components_dir is None:
|
|
647
|
+
components_dir = Path(__file__).resolve().parent / "slides" / "components"
|
|
648
|
+
self.components_dir = components_dir
|
|
649
|
+
|
|
650
|
+
def available(self) -> list[str]:
|
|
651
|
+
"""Return the sorted layout names backed by a `<name>.html` partial."""
|
|
652
|
+
if not self.components_dir.is_dir():
|
|
653
|
+
return []
|
|
654
|
+
names = [
|
|
655
|
+
p.stem
|
|
656
|
+
for p in self.components_dir.glob("*.html")
|
|
657
|
+
]
|
|
658
|
+
# Locale-independent ordering: plain (the default/fallback) first, then
|
|
659
|
+
# the rest in plain ASCII sort so the error message is deterministic
|
|
660
|
+
# across CI locales.
|
|
661
|
+
rest = sorted(n for n in names if n != DEFAULT_LAYOUT)
|
|
662
|
+
head = [DEFAULT_LAYOUT] if DEFAULT_LAYOUT in names else []
|
|
663
|
+
return head + rest
|
|
664
|
+
|
|
665
|
+
def resolve(self, layout: str) -> Path:
|
|
666
|
+
"""
|
|
667
|
+
Return the partial path for `layout`, or raise ValueError with the list
|
|
668
|
+
of available layouts when no matching partial file exists.
|
|
669
|
+
|
|
670
|
+
Layout names are restricted to `[a-z0-9-]+` so a malicious or malformed
|
|
671
|
+
`layout:` field (e.g. `../../etc/passwd`) can never escape
|
|
672
|
+
`components_dir` — anything else is reported as an unknown layout.
|
|
673
|
+
"""
|
|
674
|
+
if not re.fullmatch(r"[a-z0-9-]+", layout):
|
|
675
|
+
avail = ", ".join(self.available())
|
|
676
|
+
raise ValueError(f"Unknown layout: {layout}; available: {avail}")
|
|
677
|
+
path = self.components_dir / f"{layout}.html"
|
|
678
|
+
if not path.is_file():
|
|
679
|
+
avail = ", ".join(self.available())
|
|
680
|
+
raise ValueError(f"Unknown layout: {layout}; available: {avail}")
|
|
681
|
+
return path
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def render_slide_inner(slide: dict, resolver: LayoutResolver) -> str:
|
|
685
|
+
"""
|
|
686
|
+
Resolve the slide's layout to a partial, render that partial against the
|
|
687
|
+
slide dict, and return the inner HTML to inject into the main template's
|
|
688
|
+
slide slot.
|
|
689
|
+
|
|
690
|
+
The result is stripped of the partial's leading `<!-- ... -->` doc comment
|
|
691
|
+
and of surrounding blank lines so that, for the default `plain` layout, the
|
|
692
|
+
output is byte-identical to the pre-layout template body block.
|
|
693
|
+
"""
|
|
694
|
+
layout = slide.get("layout") or DEFAULT_LAYOUT
|
|
695
|
+
partial_path = resolver.resolve(layout)
|
|
696
|
+
partial = partial_path.read_text(encoding="utf-8")
|
|
697
|
+
# Drop a leading HTML doc comment (the `<!-- layout: requires ... -->`
|
|
698
|
+
# header every partial carries) so it never leaks into the rendered deck.
|
|
699
|
+
partial = re.sub(
|
|
700
|
+
r"\A\s*<!--.*?-->(?:\s*\n)?", "", partial, count=1, flags=re.DOTALL
|
|
701
|
+
)
|
|
702
|
+
rendered = mustache(partial, slide)
|
|
703
|
+
return rendered.strip("\n")
|
|
704
|
+
|
|
705
|
+
|
|
421
706
|
# ───────────────────────────── render_deck ──────────────────────────────────
|
|
422
707
|
|
|
423
708
|
|
|
@@ -429,6 +714,8 @@ def render_deck(deck_path: Path, template_path: Path) -> str:
|
|
|
429
714
|
fm, body = parse_frontmatter(src)
|
|
430
715
|
slides = parse_slides(body)
|
|
431
716
|
|
|
717
|
+
resolver = LayoutResolver()
|
|
718
|
+
|
|
432
719
|
# Pre-render body_en / body_zh markdown into HTML strings so the template
|
|
433
720
|
# can drop them in via {{{body_en_html}}}.
|
|
434
721
|
for slide in slides:
|
|
@@ -437,6 +724,9 @@ def render_deck(deck_path: Path, template_path: Path) -> str:
|
|
|
437
724
|
# Provide an `evidence` flag for inverted-section use in templates.
|
|
438
725
|
if "evidence" not in slide:
|
|
439
726
|
slide["evidence"] = []
|
|
727
|
+
# US-DECK-018: route each slide through its layout partial and inject
|
|
728
|
+
# the result via the main template's {{{slide_inner_html}}} slot.
|
|
729
|
+
slide["slide_inner_html"] = render_slide_inner(slide, resolver)
|
|
440
730
|
|
|
441
731
|
context: dict = dict(fm)
|
|
442
732
|
context["slides"] = slides
|
package/lib/slides-validate.py
CHANGED
|
@@ -45,6 +45,76 @@ REQUIRED_FRONTMATTER = (
|
|
|
45
45
|
)
|
|
46
46
|
REQUIRED_SLIDE_KEYS = ("title_en", "title_zh", "body_en", "body_zh")
|
|
47
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
|
+
|
|
48
118
|
|
|
49
119
|
def _load_renderer():
|
|
50
120
|
"""Import lib/slides-render.py as a module (hyphenated filename, so we
|
|
@@ -79,8 +149,34 @@ def validate_frontmatter(fm: dict) -> list[str]:
|
|
|
79
149
|
return errors
|
|
80
150
|
|
|
81
151
|
|
|
82
|
-
def
|
|
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]:
|
|
83
178
|
errors: list[str] = []
|
|
179
|
+
line_of = line_of or {}
|
|
84
180
|
actual = len(slides)
|
|
85
181
|
declared = fm.get("total_slides")
|
|
86
182
|
if isinstance(declared, int) and declared != actual:
|
|
@@ -89,14 +185,98 @@ def validate_slides(fm: dict, slides: list[dict]) -> list[str]:
|
|
|
89
185
|
f"found {actual} `## Slide N` sections"
|
|
90
186
|
)
|
|
91
187
|
for slide in slides:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
)
|
|
97
257
|
return errors
|
|
98
258
|
|
|
99
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
|
+
|
|
100
280
|
def evaluate_grounding(slides: list[dict]) -> tuple[int, int, bool]:
|
|
101
281
|
"""
|
|
102
282
|
Return (citations, threshold, meets_threshold).
|
|
@@ -141,15 +321,23 @@ def main(argv: list[str]) -> int:
|
|
|
141
321
|
err(f"failed to parse deck.md: {e}", "解析 deck.md 失败")
|
|
142
322
|
return 3
|
|
143
323
|
|
|
324
|
+
line_of = _slide_header_lines(src)
|
|
325
|
+
|
|
144
326
|
schema_errors: list[str] = []
|
|
145
327
|
schema_errors += validate_frontmatter(fm)
|
|
146
|
-
schema_errors += validate_slides(fm, slides)
|
|
328
|
+
schema_errors += validate_slides(fm, slides, line_of)
|
|
147
329
|
|
|
148
330
|
if schema_errors:
|
|
149
331
|
for e in schema_errors:
|
|
150
332
|
err(e)
|
|
151
333
|
return 1
|
|
152
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
|
+
|
|
153
341
|
citations, threshold, ok = evaluate_grounding(slides)
|
|
154
342
|
if not ok:
|
|
155
343
|
err(
|