@kira905/dsh-memory-toolkit 0.1.2 → 0.2.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 (76) hide show
  1. package/CHANGELOG.md +130 -1
  2. package/README.en.md +225 -2
  3. package/README.md +208 -2
  4. package/cordis.patch.yml +32 -0
  5. package/lib/deploy-guard.mjs +463 -275
  6. package/lib/guard-http.mjs +59 -62
  7. package/lib/pitfall-form-map.mjs +94 -84
  8. package/lib/pitfall-score.mjs +262 -185
  9. package/lib/session-file-name.mjs +116 -100
  10. package/lib/zstd-frames.mjs +88 -67
  11. package/memory-db-tools/README.md +29 -1
  12. package/memory-db-tools/audit-analyze.mjs +1 -1
  13. package/memory-db-tools/audit-export.mjs +60 -16
  14. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  15. package/memory-db-tools/lib/known-devices.mjs +137 -0
  16. package/memory-engine/README.md +58 -4
  17. package/memory-engine/bench/bench.mjs +1 -1
  18. package/memory-engine/cli/band-sentinel.mjs +164 -0
  19. package/memory-engine/cli/distill-rollback.mjs +112 -44
  20. package/memory-engine/cli/distill-sessions.mjs +237 -0
  21. package/memory-engine/cli/distill.mjs +196 -95
  22. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  23. package/memory-engine/cli/doc-index.mjs +47 -38
  24. package/memory-engine/cli/domain-tags.mjs +246 -162
  25. package/memory-engine/cli/layer-migrate.mjs +176 -0
  26. package/memory-engine/cli/layer-move.mjs +189 -0
  27. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  28. package/memory-engine/cli/recall.mjs +243 -110
  29. package/memory-engine/cli/selfcheck.mjs +93 -44
  30. package/memory-engine/cli/serve-start.mjs +221 -140
  31. package/memory-engine/cli/sweep.mjs +83 -44
  32. package/memory-engine/cli/sync.mjs +93 -57
  33. package/memory-engine/config/audit-devices.json +6 -0
  34. package/memory-engine/config/bands.json +7 -0
  35. package/memory-engine/config/layers.json +7 -0
  36. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  37. package/memory-engine/dict/lexicon-config.json +11 -0
  38. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  39. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  40. package/memory-engine/lib/band-sentinel.mjs +417 -0
  41. package/memory-engine/lib/cli-strict.mjs +128 -0
  42. package/memory-engine/lib/cli-util.mjs +147 -125
  43. package/memory-engine/lib/config.mjs +83 -96
  44. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  45. package/memory-engine/lib/distill-rollback.mjs +173 -133
  46. package/memory-engine/lib/distill.mjs +878 -585
  47. package/memory-engine/lib/doc-index-store.mjs +208 -235
  48. package/memory-engine/lib/doc-index.mjs +351 -350
  49. package/memory-engine/lib/doc-scan.mjs +178 -156
  50. package/memory-engine/lib/doc-summarize.mjs +220 -205
  51. package/memory-engine/lib/domain-tags.mjs +358 -277
  52. package/memory-engine/lib/embedder.mjs +175 -194
  53. package/memory-engine/lib/hashes.mjs +22 -7
  54. package/memory-engine/lib/layer-migrate.mjs +330 -0
  55. package/memory-engine/lib/layer-move.mjs +307 -0
  56. package/memory-engine/lib/layers.mjs +191 -0
  57. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  58. package/memory-engine/lib/memory-source.mjs +78 -80
  59. package/memory-engine/lib/pitfall-source.mjs +115 -114
  60. package/memory-engine/lib/policy-source.mjs +279 -261
  61. package/memory-engine/lib/recall-log.mjs +285 -0
  62. package/memory-engine/lib/recall.mjs +565 -240
  63. package/memory-engine/lib/selfcheck.mjs +142 -106
  64. package/memory-engine/lib/session-distill.mjs +603 -0
  65. package/memory-engine/lib/session-source.mjs +421 -0
  66. package/memory-engine/lib/sweep.mjs +107 -130
  67. package/memory-engine/lib/sync.mjs +110 -140
  68. package/memory-engine/lib/tick-hook.mjs +123 -148
  69. package/memory-engine/lib/vectors-store.mjs +213 -188
  70. package/memory-engine/test/12-layering.test.mjs +539 -0
  71. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  72. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  73. package/memory-engine/tools/fetch-model.mjs +167 -87
  74. package/memory-engine/tools/smoke-recall.mjs +94 -38
  75. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  76. package/package.json +11 -1
@@ -1,192 +1,318 @@
1
- // tools\lib\deploy-guard.mjs —— 部署门禁共享件(参数白名单 / 逃逸 import 静态扫 / 部署位置冒烟 import)
1
+ // 部署前的三道机械闸:参数白名单、逃逸 import 静态扫、按部署位置冒烟 import。
2
2
  //
3
- // ❗来由(2026-09-16 23:21 DSH 崩溃事故,2026-09-17 推广):某 deploy.mjs 收到不认识的 `--verify`
4
- // 被**静默忽略** → 以为在做只读检查,实际执行了真备份 + 真部署 + 内部 spawnSync dsh-restart.ps1
5
- // 硬重启(绕开 P0 闸门);而部署进 profile 的模块用相对路径跨包 import(仓库里层级恰好对上、
6
- // 装进 profiles\web\node_modules 后指向不存在的包)→ dsh boot 期 ERR_MODULE_NOT_FOUND,
7
- // 120s 内连崩 3 次唤分身。教训:`node --check` 只查**语法**,查不出**解析期**失败。
8
- // 首版落地在 tools\dsh-butler-panel\{preflight-imports.mjs,deploy.mjs}(拷问修复·B 卡),本文件是
9
- // 同口径的共享实现,供其余 deploy.mjs 复用。
3
+ // 为什么非得有这三道(一次真实事故换来的):
4
+ // ① 某个部署脚本收到一个它不认识的参数,静默忽略了 —— 操作者以为在跑只读检查,
5
+ // 脚本却照旧做了真备份、真写入,还顺手触发了重启。→ 参数必须 fail-closed。
6
+ // ② 同一批被装进去的模块用了相对路径跨包 import:在仓库里层级恰好对得上,装到目标
7
+ // 位置后就指向不存在的邻居包,宿主一启动就报模块找不到,连崩几次只能靠自愈拉起。
8
+ // → `node --check` 只查语法,查不出**解析期**失败,得按部署位置真 import 一遍。
10
9
  //
11
- // 三个导出(纯函数 + 薄 IO,零外部依赖):
12
- // parseArgs(argv, known) fail-closed 参数白名单:未知参数 → ok:false(调用方 rc=2 退出)
13
- // findEscapingImports(srcDir, opts) 静态扫「相对 import 解析后逃出插件根」的写法 → 命中清单
14
- // smokeImportAll(entityDir, libSpec) 以**部署位置**为解析上下文,逐模块冒烟 import(子进程隔离)
10
+ // 三个导出(纯函数为主,只有冒烟那一步会起子进程):
11
+ // parseArgs(argv, known) 白名单解析参数,认不出的原样报出来
12
+ // findEscapingImports(srcDir, opts) 静态扫"相对 import 解析后跑出实体根"的写法
13
+ // smokeImportAll(entityDir, libSpec) 以部署位置为解析上下文,逐个模块真 import
15
14
  //
16
- // 退出码口径(与 tools\dsh-butler-panel\deploy.mjs 对齐):
17
- // 0 = 通过 · 1 = 门禁未过(拒绝部署)· 2 = 用法/参数错误
15
+ // 退出码约定(调用方照抄):0 = 通过 · 1 = 门禁没过 · 2 = 用法/参数错误
18
16
  //
19
- // 用法示例见各 deploy.mjs 接入段;本文件自身不写任何文件(除子进程无副作用)。
20
- import fs from 'node:fs';
21
- import path from 'node:path';
17
+ // 本文件不写盘、不联网;唯一的外部动作是冒烟时起的那些子进程。
18
+
19
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
20
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
22
21
  import { spawnSync } from 'node:child_process';
23
22
  import { pathToFileURL } from 'node:url';
24
23
 
25
- export const EXIT = { OK: 0, GATE: 1, USAGE: 2 };
24
+ export const EXIT = {
25
+ OK: 0,
26
+ GATE: 1,
27
+ USAGE: 2,
28
+ };
26
29
 
27
- // 递归扫描时一律跳过的目录(node_modules 是依赖闭包、_archive/_tmp 是历史与临时产物;
28
- // task-board-local 的 node_modules 还是 junction → 扫进去会顺着盘外闭包跑几十个包)。
29
- const DEFAULT_EXCLUDE_DIRS = new Set([
30
- 'node_modules', '.git', '.hg', '.svn', '_archive', '_tmp', 'dist', 'build',
31
- 'coverage', '.cache', '.dsh', '.venv', '__pycache__', 'snapshots', 'backups',
30
+ // 递归时永远不进的目录名:依赖闭包、版本库元数据、历史与临时产物、构建产物。
31
+ // 依赖目录在这台机器上常常是链接,跟进去会跑到盘外扫几十个包。
32
+ const SKIP_DIR_NAMES = new Set([
33
+ 'node_modules', '.git', '.hg', '.svn', '_archive', '_tmp',
34
+ 'dist', 'build', 'coverage', '.cache', '.dsh', '.venv',
35
+ '__pycache__', 'snapshots', 'backups',
32
36
  ]);
33
- const JS_EXT = new Set(['.mjs', '.js', '.cjs']);
34
- // 前端产物:浏览器 bundle 本来就不该在 node 里 import(真 import 必报 window is not defined)。
35
- const DEFAULT_SMOKE_SKIP = [/^client\.js$/, /\.browser\.js$/, /\.min\.js$/];
36
37
 
37
- // ───────────────────────────────────────────────────────────── 1) 参数白名单
38
+ // 参与扫描的扩展名
39
+ const CODE_EXTS = new Set(['.mjs', '.js', '.cjs']);
40
+
41
+ // 冒烟时默认跳过的文件名(浏览器 bundle 在 node 里 import 必然报错,不该算部署失败)
42
+ const SKIP_SMOKE_FILES = [
43
+ /^client\.js$/,
44
+ /\.browser\.js$/,
45
+ /\.min\.js$/,
46
+ ];
47
+
48
+ // ── 1) 参数白名单 ────────────────────────────────────────────────────────────
49
+
50
+ // 编译白名单:字符串 = 布尔开关;对象 = 可取值开关(`--src X` 或 `--src=X` 都认)。
51
+ // 表用无原型对象 —— token 叫 `--constructor` 之类的时候不会撞上继承来的属性。
52
+ function compileSpec(known) {
53
+ const table = Object.create(null);
54
+ for (const item of known) {
55
+ if (typeof item === 'string') {
56
+ table[item] = { wantsValue: false };
57
+ continue;
58
+ }
59
+ if (!item || !item.flag) continue;
60
+ const wantsValue = Boolean(item.takesValue);
61
+ table[item.flag] = { wantsValue };
62
+ if (item.alias) table[item.alias] = { wantsValue, aliasOf: item.flag };
63
+ }
64
+ return table;
65
+ }
38
66
 
39
67
  /**
40
- * fail-closed 解析命令行参数。
41
- * @param {string[]} argv process.argv.slice(2)
42
- * @param {Array<string|{flag:string, takesValue?:boolean, alias?:string}>} known 白名单
43
- * 字符串 = 布尔开关;对象 = 可取值的参数(--src X 或 --src=X)
68
+ * 解析命令行参数,只认白名单里的 token。
69
+ * @param {string[]} argv 一般是 process.argv.slice(2)
70
+ * @param {Array<string|{flag:string, takesValue?:boolean, alias?:string}>} known
71
+ * 字符串 = 布尔开关;对象 = 可取值参数(同时可带 alias)
44
72
  * @returns {{ok:boolean, unknown:string[], flags:Record<string,boolean>,
45
73
  * values:Record<string,string>, positionals:string[]}}
46
- * ok=false 时 unknown 为全部不认识的 token(调用方负责打印 + 以 rc=2 退出)
74
+ * ok=false 时 unknown 里是全部不认识的 token(调用方负责打出来并以 rc=2 退出)
47
75
  */
48
76
  export function parseArgs(argv, known = []) {
49
- const spec = new Map();
50
- for (const k of known) {
51
- if (typeof k === 'string') spec.set(k, { takesValue: false });
52
- else if (k && k.flag) {
53
- spec.set(k.flag, { takesValue: !!k.takesValue });
54
- if (k.alias) spec.set(k.alias, { takesValue: !!k.takesValue, aliasOf: k.flag });
55
- }
56
- }
77
+ const spec = compileSpec(known);
57
78
  const flags = {};
58
79
  const values = {};
59
80
  const unknown = [];
60
81
  const positionals = [];
61
- for (let i = 0; i < argv.length; i++) {
62
- const tok = argv[i];
63
- if (!tok.startsWith('-')) { positionals.push(tok); continue; }
64
- const eq = tok.indexOf('=');
65
- const name = eq > 0 ? tok.slice(0, eq) : tok;
66
- const s = spec.get(name);
67
- if (!s) { unknown.push(tok); continue; }
68
- const key = s.aliasOf ?? name;
69
- if (s.takesValue) {
70
- let v = eq > 0 ? tok.slice(eq + 1) : null;
71
- if (v === null) { v = argv[i + 1] ?? null; i++; }
72
- values[key] = v;
73
- flags[key] = true;
74
- } else if (eq > 0) {
75
- unknown.push(tok); // 布尔开关不许带 =值(写错了要响,不要静默)
76
- } else {
77
- flags[key] = true;
82
+ let at = 0;
83
+
84
+ while (at < argv.length) {
85
+ const token = argv[at];
86
+
87
+ // 首字符不是 '-' 就是位置参数:这几个脚本一个位置参数都不吃,静默吞掉正是事故形态
88
+ if (token.charCodeAt(0) !== 45) {
89
+ positionals.push(token);
90
+ at += 1;
91
+ continue;
92
+ }
93
+
94
+ const eq = token.indexOf('=');
95
+ const head = eq > 0 ? token.slice(0, eq) : token;
96
+ const entry = spec[head];
97
+ if (!entry) {
98
+ unknown.push(token);
99
+ at += 1;
100
+ continue;
78
101
  }
102
+
103
+ const canonical = entry.aliasOf ?? head;
104
+
105
+ if (entry.wantsValue) {
106
+ let value = eq > 0 ? token.slice(eq + 1) : null;
107
+ if (value === null) {
108
+ value = argv[at + 1] ?? null;
109
+ at += 1; // 取值同样消费掉下一个 token(与老行为一致,别改成"留着继续解析")
110
+ }
111
+ values[canonical] = value;
112
+ flags[canonical] = true;
113
+ at += 1;
114
+ continue;
115
+ }
116
+
117
+ if (eq > 0) {
118
+ // 布尔开关带 =值 是写错了,要响一声,不能当开关静默放行
119
+ unknown.push(token);
120
+ at += 1;
121
+ continue;
122
+ }
123
+
124
+ flags[canonical] = true;
125
+ at += 1;
79
126
  }
80
- // 位置参数一律拒绝:这些 deploy 脚本都不吃位置参数,静默吞掉正是事故形态。
127
+
81
128
  unknown.push(...positionals);
82
129
  return { ok: unknown.length === 0, unknown, flags, values, positionals };
83
130
  }
84
131
 
85
- /** 把 unknown 渲染成人话(各脚本拼自己的「只读模式请用 X」尾巴)。 */
132
+ /** 把不认识的参数渲染成人话(调用方后面自己接"只读模式请用 X"的尾巴) */
86
133
  export function describeUnknown(unknown, known) {
87
- const all = known.map((k) => (typeof k === 'string' ? k : k.flag)).join(' ');
88
- return `不认识的参数:${unknown.join(' ')}(本脚本只认 ${all})`;
134
+ const allowed = known.map((item) => (typeof item === 'string' ? item : item.flag)).join(' ');
135
+ return `不认识的参数:${unknown.join(' ')}(本脚本只认 ${allowed})`;
89
136
  }
90
137
 
91
- // ───────────────────────────────────────────────────── 2) 逃逸 import 静态扫
138
+ // ── 2) 路径归一化 ────────────────────────────────────────────────────────────
92
139
 
93
140
  /**
94
- * 归一化路径:**存在的部分走 realpath,不存在的尾巴接回去**。
95
- * ⚠️ 必须两边都归一 —— 工作区里的目录 junction 很常见:只 realpath 一边会把
96
- * 「逻辑路径 vs 物理路径」比成 ..\..\...,于是合法的 './state.js' 也被误判成逃逸(实测踩过)。
141
+ * 归一化:能解析到的部分取真实路径,解析不到的尾巴接回去。
142
+ * 两边都得归一 —— 工作区里目录链接很常见,只归一一边会把同一棵树比成"越过上级",
143
+ * 于是合法的相邻 import 也被判成逃逸(踩过)。
144
+ * @param {string} target
145
+ * @returns {string}
97
146
  */
98
- export function normalizePath(p) {
99
- let cur = path.resolve(p);
100
- const tail = [];
147
+ export function normalizePath(target) {
148
+ const unresolved = [];
149
+ let probe = resolve(target);
150
+
101
151
  for (;;) {
152
+ let real = null;
102
153
  try {
103
- const real = fs.realpathSync.native ? fs.realpathSync.native(cur) : fs.realpathSync(cur);
104
- return tail.length ? path.join(real, ...tail.reverse()) : real;
154
+ real = realpathSync.native ? realpathSync.native(probe) : realpathSync(probe);
105
155
  } catch {
106
- const parent = path.dirname(cur);
107
- if (parent === cur) return path.resolve(p);
108
- tail.push(path.basename(cur));
109
- cur = parent;
156
+ real = null;
110
157
  }
158
+ if (real !== null) {
159
+ if (!unresolved.length) return real;
160
+ return join(real, ...unresolved.reverse());
161
+ }
162
+ const parent = dirname(probe);
163
+ if (parent === probe) return resolve(target);
164
+ unresolved.push(basename(probe));
165
+ probe = parent;
111
166
  }
112
167
  }
113
168
 
169
+ // ── 3) 去注释 ────────────────────────────────────────────────────────────────
170
+
114
171
  /**
115
- * 去注释:注释内容替换成空格(保留换行与列位置),引号内原样保留。
116
- * 目的 = 避免把注释里写的路径当成真 import(事故文件头注里就写着示例路径)。
117
- * 简易状态机:支持 // 、/* *\/ 、'…'、"…"、`…`(模板串内的 ${} 不再细分,漏掉的由门禁②兜底)。
172
+ * 把注释抹成空格(换行与列位置都保留),引号里的内容原样留着。
173
+ * 为什么必须抹:注释里常写着示例路径,不抹掉静态扫会把它当真 import。
174
+ * 状态机够用就行:`//`、块注释整段变空格;`'…'` / `"…"` / 反引号串内不动
175
+ * (模板串里的 `${}` 不再细分 —— 漏掉的情况由后面那道真 import 兜底)。
176
+ * @param {string} text
177
+ * @returns {string}
118
178
  */
119
179
  export function stripComments(text) {
120
- const out = [];
121
- let i = 0;
122
- let inBlock = false;
123
- let quote = null;
124
- while (i < text.length) {
125
- const c = text[i];
126
- const n = text[i + 1];
127
- if (inBlock) {
128
- if (c === '*' && n === '/') { out.push(' '); i += 2; inBlock = false; continue; }
129
- out.push(c === '\n' ? '\n' : ' ');
130
- i++;
180
+ const kept = [];
181
+ const length = text.length;
182
+ let pos = 0;
183
+ let mode = 'code';
184
+ let quoteChar = '';
185
+
186
+ while (pos < length) {
187
+ const ch = text[pos];
188
+
189
+ if (mode === 'block') {
190
+ if (ch === '*' && text[pos + 1] === '/') {
191
+ kept.push(' ');
192
+ pos += 2;
193
+ mode = 'code';
194
+ continue;
195
+ }
196
+ kept.push(ch === '\n' ? '\n' : ' ');
197
+ pos += 1;
131
198
  continue;
132
199
  }
133
- if (quote) {
134
- if (c === '\\') { out.push(c, n === undefined ? '' : n); i += 2; continue; }
135
- out.push(c);
136
- i++;
137
- if (c === quote) quote = null;
200
+
201
+ if (mode === 'line') {
202
+ if (ch === '\n') {
203
+ mode = 'code';
204
+ kept.push(ch);
205
+ pos += 1;
206
+ continue;
207
+ }
208
+ kept.push(' ');
209
+ pos += 1;
138
210
  continue;
139
211
  }
140
- if (c === '/' && n === '*') { out.push(' '); i += 2; inBlock = true; continue; }
141
- if (c === '/' && n === '/') {
142
- while (i < text.length && text[i] !== '\n') { out.push(' '); i++; }
212
+
213
+ if (mode === 'quote') {
214
+ if (ch === '\\') {
215
+ kept.push(ch, text[pos + 1] ?? '');
216
+ pos += 2;
217
+ continue;
218
+ }
219
+ kept.push(ch);
220
+ pos += 1;
221
+ if (ch === quoteChar) {
222
+ mode = 'code';
223
+ quoteChar = '';
224
+ }
143
225
  continue;
144
226
  }
145
- if (c === '"' || c === "'" || c === '`') { quote = c; out.push(c); i++; continue; }
146
- out.push(c);
147
- i++;
227
+
228
+ if (ch === '/' && text[pos + 1] === '*') {
229
+ kept.push(' ');
230
+ pos += 2;
231
+ mode = 'block';
232
+ continue;
233
+ }
234
+ if (ch === '/' && text[pos + 1] === '/') {
235
+ kept.push(' ');
236
+ pos += 2;
237
+ mode = 'line';
238
+ continue;
239
+ }
240
+ if (ch === '"' || ch === "'" || ch === '`') {
241
+ quoteChar = ch;
242
+ kept.push(ch);
243
+ pos += 1;
244
+ mode = 'quote';
245
+ continue;
246
+ }
247
+ kept.push(ch);
248
+ pos += 1;
148
249
  }
149
- return out.join('');
250
+
251
+ return kept.join('');
150
252
  }
151
253
 
152
- // 只认「import/export … from '…'」「import '…'」「import('…')」「require('…')」四种形态。
153
- const SPEC_RES = [
254
+ // 只认四种说明符形态:`import/export … from '…'`、`import '…'`、`import('…')`、`require('…')`。
255
+ // 前面那个字符类是为了躲开 `x.import(...)`、`$require(...)` 这类属性名/标识符写法。
256
+ const IMPORT_PATTERNS = [
154
257
  /(?:^|[^\w$.])(?:import|export)\b[^;]*?\bfrom\s*(['"])([^'"]+)\1/,
155
258
  /(?:^|[^\w$.])import\s*(['"])([^'"]+)\1/,
156
259
  /(?:^|[^\w$.])import\s*\(\s*(['"])([^'"]+)\1\s*\)/,
157
260
  /(?:^|[^\w$.])require\s*\(\s*(['"])([^'"]+)\1\s*\)/,
158
261
  ];
159
262
 
160
- /** 列出目录下全部 js/mjs/cjs(递归、排序、跳过黑名单目录)。 */
263
+ // 一行里出现的全部说明符(同一行同一个只记一次,顺序按上面四种形态的先后)
264
+ function moduleSpecsOf(line) {
265
+ const specs = [];
266
+ for (const pattern of IMPORT_PATTERNS) {
267
+ const matched = pattern.exec(line);
268
+ if (!matched || !matched[2]) continue;
269
+ if (!specs.includes(matched[2])) specs.push(matched[2]);
270
+ }
271
+ return specs;
272
+ }
273
+
274
+ /**
275
+ * 列出目录下全部 js/mjs/cjs(递归、排序、跳过黑名单目录与链接)。
276
+ * @param {string} root
277
+ * @param {{excludeDirs?:Set<string>, exts?:Set<string>}} [opts]
278
+ * @returns {string[]} 绝对路径,字典序
279
+ */
161
280
  export function listJsFiles(root, opts = {}) {
162
- const { excludeDirs = DEFAULT_EXCLUDE_DIRS, exts = JS_EXT } = opts;
163
- const out = [];
164
- const walk = (d) => {
165
- let ents = [];
166
- try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
167
- for (const e of ents) {
168
- if (e.isSymbolicLink()) continue; // junction/链接不跟随(跨盘闭包、盘外目录)
169
- const full = path.join(d, e.name);
170
- if (e.isDirectory()) {
171
- if (!excludeDirs.has(e.name)) walk(full);
172
- } else if (exts.has(path.extname(e.name))) {
173
- out.push(full);
281
+ const { excludeDirs = SKIP_DIR_NAMES, exts = CODE_EXTS } = opts;
282
+ const found = [];
283
+ const pending = [root];
284
+
285
+ while (pending.length > 0) {
286
+ const dir = pending.pop();
287
+ let entries;
288
+ try {
289
+ entries = readdirSync(dir, { withFileTypes: true });
290
+ } catch {
291
+ continue; // 读不动的目录跳过,不影响其余
292
+ }
293
+ for (const dent of entries) {
294
+ // 链接/junction 一律不跟随:跨盘闭包、盘外目录都在它后面
295
+ if (dent.isSymbolicLink()) continue;
296
+ const full = join(dir, dent.name);
297
+ if (dent.isDirectory()) {
298
+ if (!excludeDirs.has(dent.name)) pending.push(full);
299
+ continue;
174
300
  }
301
+ if (exts.has(extname(dent.name))) found.push(full);
175
302
  }
176
- };
177
- walk(root);
178
- return out.sort();
303
+ }
304
+
305
+ return found.sort();
179
306
  }
180
307
 
181
308
  /**
182
- * 静态扫:srcDir 下 js/mjs/cjs 里「相对 import 解析后逃出 root」的写法。
183
- * @param {string} srcDir 扫描起点 —— **只扫「会被复制进实体的那部分」**(各插件都是 lib/)。
184
- * 传插件根会把工具脚本一起扫进来(deploy.mjs / restore.mjs 自己 import '../lib/dsh-home.mjs'
185
- * 是合法姿势、且它们根本不进 profile)→ 自报假警,实测踩过。
186
- * @param {{root?:string}} [opts] root = 逃逸判定的根 = **实体的根**(插件被装进 profile 后的那个目录)。
187
- * 原地部署、不复制进 profile 的插件(如 tools\dsh-butler:整棵 tools 树是它的搬迁单位、
188
- * 跨子目录引用是合法设计)可放宽到树根。两边都走 realpath 归一(junction 坑)。
189
- * 跨目录引用的正解是 `file:///<绝对路径>` URL(绝对路径不计逃逸)。
309
+ * 静态扫:srcDir 下 js/mjs/cjs 里"相对 import 解析后跑出 root"的写法。
310
+ * @param {string} srcDir 扫描起点 —— 只该扫「会被复制进实体的那部分」(通常是 lib/)。
311
+ * 传整个插件根会把部署/回滚脚本一起扫进来,它们向上引用是合法姿势、又根本不进
312
+ * 目标位置,只会自报假警(踩过)。
313
+ * @param {{root?:string}} [opts] root = 逃逸判定的边界,即实体被装好之后的那层目录。
314
+ * 原地部署、整棵树一起搬的插件可以放宽到树根。两侧都走 normalizePath(链接坑)。
315
+ * 跨目录引用的正解是用 `file:///…` 绝对 URL —— 绝对路径不计逃逸。
190
316
  * @returns {{root:string, scanned:number, specs:number, absolute:number,
191
317
  * bad:Array<{file:string,line:number,spec:string,resolved:string,text:string}>}}
192
318
  */
@@ -195,197 +321,259 @@ export function findEscapingImports(srcDir, opts = {}) {
195
321
  const rootReal = normalizePath(root);
196
322
  const files = listJsFiles(srcDir, opts);
197
323
  const bad = [];
198
- let specs = 0;
199
- let absolute = 0;
324
+ let relativeSpecs = 0;
325
+ let absoluteSpecs = 0;
326
+
200
327
  for (const file of files) {
201
- let raw = '';
202
- try { raw = fs.readFileSync(file, 'utf8'); } catch { continue; }
203
- const lines = stripComments(raw).split(/\r?\n/);
204
- for (let i = 0; i < lines.length; i++) {
205
- const line = lines[i];
206
- if (!line.includes('import') && !line.includes('require')) continue;
207
- const seen = new Set();
208
- for (const re of SPEC_RES) {
209
- const m = re.exec(line);
210
- if (!m || !m[2] || seen.has(m[2])) continue;
211
- seen.add(m[2]);
212
- const spec = m[2];
213
- if (path.isAbsolute(spec)) { absolute++; continue; } // 绝对路径是 B 卡推荐的跨目录姿势,不算逃逸
214
- if (!spec.startsWith('.')) continue; // 裸包名(走 node_modules 解析)
215
- specs++;
216
- const resolved = path.resolve(path.dirname(file), spec);
217
- const rel = path.relative(rootReal, normalizePath(resolved));
218
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
219
- bad.push({
220
- file: path.relative(srcDir, file),
221
- line: i + 1,
222
- spec,
223
- resolved,
224
- text: raw.split(/\r?\n/)[i].trim().slice(0, 200),
225
- });
328
+ let raw;
329
+ try {
330
+ raw = readFileSync(file, 'utf8');
331
+ } catch {
332
+ continue; // 读不动就跳过(权限/竞态),不猜
333
+ }
334
+ const rawLines = raw.split(/\r?\n/);
335
+ const codeLines = stripComments(raw).split(/\r?\n/);
336
+
337
+ for (let index = 0; index < codeLines.length; index += 1) {
338
+ const line = codeLines[index];
339
+ if (!/import|require/.test(line)) continue;
340
+
341
+ for (const spec of moduleSpecsOf(line)) {
342
+ if (isAbsolute(spec)) {
343
+ absoluteSpecs += 1; // 绝对路径是推荐的跨目录姿势,不算逃逸
344
+ continue;
226
345
  }
346
+ if (!spec.startsWith('.')) continue; // 裸包名交给 node 自己的解析
347
+ relativeSpecs += 1;
348
+
349
+ const resolved = resolve(dirname(file), spec);
350
+ const inside = relative(rootReal, normalizePath(resolved));
351
+ if (!inside.startsWith('..') && !isAbsolute(inside)) continue;
352
+
353
+ bad.push({
354
+ file: relative(srcDir, file),
355
+ line: index + 1,
356
+ spec,
357
+ resolved,
358
+ text: rawLines[index].trim().slice(0, 200),
359
+ });
227
360
  }
228
361
  }
229
362
  }
230
- return { root, scanned: files.length, specs, absolute, bad };
363
+
364
+ return { root, scanned: files.length, specs: relativeSpecs, absolute: absoluteSpecs, bad };
231
365
  }
232
366
 
233
- // ─────────────────────────────────────────── 3) 部署位置冒烟 import(子进程)
234
-
235
- // 子进程内跑的两段代码(代码走 stdin,路径走 env —— 无引号/转义地狱)。
236
- // 快路径 · 批量:一个进程 import 全部模块,结果用 `DG_RESULT:` 前缀单行输出
237
- // (用前缀而非裸 JSON,是为了容忍被 import 的模块自己往 stdout 打日志)。
238
- const BATCH_CODE = [
239
- "const files = JSON.parse(process.env.DG_BATCH_FILES || '[]');",
240
- "const out = [];",
241
- "for (const item of files) {",
242
- " try { await import(item.url); out.push({ file: item.file, ok: true }); }",
243
- " catch (e) { out.push({ file: item.file, ok: false, err: String((e && (e.message || e)) || e) }); }",
244
- "}",
245
- "process.stdout.write('\\nDG_RESULT:' + JSON.stringify(out) + '\\n');",
246
- "if (process.env.DG_EXIT_AFTER === '1') process.exit(0);",
247
- "",
248
- ].join('\n');
249
-
250
- // 慢路径 · 单模块:批量判失败(或进程崩/超时)时逐个定位,并区分「解析失败」与「挂住」。
251
- const SMOKE_CODE = [
252
- "const url = process.env.DG_TARGET_URL;",
253
- "try {",
254
- " await import(url);",
255
- "} catch (e) {",
256
- " process.stderr.write('DG_IMPORT_FAIL: ' + String((e && (e.message || e)) || e));",
257
- " process.exitCode = 1;",
258
- "}",
259
- "// 模块 import 成功但挂着事件循环(定时器/监听)时,DG_EXIT_AFTER=1 让子进程主动收尾,",
260
- "// 以便把「解析失败」与「解析成功但进程不退出」分开判(后者不该判 FAIL)。",
261
- "if (!process.exitCode && process.env.DG_EXIT_AFTER === '1') process.exit(0);",
262
- "",
263
- ].join('\n');
264
-
265
- function spawnSmoke(file, timeoutMs, exitAfter) {
266
- return spawnSync(process.execPath, ['--input-type=module'], {
267
- input: SMOKE_CODE,
268
- encoding: 'utf8',
269
- timeout: timeoutMs,
270
- windowsHide: true,
271
- env: {
272
- ...process.env,
273
- DG_TARGET_URL: pathToFileURL(file).href,
274
- DG_EXIT_AFTER: exitAfter ? '1' : '0',
275
- },
276
- });
367
+ // ── 4) 按部署位置冒烟 import(子进程隔离) ──────────────────────────────────
368
+
369
+ // 子进程里跑的两段脚本:代码走 stdin、路径走环境变量 —— 省掉引号与转义那一堆坑。
370
+ // 批量版一次进程 import 全部模块,结果打成一行、带前缀(为了容忍被 import 的模块自己
371
+ // 往 stdout 打印日志)。
372
+ const BATCH_SNIPPET = `
373
+ const files = JSON.parse(process.env.DG_BATCH_FILES || '[]');
374
+ const report = [];
375
+ for (const item of files) {
376
+ try {
377
+ await import(item.url);
378
+ report.push({ file: item.file, ok: true });
379
+ } catch (cause) {
380
+ report.push({ file: item.file, ok: false, err: String((cause && (cause.message || cause)) || cause) });
381
+ }
382
+ }
383
+ process.stdout.write('\\nDG_RESULT:' + JSON.stringify(report) + '\\n');
384
+ if (process.env.DG_EXIT_AFTER === '1') process.exit(0);
385
+ `;
386
+
387
+ // 单模块版:失败时精确定位到文件,并把"解析失败"与"解析成功但进程不退出"分开。
388
+ const SINGLE_SNIPPET = `
389
+ const target = process.env.DG_TARGET_URL;
390
+ try {
391
+ await import(target);
392
+ } catch (cause) {
393
+ process.stderr.write('DG_IMPORT_FAIL: ' + String((cause && (cause.message || cause)) || cause));
394
+ process.exitCode = 1;
277
395
  }
396
+ if (!process.exitCode && process.env.DG_EXIT_AFTER === '1') process.exit(0);
397
+ `;
278
398
 
279
- function runBatchFast(items, timeoutMs) {
399
+ const BATCH_MARKER = 'DG_RESULT:';
400
+ const FAILURE_MARKER = 'DG_IMPORT_FAIL: ';
401
+
402
+ // 起一个子进程跑 stdin 脚本:超时、隐藏窗口、环境变量拼装都收在这一处
403
+ function runChildScript(script, extraEnv, timeoutMs) {
280
404
  return spawnSync(process.execPath, ['--input-type=module'], {
281
- input: BATCH_CODE,
405
+ input: script,
282
406
  encoding: 'utf8',
283
407
  timeout: timeoutMs,
284
408
  windowsHide: true,
285
- env: {
286
- ...process.env,
287
- DG_BATCH_FILES: JSON.stringify(items),
288
- DG_EXIT_AFTER: '1', // 批量模式只回答「能否全部解析」,import 完立刻收尾(免被定时器拖住)
289
- },
409
+ env: { ...process.env, ...extraEnv },
290
410
  });
291
411
  }
292
412
 
293
- /** 从(可能被模块日志污染的)stdout 里取最后一条 DG_RESULT 行。 */
294
- function extractBatchResult(stdout) {
295
- const marker = 'DG_RESULT:';
296
- const i = (stdout ?? '').lastIndexOf(marker);
297
- if (i < 0) return null;
298
- const line = stdout.slice(i + marker.length).split(/\r?\n/)[0];
413
+ function probeModule(file, timeoutMs, exitAfterImport) {
414
+ return runChildScript(SINGLE_SNIPPET, {
415
+ DG_TARGET_URL: pathToFileURL(file).href,
416
+ DG_EXIT_AFTER: exitAfterImport ? '1' : '0',
417
+ }, timeoutMs);
418
+ }
419
+
420
+ function probeBatch(items, timeoutMs) {
421
+ return runChildScript(BATCH_SNIPPET, {
422
+ DG_BATCH_FILES: JSON.stringify(items),
423
+ DG_EXIT_AFTER: '1', // 批量只回答"能不能全部解析",import 完立刻收尾(别被定时器拖住)
424
+ }, timeoutMs);
425
+ }
426
+
427
+ // 从(可能混着模块日志的)stdout 里取最后一条批量结果
428
+ function readBatchReport(stdout) {
429
+ const text = stdout ?? '';
430
+ const started = text.lastIndexOf(BATCH_MARKER);
431
+ if (started < 0) return null;
432
+ const payload = text.slice(started + BATCH_MARKER.length).split(/\r?\n/)[0];
433
+ let parsed;
299
434
  try {
300
- const arr = JSON.parse(line);
301
- return Array.isArray(arr) ? arr : null;
435
+ parsed = JSON.parse(payload);
302
436
  } catch {
303
437
  return null;
304
438
  }
439
+ return Array.isArray(parsed) ? parsed : null;
440
+ }
441
+
442
+ // 从 stderr 里取第一行有效错误信息(截到 300 字,够定位就行)
443
+ function readFailureText(stderr) {
444
+ const text = stderr ?? '';
445
+ const started = text.indexOf(FAILURE_MARKER);
446
+ const body = started >= 0 ? text.slice(started + FAILURE_MARKER.length) : text;
447
+ const firstLine = body.split(/\r?\n/).find((row) => row.trim()) ?? '';
448
+ return firstLine.trim().slice(0, 300);
449
+ }
450
+
451
+ // 快路径:整批 import。判据从严 —— 只有"整批都说 ok"才算通过,否则交给慢路径确认。
452
+ // 理由:批量只会误报失败(模块之间互相干扰),不会误报成功(每个模块都真 import 过)。
453
+ function attemptBatch(files, libDir, timeoutMs) {
454
+ const manifest = files.map((file) => ({
455
+ file: relative(libDir, file),
456
+ url: pathToFileURL(file).href,
457
+ }));
458
+ const run = probeBatch(manifest, timeoutMs);
459
+ const report = readBatchReport(run.stdout);
460
+ if (!report || report.length !== files.length) return null;
461
+ if (!report.every((row) => row.ok)) return null;
462
+ return report.map((row) => ({ file: row.file, ok: true, ms: 0, hung: null }));
463
+ }
464
+
465
+ // 慢路径:一个模块一个进程。三态 —— 通过 / 能解析但把事件循环挂住了 / 解析失败(带原因)
466
+ function inspectModule(file, timeoutMs) {
467
+ const first = probeModule(file, timeoutMs, false);
468
+ if (!(first.error || first.signal)) {
469
+ if (first.status === 0) return { resolved: true, hung: false };
470
+ return {
471
+ resolved: false,
472
+ timeout: false,
473
+ err: readFailureText(first.stderr ?? '') || `exit ${first.status}`,
474
+ };
475
+ }
476
+
477
+ // 第一次没跑完未必是解析失败:模块 import 成功后也可能挂着定时器/监听器。
478
+ // 让它 import 完主动收尾再试一次,两种原因就分开了。
479
+ const second = probeModule(file, timeoutMs, true);
480
+ if (second.status === 0) return { resolved: true, hung: true };
481
+ if (!(second.error || second.signal)) {
482
+ return {
483
+ resolved: false,
484
+ timeout: false,
485
+ err: readFailureText(second.stderr ?? '') || `exit ${second.status}`,
486
+ };
487
+ }
488
+
489
+ // 两次都没跑完:仍按"卡住"报,附带第一次拿到的信号/错误码
490
+ return {
491
+ resolved: false,
492
+ timeout: true,
493
+ err: `${first.error?.code ?? first.signal ?? '超时'}:${timeoutMs}ms 内未结束(import 后进程既不退出也不报错)`,
494
+ };
305
495
  }
306
496
 
307
497
  /**
308
- * 以**部署位置**为解析上下文,逐个 import lib 里的模块。
309
- * 关键点:import 的是**实体目录里的那个文件**(file URL),模块内部的相对 import 才会按实体
310
- * 位置解析 —— 这正是「仓库里跑得通、装进 profile 就崩」的暴露面。子进程隔离:模块若有
311
- * 副作用/挂住事件循环,不会污染部署进程。
312
- * @param {string} entityDir 部署实体目录(必须是真实存在的路径;原地部署的插件传源码目录自身)
498
+ * 以**部署位置**为解析上下文,把 lib 里的模块逐个 import 一遍。
499
+ * 关键点:import 的是实体目录里的那个文件(file URL),模块自己的相对 import 才会按
500
+ * 部署后的位置解析 —— 这正是"仓库里跑得通、装到目标位置就崩"的暴露面。
501
+ * 每个模块单独一个子进程:模块有副作用或挂着事件循环,也污染不到部署进程。
502
+ * @param {string} entityDir 部署实体目录(必须真实存在;原地部署的插件传源码目录自身)
313
503
  * @param {string|{dir?:string, skip?:RegExp[], timeoutMs?:number}} [libSpec='lib']
314
- * @param {{dir?:string, skip?:RegExp[], timeoutMs?:number, only?:RegExp}} [opts]
315
- * @returns {{dir:string, error:string|null, total:number, skipped:number,
316
- * bad:Array<{file:string,err:string,timeout:boolean}>, results:Array<{file:string,ok:boolean,ms:number}>}}
504
+ * @param {{dir?:string, skip?:RegExp[], timeoutMs?:number, only?:RegExp,
505
+ * batchTimeoutMs?:number, batch?:boolean}} [opts]
506
+ * @returns {{dir:string, error:string|null, mode:string|null, total:number, skipped:number,
507
+ * bad:Array<{file:string,err:string,timeout:boolean}>,
508
+ * results:Array<{file:string,ok:boolean,ms:number,hung:boolean|null}>}}
317
509
  */
318
510
  export function smokeImportAll(entityDir, libSpec = 'lib', opts = {}) {
319
511
  const cfg = typeof libSpec === 'string' ? { ...opts, dir: libSpec } : { ...opts, ...libSpec };
320
512
  const {
321
- skip = DEFAULT_SMOKE_SKIP, timeoutMs = 20000, batchTimeoutMs = 90000,
322
- batch = true, only = null,
513
+ skip = SKIP_SMOKE_FILES,
514
+ timeoutMs = 20000,
515
+ batchTimeoutMs = 90000,
516
+ batch = true,
517
+ only = null,
323
518
  } = cfg;
324
- const ent = path.resolve(entityDir);
325
- const libDir = path.isAbsolute(cfg.dir ?? 'lib') ? cfg.dir : path.join(ent, cfg.dir ?? 'lib');
519
+
520
+ const entityRoot = resolve(entityDir);
521
+ const requestedDir = cfg.dir ?? 'lib';
522
+ const libDir = isAbsolute(requestedDir) ? requestedDir : join(entityRoot, requestedDir);
326
523
  const empty = { dir: libDir, error: null, mode: null, total: 0, skipped: 0, bad: [], results: [] };
327
524
 
328
- if (!fs.existsSync(ent)) return { ...empty, error: '实体目录不存在:' + ent };
329
- if (!fs.existsSync(libDir)) return { ...empty, error: '实体下没有 lib 目录:' + libDir };
330
- // 解析上下文必须是实体目录里的文件:lib 不在实体之下 → 拒绝(防"扫了源码目录还以为在测实体")
331
- const relLib = path.relative(normalizePath(ent), normalizePath(libDir));
332
- if (relLib.startsWith('..') || path.isAbsolute(relLib)) {
333
- return { ...empty, error: 'lib 目录不在实体目录内(解析上下文错位):' + libDir };
525
+ if (!existsSync(entityRoot)) return { ...empty, error: `实体目录不存在:${entityRoot}` };
526
+ if (!existsSync(libDir)) return { ...empty, error: `实体下没有 lib 目录:${libDir}` };
527
+
528
+ // 解析上下文必须落在实体内部:lib 跑到实体外面,等于拿源码目录冒充部署位置
529
+ const containment = relative(normalizePath(entityRoot), normalizePath(libDir));
530
+ if (containment.startsWith('..') || isAbsolute(containment)) {
531
+ return { ...empty, error: `lib 目录不在实体目录内(解析上下文错位):${libDir}` };
334
532
  }
335
533
 
336
- const files = listJsFiles(libDir).filter((f) => !only || only.test(path.relative(libDir, f)));
337
- const targets = files.filter((f) => !skip.some((re) => re.test(path.relative(libDir, f))));
338
- const skipped = files.length - targets.length;
339
- const bad = [];
340
- const results = [];
534
+ const discovered = listJsFiles(libDir).filter((file) => !only || only.test(relative(libDir, file)));
535
+ const targets = discovered.filter((file) => !skip.some((pattern) => pattern.test(relative(libDir, file))));
536
+ const skipped = discovered.length - targets.length;
341
537
 
342
- // 快路径:单进程批量 import 全部模块(dsh-butler 82 个模块从 ~18s 降到 ~2s)。
343
- // 判据严格:**只有「批量说全部 ok」才算通过**,否则一律落慢路径逐个确认 —— 批量的假失败
344
- // (模块间串扰)由慢路径澄清,批量的假成功不存在(每个模块都真 import 过)。
538
+ // 快路径:一个进程 import 全部(模块一多就快一个数量级)
345
539
  if (batch && targets.length > 1) {
346
- const r = runBatchFast(
347
- targets.map((f) => ({ file: path.relative(libDir, f), url: pathToFileURL(f).href })),
348
- batchTimeoutMs,
349
- );
350
- const parsed = extractBatchResult(r.stdout ?? '');
351
- if (parsed && parsed.length === targets.length && parsed.every((x) => x.ok)) {
540
+ const overview = attemptBatch(targets, libDir, batchTimeoutMs);
541
+ if (overview) {
352
542
  return {
353
- dir: libDir, error: null, mode: 'batch', total: targets.length, skipped, bad: [],
354
- results: parsed.map((x) => ({ file: x.file, ok: true, ms: 0, hung: null })),
543
+ dir: libDir,
544
+ error: null,
545
+ mode: 'batch',
546
+ total: targets.length,
547
+ skipped,
548
+ bad: [],
549
+ results: overview,
355
550
  };
356
551
  }
357
552
  }
358
553
 
359
- // 慢路径:逐模块(精确定位到文件 + 区分「解析失败」与「import 成功但挂着不退出」)
360
- for (const f of targets) {
361
- const rel = path.relative(libDir, f);
362
- const t0 = Date.now();
363
- let r = spawnSmoke(f, timeoutMs, false);
364
- let timeout = !!(r.error || r.signal);
365
- let hung = false;
366
- if (timeout) {
367
- // 第一次超时可能是「模块挂住事件循环」而非解析失败 → 用 import 完主动收尾再试一次区分。
368
- const r2 = spawnSmoke(f, timeoutMs, true);
369
- if (r2.status === 0) { r = r2; timeout = false; hung = true; }
370
- else if (!(r2.error || r2.signal)) { r = r2; timeout = false; }
371
- }
372
- const ms = Date.now() - t0;
373
- if (!timeout && r.status === 0) {
374
- results.push({ file: rel, ok: true, ms, hung });
554
+ // 慢路径:逐模块(能精确定位到文件,也能分辨"解析失败"与"import 成功后挂着不退出")
555
+ const bad = [];
556
+ const results = [];
557
+ for (const file of targets) {
558
+ const label = relative(libDir, file);
559
+ const startedAt = Date.now();
560
+ const outcome = inspectModule(file, timeoutMs);
561
+ const spent = Date.now() - startedAt;
562
+ if (outcome.resolved) {
563
+ results.push({ file: label, ok: true, ms: spent, hung: outcome.hung });
375
564
  continue;
376
565
  }
377
- const err = timeout
378
- ? `${r.error?.code ?? r.signal ?? '超时'}:${timeoutMs}ms 内未结束(import 后进程既不退出也不报错)`
379
- : extractSmokeError(r.stderr ?? '') || `exit ${r.status}`;
380
- bad.push({ file: rel, err, timeout });
381
- results.push({ file: rel, ok: false, ms, hung: false });
566
+ bad.push({ file: label, err: outcome.err, timeout: outcome.timeout });
567
+ results.push({ file: label, ok: false, ms: spent, hung: false });
382
568
  }
383
- return { dir: libDir, error: null, mode: 'per-module', total: targets.length, skipped, bad, results };
384
- }
385
569
 
386
- function extractSmokeError(stderr) {
387
- const marker = stderr.indexOf('DG_IMPORT_FAIL: ');
388
- let msg = marker >= 0 ? stderr.slice(marker + 'DG_IMPORT_FAIL: '.length) : stderr;
389
- msg = msg.split(/\r?\n/).filter((l) => l.trim())[0] ?? '';
390
- return msg.trim().slice(0, 300);
570
+ return {
571
+ dir: libDir,
572
+ error: null,
573
+ mode: 'per-module',
574
+ total: targets.length,
575
+ skipped,
576
+ bad,
577
+ results,
578
+ };
391
579
  }