@mingmaple/dsh-handover 0.0.0-stage → 0.9.6

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +48 -0
  5. package/package.json +55 -3
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +820 -0
  8. package/scripts/build.mjs +58 -0
  9. package/scripts/check-docs.mjs +74 -0
  10. package/scripts/e2e-migration.mjs +127 -0
  11. package/scripts/lib/register.mjs +103 -0
  12. package/src/client.js +887 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +631 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +234 -0
  17. package/src/lib/archive.mjs +198 -0
  18. package/src/lib/brief.mjs +74 -0
  19. package/src/lib/charter.mjs +28 -0
  20. package/src/lib/config.mjs +36 -0
  21. package/src/lib/consent.mjs +84 -0
  22. package/src/lib/drift.mjs +65 -0
  23. package/src/lib/events.mjs +89 -0
  24. package/src/lib/extra-prompt.mjs +41 -0
  25. package/src/lib/facts.mjs +70 -0
  26. package/src/lib/gates.mjs +148 -0
  27. package/src/lib/guide.mjs +99 -0
  28. package/src/lib/handover-io.mjs +49 -0
  29. package/src/lib/lint.mjs +152 -0
  30. package/src/lib/manual.mjs +124 -0
  31. package/src/lib/mover.mjs +663 -0
  32. package/src/lib/names.mjs +33 -0
  33. package/src/lib/output.mjs +11 -0
  34. package/src/lib/pack-ops.mjs +329 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +34 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/registry.mjs +53 -0
  39. package/src/lib/retarget-helper.tmpl +46 -0
  40. package/src/lib/role.mjs +217 -0
  41. package/src/lib/rpc-carrier.mjs +65 -0
  42. package/src/lib/scaffold.mjs +118 -0
  43. package/src/lib/schedule.mjs +227 -0
  44. package/src/lib/session-ops.mjs +362 -0
  45. package/src/lib/sessions.mjs +142 -0
  46. package/src/lib/sevenzip.mjs +48 -0
  47. package/src/lib/status.mjs +171 -0
  48. package/src/lib/text.mjs +74 -0
  49. package/src/lib/webroute.mjs +130 -0
  50. package/src/lib/workspace.mjs +23 -0
  51. package/src/lib/zstd-log.mjs +65 -0
  52. package/src/selftest.mjs +84 -0
  53. package/src/templates/Changelog.skeleton.md +10 -0
  54. package/src/templates/Charter.skeleton.md +53 -0
  55. package/src/templates/Handover.global.skeleton.md +56 -0
  56. package/src/templates/Prompt_Templates.md +96 -0
  57. package/src/templates/Runtime_Manual.md +166 -0
  58. package/src/templates/rules/Handover_Rules.md +43 -0
  59. package/src/templates/rules/Migration_Rules.md +32 -0
  60. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  61. package/src/templates/rules/Writing_Rules.md +24 -0
  62. package/src/tools/ho_brief.mjs +57 -0
  63. package/src/tools/ho_changelog.mjs +59 -0
  64. package/src/tools/ho_charter.mjs +89 -0
  65. package/src/tools/ho_checkpoint.mjs +125 -0
  66. package/src/tools/ho_diag.mjs +85 -0
  67. package/src/tools/ho_drift.mjs +41 -0
  68. package/src/tools/ho_gate.mjs +69 -0
  69. package/src/tools/ho_handover.mjs +83 -0
  70. package/src/tools/ho_init.mjs +82 -0
  71. package/src/tools/ho_pack.mjs +38 -0
  72. package/src/tools/ho_phase.mjs +37 -0
  73. package/src/tools/ho_profile.mjs +97 -0
  74. package/src/tools/ho_sessions.mjs +114 -0
  75. package/src/tools/ho_snapshot.mjs +103 -0
  76. package/src/tools/ho_tokens.mjs +57 -0
  77. package/src/tools/ho_workspace.mjs +213 -0
  78. package/src/tools/peer_list.mjs +37 -0
  79. package/src/tools/peer_new.mjs +75 -0
  80. package/src/tools/peer_peek.mjs +41 -0
  81. package/src/tools/peer_rename.mjs +31 -0
  82. package/src/tools/peer_send.mjs +52 -0
  83. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 构建:把 src/ 打成一个可直接安装的 bundle 包(dist/)。
3
+ * 用法:node scripts/build.mjs [--out <目录>]
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const repo = path.resolve(here, "..");
11
+ const rootPkg = JSON.parse(fs.readFileSync(path.join(repo, "package.json"), "utf8"));
12
+
13
+ const argIdx = process.argv.indexOf("--out");
14
+ const dist = argIdx !== -1 && process.argv[argIdx + 1]
15
+ ? path.resolve(process.argv[argIdx + 1])
16
+ : path.join(repo, "dist");
17
+
18
+ function rmrf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
19
+ function copyDir(from, to) {
20
+ fs.mkdirSync(to, { recursive: true });
21
+ for (const name of fs.readdirSync(from)) {
22
+ const s = path.join(from, name);
23
+ const d = path.join(to, name);
24
+ if (fs.statSync(s).isDirectory()) copyDir(s, d);
25
+ else fs.copyFileSync(s, d);
26
+ }
27
+ }
28
+
29
+ rmrf(dist);
30
+ fs.mkdirSync(dist, { recursive: true });
31
+ copyDir(path.join(repo, "src", "lib"), path.join(dist, "lib"));
32
+ copyDir(path.join(repo, "src", "tools"), path.join(dist, "tools"));
33
+ fs.copyFileSync(path.join(repo, "src", "index.mjs"), path.join(dist, "index.mjs"));
34
+ fs.copyFileSync(path.join(repo, "src", "cordis.patch.yml"), path.join(dist, "cordis.patch.yml"));
35
+ // 浏览器半(设置页):必须是已构建好的 __ModuleLoader__ 注册脚本,宿主按 exports["./client"] 取用。
36
+ fs.copyFileSync(path.join(repo, "src", "client.js"), path.join(dist, "client.js"));
37
+ // 首次安装向导用的模板(Charter 骨架、交接骨架、台账骨架、提示词模板)。
38
+ copyDir(path.join(repo, "src", "templates"), path.join(dist, "templates"));
39
+
40
+ const distPkg = {
41
+ name: rootPkg.name,
42
+ version: rootPkg.version,
43
+ private: true,
44
+ type: "module",
45
+ description: rootPkg.description,
46
+ license: rootPkg.license,
47
+ exports: { ".": "./index.mjs", "./client": "./client.js" },
48
+ dsh: {
49
+ bundle: { patch: "./cordis.patch.yml" },
50
+ client: rootPkg.dsh && rootPkg.dsh.client ? rootPkg.dsh.client : { platform: "web" },
51
+ },
52
+ };
53
+ fs.writeFileSync(path.join(dist, "package.json"), JSON.stringify(distPkg, null, 2) + "\n");
54
+
55
+ let files = 0;
56
+ walk(dist);
57
+ function walk(p) { for (const n of fs.readdirSync(p)) { const q = path.join(p, n); if (fs.statSync(q).isDirectory()) walk(q); else files++; } }
58
+ console.log("built " + rootPkg.name + "@" + rootPkg.version + " -> " + dist + " (" + files + " files)");
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 文档校验:工具名集合、旧数与旧术语、内部链接、版本、语体。
3
+ * 只读,不做任何改写。退出码 0 表示全部通过。
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { ALL_TOOL_NAMES } from '../src/lib/names.mjs';
9
+ import { listFiles, scanFiles } from './lib/register.mjs';
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const root = path.resolve(here, '..');
13
+ const readme = path.join(root, 'README.md');
14
+ const files = [readme]
15
+ .concat(listFiles(path.join(root, 'docs'), ['.md']))
16
+ .filter((f) => fs.existsSync(f));
17
+ const problems = [];
18
+
19
+ // 1. README 长度(对外首页承载全部说明,上限放宽)
20
+ const readmeLines = fs.readFileSync(readme, 'utf8').split(/\r?\n/).length;
21
+ if (readmeLines > 200) problems.push('README 应不超过 200 行(当前 ' + readmeLines + ' 行)');
22
+
23
+ // 2. 旧数与旧术语
24
+ const FORBIDDEN = ['21 个工具', '27 个工具', '未分类', '追加系统提示词', '4,028', '4.6 KB', '每库 24 行', 'CHANGELOG'];
25
+ for (const file of files) {
26
+ const text = fs.readFileSync(file, 'utf8');
27
+ for (const w of FORBIDDEN) {
28
+ if (text.indexOf(w) !== -1) problems.push(path.relative(root, file) + ' 仍含旧表述「' + w + '」');
29
+ }
30
+ }
31
+
32
+ // 3. 工具名:文档里出现的 ho_* 与 peer_* 必须在 names.mjs 中
33
+ for (const file of files) {
34
+ const text = fs.readFileSync(file, 'utf8');
35
+ const hits = text.match(/\b(?:ho|peer)_[a-z_]+\b/g) || [];
36
+ for (const name of hits) {
37
+ if (ALL_TOOL_NAMES.indexOf(name) === -1) problems.push(path.relative(root, file) + ' 出现未定义工具名「' + name + '」');
38
+ }
39
+ }
40
+
41
+ // 4. 内部链接可达
42
+ for (const file of files) {
43
+ const text = fs.readFileSync(file, 'utf8');
44
+ const re = /\[[^\]]*\]\(([^)\s]+)\)/g;
45
+ let m;
46
+ while ((m = re.exec(text)) !== null) {
47
+ const target = m[1];
48
+ if (/^(https?:|mailto:|#)/.test(target)) continue;
49
+ const clean = target.split('#')[0];
50
+ const abs = path.resolve(path.dirname(file), clean);
51
+ if (!fs.existsSync(abs)) problems.push(path.relative(root, file) + ' 的链接不可达「' + target + '」');
52
+ }
53
+ }
54
+
55
+ // 5. 版本:README 的「当前版本」与 package.json 一致
56
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
57
+ const readmeText = fs.readFileSync(readme, 'utf8');
58
+ const ver = readmeText.match(/当前版本\s*([0-9]+\.[0-9]+\.[0-9]+)/);
59
+ if (!ver) problems.push('README 缺少「当前版本 X.Y.Z」一行');
60
+ else if (ver[1] !== pkg.version) problems.push('版本不一致:package.json 为 ' + pkg.version + ',README 为 ' + ver[1]);
61
+
62
+ // 6. 语体
63
+ const hits = scanFiles(files, { ui: false });
64
+ for (const h of hits) {
65
+ problems.push(path.relative(root, h.file) + ':' + h.line + ' ' + h.rules.join('、'));
66
+ }
67
+
68
+ if (problems.length) {
69
+ console.log('CHECK-DOCS FAIL ' + problems.length + ' 项:');
70
+ for (const p of problems) console.log(' - ' + p);
71
+ process.exitCode = 1;
72
+ } else {
73
+ console.log('CHECK-DOCS OK(' + files.length + ' 份文档,工具名 ' + ALL_TOOL_NAMES.length + ' 个)');
74
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 迁移与恢复的端到端自测(不碰真库):造一个假工作区 → 打包 → 校验 → 改一个字节看失效 → 预演恢复 → 真恢复 → 再恢复一次看备份。
3
+ * 用法:node scripts/e2e-migration.mjs [--dist <dist 目录>]
4
+ * 依赖:7-Zip(自动探测 C:\\Program Files\\7-Zip\\7z.exe)。
5
+ * 全程在系统临时目录里做,结束即删。
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const repo = path.resolve(here, '..');
14
+ const argIdx = process.argv.indexOf('--dist');
15
+ const dist = argIdx !== -1 ? path.resolve(process.argv[argIdx + 1]) : path.join(repo, 'dist');
16
+
17
+ const imp = (rel) => import(pathToFileURL(path.join(dist, rel)).href);
18
+ const { pmPackTool } = await imp('tools/ho_pack.mjs');
19
+ const { resolveConfig } = await imp('lib/config.mjs');
20
+
21
+ const fails = [];
22
+ const ok = (cond, msg, extra) => { if (!cond) fails.push(msg + (extra ? ' → ' + extra : '')); else console.log(' 通过 ' + msg); };
23
+
24
+ const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-handover-e2e-'));
25
+ const WS = path.join(SB, 'ws');
26
+ const OUT = path.join(SB, 'out');
27
+ const TGT = path.join(SB, 'target');
28
+ const ABS_LINE = '缓存目录写死成了 ' + 'D:' + path.sep + 'Work' + path.sep + 'cache(迁移时须改)';
29
+
30
+ function w(rel, text) {
31
+ const p = path.join(WS, rel.split('/').join(path.sep));
32
+ fs.mkdirSync(path.dirname(p), { recursive: true });
33
+ fs.writeFileSync(p, text, 'utf8');
34
+ }
35
+
36
+ w('_Control_Center/records/Global_Handover.md', '# 全局 交接\n\n## 0. 身份与寻址\n\n| 链内代数 | 第 1 代 |\n\n## 2. 下一步\n\n| # | 动作 |\n|---|---|\n| 1 | 试跑 |\n\n## 7. 会话 id 对照\n\n| 角色 | id |\n|---|---|\n| 全局 | session-00000000-0000-0000-0000-000000000000 |\n');
37
+ w('_Project_Meta/Demo/Handover.md', '# Demo 交接\n\n## 1. 正在做的事\n\n这是一个用于端到端自测的假项目库,内容足够长以满足体检的最短要求。\n\n## 2. 下一步待办\n\n- 改绝对路径\n');
38
+ w('_Control_Center/rules/Note.md', '# 备注\n\n' + ABS_LINE + '\n');
39
+ w('_Control_Center/rules/clean.md', '# 干净的相对引用\n\n见 ../records/Global_Handover.md\n');
40
+
41
+ const cfg = resolveConfig({ workspaceRoot: WS });
42
+ const env = {
43
+ cfg,
44
+ resolveWs: () => WS,
45
+ log: () => { },
46
+ cacheDir: () => path.join(SB, 'nocache'), // 沙箱里没有投影缓存 ⇒ check 只看交接
47
+ selfId: () => null,
48
+ selfCwd: () => null,
49
+ };
50
+ const packTool = pmPackTool(env);
51
+ /** action 分流:pack/verify/restore 都走同一个工具(合并口径)。 */
52
+ const run = (args) => packTool.execute(args, null);
53
+
54
+ console.log('(沙箱)' + SB);
55
+
56
+ // 0. 前置闸门:action=check
57
+ const r0 = await run({ action: 'check', items: ['global', 'Demo'], min_age_min: 0 });
58
+ ok(/判定:/.test(r0.text), 'action=check 应给出判定', r0.text.split('\n').pop());
59
+
60
+ // 1. 打包
61
+ const r1 = await run({ action: 'pack', kind: 'global', out: OUT, note: 'e2e' });
62
+ ok(/已打包/.test(r1.text), '打包成功');
63
+ ok(/绝对路径体检:\d+ 处/.test(r1.text), '打包输出报出绝对路径处数', r1.text.split('\n').pop());
64
+ const packs = fs.readdirSync(OUT).filter((n) => n.endsWith('.7z'));
65
+ ok(packs.length === 1, '只出一个包', packs.join(','));
66
+ const packPath = path.join(OUT, packs[0]);
67
+
68
+ // 2. 包内清单与说明
69
+ const x1 = path.join(SB, 'x1');
70
+ fs.mkdirSync(x1, { recursive: true });
71
+ const { execFileSync } = await import('node:child_process');
72
+ const sevenZip = ['C:/Program Files/7-Zip/7z.exe', 'C:/Program Files (x86)/7-Zip/7z.exe'].find((p) => fs.existsSync(p));
73
+ if (!sevenZip) { console.log('SKIP:本机没有 7-Zip,跳过解包核对'); }
74
+ else {
75
+ execFileSync(sevenZip, ['x', '-y', '-o' + x1, packPath], { stdio: 'ignore' });
76
+ const mf = JSON.parse(fs.readFileSync(path.join(x1, 'manifest.json'), 'utf8'));
77
+ ok(mf.absoluteRefs && mf.absoluteRefs.totalHits >= 1, 'manifest 记录了绝对路径命中', JSON.stringify(mf.absoluteRefs && { totalHits: mf.absoluteRefs.totalHits, files: mf.absoluteRefs.files }));
78
+ ok(typeof mf.sourcePlatform === 'string' && /\+\d{4}$/.test(mf.createdAtLocal || ''), 'manifest 带源平台与带偏移的时间');
79
+ const md = fs.readFileSync(path.join(x1, 'MIGRATION.md'), 'utf8');
80
+ ok(md.indexOf('绝对路径体检') !== -1, 'MIGRATION.md 有「绝对路径体检」段');
81
+ ok(md.indexOf('跨平台注意') !== -1, 'MIGRATION.md 有「跨平台注意」段');
82
+ ok(md.indexOf('库内引用一律相对路径') !== -1, 'MIGRATION.md 写明相对路径口径');
83
+ }
84
+
85
+ // 3. 校验通过
86
+ const r2 = await run({ action: 'verify', pack: packPath });
87
+ ok(/漂移 0/.test(r2.text) && /缺失 0/.test(r2.text), 'pack_verify 判定与盘上一致(漂移 0 / 缺失 0)', r2.text.split('\n')[1]);
88
+
89
+ // 4. 改盘上文件 → 校验应报不一致
90
+ w('_Control_Center/rules/Note.md', '# 备注\n\n改过了\n');
91
+ const r3 = await run({ action: 'verify', pack: packPath });
92
+ ok(/不一致|缺失|作废/.test(r3.text), '盘上改了之后 verify 报出不一致', r3.text.split('\n').slice(0, 2).join(' / '));
93
+
94
+ // 5. 预演恢复:不写任何文件
95
+ const r4 = await run({ action: 'restore', pack: packPath, target: TGT });
96
+ ok(/预演/.test(r4.text), '默认只预演');
97
+ const tgtExists = fs.existsSync(TGT);
98
+ ok(tgtExists === false, '预演没有创建目标目录', String(tgtExists));
99
+ ok(/绝对路径体检(恢复前)/.test(r4.text), '恢复前复检已输出');
100
+
101
+ // 6. 真恢复
102
+ const r5 = await run({ action: 'restore', pack: packPath, target: TGT, apply: true });
103
+ ok(/已写入/.test(r5.text), 'apply=true 写入成功');
104
+ ok(fs.existsSync(path.join(TGT, '_Control_Center', 'records', 'Global_Handover.md')), '目标目录出现交接文件');
105
+ ok(fs.readFileSync(path.join(TGT, '_Control_Center', 'rules', 'Note.md'), 'utf8').indexOf('改过了') === -1, '写回的是包内版本(不是盘上被改过的版本)');
106
+
107
+ // 7. 再恢复一次 → 覆盖并备份
108
+ const r6 = await run({ action: 'restore', pack: packPath, target: TGT, apply: true });
109
+ ok(/覆盖/.test(r6.text), '第二次恢复报出覆盖');
110
+ const snap = path.join(WS, '_Temp', '_Global', 'Snapshots');
111
+ const baks = fs.existsSync(snap) ? fs.readdirSync(snap) : [];
112
+ ok(baks.length >= 1, '覆盖前备份目录已建立', baks.join(','));
113
+
114
+ // 8. 不删除目标文件:放一个包外的文件,恢复后仍在
115
+ const keep = path.join(TGT, 'keep.txt');
116
+ fs.writeFileSync(keep, 'keep', 'utf8');
117
+ await run({ action: 'restore', pack: packPath, target: TGT, apply: true });
118
+ ok(fs.existsSync(keep), '恢复不删除目标处原有文件');
119
+
120
+ fs.rmSync(SB, { recursive: true, force: true });
121
+ console.log('');
122
+ if (fails.length) {
123
+ console.log('E2E FAIL ' + fails.length + ' 项:');
124
+ for (const f of fails) console.log(' - ' + f);
125
+ process.exit(1);
126
+ }
127
+ console.log('E2E OK(迁移与恢复端到端全过)');
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 用户可见文案的语体扫描:词表、符号与时间格式。
3
+ * 词表只收「脱离上下文即不可解」与「书面语中不出现」两类;新增豁免须在注释里写明理由。
4
+ * 抽取器只取含中文的字符串字面量(跳过注释行),因此工具 description 与错误消息同样覆盖。
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ /** 命中即违规:口语、俚语、生造缩略。 */
10
+ const BAD_WORDS = [
11
+ '搞定', '弄好', '弄成', '取不到', '装好', '重启过', '接上', '没找到',
12
+ '拍板', '干活', '收工', '开工', '顺手', '回头', '一眼', '一趟',
13
+ '跑一遍', '走一遍', '接着干', '咱们', '咋', '啥', '差不多', '算了',
14
+ '懒得', '省得', '别忘', '别再', '点单', '找活', '白花',
15
+ '整理并设', '改址', '体量', '忙闲', '抠', '跑一轮',
16
+ ];
17
+
18
+ /** 符号与格式类规则;source 用于定位,test 返回是否命中。 */
19
+ const SYMBOL_RULES = [
20
+ { id: '感叹号', test: (s) => /[!!]/.test(s) },
21
+ { id: '图形结论符号', test: (s) => /[✅❌⚠✗]/.test(s) },
22
+ { id: '全角斜杠', test: (s) => s.indexOf('/') !== -1 },
23
+ { id: '半角箭头', test: (s) => s.indexOf('->') !== -1 || s.indexOf('=>') !== -1 },
24
+ { id: '中文中的半角括号', test: (s) => /[\u4e00-\u9fff][(]/.test(s) },
25
+ ];
26
+
27
+ /** 只对界面文件生效的规则:箭头连接句子。 */
28
+ const UI_RULES = [
29
+ { id: '箭头连接句子', test: (s) => /[→←]/.test(s) },
30
+ ];
31
+
32
+ /** 时间缺时区:2026-11-07 18:50 这类写法。 */
33
+ const TIME_RULE = { id: '时间缺时区', test: (s) => /\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?!\s*[+-]\d{4})/.test(s) };
34
+
35
+ const CJK = /[\u3400-\u9fff]/;
36
+
37
+ /** 从源码或文档里抽用户可见的中文字符串。返回 [{ line, text }]。 */
38
+ function extractStrings(file) {
39
+ const src = fs.readFileSync(file, 'utf8');
40
+ const out = [];
41
+ const ext = path.extname(file).toLowerCase();
42
+ if (ext === '.md') {
43
+ let fence = false;
44
+ src.split(/\r?\n/).forEach((line, i) => {
45
+ const t = line.trim();
46
+ if (t.startsWith('```')) { fence = !fence; return; }
47
+ if (fence || !CJK.test(t)) return;
48
+ out.push({ line: i + 1, text: t });
49
+ });
50
+ return out;
51
+ }
52
+ let offset = 0;
53
+ src.split(/\r?\n/).forEach((line, i) => {
54
+ const t = line.trim();
55
+ offset = i + 1;
56
+ if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*') || t.indexOf('语体豁免') !== -1) return;
57
+ // 单双引号与反引号里的单行字符串;转义引号不参与抽取。
58
+ const re = /'([^'\\]|\\.)*'|"([^"\\]|\\.)*"|`([^`\\]|\\.)*`/g;
59
+ let m;
60
+ while ((m = re.exec(line)) !== null) {
61
+ const lit = m[0];
62
+ if (!CJK.test(lit) && !/[✅❌⚠✗]/.test(lit)) continue;
63
+ out.push({ line: offset, text: lit });
64
+ }
65
+ });
66
+ return out;
67
+ }
68
+
69
+ /** 扫描一组文件:{ file, line, text, rules }。 */
70
+ export function scanFiles(files, opts) {
71
+ const o = opts || {};
72
+ const rules = SYMBOL_RULES.concat(o.ui ? UI_RULES : []).concat([TIME_RULE]);
73
+ const hits = [];
74
+ for (const file of files) {
75
+ const strings = extractStrings(file);
76
+ for (const s of strings) {
77
+ const found = [];
78
+ for (const w of BAD_WORDS) if (s.text.indexOf(w) !== -1) found.push('口语词「' + w + '」');
79
+ for (const r of rules) if (r.test(s.text)) found.push(r.id);
80
+ if (found.length) hits.push({ file, line: s.line, text: s.text, rules: found });
81
+ }
82
+ }
83
+ return hits;
84
+ }
85
+
86
+ /** 列出目录下的指定扩展名文件。 */
87
+ export function listFiles(dir, exts, opts) {
88
+ const out = [];
89
+ const stack = [dir];
90
+ const skip = (opts && opts.skip) || [];
91
+ while (stack.length) {
92
+ const cur = stack.pop();
93
+ for (const name of fs.readdirSync(cur)) {
94
+ const p = path.join(cur, name);
95
+ const st = fs.statSync(p);
96
+ if (st.isDirectory()) {
97
+ if (skip.some((s) => p.indexOf(s) !== -1)) continue;
98
+ stack.push(p);
99
+ } else if (exts.indexOf(path.extname(name).toLowerCase()) !== -1) out.push(p);
100
+ }
101
+ }
102
+ return out.sort();
103
+ }