@thincoder/core 0.10.9 → 0.10.10
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/advisor/messages.mjs +4 -5
- package/advisor/project-context.mjs +69 -44
- package/agent/family-tools.mjs +9 -7
- package/agent/helpers.mjs +6 -0
- package/agent/run-start.mjs +1 -0
- package/agent/turn-loop.mjs +17 -13
- package/agent-tools/batch-gate.mjs +4 -5
- package/agent-tools/checkpoint.mjs +11 -6
- package/agent-tools/doc-check.mjs +18 -13
- package/agent-tools/manifest.mjs +44 -12
- package/agent-tools/subagent-spawn.mjs +34 -19
- package/agent-tools/subagent.mjs +1 -1
- package/agent-tools.mjs +1 -1
- package/declaration.mjs +5 -12
- package/doc-check/anchors.mjs +51 -59
- package/doc-check/index.mjs +96 -134
- package/doc-check/width.mjs +9 -54
- package/i18n.mjs +5 -0
- package/manifest-schema.mjs +7 -35
- package/manifest.mjs +4 -4
- package/memory/code-search.mjs +5 -14
- package/memory/docs.mjs +5 -14
- package/memory/read-gate.mjs +26 -0
- package/package.json +1 -1
- package/prompts/advisor-design.md +89 -38
- package/prompts/advisor-round1.md +72 -24
- package/prompts/advisor-round2.md +62 -25
- package/prompts/advisor-round3.md +68 -24
- package/prompts/common.md +212 -57
- package/prompts/consult-base.md +10 -3
- package/prompts/discipline-engineering.md +180 -53
- package/prompts/discipline-normal.md +91 -40
- package/prompts/persona-coder.md +3 -1
- package/prompts/persona-eng-coder.md +29 -9
- package/prompts/persona-eng-designer.md +87 -33
- package/prompts/persona-engineering.md +322 -89
- package/prompts/persona-explore.md +1 -1
- package/prompts/persona-normal.md +16 -8
- package/tool-docs/doc_check.md +27 -15
- package/tool-docs/manifest-read.md +8 -0
- package/tool-docs/manifest.md +9 -8
- package/tool-docs/subagent.md +2 -2
|
@@ -128,7 +128,7 @@ export function resolveDesignSlot(parent, designIdArg) {
|
|
|
128
128
|
throw new Error(`Multiple approved designs in this session (${size}) — pass the designId parameter (echoed with each token) to choose which design this eng-coder spawn belongs to. Held design ids: ${heldIds}`)
|
|
129
129
|
}
|
|
130
130
|
if (size === 1) return { token: [...slots.values()][0] }
|
|
131
|
-
throw
|
|
131
|
+
throw designTokenRefusal("missing")
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
@@ -237,25 +237,11 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
|
|
|
237
237
|
if (role === "eng-coder" || role === "eng-designer") validateRoundParam(args)
|
|
238
238
|
validateTaskBookParams(args, role)
|
|
239
239
|
|
|
240
|
-
// eng-coder token gate
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// single-slot fallthrough); token validation itself is unchanged (2026-09-06:
|
|
244
|
-
// HMAC removed — validateDesignToken now checks format + TTL only).
|
|
240
|
+
// eng-coder token gate(TOOLS.md §6.25 ④):判定本体 = gateDesignToken(本档模块尾)——
|
|
241
|
+
// missing / mismatch / expired / invalid 四态各出互异拒句;拒绝集合与旧三条件合取全等。
|
|
242
|
+
// 返回值 = 槽内令牌,必须落回 issuedToken:下方 `child._engDesignId` 回写依赖它。
|
|
245
243
|
let issuedToken
|
|
246
|
-
if (role === "eng-coder")
|
|
247
|
-
issuedToken = resolveDesignSlot(parent, args.designId).token
|
|
248
|
-
// R16 D-R16c ③ — spawn-gate cleanup: only an EXPIRY rejection removes the slot
|
|
249
|
-
// (the caller passed the slot's own token and it is past TTL — 长跑不重启也清);
|
|
250
|
-
// mismatch / format rejections never delete — the slot's own token may still be
|
|
251
|
-
// valid and a caller error must not destroy it (T-R16d). 单值镜像已退役(D3)——
|
|
252
|
-
// 无镜像同步清。
|
|
253
|
-
const expiredReject = !!issuedToken && args.designToken === issuedToken && tokenExpired(issuedToken)
|
|
254
|
-
if (!issuedToken || args.designToken !== issuedToken || !validateDesignToken(args.designToken)) {
|
|
255
|
-
if (expiredReject) removeDesignTokenSlot(parent, args.designId, issuedToken)
|
|
256
|
-
throw new Error("Invalid or missing design token — run advisor with type='design' first and pass the returned token as designToken.")
|
|
257
|
-
}
|
|
258
|
-
}
|
|
244
|
+
if (role === "eng-coder") issuedToken = gateDesignToken(parent, args)
|
|
259
245
|
|
|
260
246
|
// Filter tool set by role: explore/plan are read-only (plan is a planning agent, its deliverable is the plan itself)
|
|
261
247
|
let tools
|
|
@@ -417,3 +403,32 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
|
|
|
417
403
|
const childRunOpts = buildChildRunOpts(ctx)
|
|
418
404
|
return { child, input, childOpts, childRunOpts, relayPrefix, childProvider }
|
|
419
405
|
}
|
|
406
|
+
|
|
407
|
+
// ── design-token 门(TOOLS.md §6.25 ③④——四态互异拒句;拒绝集合与旧三条件合取全等) ──
|
|
408
|
+
|
|
409
|
+
/** 四态拒句(逐字 = TOOLS.md §6.25 ③):missing / mismatch / expired / invalid。
|
|
410
|
+
* mismatch 可携 designId(非凭证);四句均零令牌值回显。 */
|
|
411
|
+
export function designTokenRefusal(kind, designId) {
|
|
412
|
+
if (kind === "missing") return new Error("designToken missing — the eng-coder spawn is refused: no usable token for an approved design slot came with this spawn. Pass the designToken parameter with the exact value from the subagent action:'design-slots' row (the text after designToken=), copied verbatim; if that list is empty, no design review has been approved in this session yet — run advisor with type='design' first. No token value is echoed here.")
|
|
413
|
+
if (kind === "mismatch") return new Error("designToken mismatch — the eng-coder spawn is refused: the value passed does not equal the token held for this design slot. Re-read the subagent action:'design-slots' row and pass the value after designToken= verbatim, unchanged. No token value is echoed here." + (designId ? ` (designId: ${designId})` : ""))
|
|
414
|
+
if (kind === "expired") return new Error("designToken expired — the eng-coder spawn is refused: the token matches this design slot but is past its TTL. The expired slot has been removed; run advisor with type='design' again on the current design and pass the newly issued token — an expired authorization cannot be retried. No token value is echoed here.")
|
|
415
|
+
return new Error("designToken invalid — the eng-coder spawn is refused: the value passed equals this design slot's stored token, but that stored value is not a well-formed token, so no expiry can be judged and the slot was left in place. Run advisor with type='design' to issue a fresh token for this design; a malformed record never passes the gate. No token value is echoed here.")
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** eng-coder spawn 的令牌门(TOOLS.md §6.25 ④):resolveDesignSlot 的既有两条 designId
|
|
419
|
+
* 诊断零改 → 参数在场 → 值相等 → 值有效。槽删除只在 tokenExpired(given) 成立时发生
|
|
420
|
+
* (D-R16c ③);mismatch / invalid 不删槽(T-R16d)。返回槽内令牌(调用方回写 designId
|
|
421
|
+
* 依赖它);sync / async 两路共经 buildSpawnChild——一处判定双路生效。 */
|
|
422
|
+
export function gateDesignToken(parent, args) {
|
|
423
|
+
const issuedToken = resolveDesignSlot(parent, args?.designId).token
|
|
424
|
+
// 非字符串 ⇒ 空串 ⇒ missing(本意——T9 锁定)
|
|
425
|
+
const given = typeof args?.designToken === "string" ? args.designToken : ""
|
|
426
|
+
if (!issuedToken || !given.trim()) throw designTokenRefusal("missing")
|
|
427
|
+
if (given !== issuedToken) throw designTokenRefusal("mismatch", args?.designId)
|
|
428
|
+
if (!validateDesignToken(given)) {
|
|
429
|
+
const expired = tokenExpired(given)
|
|
430
|
+
if (expired) removeDesignTokenSlot(parent, args?.designId, given)
|
|
431
|
+
throw designTokenRefusal(expired ? "expired" : "invalid")
|
|
432
|
+
}
|
|
433
|
+
return issuedToken
|
|
434
|
+
}
|
package/agent-tools/subagent.mjs
CHANGED
|
@@ -143,7 +143,7 @@ export const subagentTool = {
|
|
|
143
143
|
context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
|
|
144
144
|
role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder", "eng-designer"], description: "The sub-agent role — see the role matrix in the tool description. Exact spelling required. action:'spawn' only (escalate spawns its own expert internally)." },
|
|
145
145
|
model: { type: "string", description: "action:'spawn': provider/model override ('provider:model', a provider, or a model name on the parent's provider) — defaults config.agent.subagentModels[role] → config.agent.subagentModel → the parent's provider; \"default\" inherits. action:'escalate': a consult candidate (default = the first)." },
|
|
146
|
-
designToken: { type: "string", description: "
|
|
146
|
+
designToken: { type: "string", description: "REQUIRED for role='eng-coder': the exact token issued by advisor(type='design') after the review passed — copy it verbatim from the design-slots row (the text after designToken=). missing or mismatched = the spawn is refused; an expired token is refused with expired named. Pass it on every eng-coder spawn, fix rounds included (they reuse the same token)." },
|
|
147
147
|
designId: { type: "string", description: "role='eng-coder': the designId echoed with the approved token — required to pick between concurrent designs (optional for one). action:'consume-design': the single-slot selector (required to pick between several; the gate refuses to guess); see designIds / expired / olderThanDays for batch consumption. action:'design-slots': filter the inventory to one slot (trimmed exact match; an empty string means no filter; an unknown id is refused with the held id list)." },
|
|
148
148
|
designIds: { type: "array", items: { type: "string" }, description: "action:'consume-design' (batch): an explicit non-empty list of design ids to close out — pass exactly ONE selector per call (designId / designIds / expired / olderThanDays)." },
|
|
149
149
|
expired: { type: "boolean", description: "action:'consume-design' (batch): true = close out every expired slot (exactly ONE selector per call)." },
|
package/agent-tools.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export { recentChangesTool } from "./agent-tools/recent-changes.mjs"
|
|
|
13
13
|
export { timerTool } from "./agent-tools/timer.mjs"
|
|
14
14
|
export { advisorTool } from "./agent-tools/advisor.mjs"
|
|
15
15
|
export { engTool } from "./agent-tools/eng.mjs"
|
|
16
|
-
export { manifestTool } from "./agent-tools/manifest.mjs"
|
|
16
|
+
export { manifestTool, manifestReadTool } from "./agent-tools/manifest.mjs"
|
|
17
17
|
export { readHistoryTool } from "./agent-tools/read-history.mjs"
|
|
18
18
|
// 模型主动整理上下文(CONTEXT-COMPACTION.md §6.16 · F-CC5):单工具三操作 stats / prune / compact
|
|
19
19
|
// ——depth-0 段挂载(子代理面裁见 §6.16.6 `family-tools.mjs` `depthOnly`)。
|
package/declaration.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* declaration.mjs — 声明装载面(PROJECT-MANIFEST.json
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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)
|
package/doc-check/anchors.mjs
CHANGED
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
* **行号坐标(`:N`)不作节指示**——行号不作引用;
|
|
7
7
|
* · 档不在盘 ⇒ `档缺`;带节指示 ∧ 节不在盘 ⇒ `节缺`。
|
|
8
8
|
*
|
|
9
|
-
* 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面);
|
|
10
|
+
* 提示词面另剥 CANON 操作数(面判据 = 本档 `OPERAND_FACE`——六名整串删除后再抽,见 `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
20
|
import { fenceMark, isExecutableLine } from "./width.mjs"
|
|
20
21
|
|
|
21
22
|
/** 文档指针 token:`(目录段/)*名.md` + 可选行号坐标尾 + 可选节指示(`§` + 编号)。
|
|
@@ -34,12 +35,34 @@ 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
|
+
* 换个目录解析必然 `档缺`。**只对提示词面剥**(面判据 = 下方 `OPERAND_FACE`):`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
|
+
]
|
|
49
|
+
|
|
50
|
+
/** 操作数剥除:逐行先整串删除六名 CANON 操作数再抽(与 `scripts/prompt-refs-check.mjs` 的 J3 豁免同式)。 */
|
|
51
|
+
export function stripCanonOperands(line) {
|
|
52
|
+
let out = line
|
|
53
|
+
for (const s of CANON_OPERANDS) out = out.split(s).join("")
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 操作数豁免的**作用面** = 提示词面(`thincoder-core/prompts/**`)——**只此一面**:`docs/` 面同名 token 仍按指针判
|
|
58
|
+
* (`README.md` / `MANIFEST.md` 在那里照报 `档缺`;其余四名走既有的 `FILENAME_CLASSES` 类名免,非本条引入)。
|
|
59
|
+
* 这是锚判自己的面判据——与行宽判据零共享开关:行宽按传入档全量判(提示词面照判,无面级豁免)。 */
|
|
60
|
+
const OPERAND_FACE = /(?:^|\/)thincoder-core\/prompts\//i
|
|
61
|
+
const isOperandFace = (abs) => OPERAND_FACE.test(String(abs).replace(/\\/g, "/"))
|
|
62
|
+
|
|
37
63
|
/** 文件存在性(目录 / 读错 ⇒ false)。 */
|
|
38
64
|
function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
|
|
39
65
|
|
|
40
|
-
/** 目录存在性(文件 / 读错 ⇒ false)。 */
|
|
41
|
-
function isDir(p) { try { return statSync(p).isDirectory() } catch { return false } }
|
|
42
|
-
|
|
43
66
|
/** 标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`);非标题行 ⇒ null。 */
|
|
44
67
|
function headingNumber(line) {
|
|
45
68
|
const m = /^#{1,6}\s+/.exec(line)
|
|
@@ -56,63 +79,32 @@ function sectionOnDisk(path, num) {
|
|
|
56
79
|
return false
|
|
57
80
|
}
|
|
58
81
|
|
|
59
|
-
/** 档解析(判据 ①
|
|
82
|
+
/** 档解析(判据 ① 的解析序两级):命中 ⇒ `{ ok, path }`;零命中 ⇒ `{ ok:false }`。
|
|
60
83
|
* 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
|
|
61
|
-
* `
|
|
62
|
-
function resolveDoc(token, refFile, base
|
|
84
|
+
* `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。 */
|
|
85
|
+
function resolveDoc(token, refFile, base) {
|
|
63
86
|
const p1 = resolve(base, token)
|
|
64
|
-
if (isFile(p1)) return { ok: true, path: p1 } // ①
|
|
65
|
-
|
|
66
|
-
|
|
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 } } // ⑤ 声明解析根相对
|
|
87
|
+
if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对全路径
|
|
88
|
+
const p2 = resolve(dirname(refFile), token)
|
|
89
|
+
if (isFile(p2)) return { ok: true, path: p2 } // ② 引用方档所在目录相对
|
|
72
90
|
return { ok: false }
|
|
73
91
|
}
|
|
74
92
|
|
|
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
93
|
/**
|
|
91
|
-
*
|
|
92
|
-
* @param {string} root
|
|
93
|
-
* @param {{
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* `roots` = 声明解析根原值(值形 ∥ 根在盘的门归本函数——报告面逐元素弃用 / 列缺位)。
|
|
97
|
-
* @returns {{hits:Array, reports:Array, rootGaps:Array}} hits = 命中(`档缺` / `节缺`);reports = 报告面(多义——不作判);
|
|
98
|
-
* rootGaps = 报告面(解析根弃用 ∥ 缺位——不作判)
|
|
94
|
+
* 锚判据主行程:传入档逐档逐行抽取 + 解析 + 节在盘判定。
|
|
95
|
+
* @param {string} root 项目根(解析序 ① 的基准)
|
|
96
|
+
* @param {{files?:string[]}} o `files` = **判定集兼解析集**(调用方传入的档清单——判定范围 = 解析范围,
|
|
97
|
+
* 无目录遍历、无全域索引:清单外的档既不判也不参与解析)
|
|
98
|
+
* @returns {{hits:Array}} hits = 命中(`档缺` / `节缺`)
|
|
99
99
|
*/
|
|
100
|
-
export function checkAnchors(root, {
|
|
100
|
+
export function checkAnchors(root, { files = [] } = {}) {
|
|
101
101
|
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
102
|
const hits = []
|
|
111
|
-
const
|
|
112
|
-
for (const f of checkFiles ?? files) {
|
|
103
|
+
for (const f of files) {
|
|
113
104
|
let text = ""
|
|
114
105
|
try { text = readFileSync(f, "utf8") } catch { continue }
|
|
115
106
|
const re = new RegExp(ANCHOR_RE.source, "g") // 每档一份实例(零共享 lastIndex 状态)
|
|
107
|
+
const stripOperands = isOperandFace(f) // 只有提示词面剥(见 `OPERAND_FACE`)
|
|
116
108
|
let fence = null // 围栏标记(` ∥ ~);null = 不在块内
|
|
117
109
|
text.split("\n").forEach((l, i) => {
|
|
118
110
|
const mark = fenceMark(l)
|
|
@@ -123,20 +115,20 @@ export function checkAnchors(root, { scanDirs = [], files = [], checkFiles, root
|
|
|
123
115
|
}
|
|
124
116
|
if (fence !== null) return
|
|
125
117
|
if (isExecutableLine(l)) return // 可执行行整行跳过
|
|
118
|
+
const scan = stripOperands ? stripCanonOperands(l) : l // 提示词面:操作数先剥再抽;文档面 = 原行
|
|
126
119
|
let m
|
|
127
|
-
while ((m = re.exec(
|
|
120
|
+
while ((m = re.exec(scan)) !== null) {
|
|
128
121
|
const token = m[1]
|
|
129
122
|
const num = m[2] ?? null
|
|
130
123
|
const bn = basename(token).toLowerCase()
|
|
131
124
|
const stem = bn.replace(/\.md$/, "")
|
|
132
125
|
// 类名 ∥ 占位符两免:抽取面不把它们当指针(判据 = DOC-DISCIPLINE §3.20 ① 的「档 token」定义面)
|
|
133
126
|
if (FILENAME_CLASSES.has(bn) || stem.length <= 1 || PLACEHOLDER_STEMS.has(stem)) continue
|
|
134
|
-
const r = resolveDoc(token, f, base
|
|
135
|
-
if (r.ambiguous) { reports.push({ file: f, line: i + 1, token, candidates: r.candidates }); continue }
|
|
127
|
+
const r = resolveDoc(token, f, base)
|
|
136
128
|
if (!r.ok) { hits.push({ file: f, line: i + 1, token, clazz: "档缺" }); continue }
|
|
137
129
|
if (num !== null && !sectionOnDisk(r.path, num)) hits.push({ file: f, line: i + 1, token, clazz: "节缺", section: num, target: r.path })
|
|
138
130
|
}
|
|
139
131
|
})
|
|
140
132
|
}
|
|
141
|
-
return { hits
|
|
133
|
+
return { hits }
|
|
142
134
|
}
|