@thincoder/core 0.10.9 → 0.10.11

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 (92) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/advisor/messages.mjs +4 -5
  3. package/advisor/project-context.mjs +69 -44
  4. package/agent/completion.mjs +4 -1
  5. package/agent/family-tools.mjs +12 -10
  6. package/agent/helpers.mjs +19 -11
  7. package/agent/light-round.mjs +1 -4
  8. package/agent/record-results.mjs +6 -1
  9. package/agent/run-start.mjs +1 -0
  10. package/agent/setup-reminders.mjs +1 -1
  11. package/agent/turn-loop.mjs +17 -13
  12. package/agent/unbooked-pen.mjs +62 -0
  13. package/agent-tools/advisor.mjs +15 -31
  14. package/agent-tools/audit-block.mjs +16 -62
  15. package/agent-tools/batch-gate.mjs +12 -21
  16. package/agent-tools/checkpoint.mjs +11 -6
  17. package/agent-tools/context.mjs +1 -1
  18. package/agent-tools/design-token.mjs +9 -2
  19. package/agent-tools/digest-budget.mjs +2 -0
  20. package/agent-tools/doc-check.mjs +17 -23
  21. package/agent-tools/manifest.mjs +45 -14
  22. package/agent-tools/review-facts.mjs +35 -81
  23. package/agent-tools/settings.mjs +65 -5
  24. package/agent-tools/spawn-gates.mjs +68 -31
  25. package/agent-tools/subagent-async.mjs +6 -3
  26. package/agent-tools/subagent-spawn.mjs +76 -80
  27. package/agent-tools/subagent.mjs +16 -16
  28. package/agent-tools.mjs +1 -1
  29. package/browser/launch.mjs +14 -3
  30. package/browser/queue.mjs +31 -3
  31. package/browser/session.mjs +4 -0
  32. package/config-defaults.mjs +8 -0
  33. package/config-presets.mjs +1 -1
  34. package/config.mjs +60 -11
  35. package/declaration.mjs +5 -12
  36. package/doc-check/anchors.mjs +53 -60
  37. package/doc-check/index.mjs +106 -140
  38. package/doc-check/paragraph.mjs +119 -0
  39. package/fts-text.mjs +4 -1
  40. package/i18n.mjs +5 -0
  41. package/ledger-cmd.mjs +17 -4
  42. package/ledger-db.mjs +14 -8
  43. package/ledger.mjs +1 -1
  44. package/manifest-discovery.mjs +19 -10
  45. package/manifest-schema.mjs +7 -35
  46. package/manifest.mjs +21 -9
  47. package/memory/code-search.mjs +5 -14
  48. package/memory/docs.mjs +5 -14
  49. package/memory/read-gate.mjs +26 -0
  50. package/model-context.mjs +98 -0
  51. package/model-specs.mjs +110 -37
  52. package/package.json +2 -2
  53. package/permission.mjs +3 -1
  54. package/prompts/advisor-design.md +104 -38
  55. package/prompts/advisor-round1.md +72 -24
  56. package/prompts/advisor-round2.md +62 -25
  57. package/prompts/advisor-round3.md +68 -24
  58. package/prompts/common.md +214 -58
  59. package/prompts/consult-base.md +10 -3
  60. package/prompts/discipline-engineering.md +202 -54
  61. package/prompts/discipline-normal.md +95 -44
  62. package/prompts/persona-coder.md +3 -1
  63. package/prompts/persona-eng-coder.md +29 -9
  64. package/prompts/persona-eng-designer.md +101 -35
  65. package/prompts/persona-engineering.md +320 -98
  66. package/prompts/persona-explore.md +1 -1
  67. package/prompts/persona-normal.md +16 -8
  68. package/provider/anthropic.mjs +127 -4
  69. package/provider/core.mjs +16 -6
  70. package/provider/google.mjs +8 -8
  71. package/provider/list-models.mjs +37 -5
  72. package/provider/normalize.mjs +2 -2
  73. package/provider/responses.mjs +7 -7
  74. package/provider/sse.mjs +6 -5
  75. package/proxy-transport.mjs +5 -3
  76. package/proxy.mjs +2 -1
  77. package/session-guard.mjs +7 -1
  78. package/session-lifecycle.mjs +23 -8
  79. package/session-segments.mjs +1 -0
  80. package/session-slot-claims.mjs +4 -0
  81. package/session-slot-write.mjs +6 -0
  82. package/session-slots-manifest.mjs +4 -0
  83. package/token-ttl.mjs +10 -0
  84. package/tool-docs/advisor.md +24 -5
  85. package/tool-docs/doc_check.md +44 -25
  86. package/tool-docs/ledger.md +2 -2
  87. package/tool-docs/manifest-read.md +8 -0
  88. package/tool-docs/manifest.md +9 -8
  89. package/tool-docs/subagent.md +41 -16
  90. package/tools/web-deepseek.mjs +26 -11
  91. package/doc-check/version.mjs +0 -84
  92. package/doc-check/width.mjs +0 -132
package/declaration.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * declaration.mjs — 声明装载面(PROJECT-MANIFEST.json 三族 `codePaths` / `index.*` / `advisor.*`)
2
+ * declaration.mjs — 声明装载面(PROJECT-MANIFEST.json 两族 `codePaths` / `index.*`)
3
3
  * · 自 `conventions.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786。
4
4
  * 投影单源 = `manifest.mjs`(ONE reader / ONE root resolution——`readManifest` + `manifestFilePath`;
5
5
  * name / shape / defaults = schema 面 `docs/core/design/MANIFEST.md` §2.2)。迁出块逐字;
@@ -62,11 +62,7 @@ function normalizePathList(v) {
62
62
  return out
63
63
  }
64
64
 
65
- function normalizeString(v) {
66
- return typeof v === "string" && v.trim() ? v.trim() : ""
67
- }
68
-
69
- /** Project the manifest three families into the frozen declaration object.
65
+ /** Project the manifest two families into the frozen declaration object.
70
66
  * `declared` = VALUE comparison against the defaults — at least one family deviates
71
67
  * (a key written with its default value still counts as NOT declared; the design-gate
72
68
  * hint reads it to decide whether to point at the manifest, PORTABILITY §3.1).
@@ -80,11 +76,9 @@ function buildDeclaration(m, root = null) {
80
76
  const excludePaths = normalizePathList(m?.index?.excludePaths)
81
77
  const publicRepos = normalizePathList(m?.index?.publicRepos)
82
78
  const batchRecords = batchSegments(m?.docRoot?.batches) // §6.14 面⑥ 声明值投影(`declared` 旗不涉——docRoot 族现状零改)
83
- const docMap = normalizeString(m?.advisor?.docMap)
84
- const standardsDoc = normalizeString(m?.advisor?.standardsDoc)
85
79
  const defaults = normalizeCodePaths(DEFAULT_MANIFEST.codePaths) ?? []
86
80
  const pathsDiffer = codePaths.length !== defaults.length || codePaths.some((p, i) => p !== defaults[i])
87
- const declared = pathsDiffer || codeExtensions.length > 0 || docExtensions.length > 0 || excludePaths.length > 0 || publicRepos.length > 0 || Boolean(docMap) || Boolean(standardsDoc)
81
+ const declared = pathsDiffer || codeExtensions.length > 0 || docExtensions.length > 0 || excludePaths.length > 0 || publicRepos.length > 0
88
82
  return Object.freeze({
89
83
  declared,
90
84
  codePaths: Object.freeze(codePaths),
@@ -95,7 +89,6 @@ function buildDeclaration(m, root = null) {
95
89
  excludePaths: Object.freeze(excludePaths),
96
90
  publicRepos: Object.freeze(publicRepos),
97
91
  }),
98
- advisor: Object.freeze({ docMap, standardsDoc }),
99
92
  root,
100
93
  })
101
94
  }
@@ -317,7 +310,7 @@ onManifestInit(clearDeclarationCache)
317
310
 
318
311
  /** Retired-carrier warning (existence check only — zero content parsing, zero fallback). */
319
312
  function warnRetiredCarrier(root) {
320
- console.warn(`[declaration] ${RETIRED_REL_PATH} is retired and no longer read — move codePaths / index.*Extensions / advisor.{docMap,standardsDoc} into PROJECT-MANIFEST.json`)
313
+ console.warn(`[declaration] ${RETIRED_REL_PATH} is retired and no longer read — move codePaths / index.*Extensions into PROJECT-MANIFEST.json`)
321
314
  logEvent("declaration:retired-file", { cwd: root })
322
315
  }
323
316
 
@@ -334,7 +327,7 @@ function warnRetiredCarrier(root) {
334
327
  * @param {string} cwd — project dir / anchor (root resolved by manifest.mjs)
335
328
  * @returns {Readonly<{declared: boolean, codePaths: readonly string[], batchRecords: readonly (readonly string[])[],
336
329
  * index: {codeExtensions: string[], docExtensions: string[], excludePaths: string[], publicRepos: string[]},
337
- * advisor: {docMap: string, standardsDoc: string}, root: string|null}>}
330
+ * root: string|null}>}
338
331
  */
339
332
  export function loadProjectDeclaration(cwd) {
340
333
  const file = manifestFilePath(cwd) // data-file path (single source — manifest.mjs KD-M1-18)
@@ -6,17 +6,18 @@
6
6
  * **行号坐标(`:N`)不作节指示**——行号不作引用;
7
7
  * · 档不在盘 ⇒ `档缺`;带节指示 ∧ 节不在盘 ⇒ `节缺`。
8
8
  *
9
- * 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面)。
10
- * 解析序:① 仓根相对 → ② 域根相对(`scanDirs` 逐根)→ ③ 引用方档所在目录相对 →
11
- * ④ 域内唯一 basename 索引(同名 ≥2 ⇒ **不判**,列报告面 = 终结)→ ⑤ 声明解析根相对(`anchors.roots` 逐根,按声明序)。
12
- * 节在盘:目标档内存在标题行,其标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
13
- * 残余登记(fail-open 方向——归评审核验面):裸档名在域内多义 ⇒ 不判;未闭合围栏 ⇒ 至档尾免;
14
- * 指针行的坐标尾不入判、同行的档 token 照判。
9
+ * 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面);
10
+ * 提示词面另剥 CANON 操作数(面判据 = `./paragraph.mjs` 的 `isPromptFace`——七名整串删除后再抽,见 `CANON_OPERANDS`)。
11
+ * 解析序**两级**:① 仓根相对全路径 → ② 引用方档所在目录相对。零命中 ⇒ `档缺`。
12
+ * **裸名不跨目录解析**:跨目录解析须先建全域目录索引——为认出一个裸名而重建索引,等于把全仓扫描请回来;
13
+ * 写全路径后解析零索引,逐档判就是全部(与 D4 实现坐标口径一致)。
14
+ * 节在盘:**只读解析命中的那一档**(点查,不遍历)——目标档内存在标题行,其标题编号
15
+ * (标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
16
+ * 残余登记(fail-open 方向——归评审核验面):未闭合围栏 ⇒ 至档尾免;指针行的坐标尾不入判、同行的档 token 照判。
15
17
  */
16
18
  import { readFileSync, statSync } from "node:fs"
17
19
  import { basename, dirname, resolve } from "node:path"
18
- import { anchorRootError } from "../manifest-schema.mjs"
19
- import { fenceMark, isExecutableLine } from "./width.mjs"
20
+ import { fenceMark, isExecutableLine, isPromptFace } from "./paragraph.mjs"
20
21
 
21
22
  /** 文档指针 token:`(目录段/)*名.md` + 可选行号坐标尾 + 可选节指示(`§` + 编号)。
22
23
  * 左界守卫排除更长 token 的尾段;右界排除后续字母数字(`.md` 之后紧邻 `[\w]` ⇒ 非完整 token)。 */
@@ -34,12 +35,35 @@ const PLACEHOLDER_STEMS = new Set([
34
35
  "x", "a", "b", "file", "out", "foo", "bar", "example", "sample", "name", "path",
35
36
  ])
36
37
 
38
+ /** CANON 操作数族(**规范豁免串** · 判据单源 = `docs/core/design/PROMPT-SYSTEM.md` §6.6「判保留面」):
39
+ * 七个名字在模型面是**模型要写出来 / 读写的文件名**(行为操作数 · 路径操作数),不是「去读这个档」的指针——
40
+ * 换个目录解析必然 `档缺`。**只对提示词面剥**(面判据 = `isPromptFace`,单源住 `./paragraph.mjs`):`docs/` 面不会因本条多放行一个名——
41
+ * `README.md` / `MANIFEST.md` 在那里仍按指针判(其余四名早已走 `FILENAME_CLASSES` 类名免,非本条引入)。
42
+ * **一处定义、两处消费**:`scripts/prompt-refs-check.mjs` 的 J3 豁免从本档 import 本表与剥除函数(不复制);
43
+ * 反方向零 import——判据链自持 = `docs/core/design/DOC-DISCIPLINE.md` A-DD29 ⑤(`thincoder-core/**` 对
44
+ * `scripts/**` 的 import 点 = 0)。闭集外的同形 token(非操作数)照报。 */
45
+ export const CANON_OPERANDS = [
46
+ "AGENTS.md", "SKILL.md", "README.md", "MANIFEST.md",
47
+ ".thincoder/advisor.md", "project_rules.md",
48
+ "RTM.md",
49
+ ]
50
+
51
+ /** 操作数剥除:逐行先整串删除七名 CANON 操作数再抽(与 `scripts/prompt-refs-check.mjs` 的 J3 豁免同式)。 */
52
+ export function stripCanonOperands(line) {
53
+ let out = line
54
+ for (const s of CANON_OPERANDS) out = out.split(s).join("")
55
+ return out
56
+ }
57
+
58
+ /** 操作数豁免的**作用面** = 提示词面(`thincoder-core/prompts/**`)——**只此一面**:`docs/` 面同名 token 仍按指针判
59
+ * (`README.md` / `MANIFEST.md` 在那里照报 `档缺`;其余四名走既有的 `FILENAME_CLASSES` 类名免,非本条引入)。
60
+ * 这是锚判自己的面判据——与段判据零共享开关:段判据按传入档全量判(提示词面照判——逐行裸判,无面级豁免)。 */
61
+ // 提示词面判据单源 = `./paragraph.mjs` 的 `isPromptFace`(§3.18 ②——形态与旧 `OPERAND_FACE` 逐字同)。
62
+ const isOperandFace = isPromptFace
63
+
37
64
  /** 文件存在性(目录 / 读错 ⇒ false)。 */
38
65
  function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
39
66
 
40
- /** 目录存在性(文件 / 读错 ⇒ false)。 */
41
- function isDir(p) { try { return statSync(p).isDirectory() } catch { return false } }
42
-
43
67
  /** 标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`);非标题行 ⇒ null。 */
44
68
  function headingNumber(line) {
45
69
  const m = /^#{1,6}\s+/.exec(line)
@@ -56,63 +80,32 @@ function sectionOnDisk(path, num) {
56
80
  return false
57
81
  }
58
82
 
59
- /** 档解析(判据 ① 的解析序 ①–⑤):命中 ⇒ `{ ok, path }`;同名 ≥2 ⇒ `{ ambiguous, candidates }`;零命中 ⇒ `{ ok:false }`。
83
+ /** 档解析(判据 ① 的解析序两级):命中 ⇒ `{ ok, path }`;零命中 ⇒ `{ ok:false }`。
60
84
  * 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
61
- * `roots` = 声明解析根(已按值形 ∧ 根在盘过滤)——第 ⑤ 步,口径单源 = `DOC-DISCIPLINE.md` §3.20 判据 ①。 */
62
- function resolveDoc(token, refFile, base, scanDirs, index, roots) {
85
+ * `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。 */
86
+ function resolveDoc(token, refFile, base) {
63
87
  const p1 = resolve(base, token)
64
- if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对
65
- for (const d of scanDirs) { const p = resolve(base, d, token); if (isFile(p)) return { ok: true, path: p } } // ② 域根相对
66
- const p3 = resolve(dirname(refFile), token)
67
- if (isFile(p3)) return { ok: true, path: p3 } // ③ 引用方档所在目录相对
68
- const cands = index.get(basename(token)) ?? [] // ④ 域内唯一 basename 索引
69
- if (cands.length === 1) return { ok: true, path: cands[0] }
70
- if (cands.length > 1) return { ambiguous: true, candidates: cands } // ④ 多义 ⇒ 终结,不进 ⑤
71
- for (const r of roots) { const p = resolve(base, r, token); if (isFile(p)) return { ok: true, path: p } } // ⑤ 声明解析根相对
88
+ if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对全路径
89
+ const p2 = resolve(dirname(refFile), token)
90
+ if (isFile(p2)) return { ok: true, path: p2 } // ② 引用方档所在目录相对
72
91
  return { ok: false }
73
92
  }
74
93
 
75
- /** 声明解析根逐元素归类(口径单源 = `DOC-DISCIPLINE.md` §3.20 判据 ① 解析序 ⑤):
76
- * `usable` = 参与解析的根(声明序);`gaps` = 报告面(不作判)。缺键 ⇒ 本步零效(不产报告行)。 */
77
- function classifyRoots(base, roots) {
78
- const out = { usable: [], gaps: [] }
79
- if (roots === undefined) return out
80
- if (!Array.isArray(roots)) { out.gaps.push({ kind: "invalid", root: roots, reason: "非字符串数组" }); return out }
81
- for (const r of roots) {
82
- const why = anchorRootError(r)
83
- if (why) { out.gaps.push({ kind: "invalid", root: r, reason: why }); continue }
84
- if (!isDir(resolve(base, r))) { out.gaps.push({ kind: "missing", root: r }); continue }
85
- out.usable.push(r)
86
- }
87
- return out
88
- }
89
-
90
94
  /**
91
- * 锚判据主行程:域内逐档逐行抽取 + 解析 + 节在盘判定。
92
- * @param {string} root 项目根(判据参数已由调用方自 `checkConfig` 读出)
93
- * @param {{scanDirs?:string[], files?:string[], checkFiles?:string[], roots?:string[]}} o `files` = 域内 `.md` 全集(单源 = `scanDomain`);
94
- * `checkFiles` = **被判集**(gate 的范围收窄面——缺省 ⇒ `files`):索引仍按 `files` 建(解析基线不缩),
95
- * 只把「抽哪几档的行」收窄——否则改过的档引用未改档会假报 `档缺`(假阳即机制失效);
96
- * `roots` = 声明解析根原值(值形 ∥ 根在盘的门归本函数——报告面逐元素弃用 / 列缺位)。
97
- * @returns {{hits:Array, reports:Array, rootGaps:Array}} hits = 命中(`档缺` / `节缺`);reports = 报告面(多义——不作判);
98
- * rootGaps = 报告面(解析根弃用 ∥ 缺位——不作判)
95
+ * 锚判据主行程:传入档逐档逐行抽取 + 解析 + 节在盘判定。
96
+ * @param {string} root 项目根(解析序 ① 的基准)
97
+ * @param {{files?:string[]}} o `files` = **判定集兼解析集**(调用方传入的档清单——判定范围 = 解析范围,
98
+ * 无目录遍历、无全域索引:清单外的档既不判也不参与解析)
99
+ * @returns {{hits:Array}} hits = 命中(`档缺` / `节缺`)
99
100
  */
100
- export function checkAnchors(root, { scanDirs = [], files = [], checkFiles, roots } = {}) {
101
+ export function checkAnchors(root, { files = [] } = {}) {
101
102
  const base = resolve(root)
102
- const dirs = Array.isArray(scanDirs) ? scanDirs : []
103
- const { usable, gaps } = classifyRoots(base, roots)
104
- const index = new Map()
105
- for (const f of files) {
106
- const k = basename(f)
107
- if (!index.has(k)) index.set(k, [])
108
- index.get(k).push(f)
109
- }
110
103
  const hits = []
111
- const reports = []
112
- for (const f of checkFiles ?? files) {
104
+ for (const f of files) {
113
105
  let text = ""
114
106
  try { text = readFileSync(f, "utf8") } catch { continue }
115
107
  const re = new RegExp(ANCHOR_RE.source, "g") // 每档一份实例(零共享 lastIndex 状态)
108
+ const stripOperands = isOperandFace(f) // 只有提示词面剥(面判据 = `isPromptFace`)
116
109
  let fence = null // 围栏标记(` ∥ ~);null = 不在块内
117
110
  text.split("\n").forEach((l, i) => {
118
111
  const mark = fenceMark(l)
@@ -123,20 +116,20 @@ export function checkAnchors(root, { scanDirs = [], files = [], checkFiles, root
123
116
  }
124
117
  if (fence !== null) return
125
118
  if (isExecutableLine(l)) return // 可执行行整行跳过
119
+ const scan = stripOperands ? stripCanonOperands(l) : l // 提示词面:操作数先剥再抽;文档面 = 原行
126
120
  let m
127
- while ((m = re.exec(l)) !== null) {
121
+ while ((m = re.exec(scan)) !== null) {
128
122
  const token = m[1]
129
123
  const num = m[2] ?? null
130
124
  const bn = basename(token).toLowerCase()
131
125
  const stem = bn.replace(/\.md$/, "")
132
126
  // 类名 ∥ 占位符两免:抽取面不把它们当指针(判据 = DOC-DISCIPLINE §3.20 ① 的「档 token」定义面)
133
127
  if (FILENAME_CLASSES.has(bn) || stem.length <= 1 || PLACEHOLDER_STEMS.has(stem)) continue
134
- const r = resolveDoc(token, f, base, dirs, index, usable)
135
- if (r.ambiguous) { reports.push({ file: f, line: i + 1, token, candidates: r.candidates }); continue }
128
+ const r = resolveDoc(token, f, base)
136
129
  if (!r.ok) { hits.push({ file: f, line: i + 1, token, clazz: "档缺" }); continue }
137
130
  if (num !== null && !sectionOnDisk(r.path, num)) hits.push({ file: f, line: i + 1, token, clazz: "节缺", section: num, target: r.path })
138
131
  }
139
132
  })
140
133
  }
141
- return { hits, reports, rootGaps: gaps }
134
+ return { hits }
142
135
  }
@@ -1,109 +1,95 @@
1
1
  /**
2
- * doc-check/index.mjs — 文档纪律机检 · 引擎入口(域扫描 + 声明面读取 + 报面行)。
2
+ * doc-check/index.mjs — 文档纪律机检 · 引擎入口(两条判据 + 报面行)。
3
3
  *
4
4
  * 判据两条(起步 · 闭集——口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S):
5
- * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 行宽——口径 = §3.18 条目 Q,实现 = `./width.mjs`。
6
- * 判据参数由**被开发项目**的 `PROJECT-MANIFEST.json` `checkConfig` 声明(不硬编码任何本仓路径)。
5
+ * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 段判据(段太长 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
6
+ * 判据参数 = **工具内置常量** `DOC_CHECK_BUILTINS`:不读任何项目的声明面,因而没有「缺参数 ⇒ 拒跑」这一层,
7
+ * 也没有常量校验面——改判据 = 改代码、走评审。
8
+ * 判定范围 = **调用方传入的文件清单**(判定范围 = 解析范围 = 传入的那几档;无目录遍历、无全域兜底):
9
+ * 不给清单 ⇒ 明确报错要求显式清单——判了本轮没碰过的档,别轮的存量问题会记到本轮头上。
7
10
  * 两种问法(§3.20「工具形」——同一条判据):
8
- * `report`(缺省)= 全域报告态——**零门禁 / 零退出码 / 零阻断**,输出不得被读作拒执行的理由(用户令 > 机检);
9
- * `gate` = 交付时点的**范围化裁定**——只判本轮改过的档(整档入判),首行给机器可判 verdict;
10
- * 强制面住交付协议 + 批次档 §6 前置,工具本体仍不阻断任何动作。
11
+ * `report`(缺省)= 报告态——**零门禁 / 零退出码 / 零阻断**,输出不得被读作拒执行的理由(用户令 > 机检);
12
+ * 清单里不可判档(不在盘 ∥ 非 .md)⇒ 报错点名、整次零判定;
13
+ * `gate` = 交付时点的裁定——首行给机器可判 verdict;不可判档给跳过行(可见,不静默丢掉)。
14
+ * 强制面住交付协议 + 批次档 §6 前置,工具本体不阻断任何动作。
11
15
  */
12
16
  import { statSync } from "node:fs"
13
17
  import { relative, resolve } from "node:path"
14
- import { MANIFEST_REL, readManifest } from "../manifest.mjs"
15
- import { anchorRootError } from "../manifest-schema.mjs"
16
- import { scanDomain, checkDocWidths } from "./width.mjs"
18
+ import { checkParagraphs } from "./paragraph.mjs"
17
19
  import { checkAnchors } from "./anchors.mjs"
18
20
 
21
+ /** 判据参数(内置常量 · 冻结):阈值 300 字符(一段 ∥ 一行同值——§3.18 ④);两项空列表 = 区带类 / 豁免类默认值且恒空
22
+ * ——豁免谓词(表行 / 围栏 / 列表项 / 引文)住代码,没有「登记后放过」的写入口(空列表 ≠ 可登记豁免)。 */
23
+ export const DOC_CHECK_BUILTINS = Object.freeze({
24
+ paragraphMax: 300,
25
+ lineMax: 300,
26
+ paragraphExemptZones: Object.freeze([]),
27
+ exemptions: Object.freeze([]),
28
+ })
29
+
19
30
  /** 相对路径显示形(正斜杠——跨平台同形)。 */
20
31
  const rel = (root, p) => relative(root, p).replace(/\\/g, "/")
21
32
 
22
- /** 平台路径键(Windows 大小写不敏感——只用于域集比对,非判据面)。 */
33
+ /** 平台路径键(Windows 大小写不敏感——只用于清单去重,非判据面)。 */
23
34
  const pathKey = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
24
35
 
25
- /** 文件存在性(目录 / 读错 ⇒ false)。 */
26
- function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
27
-
28
- /** gate 取参失败(manifest 不可读 ∥ checkConfig 不合法)——**不产 PASS**(消费面 fail-closed)。 */
29
- function gateParamError(base, read) {
30
- const reason = read.reason === "missing"
31
- ? `${MANIFEST_REL} 不在 ${base}`
32
- : (read.errors?.length ? read.errors.join(";") : String(read.reason ?? "未知原因"))
33
- return {
34
- report: [
35
- `DOC-CHECK GATE: ERROR(manifest 不可读 —— ${reason})`,
36
- "原因:取参失败——判据参数来自 `PROJECT-MANIFEST.json` `checkConfig`,无声明面即无判据",
37
- "处置:修声明面(`checkConfig`)后复跑",
38
- ].join("\n"),
39
- hits: { total: 0, anchors: 0, width: 0 }, anchorHits: [], widthHits: [], anchorReports: [],
40
- files: [], checkFiles: [], scanDirs: [], lineWidth: null, zones: [], exclude: [],
41
- skipped: { outside: [], gone: [] }, error: "manifest",
42
- }
43
- }
44
-
45
- /** 声明面形态判据(gate 专用——报告态行为逐字不变):取参不合法 ⇒ 判据失效却出 PASS = fail-open,故 gate 先判后跑。
46
- * 分工:**整键缺** = 上方 `read.missingKeys` 判据(`fillDefaults` 会补内置默认值);**子键形态** = 本函数
47
- * (`validateManifest` 只拒 `docRoot` 值与三族键形态,`checkConfig` 子键不拒 ⇒ 不在这里判就没人判)。 */
48
- function configError(cfg) {
49
- if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) return "`checkConfig` 须为对象"
50
- if (!Array.isArray(cfg.scanDirs) || cfg.scanDirs.length === 0 || !cfg.scanDirs.every((d) => typeof d === "string" && d.trim() !== "")) {
51
- return "`checkConfig.scanDirs` 须为非空字符串数组(空域不得出 PASS——空域假绿比红更坏)"
52
- }
53
- if (typeof cfg.lineWidth !== "number" || !Number.isFinite(cfg.lineWidth) || cfg.lineWidth <= 0) return "`checkConfig.lineWidth` 须为正有限数值"
54
- if (cfg.widthExemptZones !== undefined && !Array.isArray(cfg.widthExemptZones)) return "`checkConfig.widthExemptZones` 须为数组"
55
- if (cfg.anchors !== undefined) {
56
- if (cfg.anchors === null || typeof cfg.anchors !== "object" || Array.isArray(cfg.anchors)) return "`checkConfig.anchors` 须为对象"
57
- if (cfg.anchors.exclude !== undefined && !Array.isArray(cfg.anchors.exclude)) return "`checkConfig.anchors.exclude` 须为数组"
58
- if (cfg.anchors.roots !== undefined) {
59
- if (!Array.isArray(cfg.anchors.roots)) return "`checkConfig.anchors.roots` 须为字符串数组(元素 = 相对项目根的目录)"
60
- for (const r of cfg.anchors.roots) {
61
- const why = anchorRootError(r)
62
- if (why) return `\`checkConfig.anchors.roots\` ${why}:${JSON.stringify(r)}`
63
- }
64
- }
65
- }
36
+ /** 不可判档的两因(闭集 · 判序 = 存在性 → 类型):不在盘 ∥ 非 markdown 档(目录归后因)。 */
37
+ function undecidableReason(abs) {
38
+ let st
39
+ try { st = statSync(abs) } catch { return "not on disk" }
40
+ if (!st.isFile() || !abs.toLowerCase().endsWith(".md")) return "not a markdown document"
66
41
  return null
67
42
  }
68
43
 
69
- /**
70
- * gate 范围求交:候选(`docs`)= 显式清单 ∥ 本会话触达记账 ⇒ **声明域内 ∧ 盘上存在的 `.md`**。
71
- * 域外 / 已删**不是命中**但必须可见(跳过行)——静默丢掉会把「没跑」装成「跑了且清」。
72
- * 解析基线不缩:域内全集仍交锚判据建索引(只收窄被判档)。
73
- */
74
- function resolveRange(base, files, docs) {
75
- const inDomain = new Map(files.map((f) => [pathKey(f), f]))
76
- const check = new Set()
77
- const outside = []
78
- const gone = []
79
- for (const d of Array.isArray(docs) ? docs : []) {
80
- if (typeof d !== "string" || d.trim() === "") continue
44
+ /** 清单 → 可判集(去重保序)+ 不可判档两类(`missing` ∥ `notMarkdown`)+ 非法条目下标(非字符串 / 空白)。 */
45
+ function resolveRange(base, docs) {
46
+ const seen = new Set()
47
+ const check = []
48
+ const undecidable = []
49
+ const missing = []
50
+ const notMarkdown = []
51
+ const invalid = []
52
+ docs.forEach((d, i) => {
53
+ if (typeof d !== "string" || d.trim() === "") { invalid.push(i); return }
81
54
  const abs = resolve(base, d)
82
- const hit = inDomain.get(pathKey(abs))
83
- if (hit) check.add(hit)
84
- else if (isFile(abs)) outside.push(abs)
85
- else gone.push(abs)
86
- }
87
- return { check: [...check].sort(), outside, gone }
55
+ const key = pathKey(abs)
56
+ const reason = undecidableReason(abs)
57
+ if (reason === null) { if (!seen.has(key)) { seen.add(key); check.push(abs) } return }
58
+ undecidable.push({ abs, reason })
59
+ if (reason === "not on disk") missing.push(abs)
60
+ else notMarkdown.push(abs)
61
+ })
62
+ return { check: check.sort(), undecidable, missing, notMarkdown, invalid }
88
63
  }
89
64
 
65
+ /** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。 */
66
+ const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}(${h.len} 字符)`
67
+
90
68
  /** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
91
- function gateReport({ base, docsSource, checkFiles, domainCount, skipped, anchors, widths, lineWidth, exclude }) {
92
- const total = anchors.hits.length + widths.length
69
+ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs, paragraphMax }) {
70
+ const total = anchors.hits.length + paragraphs.length
93
71
  const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
94
72
  const out = [
95
73
  total ? `DOC-CHECK GATE: REFUSED(命中 ${total} 条)` : (checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"),
96
74
  "文档纪律机检(交付门 · gate)",
97
- `范围:${src} ${checkFiles.length} 档(声明域 ${domainCount} 档)· 阈值 ${lineWidth} 字符(运行时取 checkConfig.lineWidth)· 排除:${exclude.length ? exclude.join(" ∥ ") : "(无)"}`,
98
- "判据:与报告态同源(`PROJECT-MANIFEST.json` `checkConfig`)——范围只收窄被判档,不收窄解析",
75
+ checkFiles.length
76
+ ? `范围:${src} ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)`
77
+ : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)· 阈值 ${paragraphMax} 字符(工具内置常量)`,
78
+ "判据:工具内置常量(锚 ∥ 段判据)——判定范围 = 解析范围 = 传入档",
99
79
  ]
100
- if (skipped.outside.length) out.push(`跳过 ${skipped.outside.length} 档(不在声明域内,未检):${skipped.outside.map((p) => rel(base, p)).join(" ∥ ")}`)
101
- if (skipped.gone.length) out.push(`跳过 ${skipped.gone.length} 档(已不在盘,未检):${skipped.gone.map((p) => rel(base, p)).join(" ∥ ")}`)
80
+ if (skipped.missing.length) out.push(`跳过 ${skipped.missing.length} 档(已不在盘,未检):${skipped.missing.map((p) => rel(base, p)).join(" ∥ ")}`)
81
+ if (skipped.notMarkdown.length) out.push(`跳过 ${skipped.notMarkdown.length} 档(非 markdown 档,未检):${skipped.notMarkdown.map((p) => rel(base, p)).join(" ∥ ")}`)
102
82
  if (checkFiles.length === 0) {
103
- // 空范围不静默当通过(K6)——照实说明 + 给出补充通道
83
+ // 空范围不静默当通过(K6)——照实说明 + 给出补充通道(两侧都不得读成「已检」)。
84
+ // 措辞按「有没有跳过行」分流:无跳过行时不得写「见上方跳过行」。
85
+ const hasSkips = skipped.missing.length > 0 || skipped.notMarkdown.length > 0
104
86
  out.push(docsSource === "session"
105
- ? "范围为空:本会话没有写过声明域内的文档——若确认改过 ⇒ 显式传 docs"
106
- : "范围为空:显式 docs 无一落在声明域内——见上方跳过行")
87
+ ? (hasSkips
88
+ ? "范围为空:本会话触达的档均不可判(见上方跳过行)——若确认改过 ⇒ 显式传 docs"
89
+ : "范围为空:本会话没有写过 .md 档——若确认改过 ⇒ 显式传 docs")
90
+ : (hasSkips
91
+ ? "范围为空:显式 docs 里没有可判档——见上方跳过行"
92
+ : "范围为空:显式 docs 是空清单——列出要判的档后复跑"))
107
93
  } else if (total === 0) {
108
94
  out.push(`结论:0 条命中(${checkFiles.length} 档全清)`)
109
95
  } else {
@@ -112,7 +98,7 @@ function gateReport({ base, docsSource, checkFiles, domainCount, skipped, anchor
112
98
  ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
113
99
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
114
100
  }
115
- for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
101
+ for (const w of paragraphs) out.push(paragraphHitLine(base, w))
116
102
  out.push("处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)")
117
103
  }
118
104
  return out.join("\n")
@@ -120,91 +106,71 @@ function gateReport({ base, docsSource, checkFiles, domainCount, skipped, anchor
120
106
 
121
107
  /**
122
108
  * 跑一次文档纪律机检。
123
- * @param {string} root 项目根(判据参数取该根的 `PROJECT-MANIFEST.json` `checkConfig`)
109
+ * @param {string} root 项目根(解析序 ① 的基准)
124
110
  * @param {{docs?:string[], mode?:'report'|'gate', docsSource?:'session'|'explicit'}} [o]
125
- * `mode` 缺省 = `report`(全域报告态);`docs` = **仅 gate** 的范围候选(相对 `root` 或绝对);
126
- * `docsSource` = 范围来源标签(`session` = 本会话触达记账 ∥ `explicit` = 显式 `docs`)——缺省按 `docs` 是否给定推定;只影响报面措辞。
127
- * @returns {{report:string, hits:{total:number, anchors:number, width:number}, anchorHits:Array,
128
- * widthHits:Array, anchorReports:Array, anchorRootGaps:Array, files:string[], checkFiles:string[], scanDirs:string[],
129
- * lineWidth:number|null, zones:string[], exclude:string[], skipped:{outside:string[], gone:string[]},
130
- * error?:"manifest"}}
131
- * `error` 在场 ⇒ 取参失败形(首行 = `DOC-CHECK GATE: ERROR(…)`、`lineWidth` = null、`hits` 恒 0)——
132
- * 消费面判「清」须读 `report` 首行,不得只看 `hits.total`(0 与「未跑成」不同义)。
111
+ * `mode` 缺省 = `report`;`docs` = 被判档清单(相对 `root` 或绝对)——**必填**(两种问法同):
112
+ * 缺省 ∥ 非数组 ⇒ 报错要求显式清单(零遍历、零全域兜底);`report` 面**空数组也报错**,`gate` 面空数组 ⇒
113
+ * `PASS(0 档)` + 空范围说明(「本轮一档没碰」是真实读数);`report` 面不可判档 ⇒ 报错、整次零判定;
114
+ * `gate` 面不可判档 ⇒ 跳过行;`docsSource` = 范围来源标签(`session` = 本会话触达记账 ∥ `explicit` = 显式
115
+ * `docs`)——缺省 = `explicit`;只影响报面措辞。
116
+ * @returns {{report:string, hits:{total:number, anchors:number, paragraph:number}, anchorHits:Array,
117
+ * paragraphHits:Array, files:string[], checkFiles:string[], paragraphMax:number,
118
+ * skipped:{missing:string[], notMarkdown:string[]}}}
119
+ * 消费面判「清」须读 `report` 首行(gate)∥ `hits.total`(report),不得只看其中一个。
133
120
  */
134
121
  export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
135
122
  const base = resolve(root)
136
123
  const gate = mode === "gate"
137
- // `docs` = gate 专属(report + docs ⇒ 拒,不静默忽略——与工具面同一判据)
138
- if (!gate && docs !== undefined) throw new Error("doc_check: `docs` is gate-only — report mode always checks the whole declared domain")
139
- const source = docsSource ?? (docs === undefined ? "session" : "explicit")
140
- let read
141
- try {
142
- read = readManifest(base)
143
- } catch (e) {
144
- if (!gate) throw e // 报告态既有行为逐字不变(读错 fail-closed 上抛)
145
- return gateParamError(base, { reason: "unreadable", errors: [e?.message ?? String(e)] })
124
+ if (docs !== undefined && !Array.isArray(docs)) {
125
+ throw new Error(`doc_check: \`docs\` must be an array of paths (got ${typeof docs}) — a bare string is refused rather than read as a whole-domain request; pass ["<path>"].`)
146
126
  }
147
- if (!read.ok) {
148
- if (gate) return gateParamError(base, read)
149
- throw new Error(`doc_check: cannot read ${MANIFEST_REL} at ${base} — the check parameters come from that project's checkConfig (${read.reason ?? read.errors.join(";")})`)
127
+ if (!Array.isArray(docs)) {
128
+ throw new Error('doc_check: `docs` is required — pass the explicit list of documents to judge. There is no default range: nothing is scanned unless you name it.')
150
129
  }
151
- // `checkConfig` 整键缺失:`readManifest` 会经 `fillDefaults` 灌入默认值(`missingKeys` 是唯一痕迹)——
152
- // gate 不得拿内置默认值当声明面(设计「取参失败 ⇒ ERROR」的第一支)。
153
- if (gate && (read.missingKeys ?? []).includes("checkConfig")) {
154
- return gateParamError(base, { reason: "invalid-config", errors: ["`checkConfig` 缺——声明面未声明判据参数"] })
130
+ // 报告态空清单 = 报错(「没跑」不得装成「跑了且清」);gate 面空清单照实出 `PASS(0 档)` + 空范围说明
131
+ // ——交付面「本轮一档没碰」是一种真实读数,不是省略。
132
+ if (!gate && (docs.length === 0 || docs.every((d) => typeof d !== "string" || d.trim() === ""))) {
133
+ throw new Error('doc_check: docs is empty — an empty list names no document, so nothing can be judged; list the documents you mean.')
155
134
  }
156
- const cfg = read.manifest.checkConfig ?? {}
157
- if (gate) {
158
- const bad = configError(cfg)
159
- if (bad) return gateParamError(base, { reason: "invalid-config", errors: [bad] })
135
+ const source = docsSource ?? "explicit"
136
+ const range = resolveRange(base, docs)
137
+ // 清单条目本身非法(非字符串 / 空白)——调用方错误,两种问法同拒:静默丢掉一条就是不静默地收窄范围。
138
+ if (range.invalid.length) {
139
+ throw new Error(`doc_check: \`docs\` entries ${range.invalid.map((i) => `[${i}]`).join(", ")} are not paths — every entry must be a non-empty string; no document was judged.`)
140
+ }
141
+ // 报告态 fail-closed:清单里有一个不可判档 ⇒ 报错点名、整次零判定——清单写错却报 0 命中就是「没跑」装成「跑了且清」。
142
+ if (!gate && range.undecidable.length) {
143
+ const named = range.undecidable.map((u) => `${rel(base, u.abs)}(${u.reason})`).join(" ∥ ")
144
+ throw new Error(`doc_check: docs names ${range.undecidable.length} document(s) that cannot be judged — no document was checked: ${named}. Fix the list and re-run; nothing is silently skipped and the check never widens beyond the list.`)
160
145
  }
161
- const scanDirs = (Array.isArray(cfg.scanDirs) ? cfg.scanDirs : []).filter((d) => typeof d === "string" && d !== "")
162
- const exclude = Array.isArray(cfg.anchors?.exclude) ? cfg.anchors.exclude : []
163
- const roots = cfg.anchors?.roots // 原值下传:值形 ∥ 根在盘的门归 anchors(报告态弃用、不炸)
164
- const lineWidth = cfg.lineWidth
165
- const zones = (Array.isArray(cfg.widthExemptZones) ? cfg.widthExemptZones : []).filter((z) => typeof z === "string" && z !== "")
166
-
167
- const files = scanDomain(base, { scanDirs, exclude })
168
- const range = gate ? resolveRange(base, files, docs) : { check: files, outside: [], gone: [] }
169
146
  const checkFiles = range.check
170
- const anchors = checkAnchors(base, { scanDirs, files, checkFiles, roots })
171
- const widths = checkDocWidths(base, { lineWidth, scanDirs, exclude, exemptZones: zones, files, checkFiles })
147
+ const { paragraphMax, lineMax, paragraphExemptZones } = DOC_CHECK_BUILTINS
148
+ const anchors = checkAnchors(base, { files: checkFiles })
149
+ const paragraphs = checkParagraphs({ paragraphMax, lineMax, exemptZones: paragraphExemptZones, files: checkFiles })
172
150
 
173
- const hits = { total: anchors.hits.length + widths.length, anchors: anchors.hits.length, width: widths.length }
151
+ const hits = { total: anchors.hits.length + paragraphs.length, anchors: anchors.hits.length, paragraph: paragraphs.length }
174
152
  const shared = {
175
- hits, anchorHits: anchors.hits, widthHits: widths, anchorReports: anchors.reports, anchorRootGaps: anchors.rootGaps,
176
- files, checkFiles, scanDirs, lineWidth, zones, exclude, skipped: { outside: range.outside, gone: range.gone },
153
+ hits, anchorHits: anchors.hits, paragraphHits: paragraphs, files: checkFiles, checkFiles, paragraphMax,
154
+ skipped: { missing: range.missing, notMarkdown: range.notMarkdown },
177
155
  }
178
156
  if (gate) {
179
- return {
180
- ...shared,
181
- report: gateReport({ base, docsSource: source, checkFiles, domainCount: files.length, skipped: shared.skipped, anchors, widths, lineWidth, exclude }),
182
- }
157
+ return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs, paragraphMax }) }
183
158
  }
184
159
 
185
- const total = hits.total
186
160
  const out = [
187
161
  "文档纪律机检(报告态 · 非门禁)",
188
- `射程:${base} · ${scanDirs.join(" + ") || "(无)"}(${files.length} 档)· 阈值 ${lineWidth} 字符 · 区带豁免:${zones.length ? zones.join(" ∥ ") : "无"} · 排除:${exclude.length ? exclude.join(" ∥ ") : "(无)"}`,
189
- "声明:判据参数依据 `PROJECT-MANIFEST.json` `checkConfig`",
190
- `命中 ${total} 条(锚 ${anchors.hits.length} · 行宽 ${widths.length})`,
162
+ `射程:${base} · 传入清单 ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)· 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
163
+ "声明:判据参数 = 工具内置常量;范围 = 传入文件(判定范围 = 解析范围)",
164
+ `范围:传入清单 ${checkFiles.length} 档(整档入判 · 判定范围 = 解析范围)`,
165
+ `命中 ${hits.total} 条(锚 ${anchors.hits.length} · 段太长 ${paragraphs.filter((h) => h.clazz === "段太长").length} · 行太长 ${paragraphs.filter((h) => h.clazz === "行太长").length})`,
191
166
  ]
192
- // 报告面(不作判):① 声明解析根弃用 ∥ 缺位(⑤ 的配置面);② 域内同名 ≥2 ⇒ 多义不判(残余登记——fail-open 方向,归评审核验面)
193
- for (const g of anchors.rootGaps) {
194
- out.push(g.kind === "missing"
195
- ? `报告 锚·声明解析根缺位 \`${g.root}\`(未核、不入判)`
196
- : `报告 锚·解析根弃用 ${JSON.stringify(g.root)}(值形非法:${g.reason} · 本元素不入判)`)
197
- }
198
- for (const r of anchors.reports) {
199
- out.push(`报告 锚·多义 ${rel(base, r.file)}:${r.line} \`${r.token}\`(同名 ${r.candidates.length}:${r.candidates.map((c) => rel(base, c)).join(" ∥ ")}——报告面,不作判)`)
200
- }
201
167
  for (const h of anchors.hits) {
202
168
  out.push(h.clazz === "档缺"
203
169
  ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
204
170
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
205
171
  }
206
- for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
207
- out.push(total ? `结论:${total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 行宽 均清)")
172
+ for (const w of paragraphs) out.push(paragraphHitLine(base, w))
173
+ out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 段太长 ∥ 行太长 均清)")
208
174
 
209
175
  return { ...shared, report: out.join("\n") }
210
176
  }