@thincoder/core 0.10.12 → 0.11.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 +42 -0
- package/advisor/messages.mjs +4 -2
- package/advisor/notice.mjs +1 -1
- package/advisor/project-context.mjs +121 -11
- package/agent/completion.mjs +13 -1
- package/agent/digest-account.mjs +73 -46
- package/agent/dispatch-run.mjs +1 -1
- package/agent/dispatch.mjs +29 -21
- package/agent/family-tools.mjs +9 -5
- package/agent/helpers.mjs +1 -94
- package/agent/offload.mjs +88 -0
- package/agent/record-results.mjs +1 -6
- package/agent/run-stages.mjs +74 -49
- package/agent/run-start.mjs +8 -8
- package/agent/setup.mjs +6 -11
- package/agent/suspension.mjs +1 -1
- package/agent/turn-loop.mjs +35 -3
- package/agent/write-face.mjs +99 -0
- package/agent/write-gate.mjs +354 -5
- package/agent-tools/advisor.mjs +127 -11
- package/agent-tools/async-settle.mjs +2 -2
- package/agent-tools/bash-async.mjs +8 -2
- package/agent-tools/batch-closeout.mjs +206 -0
- package/agent-tools/batch-gate.mjs +287 -73
- package/agent-tools/batch-ledger-bind.mjs +183 -0
- package/agent-tools/batch-ledger-link.mjs +73 -0
- package/agent-tools/batch-lifecycle.mjs +113 -354
- package/agent-tools/batch-paths.mjs +45 -40
- package/agent-tools/batch-read.mjs +1 -1
- package/agent-tools/batch-reopen.mjs +142 -0
- package/agent-tools/batch-skeleton.mjs +283 -80
- package/agent-tools/batch-unit-test-gate.mjs +107 -0
- package/agent-tools/batch.mjs +243 -50
- package/agent-tools/context.mjs +6 -3
- package/agent-tools/design-slots.mjs +24 -24
- package/agent-tools/design-token.mjs +96 -15
- package/agent-tools/doc-check.mjs +18 -2
- package/agent-tools/eng.mjs +1 -1
- package/agent-tools/escalate-async.mjs +3 -3
- package/agent-tools/manifest.mjs +8 -5
- package/agent-tools/read-history.mjs +5 -5
- package/agent-tools/review-facts.mjs +31 -13
- package/agent-tools/spawn-gates.mjs +10 -14
- package/agent-tools/ssh-session.mjs +120 -111
- package/agent-tools/ssh-transfer.mjs +210 -0
- package/agent-tools/ssh-transport.mjs +277 -46
- package/agent-tools/subagent-actions-query.mjs +1 -1
- package/agent-tools/subagent-actions.mjs +1 -1
- package/agent-tools/subagent-async.mjs +3 -2
- package/agent-tools/subagent-scheduler.mjs +43 -9
- package/agent-tools/subagent-spawn.mjs +68 -9
- package/agent-tools/subagent.mjs +16 -14
- package/agent.mjs +1 -0
- package/client-identity.mjs +70 -0
- package/config-presets.mjs +5 -3
- package/config.mjs +19 -13
- package/context.mjs +39 -8
- package/declaration.mjs +2 -2
- package/doc-check/anchors.mjs +128 -28
- package/doc-check/index.mjs +93 -27
- package/doc-check/line-refs.mjs +112 -0
- package/doc-check/paragraph.mjs +24 -8
- package/embedding.mjs +2 -0
- package/explore-distill.mjs +1 -1
- package/generate-title.mjs +3 -2
- package/i18n.mjs +1 -1
- package/index-discover.mjs +4 -1
- package/ledger-close-proof.mjs +62 -0
- package/ledger-cmd.mjs +79 -194
- package/ledger-db.mjs +29 -28
- package/ledger-migrate.mjs +4 -4
- package/ledger-read.mjs +10 -8
- package/ledger-surface.mjs +1 -1
- package/ledger-tools.mjs +10 -10
- package/ledger-variant-notice.mjs +1 -1
- package/ledger-write-ops.mjs +373 -0
- package/ledger.mjs +2 -2
- package/manifest-discovery.mjs +2 -2
- package/manifest-schema.mjs +4 -0
- package/memory/memory-tool.mjs +5 -2
- package/memory/schema.mjs +5 -26
- package/model-specs.mjs +6 -5
- package/package.json +1 -1
- package/path-resolve.mjs +217 -0
- package/permission.mjs +4 -0
- package/prompt-overlays.mjs +5 -0
- package/prompts/advisor-design.md +9 -6
- package/prompts/advisor-round1.md +4 -3
- package/prompts/advisor-round2.md +6 -5
- package/prompts/advisor-round3.md +6 -5
- package/prompts/common.md +23 -10
- package/prompts/consult-base.md +2 -1
- package/prompts/discipline-engineering.md +29 -48
- package/prompts/discipline-normal.md +10 -5
- package/prompts/persona-eng-coder.md +8 -0
- package/prompts/persona-eng-designer.md +22 -12
- package/prompts/persona-engineering.md +81 -66
- package/prompts/persona-plan.md +2 -1
- package/prompts/persona-tester.md +55 -0
- package/provider/anthropic.mjs +12 -7
- package/provider/core.mjs +21 -5
- package/provider/errors.mjs +41 -0
- package/provider/google.mjs +25 -9
- package/provider/list-models.mjs +4 -3
- package/provider/rate.mjs +11 -3
- package/provider/responses.mjs +17 -7
- package/provider/sse.mjs +15 -4
- package/session-identity.mjs +21 -0
- package/session-lifecycle.mjs +12 -4
- package/session-slots.mjs +9 -14
- package/session.mjs +5 -0
- package/skip-dirs.mjs +48 -0
- package/team-ledger.mjs +4 -4
- package/text-budget.mjs +4 -0
- package/token-window.mjs +107 -10
- package/tool-docs/advisor.md +2 -2
- package/tool-docs/apply_patch.md +2 -0
- package/tool-docs/bash.md +4 -0
- package/tool-docs/batch.md +85 -1
- package/tool-docs/browser.md +3 -1
- package/tool-docs/code_search.md +3 -1
- package/tool-docs/consult_start.md +6 -2
- package/tool-docs/consult_stop.md +3 -1
- package/tool-docs/delete.md +3 -0
- package/tool-docs/doc_check.md +43 -8
- package/tool-docs/doc_search.md +5 -1
- package/tool-docs/edit.md +22 -1
- package/tool-docs/execute.md +7 -1
- package/tool-docs/fetch.md +3 -1
- package/tool-docs/file_ops.md +3 -1
- package/tool-docs/git.md +9 -1
- package/tool-docs/goal.md +2 -0
- package/tool-docs/grep.md +1 -1
- package/tool-docs/hashline_edit.md +2 -0
- package/tool-docs/insert_after.md +3 -1
- package/tool-docs/ledger.md +5 -5
- package/tool-docs/lint.md +1 -0
- package/tool-docs/manifest-read.md +2 -1
- package/tool-docs/manifest.md +9 -2
- package/tool-docs/memory.md +11 -1
- package/tool-docs/notify_parent.md +1 -1
- package/tool-docs/plan.md +2 -0
- package/tool-docs/question.md +1 -1
- package/tool-docs/read_history.md +15 -3
- package/tool-docs/read_image.md +3 -1
- package/tool-docs/recent_changes.md +3 -1
- package/tool-docs/repo_outline.md +3 -1
- package/tool-docs/settings.md +10 -2
- package/tool-docs/skill.md +2 -0
- package/tool-docs/ssh.md +30 -17
- package/tool-docs/subagent.md +50 -13
- package/tool-docs/task.md +2 -0
- package/tool-docs/team_ledger.md +1 -1
- package/tool-docs/timer.md +7 -0
- package/tool-docs/verify.md +13 -1
- package/tool-docs/websearch.md +3 -1
- package/tool-docs/write.md +2 -0
- package/tools/bash.mjs +12 -2
- package/tools/edit-batch.mjs +40 -33
- package/tools/edit-diff.mjs +69 -1
- package/tools/execute.mjs +10 -4
- package/tools/file.mjs +17 -27
- package/tools/git.mjs +27 -1
- package/tools/ops.mjs +2 -1
- package/tools/patch.mjs +12 -3
- package/tools/process-doc-mark.mjs +139 -0
- package/tools/search.mjs +15 -6
- package/tools/shared.mjs +24 -1
- package/tools/ssh.mjs +48 -7
- package/tools/tree.mjs +4 -1
- package/tools/write-path.mjs +64 -3
- package/traces/trace-store.mjs +4 -3
- package/agent/light-round.mjs +0 -63
- package/agent/unbooked-pen.mjs +0 -62
package/doc-check/anchors.mjs
CHANGED
|
@@ -5,19 +5,42 @@
|
|
|
5
5
|
* · 文档指针 = 档 token(含 `.md`,可带目录段)± 节指示(紧随 `§` + 编号 N / N.M / N.M.K);
|
|
6
6
|
* **行号坐标(`:N`)不作节指示**——行号不作引用;
|
|
7
7
|
* · 档不在盘 ⇒ `档缺`;带节指示 ∧ 节不在盘 ⇒ `节缺`。
|
|
8
|
+
* · 依据行(`依据 <档路径>「<逐字引文>」`——口径单源 = 同档 §3.25 条目 Y):路径面走**同一解析序**,
|
|
9
|
+
* 引文面要求「目标档全文里的一段连续子串」——不是 ⇒ `引缺`。不构成标记的行不判;不新增判据条数、
|
|
10
|
+
* 不新增 verdict 词(三个命中类同计「锚 N 条」)。
|
|
8
11
|
*
|
|
9
12
|
* 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面);
|
|
10
13
|
* 提示词面另剥 CANON 操作数(面判据 = `./paragraph.mjs` 的 `isPromptFace`——七名整串删除后再抽,见 `CANON_OPERANDS`)。
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* 解析序**两级**(算法单源 = `../path-resolve.mjs` 的统一解析层,本档只声明本面参数):
|
|
15
|
+
* ① 仓根相对全路径 → ② 引用方档所在目录相对。零命中 ⇒ `档缺`,并**列同名候选**(「你是不是指这个」——
|
|
16
|
+
* 候选只作提示,**不产命中档**:裸名仍不跨目录解析,全域索引不建)。
|
|
17
|
+
* 跨仓形(`仓名/相对路径`)⇒ **零命中**,回执记一行(声明源引用 / 须先声明——只判引用写对没有,不查存在性)。
|
|
14
18
|
* 节在盘:**只读解析命中的那一档**(点查,不遍历)——目标档内存在标题行,其标题编号
|
|
15
19
|
* (标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
|
|
16
|
-
* 残余登记(fail-open 方向——归评审核验面):未闭合围栏 ⇒ 至档尾免;指针行的坐标尾不入判、同行的档 token
|
|
20
|
+
* 残余登记(fail-open 方向——归评审核验面):未闭合围栏 ⇒ 至档尾免;指针行的坐标尾不入判、同行的档 token 照判;
|
|
21
|
+
* **引文含 `」` 时只抽到第一个 `」`**(抽取段是 `[^」]`)——逐字引用一段内含「…」的句子时被验的只是前缀,
|
|
22
|
+
* 错的后半段静默放行(方向是不误拦,不是不误放;这一半的最终裁定归评审核验面,同设计 §3.25「评审面」)。
|
|
23
|
+
* 档名豁免(类名 ∥ 占位词干)的**判定只此一处** = 本档导出的 `docNameExemption`(判据单源 = §3.24 条目 X):
|
|
24
|
+
* 两张表保持私有、不上导出面;另两处消费面(兄弟脚本 ∥ 行号坐标清单)只拿判定、拿不到名单。
|
|
17
25
|
*/
|
|
18
|
-
import { readFileSync
|
|
19
|
-
import { basename,
|
|
20
|
-
import {
|
|
26
|
+
import { readFileSync } from "node:fs"
|
|
27
|
+
import { basename, resolve } from "node:path"
|
|
28
|
+
import { docRootPaths, readManifest } from "../manifest.mjs"
|
|
29
|
+
import { resolvePathTarget } from "../path-resolve.mjs"
|
|
30
|
+
import { fenceMark, isFenceClose, isExecutableLine, isPromptFace } from "./paragraph.mjs"
|
|
31
|
+
|
|
32
|
+
/** `docRoot` 各键 → 绝对根集(**只作同名候选的扫描根**,不作受理边界——受理面仍是「档在盘 / 节在盘」)。
|
|
33
|
+
* 读声明面单源 = `docRootPaths`(本档零自持根判定);无 manifest / 非法 ⇒ 空集。 */
|
|
34
|
+
function docRootBaseKeys(root) {
|
|
35
|
+
let man = { ok: false }
|
|
36
|
+
try { man = readManifest(root) } catch { return [] }
|
|
37
|
+
if (!man.ok) return []
|
|
38
|
+
const docRoot = man.manifest?.docRoot
|
|
39
|
+
if (!docRoot || typeof docRoot !== "object") return []
|
|
40
|
+
const out = []
|
|
41
|
+
for (const key of Object.keys(docRoot)) for (const abs of docRootPaths(docRoot[key], root)) if (!out.includes(abs)) out.push(abs)
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
21
44
|
|
|
22
45
|
/** 文档指针 token:`(目录段/)*名.md` + 可选行号坐标尾 + 可选节指示(`§` + 编号)。
|
|
23
46
|
* 左界守卫排除更长 token 的尾段;右界排除后续字母数字(`.md` 之后紧邻 `[\w]` ⇒ 非完整 token)。 */
|
|
@@ -35,6 +58,52 @@ const PLACEHOLDER_STEMS = new Set([
|
|
|
35
58
|
"x", "a", "b", "file", "out", "foo", "bar", "example", "sample", "name", "path",
|
|
36
59
|
])
|
|
37
60
|
|
|
61
|
+
/** 扩展名闭集(档名后缀族——与 `thincoder-core/doc-check/line-refs.mjs` 的抽取式同一组枚举):
|
|
62
|
+
* 谓词按这一组剥后缀取词干。**两处必须同改**——两档互 import 会成环(`line-refs.mjs` 已 import 本档谓词),
|
|
63
|
+
* 所以这里留一份,改扩展名族时两处一起改。 */
|
|
64
|
+
const DOC_EXTS = ["md", "mjs", "cjs", "js", "json", "css", "html", "svg", "yml", "yaml", "sh", "ps1"]
|
|
65
|
+
|
|
66
|
+
/** 依据行标记(口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.25 条目 Y「写法」表):
|
|
67
|
+
* `依据` + 可选分隔符(`:` ∥ `:` ∥ `=`)+ 档路径(可带反引号)+ `「引文」`。
|
|
68
|
+
* 「相邻」行 = 路径 token 与 `「` 之间**只允许分隔符与反引号**——夹进别的字符(散文词 ∥ 标点 ∥ 空白)
|
|
69
|
+
* 就**不构成标记**,不判:讲写法的档里本就有「`依据` = 判据契约(…)+用户裁定「…」」这类散文句,
|
|
70
|
+
* 松判会把它们读成标记形,而批档恒在收口判域,一次假命中就拦一次合法收口。
|
|
71
|
+
* (`依据` 与路径之间、以及那个分隔符是否带空白不限——那一段不是判据要防的那一处。)
|
|
72
|
+
* 扩展名族 = `DOC_EXTS`(与行号坐标族同组,单源)。
|
|
73
|
+
* **导出面说明**:源串导出只作**量法扫串的单一源**(改面单测档的「存量读数」用它,不再自建第二份字面)——
|
|
74
|
+
* 生产行为不变,别处不去拼它。 */
|
|
75
|
+
export const CITE_RE_SRC = `依据\\s*[::=]?\\s*\`?((?:[A-Za-z0-9_.\\-]+/)*[A-Za-z0-9_.\\-]+\\.(?:${DOC_EXTS.join("|")}))\`?[::=]?「([^」]*)」`
|
|
76
|
+
|
|
77
|
+
/** 引文面的目标档读法(**同档一次读、同轮缓存**;CRLF→LF 归一——比较基准的形态单源)。 */
|
|
78
|
+
function citedTextReader() {
|
|
79
|
+
const cache = new Map()
|
|
80
|
+
return (abs) => {
|
|
81
|
+
if (!cache.has(abs)) {
|
|
82
|
+
let text = ""
|
|
83
|
+
try { text = readFileSync(abs, "utf8") } catch { text = "" }
|
|
84
|
+
cache.set(abs, text.replace(/\r\n/g, "\n"))
|
|
85
|
+
}
|
|
86
|
+
return cache.get(abs)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 档名豁免判定(**唯一一处** · 判据单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.24 条目 X):
|
|
92
|
+
* 三处消费同一个谓词——锚判(本档)∥ `scripts/prompt-refs-check.mjs`(J1 ∥ J3 命中过滤)∥ 行号坐标清单(`./line-refs.mjs`)。
|
|
93
|
+
* @param {string} name 档名 = **末段名、不带目录段**,大小写不敏感
|
|
94
|
+
* @returns {null|"filename-class"|"placeholder"} `"filename-class"` = 约定的类名(整名判,不剥后缀);
|
|
95
|
+
* `"placeholder"` = 占位词干(按闭集剥后缀后取词干,词干 ≤ 1 字符或命中占位表);`null` = 不是豁免名
|
|
96
|
+
*/
|
|
97
|
+
export function docNameExemption(name) {
|
|
98
|
+
const bn = String(name ?? "").toLowerCase()
|
|
99
|
+
if (FILENAME_CLASSES.has(bn)) return "filename-class"
|
|
100
|
+
const dot = bn.lastIndexOf(".")
|
|
101
|
+
const ext = dot > 0 ? bn.slice(dot + 1) : ""
|
|
102
|
+
const stem = DOC_EXTS.includes(ext) ? bn.slice(0, dot) : bn
|
|
103
|
+
if (stem.length <= 1 || PLACEHOLDER_STEMS.has(stem)) return "placeholder"
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
38
107
|
/** CANON 操作数族(**规范豁免串** · 判据单源 = `docs/core/design/PROMPT-SYSTEM.md` §6.6「判保留面」):
|
|
39
108
|
* 七个名字在模型面是**模型要写出来 / 读写的文件名**(行为操作数 · 路径操作数),不是「去读这个档」的指针——
|
|
40
109
|
* 换个目录解析必然 `档缺`。**只对提示词面剥**(面判据 = `isPromptFace`,单源住 `./paragraph.mjs`):`docs/` 面不会因本条多放行一个名——
|
|
@@ -61,9 +130,6 @@ export function stripCanonOperands(line) {
|
|
|
61
130
|
// 提示词面判据单源 = `./paragraph.mjs` 的 `isPromptFace`(§3.18 ②——形态与旧 `OPERAND_FACE` 逐字同)。
|
|
62
131
|
const isOperandFace = isPromptFace
|
|
63
132
|
|
|
64
|
-
/** 文件存在性(目录 / 读错 ⇒ false)。 */
|
|
65
|
-
function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
|
|
66
|
-
|
|
67
133
|
/** 标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`);非标题行 ⇒ null。 */
|
|
68
134
|
function headingNumber(line) {
|
|
69
135
|
const m = /^#{1,6}\s+/.exec(line)
|
|
@@ -80,15 +146,14 @@ function sectionOnDisk(path, num) {
|
|
|
80
146
|
return false
|
|
81
147
|
}
|
|
82
148
|
|
|
83
|
-
/** 档解析(判据 ①
|
|
149
|
+
/** 档解析(判据 ① 的解析序两级——**算法单源 = 统一解析层** `../path-resolve.mjs`:本面只声明
|
|
150
|
+
* `kind:"file"` / `order:"root-first"`(与既有两级序逐字同序)/ `crossRepo:"visible"`(文档锚面是
|
|
151
|
+
* 唯一开跨仓读数的面),`bases` = 无、`scanBases` = `docRoot` 各键(只喂同名候选提示)。
|
|
84
152
|
* 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
|
|
85
|
-
* `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const p2 = resolve(dirname(refFile), token)
|
|
90
|
-
if (isFile(p2)) return { ok: true, path: p2 } // ② 引用方档所在目录相对
|
|
91
|
-
return { ok: false }
|
|
153
|
+
* `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。
|
|
154
|
+
* @returns {{state:string, ...}} 层的四态结果 */
|
|
155
|
+
function resolveDoc(token, refFile, base, scanBases) {
|
|
156
|
+
return resolvePathTarget(base, token, { kind: "file", order: "root-first", referrer: refFile, crossRepo: "visible", scanBases })
|
|
92
157
|
}
|
|
93
158
|
|
|
94
159
|
/**
|
|
@@ -96,40 +161,75 @@ function resolveDoc(token, refFile, base) {
|
|
|
96
161
|
* @param {string} root 项目根(解析序 ① 的基准)
|
|
97
162
|
* @param {{files?:string[]}} o `files` = **判定集兼解析集**(调用方传入的档清单——判定范围 = 解析范围,
|
|
98
163
|
* 无目录遍历、无全域索引:清单外的档既不判也不参与解析)
|
|
99
|
-
* @returns {{hits:Array}} hits = 命中(`档缺` /
|
|
164
|
+
* @returns {{hits:Array, crossRepo:Array}} hits = 命中(`档缺` / `节缺` / `引缺`——`档缺` 携 `sameName` 同名候选,
|
|
165
|
+
* `引缺` 携 `quote` 引文原文与 `target` 命中的档);
|
|
166
|
+
* `crossRepo` = 跨仓引用读数(零命中、只入回执——§2.6 一行)
|
|
100
167
|
*/
|
|
101
168
|
export function checkAnchors(root, { files = [] } = {}) {
|
|
102
169
|
const base = resolve(root)
|
|
170
|
+
const scanBases = docRootBaseKeys(base)
|
|
103
171
|
const hits = []
|
|
172
|
+
const crossRepo = []
|
|
173
|
+
const citedTextOf = citedTextReader() // 引文面:目标档全文(同档一次读、同轮缓存)
|
|
104
174
|
for (const f of files) {
|
|
105
175
|
let text = ""
|
|
106
176
|
try { text = readFileSync(f, "utf8") } catch { continue }
|
|
107
177
|
const re = new RegExp(ANCHOR_RE.source, "g") // 每档一份实例(零共享 lastIndex 状态)
|
|
178
|
+
const citeRe = new RegExp(CITE_RE_SRC, "g") // 同上(引文标记面)
|
|
108
179
|
const stripOperands = isOperandFace(f) // 只有提示词面剥(面判据 = `isPromptFace`)
|
|
109
180
|
let fence = null // 围栏标记(` ∥ ~);null = 不在块内
|
|
110
181
|
text.split("\n").forEach((l, i) => {
|
|
111
182
|
const mark = fenceMark(l)
|
|
112
183
|
if (mark !== null) { // 围栏块整块跳过:起于围栏行、止于同形围栏行(未闭合 ⇒ 至档尾免)
|
|
113
184
|
if (fence === null) fence = mark
|
|
114
|
-
else if (mark
|
|
185
|
+
else if (isFenceClose(mark, fence)) fence = null
|
|
115
186
|
return
|
|
116
187
|
}
|
|
117
188
|
if (fence !== null) return
|
|
118
189
|
if (isExecutableLine(l)) return // 可执行行整行跳过
|
|
119
190
|
const scan = stripOperands ? stripCanonOperands(l) : l // 提示词面:操作数先剥再抽;文档面 = 原行
|
|
191
|
+
// ── 依据行(§3.25 条目 Y):一行多处各自判;路径 token 的区间要记下来——锚抽取得跳过它,
|
|
192
|
+
// 否则一个解析不到的 `.md` 路径会被两条判法各报一次(同一处缺陷两条命中)。
|
|
193
|
+
// **标记先成立才占区间**:空引文 ∥ 豁免名两条不构成「引文判的标记」——它们的 token
|
|
194
|
+
// 仍归锚面照常判(既有类零改):空引文那行里的悬空 `.md` 照旧 `档缺`,不会凭空消失。
|
|
195
|
+
const cited = []
|
|
196
|
+
if (scan.includes("依据")) {
|
|
197
|
+
let c
|
|
198
|
+
while ((c = citeRe.exec(scan)) !== null) {
|
|
199
|
+
const token = c[1]
|
|
200
|
+
const quote = c[2].trim()
|
|
201
|
+
if (quote === "") continue // `「」` 空引文不构成标记(§3.25 边界:没有第二分支)
|
|
202
|
+
// 豁免名(类名 ∥ 占位)在锚面不抽 ⇒ 引文面同判:同一 token 不得两套口径(§3.24 条目 X / F12 单源)
|
|
203
|
+
if (docNameExemption(basename(token)) !== null) continue
|
|
204
|
+
const start = c.index + c[0].indexOf(token)
|
|
205
|
+
cited.push({ start, end: start + token.length })
|
|
206
|
+
const r = resolveDoc(token, f, base, scanBases)
|
|
207
|
+
if (r.state === "crossRepo") { crossRepo.push({ file: f, line: i + 1, token, ...r }); continue } // 跨仓:零命中,回执一行(同锚面)
|
|
208
|
+
if (r.state !== "resolved") { // 路径先判存在性——两因不混(路径错 ⇒ `档缺`,引文错 ⇒ `引缺`)
|
|
209
|
+
hits.push({ file: f, line: i + 1, token, clazz: "档缺", missing: r.state === "dangling" ? r.missing : "", sameName: r.state === "dangling" ? r.sameName : [] })
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
// 引文面:只判「是不是该档里的一段连续子串」——不折叠空白、不换全半角、不换算大小写(§3.25 边界)
|
|
213
|
+
if (!citedTextOf(r.abs).includes(quote)) hits.push({ file: f, line: i + 1, token, clazz: "引缺", quote, target: r.abs })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
120
216
|
let m
|
|
121
217
|
while ((m = re.exec(scan)) !== null) {
|
|
122
218
|
const token = m[1]
|
|
219
|
+
if (cited.some((c) => m.index >= c.start && m.index < c.end)) continue // 该 token 归引文判(上面已判,不重报)
|
|
123
220
|
const num = m[2] ?? null
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
221
|
+
// 类名 ∥ 占位符两免:抽取面不把它们当指针(判据 = DOC-DISCIPLINE §3.24 条目 X——
|
|
222
|
+
// 判定只此一处,兄弟脚本与行号坐标清单消费同一谓词)
|
|
223
|
+
if (docNameExemption(basename(token)) !== null) continue
|
|
224
|
+
const r = resolveDoc(token, f, base, scanBases)
|
|
225
|
+
if (r.state === "crossRepo") { crossRepo.push({ file: f, line: i + 1, token, ...r }); continue } // 跨仓:零命中,回执一行
|
|
226
|
+
if (r.state !== "resolved") {
|
|
227
|
+
hits.push({ file: f, line: i + 1, token, clazz: "档缺", missing: r.state === "dangling" ? r.missing : "", sameName: r.state === "dangling" ? r.sameName : [] })
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (num !== null && !sectionOnDisk(r.abs, num)) hits.push({ file: f, line: i + 1, token, clazz: "节缺", section: num, target: r.abs })
|
|
131
231
|
}
|
|
132
232
|
})
|
|
133
233
|
}
|
|
134
|
-
return { hits }
|
|
234
|
+
return { hits, crossRepo }
|
|
135
235
|
}
|
package/doc-check/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* doc-check/index.mjs — 文档纪律机检 · 引擎入口(两条判据 + 报面行)。
|
|
3
3
|
*
|
|
4
4
|
* 判据两条(起步 · 闭集——口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S):
|
|
5
|
-
* ① 锚(档缺 /
|
|
5
|
+
* ① 锚(档缺 / 节缺 / 引缺)——实现 = `./anchors.mjs`;② 段判据(文字太长,难于阅读 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
|
|
6
6
|
* 判据参数 = **工具内置常量** `DOC_CHECK_BUILTINS`:不读任何项目的声明面,因而没有「缺参数 ⇒ 拒跑」这一层,
|
|
7
7
|
* 也没有常量校验面——改判据 = 改代码、走评审。
|
|
8
8
|
* 判定范围 = **调用方传入的文件清单**(判定范围 = 解析范围 = 传入的那几档;无目录遍历、无全域兜底):
|
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* 清单里不可判档(不在盘 ∥ 非 .md)⇒ 报错点名、整次零判定;
|
|
13
13
|
* `gate` = 交付时点的裁定——首行给机器可判 verdict;不可判档给跳过行(可见,不静默丢掉)。
|
|
14
14
|
* 强制面住交付协议 + 批次档 §6 前置,工具本体不阻断任何动作。
|
|
15
|
+
* **`report` 面另有一份只读清点:行号坐标清单**(`./line-refs.mjs` · 口径 = `docs/core/design/DOC-DISCIPLINE.md` §3.23 条目 W)——
|
|
16
|
+
* **只报不拦**:不进 `gate`、不计入 `hits`、不阻断写入。判据面仍是上面两条,一字不改。
|
|
15
17
|
*/
|
|
16
18
|
import { statSync } from "node:fs"
|
|
17
19
|
import { relative, resolve } from "node:path"
|
|
18
|
-
import { checkParagraphs } from "./paragraph.mjs"
|
|
20
|
+
import { checkParagraphs, DOC_CLASS } from "./paragraph.mjs"
|
|
19
21
|
import { checkAnchors } from "./anchors.mjs"
|
|
22
|
+
import { collectLineRefs, lineRefsBlock } from "./line-refs.mjs"
|
|
23
|
+
// 跨仓引用一行读数的文案骨架(单源 = 统一解析层;本档只把它摆进报面)。
|
|
24
|
+
import { crossRepoNote } from "../path-resolve.mjs"
|
|
20
25
|
|
|
21
26
|
/** 判据参数(内置常量 · 冻结):阈值 300 字符(一段 ∥ 一行同值——§3.18 ④);两项空列表 = 区带类 / 豁免类默认值且恒空
|
|
22
27
|
* ——豁免谓词(表行 / 围栏 / 列表项 / 引文)住代码,没有「登记后放过」的写入口(空列表 ≠ 可登记豁免)。 */
|
|
@@ -27,6 +32,26 @@ export const DOC_CHECK_BUILTINS = Object.freeze({
|
|
|
27
32
|
exemptions: Object.freeze([]),
|
|
28
33
|
})
|
|
29
34
|
|
|
35
|
+
/** 命中类名单源再出口(`./paragraph.mjs` 的 `DOC_CLASS`):`agent-tools/batch-gate.mjs` 要判「处置块何时出」,
|
|
36
|
+
* 而它按判据链纪律只 import 本档(引擎 import 只指向 `../doc-check/index.mjs`)⇒ 类名由本档再出口,
|
|
37
|
+
* 消费面不抄字面类名——抄一次就是「改报面忘改过滤」那类字面漂移(`paragraph.mjs` 头注记着 2026-10-26 实发过一次)。 */
|
|
38
|
+
export { DOC_CLASS }
|
|
39
|
+
|
|
40
|
+
/** 段命中的处置块(五行 · 逐字单源):口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「报面(固定句形 · 交付门 gate)」尾行。
|
|
41
|
+
* 三处消费面(本档 `gateReport` ∥ `agent-tools/batch-gate.mjs` 的报面组装与拒串 ∥ `scripts/doc-check.mjs` 的段报告段)
|
|
42
|
+
* 只 import 本数组、零字面复制——同一句话抄三遍,改一处漏两处是必然。
|
|
43
|
+
* **为什么住本档**:报面尾行本来就住这里,`batch-gate.mjs` 已 import 本档 ⇒ 字面随报面同源,不新增第三个 import 面。
|
|
44
|
+
* **为什么是五行而不是一句命令口吻的单条出路**:被拒的人当场只读得到这一面——旧句只说「你该怎么改」且只给一条出路;
|
|
45
|
+
* 规则面本有三条,且「过机检」与「是处置」是两件事(切段 ∥ 缩短能过计数,读者拿到的还是同一堵墙)。
|
|
46
|
+
* **何时出**:只跟「文字太长,难于阅读」类命中走;仅锚 ∥ 仅行命中 ⇒ 零处置块(叫人对段落动手而问题不在此 = 指错方向)。 */
|
|
47
|
+
export const PARA_DISPOSE = Object.freeze([
|
|
48
|
+
"处置:上面这些段落,读者一口气读不完——不是字符数超了,是这一段装的事太多。",
|
|
49
|
+
"出路三条:① 重组结构——无标记的续行逐条添标记、逐条起名,拆成带名的列表项(逐字不改)∥ ② 重写文字——把长句拆成几句短的(意思不变,句子变少)∥ ③ 改写成表格行——内容本是逐条读数 / 映射 / 清单时成立。",
|
|
50
|
+
"① ∥ ③ 只对能逐条起名的并列断言成立——一条因果链拆成带名条目还是同一条因果链,读者拿到的只有断点;讲因果的长段走 ②。",
|
|
51
|
+
"折行连机检都过不了——句子中间插换行,读者拿到的还是同一堵墙;切段 ∥ 缩短能过机检,但不是处置——机检绿了不等于读者读得动。",
|
|
52
|
+
"同轮修正后复跑;不转台账。",
|
|
53
|
+
])
|
|
54
|
+
|
|
30
55
|
/** 相对路径显示形(正斜杠——跨平台同形)。 */
|
|
31
56
|
const rel = (root, p) => relative(root, p).replace(/\\/g, "/")
|
|
32
57
|
|
|
@@ -62,23 +87,62 @@ function resolveRange(base, docs) {
|
|
|
62
87
|
return { check: check.sort(), undecidable, missing, notMarkdown, invalid }
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
/** 段判据命中行(报面单源——两类命中同形,按 `clazz`
|
|
66
|
-
const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}
|
|
90
|
+
/** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。**不报字符数**:用户 2026-10-26 11:16「不能表述成超长多少个字符,要表述成为文字太长难于阅读」。 */
|
|
91
|
+
const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}`
|
|
92
|
+
|
|
93
|
+
/** 跨仓引用读数行(零命中、只入回执——文案骨架单源 = `path-resolve.mjs` 的 `crossRepoNote`)。 */
|
|
94
|
+
const crossRepoLine = (h) => `跨仓引用:${crossRepoNote(h.token, h)}`
|
|
95
|
+
|
|
96
|
+
/** `档缺` 命中行的追加提示(§2.6:缺的那一段 + 盘上同名——候选只提示,不改判态)。 */
|
|
97
|
+
const missingHint = (base, h) => {
|
|
98
|
+
const seg = typeof h.missing === "string" && h.missing !== "" ? `缺「${h.missing}」` : ""
|
|
99
|
+
const same = Array.isArray(h.sameName) && h.sameName.length > 0 ? `盘上同名:${h.sameName.map((p) => rel(base, p)).join(" ∥ ")}` : ""
|
|
100
|
+
const parts = [seg, same].filter(Boolean)
|
|
101
|
+
return parts.length > 0 ? `(${parts.join(";")})` : ""
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 锚命中的类名词(报面标签——类名由引擎定,本档只配显示形;三属同一条判据、同计「锚 N 条」)。 */
|
|
105
|
+
const ANCHOR_LABELS = { 档缺: "锚·档缺", 节缺: "锚·节缺", 引缺: "锚·引缺" }
|
|
106
|
+
|
|
107
|
+
/** 锚命中行(报面单源——三命中类同形,按 `clazz` 分词):`档缺` 追加缺哪一段与盘上同名,
|
|
108
|
+
* `节缺` 点名目标档,`引缺` 点名引文前 40 字(读者不必回目标档自己找哪一段不对)。
|
|
109
|
+
* 两处消费(`gate` 报面 ∥ 报告态报面)只调本函数——各写一份三元式改一处必漏一处。 */
|
|
110
|
+
const anchorHitLine = (base, h) => {
|
|
111
|
+
const head = `✗ ${ANCHOR_LABELS[h.clazz] ?? `锚·${h.clazz}`} ${rel(base, h.file)}:${h.line} \`${h.token}\``
|
|
112
|
+
if (h.clazz === "引缺") return `${head}(引文不在该档内:「${String(h.quote ?? "").slice(0, 40)}…」)`
|
|
113
|
+
if (h.clazz === "节缺") return `${head}(目标 ${rel(base, h.target)} 无 §${h.section})`
|
|
114
|
+
return head + missingHint(base, h)
|
|
115
|
+
}
|
|
67
116
|
|
|
68
117
|
/** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
|
|
69
|
-
function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
|
|
118
|
+
function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs }) {
|
|
70
119
|
const total = anchors.hits.length + paragraphs.length
|
|
71
120
|
const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
|
|
121
|
+
// 首行:锚与段分列(用户 11:16:段的问题叫「文字太长,难于阅读」,不是「命中 N 条」也不是字符数)
|
|
122
|
+
const verdict = () => {
|
|
123
|
+
if (total === 0) return checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"
|
|
124
|
+
const parts = []
|
|
125
|
+
if (anchors.hits.length) parts.push(`锚 ${anchors.hits.length} 条`)
|
|
126
|
+
// 段与行分列——两类命中不得合并(提示词面一行 = 一个块,属「行太长」,不是「一段文字太长」)
|
|
127
|
+
const paraCount = paragraphs.filter((h) => h.clazz === DOC_CLASS.para).length
|
|
128
|
+
const lineCount = paragraphs.filter((h) => h.clazz === DOC_CLASS.line).length
|
|
129
|
+
if (paraCount) parts.push(`${paraCount} 段${DOC_CLASS.para}`)
|
|
130
|
+
if (lineCount) parts.push(`${lineCount} ${DOC_CLASS.line}`)
|
|
131
|
+
return `DOC-CHECK GATE: REFUSED(${parts.join(" · ")})`
|
|
132
|
+
}
|
|
72
133
|
const out = [
|
|
73
|
-
|
|
134
|
+
verdict(),
|
|
74
135
|
"文档纪律机检(交付门 · gate)",
|
|
75
136
|
checkFiles.length
|
|
76
|
-
? `范围:${src} ${checkFiles.length}
|
|
77
|
-
: `范围:${src} —— **0
|
|
78
|
-
"
|
|
137
|
+
? `范围:${src} ${checkFiles.length} 档`
|
|
138
|
+
: `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)`,
|
|
139
|
+
"判据:逐段检查——" + DOC_CLASS.para,
|
|
79
140
|
]
|
|
80
141
|
if (skipped.missing.length) out.push(`跳过 ${skipped.missing.length} 档(已不在盘,未检):${skipped.missing.map((p) => rel(base, p)).join(" ∥ ")}`)
|
|
81
142
|
if (skipped.notMarkdown.length) out.push(`跳过 ${skipped.notMarkdown.length} 档(非 markdown 档,未检):${skipped.notMarkdown.map((p) => rel(base, p)).join(" ∥ ")}`)
|
|
143
|
+
// 跨仓引用读数(零命中类——不进 `total`、不改 verdict,只让作者看到「这一处没保证」);
|
|
144
|
+
// 位置 = 命中区之前,末行仍留给结论 / 处置块。
|
|
145
|
+
for (const h of anchors.crossRepo ?? []) out.push(crossRepoLine(h))
|
|
82
146
|
if (checkFiles.length === 0) {
|
|
83
147
|
// 空范围不静默当通过(K6)——照实说明 + 给出补充通道(两侧都不得读成「已检」)。
|
|
84
148
|
// 措辞按「有没有跳过行」分流:无跳过行时不得写「见上方跳过行」。
|
|
@@ -91,15 +155,12 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
|
|
|
91
155
|
? "范围为空:显式 docs 里没有可判档——见上方跳过行"
|
|
92
156
|
: "范围为空:显式 docs 是空清单——列出要判的档后复跑"))
|
|
93
157
|
} else if (total === 0) {
|
|
94
|
-
out.push(`结论:0
|
|
158
|
+
out.push(`结论:0 段有问题(${checkFiles.length} 档全清)`)
|
|
95
159
|
} else {
|
|
96
|
-
for (const h of anchors.hits)
|
|
97
|
-
out.push(h.clazz === "档缺"
|
|
98
|
-
? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
|
|
99
|
-
: `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
|
|
100
|
-
}
|
|
160
|
+
for (const h of anchors.hits) out.push(anchorHitLine(base, h))
|
|
101
161
|
for (const w of paragraphs) out.push(paragraphHitLine(base, w))
|
|
102
|
-
|
|
162
|
+
// 处置块只跟「段」命中走:仅有行太长时不得叫人对段落重写;字面单源 = `PARA_DISPOSE`(三处消费面同一份)
|
|
163
|
+
if (paragraphs.some((h) => h.clazz === DOC_CLASS.para)) out.push(...PARA_DISPOSE)
|
|
103
164
|
}
|
|
104
165
|
return out.join("\n")
|
|
105
166
|
}
|
|
@@ -115,7 +176,8 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
|
|
|
115
176
|
* `docs`)——缺省 = `explicit`;只影响报面措辞。
|
|
116
177
|
* @returns {{report:string, hits:{total:number, anchors:number, paragraph:number}, anchorHits:Array,
|
|
117
178
|
* paragraphHits:Array, files:string[], checkFiles:string[], paragraphMax:number,
|
|
118
|
-
* skipped:{missing:string[], notMarkdown:string[]}}}
|
|
179
|
+
* skipped:{missing:string[], notMarkdown:string[]}, lineRefs?:{body:Array, fenced:Array, total:number}}}
|
|
180
|
+
* `lineRefs`(行号坐标清单的采集结果)**只在 `report` 面返回**——`gate` 面构造上不产该字段。
|
|
119
181
|
* 消费面判「清」须读 `report` 首行(gate)∥ `hits.total`(report),不得只看其中一个。
|
|
120
182
|
*/
|
|
121
183
|
export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
|
|
@@ -154,23 +216,27 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
|
|
|
154
216
|
skipped: { missing: range.missing, notMarkdown: range.notMarkdown },
|
|
155
217
|
}
|
|
156
218
|
if (gate) {
|
|
157
|
-
return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs
|
|
219
|
+
return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs }) }
|
|
158
220
|
}
|
|
159
221
|
|
|
222
|
+
// 行号坐标清单的**唯一采集点**:放在 `if (gate)` 早返回之后——`gate` 分支上一行已返回,够不着这里。
|
|
223
|
+
// 「闸看不见清单」靠这个结构成立(不是「算出来再声明它不参与」——一份存在但被声明无用的数,读者迟早拿它当判据)。
|
|
224
|
+
const lineRefs = collectLineRefs({ files: checkFiles })
|
|
225
|
+
|
|
160
226
|
const out = [
|
|
161
227
|
"文档纪律机检(报告态 · 非门禁)",
|
|
162
|
-
`射程:${base} · 传入清单 ${checkFiles.length} 档 ·
|
|
228
|
+
`射程:${base} · 传入清单 ${checkFiles.length} 档 · 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
|
|
163
229
|
"声明:判据参数 = 工具内置常量;范围 = 传入文件(判定范围 = 解析范围)",
|
|
164
230
|
`范围:传入清单 ${checkFiles.length} 档(整档入判 · 判定范围 = 解析范围)`,
|
|
165
|
-
`命中 ${hits.total} 条(锚 ${anchors.hits.length} ·
|
|
231
|
+
`命中 ${hits.total} 条(锚 ${anchors.hits.length} · ${DOC_CLASS.para} ${paragraphs.filter((h) => h.clazz === DOC_CLASS.para).length} · ${DOC_CLASS.line} ${paragraphs.filter((h) => h.clazz === DOC_CLASS.line).length})`,
|
|
166
232
|
]
|
|
167
|
-
for (const h of anchors.hits)
|
|
168
|
-
out.push(h.clazz === "档缺"
|
|
169
|
-
? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
|
|
170
|
-
: `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
|
|
171
|
-
}
|
|
233
|
+
for (const h of anchors.hits) out.push(anchorHitLine(base, h))
|
|
172
234
|
for (const w of paragraphs) out.push(paragraphHitLine(base, w))
|
|
173
|
-
|
|
235
|
+
// 跨仓引用读数(零命中类——只报不拦,位置 = 命中区之后、清单块之前)
|
|
236
|
+
for (const h of anchors.crossRepo ?? []) out.push(crossRepoLine(h))
|
|
237
|
+
// 清单块位置 = 命中区之后、末行 `结论:…` 之前(报面的末行恒是结论句,清单不抢它的位)。
|
|
238
|
+
out.push(...lineRefsBlock({ base, files: checkFiles, refs: lineRefs }))
|
|
239
|
+
out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : `结论:0 条命中(锚 ∥ ${DOC_CLASS.para} ∥ ${DOC_CLASS.line} 均清)`)
|
|
174
240
|
|
|
175
|
-
return { ...shared, report: out.join("\n") }
|
|
241
|
+
return { ...shared, report: out.join("\n"), lineRefs }
|
|
176
242
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-check/line-refs.mjs — `report` 面的行号坐标清单(只报不拦 · 三形归属窗)。
|
|
3
|
+
*
|
|
4
|
+
* 判据句单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.23 条目 W(本档只实现,不重述口径):
|
|
5
|
+
* · 行号坐标 = **一个档名之后出现的 `:` 加数字**;归属窗三形闭集 = 紧贴 ∥ 承名 ∥ 段落承名
|
|
6
|
+
* (三形是**读数分类**,不是判据分级——同一命中按处计,不按形加权);
|
|
7
|
+
* · 它是**清点,不是判据**:不产 verdict、不计入 `hits`、不阻断任何写入。
|
|
8
|
+
* 调用点只在 `./index.mjs` 的 `if (gate) { return … }` 早返回之后的报面构造段——`gate` 分支够不着本档,
|
|
9
|
+
* 所以「闸看不见清单」是构造上的事实,不是「算出来但声明不参与」。
|
|
10
|
+
* · 档名豁免 = 与锚判同一谓词 `docNameExemption`(单源 = `./anchors.mjs` ∥ §3.24 条目 X)——
|
|
11
|
+
* 本档零自建名集:占位形(`x.md`)∥ 类名(`todo.md`)不算档名,也就不构成任何归属窗。
|
|
12
|
+
*
|
|
13
|
+
* 量法(清单块**首行**自承):抽取式 = 本档两条正则;扫描域 = 调用方传入的档清单
|
|
14
|
+
* (与两条判据同一集、同一档数;不另取范围、不做全仓扫描)。
|
|
15
|
+
* 两个面并列:**正文面** = 围栏块整块跳过 ∧ 可执行行跳过;**围栏内** = 不跳围栏时独有的处;**合计** = 两数之和。
|
|
16
|
+
* 为什么围栏面要留:围栏块里躺着成片的存量坐标(改前 / 改后对照块的左半),清账要看见的正是它们;
|
|
17
|
+
* 反方向也要防——围栏同时是举例的家,只报全文面会把举例混进读数,读数就没法跟下一份比。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs"
|
|
20
|
+
import { basename, relative } from "node:path"
|
|
21
|
+
import { docNameExemption } from "./anchors.mjs"
|
|
22
|
+
import { fenceMark, isFenceClose, isExecutableLine } from "./paragraph.mjs"
|
|
23
|
+
|
|
24
|
+
/** 档名 token(抽取式 · 逐字 = 判据句):目录段可选,扩展名闭集。左界排除更长 token 的尾段、右界排除后续字母数字。 */
|
|
25
|
+
export const NAME_RE = /(?<![A-Za-z0-9_.\-\\/@])((?:[A-Za-z0-9_.\-]+\/)*[A-Za-z0-9_.\-]+\.(?:md|mjs|cjs|js|json|css|html|svg|yml|yaml|sh|ps1))(?![\w])/
|
|
26
|
+
|
|
27
|
+
/** 行号坐标(抽取式 · 逐字 = 判据句):`:` 后接数字(可带 `-` 区间);左界只排数字 / 冒号前导(挡时刻 `12:30`)。 */
|
|
28
|
+
export const COORD_RE = /(?<![0-9:]):(\d+)(?:-(\d+))?(?![\w])/
|
|
29
|
+
|
|
30
|
+
/** 相对路径显示形(正斜杠——跨平台同形)。 */
|
|
31
|
+
const rel = (base, p) => relative(base, p).replace(/\\/g, "/")
|
|
32
|
+
|
|
33
|
+
/** 原文片段 ≤ 40 字(CJK 按字计——`[...]` 逐码点切,不按 UTF-16 单元)。 */
|
|
34
|
+
const snippet = (line) => [...line.trim()].slice(0, 40).join("")
|
|
35
|
+
|
|
36
|
+
/** 一行内的档名位置(已过豁免谓词——豁免名不算档名,也就不构成任何归属窗)。 */
|
|
37
|
+
function nameSpans(line) {
|
|
38
|
+
const out = []
|
|
39
|
+
const re = new RegExp(NAME_RE.source, "g")
|
|
40
|
+
let m
|
|
41
|
+
while ((m = re.exec(line)) !== null) {
|
|
42
|
+
if (docNameExemption(basename(m[1])) !== null) continue
|
|
43
|
+
out.push({ start: m.index, end: m.index + m[0].length })
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 采集:逐档逐行找行号坐标,定归属窗,分两面。
|
|
50
|
+
* @param {{files?:string[]}} o `files` = 扫描域(调用方传入的档清单——本档不遍历目录、不做全仓扫描)
|
|
51
|
+
* @returns {{body:Array, fenced:Array, total:number}}
|
|
52
|
+
* `body` = 正文面的处(围栏块外 ∧ 非可执行行);`fenced` = 围栏内独有的处;每处 = `{file, line, form, fenced, snippet}`
|
|
53
|
+
*/
|
|
54
|
+
export function collectLineRefs({ files = [] } = {}) {
|
|
55
|
+
const body = []
|
|
56
|
+
const fenced = []
|
|
57
|
+
for (const f of files) {
|
|
58
|
+
let text = ""
|
|
59
|
+
try { text = readFileSync(f, "utf8") } catch { continue }
|
|
60
|
+
let fence = null // 围栏标记(` ∥ ~);null = 不在块内
|
|
61
|
+
let prevNames = [] // 上一行的档名位置(③ 段落承名取其中最后一个)
|
|
62
|
+
text.split("\n").forEach((l, i) => {
|
|
63
|
+
const mark = fenceMark(l)
|
|
64
|
+
const inFence = fence !== null
|
|
65
|
+
if (mark !== null) {
|
|
66
|
+
if (fence === null) fence = mark
|
|
67
|
+
else if (isFenceClose(mark, fence)) fence = null
|
|
68
|
+
prevNames = [] // 围栏标记行不承载档名(③ 因此不跨过围栏边界)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (isExecutableLine(l)) { prevNames = []; return } // 可执行行整行不判(命令保字面——两面同跳)
|
|
72
|
+
const names = nameSpans(l)
|
|
73
|
+
const re = new RegExp(COORD_RE.source, "g")
|
|
74
|
+
let m
|
|
75
|
+
while ((m = re.exec(l)) !== null) {
|
|
76
|
+
const at = m.index
|
|
77
|
+
const before = names.filter((n) => n.end <= at)
|
|
78
|
+
let form
|
|
79
|
+
if (before.length && before[before.length - 1].end === at) form = "①" // 紧贴:档名与 `:` 之间零字符
|
|
80
|
+
else if (before.length) form = "②" // 承名:本行前文有档名(反引号收尾的坐标归此形)
|
|
81
|
+
else if (prevNames.length) form = "③" // 段落承名:本行无前文档名,取上一行最后一个档名
|
|
82
|
+
else continue // 无主——判据句自然排除,不列例外表
|
|
83
|
+
const hit = { file: f, line: i + 1, form, fenced: inFence, snippet: snippet(l) }
|
|
84
|
+
;(inFence ? fenced : body).push(hit)
|
|
85
|
+
}
|
|
86
|
+
prevNames = names
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return { body, fenced, total: body.length + fenced.length }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 清单块(四件 · 定名 = §3.23「清单块的形态与位置」):**量法行**(首行:抽取式 + 扫描域)·
|
|
94
|
+
* **合计行** · **逐处行**(`源档:行` + 原文片段 ≤ 40 字,围栏内那处带 `围栏内` 标)· **空态行**(无命中时:`行号坐标 0 处`)。
|
|
95
|
+
* 块内不留空行——空行读起来像「没跑」。
|
|
96
|
+
* @param {{base:string, files?:string[], refs?:{body:Array, fenced:Array, total:number}}} o
|
|
97
|
+
* `refs` 缺省 ⇒ 本函数现采一次;调用方要给返回值时传进来,别采两遍
|
|
98
|
+
* @returns {string[]} 块内各行(按序)
|
|
99
|
+
*/
|
|
100
|
+
export function lineRefsBlock({ base, files = [], refs = collectLineRefs({ files }) }) {
|
|
101
|
+
const out = [
|
|
102
|
+
`量法(行号坐标清单):档名 = ${NAME_RE} · 坐标 = ${COORD_RE} · 扫描域 = 传入清单 ${files.length} 档(${base})`,
|
|
103
|
+
]
|
|
104
|
+
if (refs.total === 0) {
|
|
105
|
+
out.push("行号坐标 0 处")
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
out.push(`正文面 ${refs.body.length} 处 · 围栏内 ${refs.fenced.length} 处 · 合计 ${refs.total} 处`)
|
|
109
|
+
for (const h of refs.body) out.push(` · ${h.form} ${rel(base, h.file)}:${h.line} ${h.snippet}`)
|
|
110
|
+
for (const h of refs.fenced) out.push(` · ${h.form} ${rel(base, h.file)}:${h.line} 围栏内 ${h.snippet}`)
|
|
111
|
+
return out
|
|
112
|
+
}
|
package/doc-check/paragraph.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 产品内建裁定 = 同档 §3.20 条目 S——谓词单源随迁:本仓 `scripts/doc-check-paragraph.mjs` 改述为
|
|
6
6
|
* **再出口缝**(薄档 · 零逻辑 · 逐名再出口),盘上不留第二份谓词实现。
|
|
7
7
|
* 两面切块:文档面量段(相邻非空正文行合并成一段,量段长)· 提示词面(`isPromptFace`)量行
|
|
8
|
-
*
|
|
8
|
+
* (逐行裸判,无结构豁免)——判据不分适用面,工具描述面同样按段判(口径 = §3.18 条 7)。度量 = `[...line].length`(CJK 计 1——不引显示宽度 / 字体);
|
|
9
9
|
* 阈值 = 调用方的内置常量(`DOC_CHECK_BUILTINS.paragraphMax` / `lineMax`)——不读声明面,也不扫目录:
|
|
10
10
|
* **判定集 = 调用方传入的文件清单**。
|
|
11
11
|
* 不计入段的行类(同时把段断开)= 空行 ∥ 表格行 ∥ 标题 ∥ 列表项 ∥ 引用行 ∥ 围栏(标记行与块内整行)
|
|
@@ -15,17 +15,25 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync } from "node:fs"
|
|
17
17
|
|
|
18
|
+
/** 段判据类名单源(报面与统计同取此名——改报面忘改过滤的字面漂移,2026-10-26 实发过一次)。 */
|
|
19
|
+
export const DOC_CLASS = Object.freeze({ para: "文字太长,难于阅读", line: "行太长" })
|
|
20
|
+
|
|
18
21
|
/** 表格行谓词(markdown 表格行结构性不可折行——不计入段,与锚检查同源)。 */
|
|
19
22
|
export const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(String(l))
|
|
20
23
|
|
|
21
|
-
/** 围栏标记(行首可选空白 + 三反引号 ∥ 三波浪号 ⇒
|
|
24
|
+
/** 围栏标记(行首可选空白 + 三反引号 ∥ 三波浪号 ⇒ `{ ch, len }`;非围栏行 ⇒ null)——**单源**:
|
|
22
25
|
* `isFenceLine` 与两处块扫描(本节段判据 ∥ `./anchors.mjs` 锚抽取)同取此式,使「同形围栏行止」只此一解
|
|
23
|
-
* (同一模块内同符两义 = 单源可漂的结构口子)。
|
|
26
|
+
* (同一模块内同符两义 = 单源可漂的结构口子)。
|
|
27
|
+
* **长度带出**:闭合 = 同字符 ∧ 长度 ≥ 开启长度(CommonMark 围栏语义)——只比字符会让
|
|
28
|
+
* `` ```` `` 包 `` ``` `` 的嵌套围栏在外层开启行处提前闭合,内层代码行落入正文并被并成段。 */
|
|
24
29
|
export function fenceMark(l) {
|
|
25
30
|
const m = /^\s*(`{3,}|~{3,})/.exec(String(l))
|
|
26
|
-
return m ? m[1][0] : null
|
|
31
|
+
return m ? { ch: m[1][0], len: m[1].length } : null
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
/** 围栏闭合谓词(单源 = 上面的长度语义):两个 `{ ch, len }` 判闭合。 */
|
|
35
|
+
export const isFenceClose = (mark, fence) => mark.ch === fence.ch && mark.len >= fence.len
|
|
36
|
+
|
|
29
37
|
/** 围栏行谓词(单源 = `fenceMark`)——围栏行自身与块内整行均不计入段。 */
|
|
30
38
|
export const isFenceLine = (l) => fenceMark(l) !== null
|
|
31
39
|
|
|
@@ -62,7 +70,7 @@ export function inCodeSpan(line, idx) {
|
|
|
62
70
|
|
|
63
71
|
/** 段判据(阈值 = 调用方内置常量 `paragraphMax` ∥ `lineMax`;口径逐字 = §3.18 条目 Q):
|
|
64
72
|
* · 文档面:相邻非空正文行合并成一段,段长 = 组成行 `[...line].length` 之和 > `paragraphMax` ⇔ 命中
|
|
65
|
-
* (`clazz: "
|
|
73
|
+
* (`clazz: "文字太长,难于阅读"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
|
|
66
74
|
* ∧ 非围栏(标记行与块内整行)∧ 非区带内;其余行类不计入、且把段断开。
|
|
67
75
|
* · 提示词面(`isPromptFace`):逐行裸判——`[...line].length` > `lineMax` ⇔ 命中(`clazz: "行太长"`,
|
|
68
76
|
* 无结构豁免)。
|
|
@@ -71,6 +79,14 @@ export function inCodeSpan(line, idx) {
|
|
|
71
79
|
* `exemptZones` 缺省 ∥ 空集 ⇒ 零豁免。`files`(必填)= **判定集**(调用方传入的档清单——本档不遍历
|
|
72
80
|
* 任何目录;空集 ⇒ 零命中)。 */
|
|
73
81
|
export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files = [] } = {}) {
|
|
82
|
+
// 配置缺失 = 硬错,不是静默不命中。
|
|
83
|
+
// `para.len > undefined` 恒 false ⇒ **永不命中 = 假绿**;而误报会被人发现,假绿不会。
|
|
84
|
+
// 实测来源:围栏批(2026-10-27)调试时把配置当第二参数传,一度以为修复无效——
|
|
85
|
+
// 两个阈值都是 undefined,门一路绿灯。
|
|
86
|
+
if (!Number.isFinite(paragraphMax) || !Number.isFinite(lineMax)) {
|
|
87
|
+
throw new Error(`checkParagraphs: paragraphMax ∥ lineMax 必须是有限数字(收到 ${paragraphMax} / ${lineMax})。` +
|
|
88
|
+
"配置全在第一个参数:{ paragraphMax, lineMax, exemptZones, files }。阈值缺失时永不命中,读起来就是「全部过关」。")
|
|
89
|
+
}
|
|
74
90
|
const zones = (Array.isArray(exemptZones) ? exemptZones : []).filter((z) => typeof z === "string" && z !== "")
|
|
75
91
|
const hits = []
|
|
76
92
|
for (const f of files) {
|
|
@@ -79,7 +95,7 @@ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files
|
|
|
79
95
|
if (isPromptFace(f)) {
|
|
80
96
|
text.split("\n").forEach((l, i) => {
|
|
81
97
|
const len = [...l].length
|
|
82
|
-
if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz:
|
|
98
|
+
if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: DOC_CLASS.line })
|
|
83
99
|
})
|
|
84
100
|
continue
|
|
85
101
|
}
|
|
@@ -88,14 +104,14 @@ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files
|
|
|
88
104
|
let para = null // 在写的段 —— { start, len };非正文行 ⇒ 断开
|
|
89
105
|
const lines = text.split("\n")
|
|
90
106
|
const flush = () => {
|
|
91
|
-
if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz:
|
|
107
|
+
if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: DOC_CLASS.para })
|
|
92
108
|
para = null
|
|
93
109
|
}
|
|
94
110
|
lines.forEach((l, i) => {
|
|
95
111
|
const mark = fenceMark(l)
|
|
96
112
|
if (mark !== null) {
|
|
97
113
|
if (fence === null) fence = mark
|
|
98
|
-
else if (mark
|
|
114
|
+
else if (isFenceClose(mark, fence)) fence = null
|
|
99
115
|
flush()
|
|
100
116
|
return // 围栏标记行自身断段
|
|
101
117
|
}
|