@yangdcm/dsh-expert-team 1.1.0

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +141 -0
  3. package/README.md +135 -0
  4. package/client.js +2473 -0
  5. package/cordis.patch.yml +24 -0
  6. package/lib/artifact-writer.js +379 -0
  7. package/lib/command-parse.js +181 -0
  8. package/lib/command.js +5100 -0
  9. package/lib/dispatch-ledger.js +229 -0
  10. package/lib/index.js +15 -0
  11. package/lib/interception.js +266 -0
  12. package/lib/lead-toolface.js +179 -0
  13. package/lib/log-parse.js +181 -0
  14. package/lib/loop-guard.js +165 -0
  15. package/lib/metrics/collect.js +70 -0
  16. package/lib/metrics/render.js +100 -0
  17. package/lib/metrics/session-usage.js +319 -0
  18. package/lib/metrics/timing.js +188 -0
  19. package/lib/metrics/token-usage.js +352 -0
  20. package/lib/metrics/tokens.js +271 -0
  21. package/lib/routes/shared.js +83 -0
  22. package/lib/settings.js +289 -0
  23. package/lib/tier.js +190 -0
  24. package/lib/validate.js +681 -0
  25. package/lib/vocab.js +121 -0
  26. package/lib/write-tracer.js +58 -0
  27. package/package.json +119 -0
  28. package/presets/expert-team/agent.cordis.yml +542 -0
  29. package/presets/expert-team/preset.yml +3 -0
  30. package/skills/expert-team/SKILL.md +328 -0
  31. package/skills/expert-team/assets/templates/AUTHORITY.md +32 -0
  32. package/skills/expert-team/assets/templates/PLAN.md +27 -0
  33. package/skills/expert-team/assets/templates/RESEARCH.md +13 -0
  34. package/skills/expert-team/assets/templates/RETRO.md +24 -0
  35. package/skills/expert-team/assets/templates/REVIEW.md +10 -0
  36. package/skills/expert-team/assets/templates/ROSTER.json +6 -0
  37. package/skills/expert-team/assets/templates/SPEC.md +62 -0
  38. package/skills/expert-team/assets/templates/STATE.json +10 -0
  39. package/skills/expert-team/assets/templates/SUMMARY.md +25 -0
  40. package/skills/expert-team/assets/templates/TASK.md +23 -0
  41. package/skills/expert-team/assets/templates/TASKS.json +3 -0
  42. package/skills/expert-team/assets/templates/TEST.md +9 -0
  43. package/skills/expert-team/assets/templates//344/273/273/345/212/241/347/234/213/346/235/277.md +23 -0
  44. package/skills/expert-team/references/EFFICIENCY.md +79 -0
  45. package/skills/expert-team/references/LOGGING.md +82 -0
  46. package/skills/expert-team/references/PERSIST.md +57 -0
  47. package/skills/expert-team/references/PIPELINE.md +58 -0
  48. package/skills/expert-team/references/ROLES.md +297 -0
  49. package/skills/expert-team/references/WORKSPACE.md +123 -0
  50. package/skills/expert-team/references/workflow.team.js +97 -0
  51. package/skills/expert-team/scripts/scan-authority.mjs +114 -0
  52. package/skills/expert-team/scripts/scan-single-source.mjs +292 -0
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // 工件单源化的**分叉检测**(设计稿 §十二 第 2 步):读 `AUTHORITY.md`,对**每一行声明的事实**,
3
+ // 扫本 run 的权威文件,报出"同一个事实在两个及以上权威文件里**各有定义**"的情况。
4
+ //
5
+ // 为什么需要它:规则 34 已经要求"见一个扫全部",但人工逐个事实名去扫**不会发生**(实测那个真实 run
6
+ // 里 64% 的返工来自"同一事实多份拷贝",而扫描器当时也没被用起来 —— 见 `scan:limitation`)。
7
+ // 本脚本把"逐条事实 × 全部权威文件"这件事变成**一条命令**。
8
+ //
9
+ // 复用:`scanSingleSource`(同一目录,规则 34 用的就是它)—— 不另写一套扫描。
10
+ //
11
+ // ⚠️ 表格式的**完整校验**在 host 侧(`lib/validate.js` 的 `authorityViolations`,含列数/文件存在/
12
+ // 写者唯一/核心工件纳入治理)。本脚本只做**极简的第 1、2 列提取**(两个发行位置不同:`skills/` 随技能
13
+ // 装到 `~/.dsh/skills`,`lib/` 在 profile 的 node_modules 里,跨发行互 import 不可靠)。
14
+ // 表格格式由模板 + host 侧校验钉住,这里刻意不重复实现校验逻辑。
15
+ //
16
+ // 退出码:0 = 无分叉;1 = 发现分叉;2 = 用法/输入错误。
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { scanSingleSource } from './scan-single-source.mjs';
21
+
22
+ /** 极简提取:`| 事实类别 | 唯一权威文件 | … |` 的前两列(跳过表头/分隔/空行/示例行)。 */
23
+ export function authorityFacts(text) {
24
+ const out = [];
25
+ for (const line of String(text || '').split('\n')) {
26
+ if (!/^\s*\|.*\|\s*$/.test(line)) continue;
27
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
28
+ if (cells.length < 2) continue;
29
+ if (/^事实类别$/.test(cells[0])) continue;
30
+ if (cells.every((c) => c === '' || /^:?-{2,}:?$/.test(c))) continue;
31
+ if (!cells[0] || /示例/.test(cells[0])) continue; // 模板示例行不算声明
32
+ const file = (cells[1].match(/[^\s`"'§|]+\.(?:md|json|ya?ml|txt)/i) || [''])[0];
33
+ out.push({ fact: cells[0], file });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * 对一条事实判断"是否分叉"。
40
+ *
41
+ * ⚠️ **语义别搞反**(我第一版就搞反了,被测试当场抓住):权威文件**本来就该定义**这个事实 ——
42
+ * 所以在"声明的权威文件集合内部"找分叉,**永远找不出违规**(自证清白)。
43
+ * 正确的判据是:**权威文件之外**还有谁在**定义**它。
44
+ *
45
+ * @returns `{ fact, authorityFile, defsInAuthority, defsElsewhere, divergent }`
46
+ * · `defsInAuthority` —— 权威文件里的定义点(正常,通常 1 处,多着说明权威文件自己内部也散了)
47
+ * · `defsElsewhere` —— **其他文件**里的定义点(这就是分叉;引用不算)
48
+ */
49
+ export async function divergenceFor({ runDir, fact, authorityFile, maxMs = 20000 }) {
50
+ const r = await scanSingleSource({ root: runDir, fact, maxMs });
51
+ // ⚠️ **必须排除 `AUTHORITY.md` 自己**:那张表天然会逐行写下每个事实名(`| 变现体系 | SPEC.md | …`),
52
+ // 而散文定义语法里的「表格行」规则会把这种行认成"定义"⇒ 每一条声明都会被误报成"权威之外还有人定义"。
53
+ // 本文件是**关于事实的元数据**,不是事实的定义。(2026-09-13 被退出码断言当场抓到。)
54
+ const METADATA_FILES = new Set(['AUTHORITY.md']);
55
+ const defs = r.definitions.filter((d) => !METADATA_FILES.has(d.file));
56
+ const defsInAuthority = defs.filter((d) => d.file === authorityFile);
57
+ const defsElsewhere = defs.filter((d) => d.file !== authorityFile);
58
+ return { fact, authorityFile, defsInAuthority, defsElsewhere, divergent: defsElsewhere.length > 0 };
59
+ }
60
+
61
+ export function render(report) {
62
+ const { runDir, rows } = report;
63
+ const out = [];
64
+ const bad = rows.filter((r) => r.divergent);
65
+ out.push(`# 单源化分叉检测:${runDir}`);
66
+ out.push('');
67
+ out.push(`- 声明的事实 **${rows.length}** 条`);
68
+ out.push(`- **分叉 ${bad.length} 条**${bad.length ? ' ⚠️ 权威文件之外还有人在定义同一事实 —— 这就是「迟早不一致」的形状' : '(无)'}`);
69
+ out.push('');
70
+ for (const r of rows) {
71
+ const mark = r.divergent ? '✗' : '✓';
72
+ out.push(`${mark} **${r.fact}** — 权威 \`${r.authorityFile || '(未指定)'}\` 里定义 ${r.defsInAuthority.length} 处 · **权威之外定义 ${r.defsElsewhere.length} 处**`);
73
+ if (r.divergent) {
74
+ out.push(' ⚠️ 权威之外的定义点(应改成引用):');
75
+ for (const d of r.defsElsewhere) out.push(` - \`${d.file}\`:${d.line} — ${String(d.text).slice(0, 100)}`);
76
+ } else if (r.defsInAuthority.length === 0) {
77
+ out.push(' (注:权威文件里也没有"定义式"写法 —— 可能只是被引用,或该事实还没真正落笔)');
78
+ }
79
+ }
80
+ return out.join('\n');
81
+ }
82
+
83
+ async function main(argv) {
84
+ const args = argv.slice(2);
85
+ const json = args.includes('--json');
86
+ const rest = args.filter((a) => !a.startsWith('--'));
87
+ const runDir = rest[0];
88
+ if (!runDir) {
89
+ console.error('用法:node scan-authority.mjs <runDir> [--json]');
90
+ process.exit(2);
91
+ }
92
+ let text;
93
+ try {
94
+ text = await readFile(join(runDir, 'AUTHORITY.md'), 'utf8');
95
+ } catch {
96
+ console.error(`✗ 读不到 ${join(runDir, 'AUTHORITY.md')}(本 run 没有权威表 ⇒ 先按模板写一份)`);
97
+ process.exit(2);
98
+ }
99
+ const facts = authorityFacts(text);
100
+ if (!facts.length) {
101
+ console.error('✗ AUTHORITY.md 里没有有效声明(表是空的或只有表头/示例行)');
102
+ process.exit(2);
103
+ }
104
+ const rows = [];
105
+ for (const f of facts) rows.push(await divergenceFor({ runDir, fact: f.fact, authorityFile: f.file }));
106
+ const report = { runDir, files: [...new Set(facts.map((f) => f.file).filter(Boolean))], rows };
107
+ if (json) console.log(JSON.stringify(report, null, 2));
108
+ else console.log(render(report));
109
+ process.exit(rows.some((r) => r.divergent) ? 1 : 0);
110
+ }
111
+
112
+ if (import.meta.url === `file://${process.argv[1]}`) {
113
+ main(process.argv).catch((e) => { console.error('✗', (e && e.message) || e); process.exit(2); });
114
+ }
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ // 单源化扫描(E 线 E3 · SKILL §7.34「见一个,扫全部」)
3
+ //
4
+ // 为什么需要:本仓**头号返工源就是「一个事实多份拷贝」**——一次真实 run 里同类缺陷出现 6 次
5
+ // (白名单 10→11→12→13 漂移、`severity` 三张表、单位口径、`registry↔errors` 文案…),
6
+ // 占那批返工 14/29;而它的形态是**每次都要等下一轮评审才发现下一个实例**,没人做总扫。
7
+ // run 自己的复盘点名了这一点:「应该在做完第一个实例时就立刻总扫」。
8
+ //
9
+ // 这个脚本就是那次总扫的**机械化**:给一个"事实名",把它在全仓的**每一处**出处定位出来,
10
+ // 并在有多个定义时**比对它们的写法是否已经分叉**。
11
+ //
12
+ // ⚠️ 口径边界(必须如实说,否则读者会以为"扫过就安全了"):
13
+ // ① 这是**文本级**定位:只认同名 token 与同名定义。**语义重复但改了名**的副本(最贵的形态,
14
+ // 例如 `WARNING_CODES` vs `ALERT_CODES`)**扫不出来**——那要靠人读、靠评审。
15
+ // ② 不做跨语言/跨编码归一(如 Python 列表 vs JS 数组的**语义**等价),只比字面写法。
16
+ // ③ 跳过 node_modules / .git / dist / build / coverage / .refactor-snapshots 与二进制/超大文件。
17
+ // ④ 定义之间只有**写法**不同才报分叉;写法相同但意图不同,扫不出来。
18
+ //
19
+ // 用法:
20
+ // node scan-single-source.mjs <事实名> [--root <目录>] [--json] [--max-files N]
21
+ // 退出码:
22
+ // 0 = 命中(定义或引用 ≥ 1) 1 = **未命中**(扫了但一处都没有) 2 = 用法错误
23
+ // 把"未命中"单列成非 0 是有意的:`0` 与"没扫"必须能区分(本仓两种零的老坑)。
24
+
25
+ import { readdir, readFile, stat } from 'node:fs/promises';
26
+ import { join, relative, extname } from 'node:path';
27
+
28
+ /** 不进入的目录(与"是不是源码"无关,纯粹是不该扫)。 */
29
+ const SKIP_DIRS = new Set([
30
+ 'node_modules', '.git', 'dist', 'build', 'coverage', '.refactor-snapshots', '.dsh', '.next', 'vendor', '__pycache__',
31
+ // 依赖/缓存目录:实测扫一个真实项目时,**32 秒里有 31 秒花在 `.venv` 上**(它还会带来一堆
32
+ // "不是我写的副本"噪声)。这些目录一律不进 —— 它们不是"事实的出处",只是工具的复制品。
33
+ '.venv', 'venv', 'env', 'site-packages', '.cache', '.tox', '.mypy_cache', '.pytest_cache',
34
+ '.idea', '.vscode', 'target', 'Pods', 'bower_components', '.terraform', '.gradle', '.nuxt', '.output',
35
+ ]);
36
+ /** 只扫这些扩展名的文本文件(其余一律按二进制跳过,避免把 token 匹配到图片里)。 */
37
+ const TEXT_EXT = new Set([
38
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.jsonc', '.md', '.markdown',
39
+ '.yml', '.yaml', '.txt', '.css', '.scss', '.html', '.htm', '.vue', '.svelte',
40
+ '.py', '.php', '.rb', '.go', '.rs', '.java', '.kt', '.sh', '.bash', '.zsh',
41
+ '.sql', '.toml', '.ini', '.cfg', '.env', '.xml', '.gradle', '.properties',
42
+ ]);
43
+ /** 单文件上限:超过就跳过(并在结果里如实计入 skippedLarge)。 */
44
+ const MAX_BYTES = 2 * 1024 * 1024;
45
+
46
+ /** 正则元字符转义(事实名可能带 `[]`、`.` 等)。 */
47
+ export function escapeRegExp(s) {
48
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ }
50
+
51
+ const CJK_CLASS = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\u3040-\\u30ff\\uac00-\\ud7af';
52
+ const CJK_RE = new RegExp(`[${CJK_CLASS}]`);
53
+ const WORD_CHAR = /[A-Za-z0-9_]/;
54
+
55
+ /** 事实名里是否含中日韩字符 ⇒ 决定用哪套"定义语法"与哪种边界。 */
56
+ export function isCjkFact(fact) {
57
+ return CJK_RE.test(String(fact || ''));
58
+ }
59
+
60
+ /**
61
+ * 构造匹配器。**这是"中文事实名命中 0"的根因所在**:
62
+ *
63
+ * `\b` 的词边界依赖 `\w`(= `[A-Za-z0-9_]`),而**汉字是非单词字符** ⇒ `\b变现体系\b`
64
+ * 左右两侧永远不构成 `\w`/非`\w` 的转换,**永远匹配不到**。真实 run 的实测后果就是
65
+ * 「`变现体系` 命中 0,而 SPEC.md 里实际有 8 处」(该 run 自己登记了 `scan:limitation`)。
66
+ *
67
+ * **两种事实名,两套边界**(这是有意的取舍,不是偷懒):
68
+ * · ASCII 事实名(`REQUIRED_SECTIONS`、`maxItems`)⇒ 仍用 `\b…\b`:代码里词边界成立且精确,
69
+ * 既有测试逐条钉着,不动。
70
+ * · **含汉字的事实名 ⇒ 按子串匹配,不加边界**。中文没有词边界,要区分
71
+ * `变现体系的设计`(是同一个词)与 `变现体系化`(是更长的词)**需要分词**,本脚本不做。
72
+ * 取舍方向是**宁可多算、不可零命中**:这个脚本的用途是"把候选摊开给人看",
73
+ * 漏掉(零命中)会让"单源化"检查**假绿**,而多算只是多几行待人工确认。
74
+ * ⚠️ 因此**中文结果会包含"更长的词内部"的命中** —— 报告里如实标注,不假装是精确 token 计数。
75
+ */
76
+ export function factMatcher(fact) {
77
+ const f = String(fact || '');
78
+ if (!f) return /(?!)/; // 空串永不命中(别让它变成"命中一切")
79
+ // ⚠️ **不要加 `g` 标志**:调用方是 `word.test(line)` 逐行判定,而带 `g` 的正则 `.test()` 会推进
80
+ // `lastIndex` ⇒ **隔一行漏一行**(2026-09-13 我自己刚踩过:改成 `g` 之后 `变现体系化的做法` 那条
81
+ // 断言就假红了,本质是这个有状态陷阱)。需要计数的地方请另用 `new RegExp(f, 'g')`。
82
+ if (isCjkFact(f)) return new RegExp(escapeRegExp(f)); // 中文:子串(见上方取舍说明)
83
+ return new RegExp(`\\b${escapeRegExp(f)}\\b`);
84
+ }
85
+
86
+ /** 提取一行里"看起来是定义"的那种写法;不是定义返回 null。 */
87
+ export function definitionKind(line, fact) {
88
+ const f = escapeRegExp(fact);
89
+ // 中文事实名走**散文物**的定义语法(标题 / 加粗定义 / 表格行 / 引用定义 / 列表项)。
90
+ // 代码式声明(`const X =` 等)对中文散文不适用;这也让 ASCII 与 CJK 两套语义**各自独立**,
91
+ // 不去动 ASCII 那边已被既有测试钉住的行为。
92
+ if (isCjkFact(fact)) {
93
+ if (new RegExp(`^\\s*#{1,6}\\s*.*${f}`).test(line)) return 'zh-heading';
94
+ if (new RegExp(`^\\s*\\*\\*${f}\\*\\*\\s*[::]`).test(line)) return 'zh-bold';
95
+ if (new RegExp(`^\\s*[||]\\s*\\*{0,2}${f}\\*{0,2}\\s*[||]`).test(line)) return 'zh-table';
96
+ if (new RegExp(`[「『]${f}[」』]\\s*[::]`).test(line)) return 'zh-quoted';
97
+ if (new RegExp(`^\\s*(?:[-*+]|\\d+[.、])\\s*\\*{0,2}${f}\\*{0,2}\\s*[::]`).test(line)) return 'zh-list';
98
+ return null;
99
+ }
100
+ // `export const X = …` / `function X(` / `class X` / `def X` / `async function X`
101
+ if (new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var|function|class|def|struct|enum|type|interface)\\s+${f}\\b`).test(line)) return 'decl';
102
+ // 顶层赋值 / Python 常量:`X = …`、`X: …`、`X := …`
103
+ if (new RegExp(`^\\s*${f}\\s*[:=]`).test(line)) return 'assign';
104
+ // JSON / YAML 键:`"X": …`、`X: …`
105
+ if (new RegExp(`^\\s*["']?${f}["']?\\s*:`).test(line)) return 'key';
106
+ return null;
107
+ }
108
+
109
+ /** 取出定义的右值(`=` 或 `:` 之后的部分),用于比对"同一个事实的两种写法"。 */
110
+ export function definitionRhs(line) {
111
+ const i = line.search(/[:=]/);
112
+ if (i < 0) return '';
113
+ return line.slice(i + 1).replace(/\/\/.*$/, '').replace(/\s+/g, ' ').trim();
114
+ }
115
+
116
+ async function walk(dir, out, state) {
117
+ let entries;
118
+ try {
119
+ entries = await readdir(dir, { withFileTypes: true });
120
+ } catch {
121
+ return;
122
+ }
123
+ for (const ent of entries) {
124
+ if (out.length >= state.maxFiles) return;
125
+ // 目录遍历也吃时间预算:慢盘/巨大仓库上,宁可如实报"被截断"也不要卡住编排者。
126
+ // 用 `>=`:`--max-ms 0` 必须**立刻**算超预算(用 `>` 时同毫秒内 elapsed=0 不算超,预算形同虚设)。
127
+ if (Date.now() - state.startedAt >= state.maxMs) { state.timedOut = true; return; }
128
+ const p = join(dir, ent.name);
129
+ if (ent.isDirectory()) {
130
+ if (SKIP_DIRS.has(ent.name)) continue;
131
+ await walk(p, out, state);
132
+ continue;
133
+ }
134
+ if (!ent.isFile()) continue;
135
+ if (!TEXT_EXT.has(extname(ent.name).toLowerCase())) { state.skippedExt += 1; continue; }
136
+ let sz = 0;
137
+ try { sz = (await stat(p)).size; } catch { continue; }
138
+ if (sz > MAX_BYTES) { state.skippedLarge += 1; continue; }
139
+ out.push(p);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 扫 `<root>` 下所有文本文件,定位 `<fact>` 的定义点与引用点。
145
+ *
146
+ * @param args.root - 扫描根目录(绝对路径)。
147
+ * @param args.fact - 事实名(如 `WARNING_CODES`)。
148
+ * @param args.maxFiles - 文件数上限(默认 20000;到顶即停,并在结果里记 truncated)。
149
+ * @returns `{ fact, root, definitions, references, scannedFiles, skippedExt, skippedLarge, truncated, divergent, variants }`
150
+ */
151
+ export async function scanSingleSource({ root, fact, maxFiles = 20000, maxMs = 20000, concurrency = 16 }) {
152
+ const startedAt = Date.now();
153
+ const files = [];
154
+ const state = { maxFiles, skippedExt: 0, skippedLarge: 0, startedAt, maxMs, timedOut: false };
155
+ await walk(root, files, state);
156
+ const word = factMatcher(fact); // ASCII 用 `\b`、中文用汉字边界(见 `factMatcher` 的说明)
157
+ const hits = [];
158
+ const scanOne = async (abs) => {
159
+ let text;
160
+ try { text = await readFile(abs, 'utf8'); } catch { return; }
161
+ // 含 NUL 的一律按二进制跳过(扩展名可能是 .md 的伪装二进制)。
162
+ if (text.includes('\u0000')) return;
163
+ const rel = relative(root, abs);
164
+ const lines = text.split('\n');
165
+ for (let i = 0; i < lines.length; i += 1) {
166
+ const line = lines[i];
167
+ if (!word.test(line)) continue;
168
+ hits.push({ file: rel, line: i + 1, text: line.trim() });
169
+ }
170
+ };
171
+ // **有界并发**:实测在真实项目上串行读盘慢到 30 秒级(每次 fs 调用都有固定开销),
172
+ // 而 16 路并发把同样的工作压到 1 秒级;同时仍然尊重 `maxMs` 时间预算 —— 慢盘上宁可
173
+ // 如实报"被截断",也不要让一条命令把编排者的回合挂死。
174
+ let cursor = 0;
175
+ // 同样用 `>=`(见 walk 里的注释:`--max-ms 0` 必须立刻算超预算)。
176
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, files.length || 1)) }, async () => {
177
+ while (cursor < files.length) {
178
+ if (Date.now() - startedAt >= maxMs) { state.timedOut = true; return; }
179
+ const i = cursor;
180
+ cursor += 1;
181
+ await scanOne(files[i]);
182
+ }
183
+ });
184
+ await Promise.all(workers);
185
+ // 稳定输出:并发读盘会让命中顺序随机 ⇒ 统一按「文件 → 行号」排序(否则每次跑的报告都不一样)。
186
+ hits.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
187
+ const definitions = [];
188
+ const references = [];
189
+ for (const h of hits) {
190
+ const kind = definitionKind(h.text, fact);
191
+ if (kind) definitions.push({ ...h, kind });
192
+ else references.push(h);
193
+ }
194
+ // 分叉检测:多个定义之间**写法**是否已经不一致(同一事实的两种右值)。
195
+ const variants = [...new Set(definitions.map((d) => definitionRhs(d.text)).filter(Boolean))];
196
+ return {
197
+ fact,
198
+ root,
199
+ definitions,
200
+ references,
201
+ scannedFiles: files.length,
202
+ skippedExt: state.skippedExt,
203
+ skippedLarge: state.skippedLarge,
204
+ truncated: files.length >= maxFiles || state.timedOut,
205
+ truncatedReason: state.timedOut ? 'time' : (files.length >= maxFiles ? 'files' : ''),
206
+ elapsedMs: Date.now() - startedAt,
207
+ divergent: variants.length > 1,
208
+ variants,
209
+ };
210
+ }
211
+
212
+ /** 人读的报告(`--json` 时不走这里)。 */
213
+ export function renderReport(r) {
214
+ const cjkNote = isCjkFact(r && r.fact) ? '(含汉字 ⇒ 按**子串**计数:可能包含更长词内部的命中,中文无词边界;宁可多算不可零命中)' : '';
215
+ const out = [];
216
+ out.push(`# 单源化扫描:${r.fact}`);
217
+ out.push('');
218
+ out.push(`- 命中:定义 **${r.definitions.length}** 处 / 引用 **${r.references.length}** 处 · 扫描 ${r.scannedFiles} 个文本文件${cjkNote}`);
219
+ out.push(`- 跳过的非文本文件 ${r.skippedExt} 个 · 超大文件 ${r.skippedLarge} 个 · 耗时 ${r.elapsedMs} ms${r.truncated ? ` · ⚠️ **结果被截断(${r.truncatedReason === 'time' ? '超出时间预算' : '已达文件数上限'})—— 不要把它当成"扫全了"**` : ''}`);
220
+ out.push('');
221
+ out.push(`## 定义点(${r.definitions.length})`);
222
+ out.push(r.definitions.length
223
+ ? r.definitions.map((d) => `- \`${d.file}\`:${d.line} — ${d.text}`).join('\n')
224
+ : '- (无:这个事实名没有任何"定义式"的写法 —— 也可能它只是一个普通变量/字符串)');
225
+ if (r.divergent) {
226
+ out.push('');
227
+ out.push(`⚠️ **定义不一致(疑似分叉):${r.definitions.length} 处定义里有 ${r.variants.length} 种写法** —— 这正是"一个事实多份拷贝"的形态,**必须当类修**:`);
228
+ r.variants.forEach((v, i) => {
229
+ const at = r.definitions.filter((d) => definitionRhs(d.text) === v).map((d) => `${d.file}:${d.line}`);
230
+ out.push(` ▸ 写法 ${String.fromCharCode(65 + i)}(${at.length} 处:${at.join('、')}):${v.length > 160 ? v.slice(0, 160) + ' …' : v}`);
231
+ });
232
+ }
233
+ out.push('');
234
+ out.push(`## 引用点(${r.references.length})`);
235
+ out.push(r.references.length
236
+ ? r.references.map((d) => `- \`${d.file}\`:${d.line} — ${d.text.length > 160 ? d.text.slice(0, 160) + ' …' : d.text}`).join('\n')
237
+ : '- (无引用:定义了却没人用 —— 那是另一类病,见"函数写出来了但没人调用")');
238
+ out.push('');
239
+ out.push('## 口径与边界(必须如实说)');
240
+ out.push('- 只做**文本级**定位:同名 token + 同名定义。**语义重复但改了名**的副本(最贵的形态,如 `WARNING_CODES` vs `ALERT_CODES`)**扫不出来**。');
241
+ out.push('- 定义间只有**写法**不同才报分叉;写法相同、意图不同的两份,扫不出来。');
242
+ out.push('- 跳过 `node_modules`/`.git`/`dist`/`build`/`coverage` 等目录,以及二进制与超大文件(上面已计数)。');
243
+ out.push('- 命中 0 处时**退出码 1** —— "未命中"与"没扫"必须能区分。');
244
+ return out.join('\n');
245
+ }
246
+
247
+ // ── CLI ──────────────────────────────────────────────────────────────────
248
+ async function main(argv) {
249
+ const args = argv.slice(2);
250
+ // ⚠️ 不能用 `args.find((a) => !a.startsWith('--'))` —— 那会把 `--root` 的**取值**当成事实名
251
+ // (`--root /tmp/x` ⇒ 事实名变成 `/tmp/x`),于是扫描永远"未命中",而用户以为没有副本。
252
+ const OPT_WITH_VALUE = new Set(['--root', '--max-files', '--max-ms']);
253
+ let fact = null;
254
+ for (let i = 0; i < args.length; i += 1) {
255
+ if (OPT_WITH_VALUE.has(args[i])) { i += 1; continue; }
256
+ if (args[i].startsWith('--')) continue;
257
+ if (fact === null) fact = args[i];
258
+ }
259
+ const asJson = args.includes('--json');
260
+ const rootIdx = args.indexOf('--root');
261
+ const root = rootIdx >= 0 ? args[rootIdx + 1] : process.cwd();
262
+ const maxIdx = args.indexOf('--max-files');
263
+ const maxFiles = maxIdx >= 0 ? Number(args[maxIdx + 1]) : 20000;
264
+ const msIdx = args.indexOf('--max-ms');
265
+ const maxMs = msIdx >= 0 ? Number(args[msIdx + 1]) : 20000;
266
+ if (!fact) {
267
+ console.error('用法:node scan-single-source.mjs <事实名> [--root <目录>] [--json] [--max-files N] [--max-ms N]');
268
+ return 2;
269
+ }
270
+ if (!Number.isFinite(maxFiles) || maxFiles <= 0) {
271
+ console.error('--max-files 必须是正整数');
272
+ return 2;
273
+ }
274
+ if (!Number.isFinite(maxMs) || maxMs < 0) {
275
+ console.error('--max-ms 必须是非负整数');
276
+ return 2;
277
+ }
278
+ const r = await scanSingleSource({ root, fact, maxFiles, maxMs });
279
+ if (asJson) console.log(JSON.stringify(r, null, 2));
280
+ else console.log(renderReport(r));
281
+ const hit = r.definitions.length + r.references.length;
282
+ if (!hit) {
283
+ if (!asJson) console.log(`\n✗ 未命中:全仓没有任何一处 \`${fact}\`(退出码 1)—— 先确认事实名的拼写,别把"没找到"当成"没有副本"。`);
284
+ return 1;
285
+ }
286
+ return 0;
287
+ }
288
+
289
+ // 只有作为 CLI 直接运行时才跑 main(被 import 时不跑)。
290
+ if (process.argv[1] && process.argv[1].endsWith('scan-single-source.mjs')) {
291
+ main(process.argv).then((code) => process.exit(code));
292
+ }