@chengzhao-dev/dsh-quarto-book 0.6.17 → 0.6.19

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.
Files changed (34) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +3 -3
  3. package/lib/tools/pass-report.js +6 -5
  4. package/lib/tools/render-check.js +1 -1
  5. package/package.json +2 -2
  6. package/skills/designing-theme/SKILL.md +24 -12
  7. package/skills/writing-quarto/SKILL.md +52 -39
  8. package/skills/writing-quarto/examples/02-structure-shape.md +1 -1
  9. package/skills/writing-quarto/examples/03-excerpt-lead.md +19 -0
  10. package/skills/writing-quarto/examples/04-paragraph-cases.md +17 -13
  11. package/skills/writing-quarto/examples/06-wording-cases.md +1 -1
  12. package/skills/writing-quarto/examples/07-layout-list.md +17 -1
  13. package/skills/writing-quarto/quarto/authoring.md +3 -3
  14. package/skills/writing-quarto/quarto/comments.md +1 -0
  15. package/skills/writing-quarto/quarto/coords.md +1 -1
  16. package/skills/writing-quarto/quarto/prose.md +2 -3
  17. package/skills/writing-quarto/quarto/refactor.md +3 -3
  18. package/skills/writing-quarto/quarto/terminal.md +2 -0
  19. package/skills/writing-quarto/references/book-journey.md +14 -14
  20. package/skills/writing-quarto/references/fence-semantics.md +6 -6
  21. package/skills/writing-quarto/references/personas.md +15 -0
  22. package/skills/writing-quarto/references/rules-cards.md +3 -3
  23. package/skills/writing-quarto/references/token-budget.md +2 -2
  24. package/skills/writing-quarto/scripts/testing-content.py +83 -2
  25. package/skills/writing-quarto/scripts/verifying-content.py +251 -4
  26. package/skills/writing-quarto/steps/01-task.md +25 -42
  27. package/skills/writing-quarto/steps/02-structure.md +29 -54
  28. package/skills/writing-quarto/steps/03-blocks.md +112 -133
  29. package/skills/writing-quarto/steps/04-paragraphs.md +2 -1
  30. package/skills/writing-quarto/steps/05-sentences.md +45 -70
  31. package/skills/writing-quarto/steps/06-wording.md +11 -9
  32. package/skills/writing-quarto/steps/07-layout.md +3 -1
  33. package/skills/writing-quarto/steps/08-reread.md +9 -26
  34. package/skills/writing-quarto/steps/09-check.md +4 -3
@@ -53,7 +53,7 @@
53
53
  - 步骤:`steps/02-structure.md`
54
54
 
55
55
  **C10 章节按五步走。** 现象 → 最小例子 → 机制 → 边界 → 自测。
56
- - 正:`03-board.qmd` 依次讲空棋盘、落子、越界、判胜、自测。
56
+ - 正:章文件依次讲空棋盘、落子、越界、判胜、自测。
57
57
  - 反:先给完整类声明,再逐行解释。
58
58
  - 步骤:`steps/02-structure.md`
59
59
 
@@ -63,8 +63,8 @@
63
63
  - 步骤:`steps/03-blocks.md`
64
64
 
65
65
  **C12 一节一个关键。** 一章只解决一个问题,多出来的挪到下一章。
66
- - 正:03 章只讲棋盘;游戏流程留给 04 章。
67
- - 反:03 章顺手把 `Game` 的状态机也讲了。
66
+ - 正:讲棋盘的章只讲棋盘;游戏流程留给下一章。
67
+ - 反:讲棋盘的章顺手把 `Game` 的状态机也讲了。
68
68
  - 步骤:`steps/02-structure.md`
69
69
 
70
70
  **C13 章首导语 60–146 字。** 一段说清这章解决什么、读者读完能做什么。
@@ -18,11 +18,11 @@
18
18
  | `assets/**/meta.md` | 2000 |
19
19
  | 全部技能 Markdown 合计 | 140000 |
20
20
 
21
- 单次激活上限:`SKILL.md` + 1 个步骤 + 1 个被点名文件(案例、条文或脚本路径)。**九步同走**只用于新章、新阶段或用户明确要求全章重构;同轮不把同号案例与条文一起打开。
21
+ 单次激活上限:`SKILL.md` + 1 个步骤 + 1 个被点名文件(案例、条文或脚本路径)。**九步同走**只用于新章、整章改写或用户明确要求全章处理;同轮不把同号案例与条文一起打开。
22
22
 
23
23
  ## 怎么用
24
24
 
25
25
  - 新增规则先判断属于哪一层,再写进已有文件;同一判定只成文一处,其他位置写引用。
26
26
  - 文件将超限时,先删重复、合并同源条目,再考虑拆成同目录的新文件;拆文件后同步入口和 `scripts/checking-skill-links.mjs`。
27
- - 增量任务只加载 `SKILL.md`、当前一步与该步点名的一个文件;不要为了省 token 把整本书的规则塞进 `SKILL.md`。
27
+ - 单点修改只加载 `SKILL.md`、当前一步与该步点名的一个文件;不要为了省 token 把整本书的规则塞进 `SKILL.md`。
28
28
  - 预算不是目标:没必要的条文直接删,不靠缩写把句子压成暗语。
@@ -122,7 +122,7 @@ title: 入门
122
122
  下面是一张只画类型和边的类图,读者可以对照箭头理解类型之间的依赖。
123
123
 
124
124
  ::: {.class-diagram}
125
- ```{mermaid}
125
+ ```{mermaid role="img" aria-label="类型 A 依赖类型 B"}
126
126
  %%| fig-cap: "类型 A 依赖类型 B。实线箭头从使用者指向被使用者。"
127
127
  %%{init: {"fontFamily": "LXGW Bright Medium, system-ui, sans-serif", "class": {"hideEmptyMembersBox": true}}}%%
128
128
  classDiagram
@@ -178,12 +178,17 @@ def main() -> int:
178
178
  needle: str,
179
179
  project: bool = False,
180
180
  checks_json: str | None = None,
181
+ files: dict[str, str] | None = None,
181
182
  ) -> None:
182
183
  nonlocal passed_cases
183
184
  passed_cases += 1
184
185
  with tempfile.TemporaryDirectory() as raw:
185
186
  root = Path(raw)
186
187
  make_book(root, chapter)
188
+ for relative, content in (files or {}).items():
189
+ target = root / relative
190
+ target.parent.mkdir(parents=True, exist_ok=True)
191
+ target.write_text(content, encoding="utf-8")
187
192
  if project:
188
193
  skill_dir = root / ".agents" / "skills" / "quarto-book-project"
189
194
  skill_dir.mkdir(parents=True)
@@ -293,6 +298,18 @@ def main() -> int:
293
298
  "---\ntitle: 入门\n---\n\n## 目标\n\n```mermaid\ngraph LR\n```\n",
294
299
  1, "plain mermaid",
295
300
  )
301
+ expect(
302
+ "mermaid 围栏带前导点",
303
+ "---\ntitle: 入门\n---\n\n## 目标\n\n"
304
+ '```{.mermaid role="img" aria-label="流程"}\ngraph LR\n```\n',
305
+ 1, "不会载入图表脚本",
306
+ )
307
+ expect(
308
+ "mermaid 围栏正确写法",
309
+ "---\ntitle: 入门\n---\n\n## 目标\n\n"
310
+ '```{mermaid role="img" aria-label="流程"}\ngraph LR\n```\n',
311
+ 0, "OK",
312
+ )
296
313
  expect(
297
314
  "未声明围栏",
298
315
  "---\ntitle: 入门\n---\n\n## 目标\n\n```\nprint(1)\n```\n",
@@ -316,6 +333,67 @@ def main() -> int:
316
333
  0, "OK",
317
334
  )
318
335
 
336
+ excerpt_url = "https://github.com/your-org/your-book/blob/main/games/board.h"
337
+ excerpt_source = (
338
+ "/// 判断两个坐标是否相同。\n"
339
+ "///\n"
340
+ "/// @param lhs 左操作数。\n"
341
+ "/// @return 相同返回 true。\n"
342
+ "bool Same(int lhs, int rhs);\n"
343
+ )
344
+ excerpt_chapter = VALID_CHAPTER + (
345
+ f"\n声明见 [board.h]({excerpt_url})。\n\n"
346
+ )
347
+ expect(
348
+ "正文讲解文件头",
349
+ VALID_CHAPTER + "\n每个源文件开头都有一段 Doxygen 文件头。\n",
350
+ 1, "文件级",
351
+ )
352
+ expect(
353
+ "围栏摘录 @file",
354
+ excerpt_chapter
355
+ + "```{.cpp filename=\"board.h\"}\n/// @file board.h\n/// @brief 棋盘。\n```\n",
356
+ 1, "文件级",
357
+ )
358
+ expect(
359
+ "围栏注释截断",
360
+ excerpt_chapter
361
+ + "```{.cpp filename=\"board.h\"}\n"
362
+ "/// 判断两个坐标是否相同。\n"
363
+ "bool Same(int lhs, int rhs);\n"
364
+ "```\n",
365
+ 1, "只摘了",
366
+ files={"games/board.h": excerpt_source},
367
+ )
368
+ expect(
369
+ "围栏注释整段一致",
370
+ excerpt_chapter
371
+ + "```{.cpp filename=\"board.h\"}\n"
372
+ + excerpt_source
373
+ + "```\n",
374
+ 0, "OK",
375
+ files={"games/board.h": excerpt_source},
376
+ )
377
+ expect(
378
+ "围栏整段不摘注释",
379
+ excerpt_chapter
380
+ + "```{.cpp filename=\"board.h\"}\n"
381
+ "bool Same(int lhs, int rhs);\n"
382
+ "```\n",
383
+ 0, "OK",
384
+ files={"games/board.h": excerpt_source},
385
+ )
386
+ expect(
387
+ "输出围栏不比对注释",
388
+ VALID_CHAPTER
389
+ + "\n```text filename=\"运行效果\"\n"
390
+ "/// 判断两个坐标是否相同。\n"
391
+ "bool Same(int lhs, int rhs);\n"
392
+ "```\n",
393
+ 0, "OK",
394
+ files={"games/board.h": excerpt_source},
395
+ )
396
+
319
397
  expect("目录树合格", TREE_CHAPTER, 0, "OK")
320
398
  expect("原生 SVG 缺图注", HTML_FIGURE_BAD, 1, "figcaption")
321
399
  expect("原生 SVG 图注合格", HTML_FIGURE_GOOD, 0, "OK")
@@ -379,7 +457,10 @@ def main() -> int:
379
457
  expect("类图图注写在 init 之后", wrong_order, 1, "init 之前")
380
458
  expect(
381
459
  "类图缺少页面容器",
382
- MERMAID_CHAPTER.replace("::: {.class-diagram}\n```{mermaid}", "```{mermaid}").replace(
460
+ MERMAID_CHAPTER.replace(
461
+ '::: {.class-diagram}\n```{mermaid role="img" aria-label="类型 A 依赖类型 B"}',
462
+ '```{mermaid role="img" aria-label="类型 A 依赖类型 B"}',
463
+ ).replace(
383
464
  "```\n:::", "```"
384
465
  ),
385
466
  1, ".class-diagram",
@@ -78,6 +78,12 @@ TEST_RE = re.compile(r"测试|断言|用例|gtest|CTest")
78
78
  # 围栏与正文的配对:声明围栏只配契约文字。正文出现实现词而小节里没有定义围栏时
79
79
  # 报 decl-without-def,规则与正反例见 references/fence-semantics.md。
80
80
  FENCE_FILENAME_RE = re.compile(r'filename="([^"]+)"')
81
+ # 读者正文不摘文件级 Doxygen 头;成员注释的 @param、@return 不受这条限制。
82
+ EXCERPT_HEADER_RE = re.compile(r"@file\b|@brief\b|Doxygen\s*文件头")
83
+ REF_DEFINITION_RE = re.compile(r"^\s*\[([^\]]+)\]:\s*(\S+)\s*$")
84
+ REF_USE_RE = re.compile(r"\]\[([^\]]+)\]")
85
+ BLOB_URL_RE = re.compile(r"^https://github\.com/[^/]+/[^/]+/blob/[^/]+/(.+)$")
86
+ DOC_COMMENT_RE = re.compile(r"^\s*///")
81
87
  IMPL_MARKER_RE = re.compile(r"复用|委托给|扫描|遍历|对每个|短路|表达式|状态位|缓存|不再单独|递归")
82
88
  FENCE_SKIP_SECTION_RE = re.compile(r"常见错误|自测|回顾|练习|小结")
83
89
  DECL_SUFFIXES = (".h", ".hpp", ".hh", ".hxx")
@@ -105,6 +111,8 @@ HEADING_PERSONAL_RE = re.compile(r"自己|咱们|我们|你|您|跟我|试试|
105
111
  FENCE_RE = re.compile(r"^\s*(```+|~~~+)\s*(\{[^}]*\}|[^\s`]*)?")
106
112
  LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
107
113
  LINK_LABEL_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")
114
+ LIST_LINE_RE = re.compile(r"^\s*(?:[-*+]|\d+\.)\s+")
115
+ GUIDED_ITEM_RE = re.compile(r"^\*\*[^*\n]{1,30}\*\*:")
108
116
  CJK_RE = re.compile(r"[\u3400-\u9fff]")
109
117
  TITLE_RE = re.compile(r"^title:\s*(.+?)\s*$", re.MULTILINE)
110
118
 
@@ -295,6 +303,70 @@ def check_prose_block(
295
303
  ))
296
304
  break
297
305
 
306
+ if not is_list and CJK_RE.search(compact):
307
+ sentences = [
308
+ sentence.strip()
309
+ for sentence in re.split(r"(?<=[。!?])", compact)
310
+ if sentence.strip()
311
+ ]
312
+ seen_names: set[str] = set()
313
+ for index, sentence in enumerate(sentences):
314
+ if index > 0:
315
+ if sentence.startswith("它") and len(sentence) > 1 and sentence[1] != "们":
316
+ issues.append(Issue(
317
+ path, start_line,
318
+ "过简断裂:「它」开头的句子没有接住前句句末的对象;写回原对象名",
319
+ ))
320
+ break
321
+ lead = INLINE_NAME_RE.match(sentence)
322
+ if lead and lead.group(1) not in seen_names:
323
+ issues.append(Issue(
324
+ path, start_line,
325
+ f"过简断裂:后句以新标识符「{lead.group(1)}」开头,前句没有承接;先用回指词接住",
326
+ ))
327
+ break
328
+ for match in INLINE_NAME_RE.finditer(sentence):
329
+ seen_names.add(match.group(1))
330
+
331
+
332
+ def visible_length(text: str) -> int:
333
+ visible = LINK_LABEL_RE.sub(r"\1", text)
334
+ visible = re.sub(r"[*`]", "", visible)
335
+ return len(re.sub(r"\s+", "", visible))
336
+
337
+
338
+ def check_list_block(
339
+ path: Path, items: list[tuple[int, str]], issues: list[Issue], landing: bool = False
340
+ ) -> None:
341
+ if len(items) < 2:
342
+ return
343
+ guided = [bool(GUIDED_ITEM_RE.match(text)) for _, text in items]
344
+ if any(guided) and not all(guided):
345
+ issues.append(Issue(
346
+ path, items[0][0],
347
+ "同一列表混用普通句子和「**引导词**:」句式;统一成多数项使用的那一种",
348
+ ))
349
+ return
350
+ lengths = [
351
+ (line, visible_length(text))
352
+ for line, text in items
353
+ if text.strip()
354
+ ]
355
+ lengths = [(line, length) for line, length in lengths if length > 0]
356
+ if len(lengths) < 2 or landing:
357
+ return
358
+ joined = "".join(text for _, text in items)
359
+ if "出错的原因" in joined and "正确的做法" in joined:
360
+ return
361
+ longest = max(lengths, key=lambda pair: pair[1])
362
+ shortest = min(lengths, key=lambda pair: pair[1])
363
+ if longest[1] < 2 * shortest[1]:
364
+ return
365
+ issues.append(Issue(
366
+ path, longest[0],
367
+ f"列表项字数失衡:最长 {longest[1]} 字,最短 {shortest[1]} 字;删长项多余中心或把长项收成一句",
368
+ ))
369
+
298
370
 
299
371
  def display_width(text: str) -> int:
300
372
  return sum(2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1 for char in text)
@@ -653,6 +725,159 @@ def check_fence_semantics(path: Path, lines: list[str]) -> list[Issue]:
653
725
  return issues
654
726
 
655
727
 
728
+ def normalize_code(text: str) -> str:
729
+ return re.sub(r"\s+", " ", text).strip()
730
+
731
+
732
+ def collect_excerpt_links(lines: list[str]) -> list[tuple[int, str]]:
733
+ """Return GitHub blob links in reading order, including reference-style uses."""
734
+ definitions: dict[str, str] = {}
735
+ for raw in lines:
736
+ definition = REF_DEFINITION_RE.match(raw)
737
+ if definition:
738
+ definitions.setdefault(definition.group(1), definition.group(2))
739
+ links: list[tuple[int, str]] = []
740
+ for number, raw in enumerate(lines, start=1):
741
+ for match in LINK_RE.finditer(raw):
742
+ links.append((number, match.group(1).strip()))
743
+ for match in REF_USE_RE.finditer(raw):
744
+ target = definitions.get(match.group(1))
745
+ if target:
746
+ links.append((number, target))
747
+ links.sort()
748
+ return links
749
+
750
+
751
+ def excerpt_fence_blocks(lines: list[str]) -> list[tuple[int, str, list[tuple[int, str]]]]:
752
+ """Return (opening line, filename, body) for every fence with a filename."""
753
+ blocks: list[tuple[int, str, list[tuple[int, str]]]] = []
754
+ in_fence = False
755
+ start = 0
756
+ filename = ""
757
+ body: list[tuple[int, str]] = []
758
+ for number, raw in enumerate(lines, start=1):
759
+ fence = FENCE_RE.match(raw)
760
+ if fence:
761
+ if not in_fence:
762
+ in_fence = True
763
+ start = number
764
+ opening = FENCE_FILENAME_RE.search(raw)
765
+ filename = opening.group(1) if opening else ""
766
+ body = []
767
+ else:
768
+ in_fence = False
769
+ if filename:
770
+ blocks.append((start, filename, body))
771
+ filename = ""
772
+ body = []
773
+ continue
774
+ if in_fence:
775
+ body.append((number, raw))
776
+ return blocks
777
+
778
+
779
+ def source_doc_above(source: list[str], index: int) -> list[str]:
780
+ """Collect the contiguous `///` block that sits directly above index."""
781
+ block: list[str] = []
782
+ cursor = index - 1
783
+ while cursor >= 0:
784
+ raw = source[cursor]
785
+ if not DOC_COMMENT_RE.match(raw) or raw.lstrip().startswith("///<"):
786
+ break
787
+ block.append(raw)
788
+ cursor -= 1
789
+ block.reverse()
790
+ return block
791
+
792
+
793
+ def check_excerpts(path: Path, root: Path, lines: list[str]) -> list[Issue]:
794
+ """Reader text carries no file headers; kept doc comments match the source."""
795
+ issues: list[Issue] = []
796
+ for number, raw in enumerate(lines, start=1):
797
+ if EXCERPT_HEADER_RE.search(raw):
798
+ issues.append(Issue(
799
+ path, number,
800
+ "读者正文不摘文件级 Doxygen 头(@file、@brief、文件范围段);"
801
+ "删掉这段,围栏从它之后开始",
802
+ ))
803
+
804
+ links = collect_excerpt_links(lines)
805
+ for start, filename, body in excerpt_fence_blocks(lines):
806
+ candidates = [
807
+ (number, target)
808
+ for number, target in links
809
+ if number < start and target.rsplit("/", 1)[-1] == filename
810
+ ]
811
+ if not candidates:
812
+ continue
813
+ target = candidates[-1][1]
814
+ blob = BLOB_URL_RE.match(target)
815
+ if not blob:
816
+ continue
817
+ relative = blob.group(1)
818
+ source_path = (root / relative).resolve()
819
+ if root not in source_path.parents or not source_path.is_file():
820
+ continue
821
+ source = read_text(source_path).splitlines()
822
+
823
+ pending: list[tuple[int, str]] = []
824
+ pending_start = start
825
+ for number, raw in body:
826
+ stripped = raw.strip()
827
+ if DOC_COMMENT_RE.match(raw) and not stripped.startswith("///<"):
828
+ if not pending:
829
+ pending_start = number
830
+ pending.append((number, raw))
831
+ continue
832
+ if not pending:
833
+ continue
834
+ if not stripped:
835
+ pending = []
836
+ continue
837
+ declaration = normalize_code(raw)
838
+ fence_block = [item.strip() for _, item in pending]
839
+ pending = []
840
+ if not declaration or declaration.startswith(("#", "}", "{", "...")):
841
+ continue
842
+ matches = [
843
+ index for index, item in enumerate(source)
844
+ if normalize_code(item) == declaration
845
+ ]
846
+ if not matches:
847
+ continue
848
+ # 同一行声明可能在多个类里重复(如各实现类的 override);任一处注释
849
+ # 与围栏整段一致即通过,避免把第一个同名声明当成唯一出处。
850
+ source_blocks = [
851
+ [item.strip() for item in source_doc_above(source, index)]
852
+ for index in matches
853
+ ]
854
+ if any(block == fence_block for block in source_blocks):
855
+ continue
856
+ if not fence_block:
857
+ continue
858
+ prefixes = [
859
+ block for block in source_blocks
860
+ if len(block) > len(fence_block) and block[: len(fence_block)] == fence_block
861
+ ]
862
+ if prefixes:
863
+ issues.append(Issue(
864
+ path, pending_start,
865
+ f"围栏只摘了 {filename} 注释的前 {len(fence_block)} 行;"
866
+ "整段补上 @param、@return 与空 /// 行,或整段不摘",
867
+ ))
868
+ elif all(not block for block in source_blocks):
869
+ issues.append(Issue(
870
+ path, pending_start,
871
+ f"围栏带有注释,但 {filename} 对应声明上方没有 /// 注释;整段删掉",
872
+ ))
873
+ else:
874
+ issues.append(Issue(
875
+ path, pending_start,
876
+ f"围栏注释与 {filename} 对应声明上方的不一致;整段照抄或整段不摘",
877
+ ))
878
+ return issues
879
+
880
+
656
881
  def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> list[Issue]:
657
882
  issues: list[Issue] = []
658
883
  text = read_text(path)
@@ -682,7 +907,9 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
682
907
  fence_is_html = False
683
908
  paragraph: list[str] = []
684
909
  paragraph_start = 0
910
+ list_items: list[tuple[int, str]] = []
685
911
  last_fence_end: int | None = None
912
+ landing = path.resolve() == (root / "index.qmd").resolve()
686
913
 
687
914
  def flush_paragraph() -> None:
688
915
  nonlocal paragraph
@@ -690,10 +917,17 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
690
917
  check_prose_block(path, paragraph_start, " ".join(paragraph), issues)
691
918
  paragraph = []
692
919
 
920
+ def flush_list() -> None:
921
+ nonlocal list_items
922
+ if len(list_items) >= 2:
923
+ check_list_block(path, list_items, issues, landing=landing)
924
+ list_items = []
925
+
693
926
  for number, raw in enumerate(lines, start=1):
694
927
  fence = FENCE_RE.match(raw)
695
928
  if fence:
696
929
  flush_paragraph()
930
+ flush_list()
697
931
  if not in_fence:
698
932
  in_fence = True
699
933
  fence_start = number
@@ -712,7 +946,12 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
712
946
  elif fence_lang.startswith("{"):
713
947
  inner = fence_lang.strip("{}").strip()
714
948
  fence_is_html = inner == "=html"
715
- if inner and not inner.startswith((".", "=")) and "mermaid" not in inner:
949
+ if inner.startswith(".") and "mermaid" in inner:
950
+ issues.append(Issue(
951
+ path, number,
952
+ "Mermaid 围栏写成 `{.mermaid}`,Quarto 不会载入图表脚本,图会退化成代码块;改成 `{mermaid ...}`",
953
+ ))
954
+ elif inner and not inner.startswith((".", "=")) and "mermaid" not in inner:
716
955
  # ```{python} style attribute fences are valid.
717
956
  pass
718
957
  else:
@@ -742,6 +981,7 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
742
981
  heading = HEADING_RE.match(raw)
743
982
  if heading:
744
983
  flush_paragraph()
984
+ flush_list()
745
985
  level = len(heading.group(1))
746
986
  heading_text = heading.group(2)
747
987
  if level == 1:
@@ -760,26 +1000,33 @@ def check_file(path: Path, root: Path, project: dict, chapters: list[str]) -> li
760
1000
 
761
1001
  if raw.lstrip().startswith("|"):
762
1002
  flush_paragraph()
1003
+ flush_list()
763
1004
  # 表格行按列组织,不套散文句长阈值;列数与表头一致性由表格检查负责。
764
1005
  continue
765
1006
 
766
1007
  if raw.lstrip().startswith((">", "-", "*", "+", "1.")):
767
1008
  flush_paragraph()
768
- check_prose_block(
769
- path, number, re.sub(r"^\s*(?:[>|]|[-*+]|\d+\.)\s*", "", raw), issues, is_list=True
770
- )
1009
+ item_text = re.sub(r"^\s*(?:[>|]|[-*+]|\d+\.)\s*", "", raw)
1010
+ if LIST_LINE_RE.match(raw):
1011
+ list_items.append((number, item_text))
1012
+ else:
1013
+ flush_list()
1014
+ check_prose_block(path, number, item_text, issues, is_list=True)
771
1015
  continue
772
1016
 
1017
+ flush_list()
773
1018
  if not paragraph:
774
1019
  paragraph_start = number
775
1020
  paragraph.append(raw.strip())
776
1021
 
777
1022
  flush_paragraph()
1023
+ flush_list()
778
1024
 
779
1025
  if in_fence:
780
1026
  issues.append(Issue(path, fence_start, "代码围栏未闭合"))
781
1027
 
782
1028
  issues.extend(check_fence_semantics(path, lines))
1029
+ issues.extend(check_excerpts(path, root, lines))
783
1030
 
784
1031
  for block in fence_blocks:
785
1032
  issues.extend(check_tree_block(path, block))
@@ -1,65 +1,48 @@
1
1
  # 第 1 步:定任务
2
2
 
3
- 产出:一句读者做完能完成的事,加上这一章要覆盖的任务清单。
4
- 这一步不动标题层级,也不改句子。
3
+ 产出:一句读者做完能完成的事,加上本章要覆盖的任务清单。
4
+ 这一步不动标题层级,也不改句子;两条规则冲突时按 `references/priority.md` 排级,本步先让位给读者任务与事实。
5
5
 
6
6
  ## 通用情况
7
7
 
8
- ### 读者
8
+ ### 读者与术语门槛
9
9
 
10
- 读者有编程基础,可能第一次接触本书使用的语言、构建工具或自动化测试。每页只解决一个任务。正文按书的实际技术栈写,不预设某一种语言或操作系统;需要环境差异时在页面里就地交代。
10
+ 读者有编程基础,可能第一次接触本书使用的语言、构建工具或自动化测试。每页只解决一个任务;正文按书的实际技术栈写,不预设某一种语言或操作系统,需要环境差异时在页面里就地交代。
11
+
12
+ - 不假设读者知道工具名、命令参数、目录布局或错误输出代表的含义。
13
+ - 标准术语首次出现时用一句话解释或就近术语卡,再进入命令;解释写成可观察行为,缩写先写全称(写法见 `quarto/readability.md`)。
14
+ - 只保留判断成功所需的输出,不解释对当前操作没有影响的内部格式。
15
+ - 面向新人的正文不堆砌入门前置名词;标题只用读者此刻已认识的词。
11
16
 
12
17
  ### 叙事范围(硬边界)
13
18
 
14
- 本书定位是可用工程的用法与验收;编程语言机制的系统讲解不属于正文。`content/**` 正文因此只写五类内容:领域语义、分层与依赖、接口契约、构建与测试验收、可验证动作。
19
+ 本书定位是可用工程的用法与验收;编程语言机制的系统讲解不属于正文。`content/**` 正文只写五类内容:领域语义、分层与依赖、接口契约、构建与测试验收、可验证动作。
15
20
 
16
- - **禁止**用正文讲解编程语言机制(枚举、结构体、命名空间、`inline`、`constexpr`、翻译单元、ODR 等),也禁止风格指南逐条教学(命名大小写、前缀、include guard 对比等)。
21
+ - **语言机制与风格指南的取舍先看项目技能**:项目技能写明禁止时,正文不展开枚举、结构体、命名空间、`inline`、`constexpr`、翻译单元、ODR,也不逐条教命名大小写、前缀或 include guard。项目没写时按读者门槛判断,不要默认开语言课。
17
22
  - 枚举取值列表的位置见 `steps/03-blocks.md`「三级标题最小模块」。
18
23
  - **禁止**在工程章(非设计章)展开数值选型旁白:不写「用有符号整型承载…」「某字段为什么选定宽有符号整型…」,也不为选型链到外部语言笔记。`int` 可出现在代码摘录中,旁白不解释「为什么有符号」。
19
- - **禁止课堂类比**:工程坐标系、API 与数据结构只用程序事实与可观察行为表述,不写「数学课」「平面直角坐标系」等跨学科课堂对照(如「与数学课的直角坐标系正好相反」)。作者构思时可借类比想问题,落盘到 qmd 前必须换成工程对象与可观察行为;图注已交代原点与轴向的,正文不复述对照。
20
- - **禁止突兀跨仓依据句**:不写「写法依据见 / 依据见 / 机制见 / 选型见 / 承诺见 / 用法见 / 陷阱见」+ 外部语言笔记。工程约定由本书拥有;外部教材不是工程写法的依据机关。
21
- - **跨仓外链默认零条**。工程叙事须自洽。仅当本小节工程点已讲完、且确有系统学语法需求时,可在小节末**单独成句**尾置可选深潜,口吻为「若要系统阅读…,见对应章节(GitHub 绝对 URL)。」同一章此类深潜宜 ≤2,能删则删。仅设计章可保留一次分册级链接。本书固定采用外部章节的构建约定时,用「本书固定采用…」陈述,仍放工程点之后,禁用「写法依据见」。
24
+ - **禁止课堂类比**:工程坐标系、API 与数据结构只用程序事实与可观察行为表述,不写「与数学课的直角坐标系正好相反」这类跨学科课堂对照;图注已交代原点与轴向的,正文不复述对照。
25
+ - **禁止突兀跨仓依据句**:不写「写法依据见 / 依据见 / 机制见 / 选型见 / 承诺见 / 用法见 / 陷阱见」+ 外部语言笔记。工程约定由本书拥有,外部教材不是工程写法的依据机关。
26
+ - **跨仓外链默认零条**:工程叙事须自洽。仅当本小节工程点已讲完、且确有系统学语法需求时,可在小节末单独成句尾置可选深潜,口吻为「若要系统阅读…,见对应章节(GitHub 绝对 URL)」,同一章宜 ≤2,能删则删;仅设计章可保留一次分册级链接。本书固定采用外部章节的构建约定时,用「本书固定采用…」陈述,仍放工程点之后。
22
27
  - **允许**代码块逐字展示源码(关键字照抄真实代码);旁白回答工程问题:这段公共定义解决什么问题、谁读取或调用它、如何验证。
23
- - **禁止接口清单压缩段**:不得把私有成员、公开成员名单、`const`、`[[nodiscard]]` 等接口属性压进同一段旁白;定稿形态为场景(动作 → 点名 → 边界)→ 代码 → 块后只补注释未覆盖的工程信息;语言属性不进旁白讲义(对照见 `examples/04-paragraph-opening.md`「接口清单压缩段」)。
28
+ - **禁止接口清单压缩段**:不把私有成员、公开成员名单、`const`、`[[nodiscard]]` 等接口属性压进同一段旁白;定稿形态为场景(动作 → 点名 → 边界)→ 代码 → 块后只补注释未覆盖的工程信息(对照见 `examples/04-paragraph-opening.md`「接口清单压缩段」)。
24
29
  - 判定口诀:句子的主语应是工程对象(模块、接口、数据、文件),不是语言构造(`enum class`、翻译单元)。反例与改写对照见 `examples/01-language-scope.md` 与 `examples/04-paragraph-cases.md`。
25
30
 
26
- ### 内容优先级
27
-
28
- 章节出现取舍时,按下面的顺序判断,不用较低层级的格式或样式要求覆盖较高层级的教学目标:
29
-
30
- 1. 读者任务与章节边界
31
- 2. 信息顺序和渐进披露
32
- 3. 页面块与段落布局
33
- 4. 技术正确性和可验证性
34
- 5. 标题、列表、Callout 和代码块等表达形式
35
- 6. 句子精简、术语和标点
36
- 7. 视觉样式与装饰
37
-
38
- 新增内容先确定它在既有页面块序列中的位置,再写入相邻小节。修改后回读完整小节及前后切口,确认动作、结果和下一步仍然相邻。只有步骤存在先后依赖时使用有序列表;同级选项使用无序列表;连续因果解释保留为短段落。
31
+ ### 定任务的做法
39
32
 
40
- 目录树先核对磁盘,再只保留当前任务需要的文件;注释、排列顺序与树后读法句统一见 `steps/03-blocks.md`「情况:目录树」。GitHub blob 链接嵌进同节导语句子里,不单独成行贴在代码围栏上方。线性构建流程用标题和文字说明,不用 Mermaid 代替。
41
-
42
- ### 术语门槛
43
-
44
- - 保留编译、链接、目标、状态、回合等标准术语;首次出现时用一句话解释或就近术语卡,再进入命令。解释写成可观察行为,缩写先写全称;细则见 `quarto/readability.md`。
45
- - 不解释对当前操作没有影响的内部格式。只保留判断成功所需的输出。
46
- - 面向新人的正文不堆砌入门前置名词;标题只用读者此刻已认识的词。
47
-
48
- ### 定任务:先让新人完成任务
49
-
50
- - 开篇场景的写法见 `steps/02-structure.md`「六段块序列」。
51
- - 每页一条成功路径与后置内容的判定见 `steps/06-wording.md`;主线闭合见 `steps/02-structure.md`。
52
- - 标准术语的首次解释与术语卡落点见 `steps/02-structure.md`(写法见 `quarto/authoring.md`);缩写先写全称。
53
- - 不假设读者知道工具名、命令参数、目录布局或错误输出代表的含义。
54
- - 目录式堆砌禁令与清单下沉判据见 `steps/02-structure.md`。
55
- - 标题与引言的单事承诺、承诺用词与 title 变更同步见 `steps/02-structure.md`。
56
- - 语言课禁令、否定式预告、不写「下一节」都在这一步判:它们决定本章能不能出现某句,不是措辞问题(细则见 `steps/05-sentences.md`「句子规则」、本文件「叙事范围」)。
33
+ - 任务写成读者做完能完成的一件事,不写成语法清单或功能列表;每页一条成功路径。
34
+ - 开篇场景的写法见 `steps/02-structure.md`「六段块序列」;标准术语的首次解释与术语卡落点同见该节(写法见 `quarto/authoring.md`)。
35
+ - 目录式堆砌禁令与清单下沉判据见 `steps/02-structure.md`;标题与引言的单事承诺、承诺用词与 title 变更同步同见该文件。
36
+ - 语言课禁令、否定式预告、不写「下一节」在这一步判:它们决定本章能不能出现某句,不是措辞问题(细则见 `steps/05-sentences.md`「句子规则」与本文件「叙事范围(硬边界)」)。
37
+ - 新增内容先确定它在既有页面块序列中的位置,再写入相邻小节;修改后回读完整小节及前后切口,确认动作、结果和下一步仍然相邻。
38
+ - 只有步骤存在先后依赖时用有序列表;同级选项用无序列表;连续因果解释保留为短段落。
39
+ - 目录树先核对磁盘,再只保留当前任务需要的文件,注释与读法句见 `steps/03-blocks.md`「情况:目录树」。GitHub blob 链接嵌进同节导语句子,不单独成行贴在围栏上方。线性构建流程用标题和文字说明,不用 Mermaid 代替。
57
40
 
58
41
  ## 情况
59
42
 
60
43
  ### 情况:语言课
61
44
 
62
- 讲语言特性时,任务写成「读者能读懂一段真实代码」,不写成语法清单。叙事范围跟着读者的语言门槛收窄。对照 `examples/01-language-scope.md`。
45
+ 讲语言特性时,任务写成「读者能读懂一段真实代码」,不写成语法清单;叙事范围跟着读者的语言门槛收窄。对照 `examples/01-language-scope.md`。
63
46
 
64
47
  ### 情况:否定式预告
65
48
 
@@ -71,7 +54,7 @@
71
54
 
72
55
  ### 情况:没有项目技能
73
56
 
74
- 书里没有 `.agents/skills/quarto-book-project/` 时跳过书名、读者和章节表,不猜也不补。任务只从当前文件和 `_quarto.yml` 读到的内容定。
57
+ 书里没有 `.agents/skills/quarto-book-project/` 时跳过书名、读者和章节表,不猜也不补;任务只从当前文件与 `_quarto.yml` 读到的内容定。
75
58
 
76
59
  ## 正反例
77
60