dsh-any-agent 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/USAGE-NOTES.en.md +3 -1
  3. package/USAGE-NOTES.md +9 -2
  4. package/bin/dsh-any-agent-workspaces.mjs +12 -5
  5. package/bin/dsh-any-agent.mjs +27 -2
  6. package/bin/restore.mjs +1 -1
  7. package/docs/en/compatibility.md +27 -6
  8. package/docs/en/reference.md +4 -1
  9. package/docs/en/release.md +4 -0
  10. package/docs/en/security.md +1 -0
  11. package/docs/en/tests.md +59 -15
  12. package/docs/en/usage.md +22 -0
  13. package/docs/zh/compatibility.md +25 -6
  14. package/docs/zh/live-view-design.md +192 -0
  15. package/docs/zh/reference.md +4 -1
  16. package/docs/zh/release.md +3 -0
  17. package/docs/zh/security.md +1 -0
  18. package/docs/zh/tests.md +59 -15
  19. package/docs/zh/usage.md +22 -0
  20. package/package.json +4 -1
  21. package/scripts/detect-dsh.mjs +55 -0
  22. package/scripts/lint.mjs +595 -0
  23. package/scripts/sync-test-counts.mjs +699 -0
  24. package/src/acp-session.mjs +63 -0
  25. package/src/acp.mjs +14 -5
  26. package/src/config.mjs +39 -111
  27. package/src/conversation.mjs +1714 -0
  28. package/src/doctor.mjs +203 -0
  29. package/src/dsh-locate.mjs +560 -0
  30. package/src/git-snapshot.mjs +196 -0
  31. package/src/hub-store.mjs +108 -0
  32. package/src/hub.mjs +33 -1972
  33. package/src/live-cli.mjs +115 -0
  34. package/src/live-dashboard.html +535 -0
  35. package/src/live-dashboard.mjs +176 -0
  36. package/src/live-view.mjs +592 -0
  37. package/src/live.mjs +186 -0
  38. package/src/mcp.mjs +24 -75
  39. package/src/residue.mjs +282 -0
  40. package/src/sentinel.mjs +97 -0
  41. package/src/tool-defs.mjs +399 -0
  42. package/src/tool-render.mjs +262 -0
  43. package/src/tools.mjs +18 -767
  44. package/src/workspace-guard.mjs +89 -0
  45. package/src/workspace.mjs +295 -59
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 零依赖静态检查:`npm run lint`。
4
+ *
5
+ * 为什么自己写而不是装 ESLint:本项目的底线是零依赖(test/package-check.mjs 强制:
6
+ * 没有 dependencies,也不引入 devDependencies)。这里只查"改文件结构时最容易手滑、
7
+ * 且测试不一定覆盖到"的几类错误 —— 拆模块、挪函数、改导入之后,一个漏掉的导出或
8
+ * 一条没删的导入,在没有真实 DSH 的 CI 上往往要到某条冷路径才炸。
9
+ *
10
+ * 检查项(任何一项有问题就退出码 1):
11
+ * 1. syntax 对 src/ bin/ scripts/ test/ demo/ 下每个 .mjs 跑 `node --check`;
12
+ * 2. resolve 每个相对路径的 import / export ... from / import('...') 必须指向存在的文件
13
+ * (大小写也要完全一致:Windows/macOS 默认不区分大小写,只有 Linux CI 才会炸);
14
+ * 3. exports 具名导入必须是目标模块的导出(含 export { a } from、export * from 的传递);
15
+ * `const { a } = await import('./x.mjs')` 解构出来的名字同样检查;
16
+ * node:fs 这类内置模块的具名导入也对照当前 Node 实际导出检查;
17
+ * 4. unused 没有被用到的具名导入(import { a, b } 里的 a 或 b)。
18
+ *
19
+ * 实现是静态词法扫描,不是完整的 JS 解析器:先把注释 / 字符串 / 正则 / 模板字符串里的
20
+ * 纯文本剔掉,再在 token 流上识别 import / export 语句。取舍是宁可漏报也不误报 ——
21
+ * 例如被内层同名参数遮蔽的未用导入会漏掉,但不会把真正在用的导入报成未用。
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import { builtinModules } from 'node:module';
27
+ import { dirname, join, relative, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ // `--root <dir>` 只给 test/lint.mjs 用:在临时目录里造一棵有问题的树,验证每条规则真的会报。
31
+ const rootFlag = process.argv.indexOf('--root');
32
+ const ROOT =
33
+ rootFlag > 0 && process.argv[rootFlag + 1]
34
+ ? resolve(process.argv[rootFlag + 1])
35
+ : resolve(dirname(fileURLToPath(import.meta.url)), '..');
36
+ const DIRS = ['src', 'bin', 'scripts', 'test', 'demo'];
37
+ const CHECK_CONCURRENCY = 8;
38
+
39
+ // ── 词法扫描 ──────────────────────────────────────────────────────
40
+
41
+ const ID_START = /[\p{L}_$]/u;
42
+ const ID_PART = /[\p{L}\p{N}_$\u200c\u200d]/u;
43
+ const NUMBER = /^(?:0[xXoObB][\da-fA-F_]+n?|\d[\d_]*\.?[\d_]*(?:[eE][+-]?\d+)?n?|\.\d[\d_]*(?:[eE][+-]?\d+)?)/;
44
+ /** 这些关键字后面的 `/` 是正则的开头,不是除号。 */
45
+ const REGEX_AFTER_KEYWORD = new Set([
46
+ 'return', 'typeof', 'case', 'in', 'of', 'delete', 'void', 'throw', 'new', 'else', 'do', 'yield', 'await', 'instanceof',
47
+ ]);
48
+
49
+ /**
50
+ * 把源码切成 token:`{ type: 'id' | 'str' | 'punct' | 'num' | 'tpl', value, line }`。
51
+ * 注释丢弃;字符串保留内容(import 的路径要用);模板字符串只保留 `${ }` 里的代码,
52
+ * 静态文本丢弃 —— 否则模板里写的 "import {" 之类文字会被当成语句。
53
+ */
54
+ export function tokenize(src) {
55
+ const tokens = [];
56
+ const n = src.length;
57
+ let i = 0;
58
+ let line = 1;
59
+ /** 正在读的模板字符串里,`${` 之后的花括号深度(用来找到配对的 `}`)。 */
60
+ const tplStack = [];
61
+
62
+ const push = (type, value, startLine = line) => tokens.push({ type, value, line: startLine });
63
+
64
+ // 读模板字符串的静态部分,直到结束的反引号或下一个 `${`
65
+ const readTemplate = () => {
66
+ while (i < n) {
67
+ const ch = src[i];
68
+ if (ch === '\\') {
69
+ if (src[i + 1] === '\n') line++;
70
+ i += 2;
71
+ continue;
72
+ }
73
+ if (ch === '`') {
74
+ i++;
75
+ return;
76
+ }
77
+ if (ch === '$' && src[i + 1] === '{') {
78
+ i += 2;
79
+ tplStack.push({ depth: 0 });
80
+ return;
81
+ }
82
+ if (ch === '\n') line++;
83
+ i++;
84
+ }
85
+ };
86
+
87
+ if (src.startsWith('#!')) while (i < n && src[i] !== '\n') i++;
88
+
89
+ while (i < n) {
90
+ const c = src[i];
91
+ if (c === '\n') {
92
+ line++;
93
+ i++;
94
+ continue;
95
+ }
96
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\ufeff' || /\s/.test(c)) {
97
+ i++;
98
+ continue;
99
+ }
100
+ if (c === '/' && src[i + 1] === '/') {
101
+ while (i < n && src[i] !== '\n') i++;
102
+ continue;
103
+ }
104
+ if (c === '/' && src[i + 1] === '*') {
105
+ const end = src.indexOf('*/', i + 2);
106
+ const stop = end < 0 ? n : end + 2;
107
+ for (let k = i; k < stop; k++) if (src[k] === '\n') line++;
108
+ i = stop;
109
+ continue;
110
+ }
111
+ if (c === '"' || c === "'") {
112
+ const startLine = line;
113
+ let j = i + 1;
114
+ let value = '';
115
+ while (j < n && src[j] !== c && src[j] !== '\n') {
116
+ if (src[j] === '\\') {
117
+ if (src[j + 1] === '\n') line++;
118
+ value += src[j + 1] ?? '';
119
+ j += 2;
120
+ continue;
121
+ }
122
+ value += src[j];
123
+ j++;
124
+ }
125
+ push('str', value, startLine);
126
+ i = j + 1;
127
+ continue;
128
+ }
129
+ if (c === '`') {
130
+ push('tpl', '', line);
131
+ i++;
132
+ readTemplate();
133
+ continue;
134
+ }
135
+ if (ID_START.test(c)) {
136
+ let j = i + 1;
137
+ while (j < n && ID_PART.test(src[j])) j++;
138
+ push('id', src.slice(i, j));
139
+ i = j;
140
+ continue;
141
+ }
142
+ const numMatch = /[0-9]/.test(c) || (c === '.' && /[0-9]/.test(src[i + 1] ?? '')) ? NUMBER.exec(src.slice(i, i + 80)) : null;
143
+ if (numMatch) {
144
+ push('num', numMatch[0]);
145
+ i += numMatch[0].length;
146
+ continue;
147
+ }
148
+ if (c === '/') {
149
+ const prev = tokens[tokens.length - 1];
150
+ const startsRegex =
151
+ !prev ||
152
+ (prev.type === 'punct' && !')]}'.includes(prev.value)) ||
153
+ (prev.type === 'id' && REGEX_AFTER_KEYWORD.has(prev.value));
154
+ if (startsRegex) {
155
+ let j = i + 1;
156
+ let inClass = false;
157
+ let ok = false;
158
+ while (j < n && src[j] !== '\n') {
159
+ const ch = src[j];
160
+ if (ch === '\\') {
161
+ j += 2;
162
+ continue;
163
+ }
164
+ if (ch === '[') inClass = true;
165
+ else if (ch === ']') inClass = false;
166
+ else if (ch === '/' && !inClass) {
167
+ ok = true;
168
+ break;
169
+ }
170
+ j++;
171
+ }
172
+ if (ok) {
173
+ j++;
174
+ while (j < n && ID_PART.test(src[j])) j++;
175
+ push('num', 'regex');
176
+ i = j;
177
+ continue;
178
+ }
179
+ }
180
+ }
181
+ // 标点:只需要区分少数多字符形式
182
+ if (c === '}' && tplStack.length && tplStack[tplStack.length - 1].depth === 0) {
183
+ tplStack.pop();
184
+ i++;
185
+ readTemplate();
186
+ continue;
187
+ }
188
+ if (tplStack.length) {
189
+ const top = tplStack[tplStack.length - 1];
190
+ if (c === '{') top.depth++;
191
+ else if (c === '}') top.depth--;
192
+ }
193
+ if (src.startsWith('...', i)) {
194
+ push('punct', '...');
195
+ i += 3;
196
+ } else if (src.startsWith('=>', i)) {
197
+ push('punct', '=>');
198
+ i += 2;
199
+ } else if (src.startsWith('?.', i) && !/[0-9]/.test(src[i + 2] ?? '')) {
200
+ push('punct', '?.');
201
+ i += 2;
202
+ } else {
203
+ push('punct', c);
204
+ i++;
205
+ }
206
+ }
207
+ return tokens;
208
+ }
209
+
210
+ // ── import / export 识别 ─────────────────────────────────────────
211
+
212
+ const isId = (t, v) => t && t.type === 'id' && (v === undefined || t.value === v);
213
+ const isPunct = (t, v) => t && t.type === 'punct' && t.value === v;
214
+
215
+ /** 读 `{ a, b as c, default as d }` 形式的说明符列表;返回 [说明符, 下一个下标]。 */
216
+ function readSpecifiers(tokens, start) {
217
+ // tokens[start] 必须是 '{'
218
+ const specs = [];
219
+ let j = start + 1;
220
+ while (j < tokens.length && !isPunct(tokens[j], '}')) {
221
+ const first = tokens[j];
222
+ if (first.type === 'id' || first.type === 'str') {
223
+ let local = first.value;
224
+ let line = first.line;
225
+ if (isId(tokens[j + 1], 'as') && tokens[j + 2]) {
226
+ local = tokens[j + 2].value;
227
+ j += 2;
228
+ }
229
+ specs.push({ imported: first.value, local, line });
230
+ }
231
+ j++;
232
+ }
233
+ return [specs, j + 1];
234
+ }
235
+
236
+ /**
237
+ * 从 token 流里抽出模块的结构:静态导入、重新导出、本地导出、动态导入,
238
+ * 以及"不算作使用"的 token 区间(导入语句本身、`export ... from` 语句)。
239
+ */
240
+ export function parseModule(src) {
241
+ const tokens = tokenize(src);
242
+ const mod = {
243
+ tokens,
244
+ imports: [],
245
+ reExports: [],
246
+ exports: new Set(),
247
+ /** 导出表不完整(出现了解构导出之类无法静态列全的写法)。 */
248
+ openExports: false,
249
+ dynamicImports: [],
250
+ excluded: new Set(),
251
+ };
252
+ const exclude = (a, b) => {
253
+ for (let k = a; k <= b; k++) mod.excluded.add(k);
254
+ };
255
+
256
+ for (let i = 0; i < tokens.length; i++) {
257
+ const t = tokens[i];
258
+ if (t.type !== 'id') continue;
259
+ const prev = tokens[i - 1];
260
+ if (isPunct(prev, '.') || isPunct(prev, '?.')) continue;
261
+
262
+ if (t.value === 'import') {
263
+ const next = tokens[i + 1];
264
+ if (isPunct(next, '(')) {
265
+ // 动态导入:只处理字面量路径;若是 `const { a, b } = await import(...)`,把解构名字一并取出
266
+ if (tokens[i + 2]?.type === 'str' && isPunct(tokens[i + 3], ')')) {
267
+ const rec = { spec: tokens[i + 2].value, line: t.line, names: null };
268
+ let k = i - 1;
269
+ if (isId(tokens[k], 'await')) k--;
270
+ if (isPunct(tokens[k], '=') && isPunct(tokens[k - 1], '}')) {
271
+ let depth = 0;
272
+ let open = k - 1;
273
+ for (; open >= 0; open--) {
274
+ if (isPunct(tokens[open], '}')) depth++;
275
+ else if (isPunct(tokens[open], '{') && --depth === 0) break;
276
+ }
277
+ if (open > 0 && ['const', 'let', 'var'].includes(tokens[open - 1]?.value)) {
278
+ rec.names = [];
279
+ let d = 0;
280
+ let expectKey = true;
281
+ for (let m = open + 1; m < k - 1; m++) {
282
+ const x = tokens[m];
283
+ if (isPunct(x, '{') || isPunct(x, '[') || isPunct(x, '(')) d++;
284
+ else if (isPunct(x, '}') || isPunct(x, ']') || isPunct(x, ')')) d--;
285
+ else if (d === 0 && isPunct(x, ',')) expectKey = true;
286
+ else if (d === 0 && expectKey) {
287
+ if (isPunct(x, '...')) {
288
+ expectKey = false;
289
+ } else if (x.type === 'id' || x.type === 'str') {
290
+ rec.names.push({ imported: x.value, line: x.line });
291
+ expectKey = false;
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+ mod.dynamicImports.push(rec);
298
+ }
299
+ continue;
300
+ }
301
+ if (isPunct(next, '.')) continue; // import.meta
302
+ // 静态导入
303
+ const rec = { spec: null, line: t.line, defaultLocal: null, nsLocal: null, names: [] };
304
+ let j = i + 1;
305
+ if (tokens[j]?.type === 'str') {
306
+ rec.spec = tokens[j].value;
307
+ } else {
308
+ while (j < tokens.length && !isId(tokens[j], 'from')) {
309
+ const x = tokens[j];
310
+ if (isPunct(x, '{')) {
311
+ const [specs, nextJ] = readSpecifiers(tokens, j);
312
+ rec.names.push(...specs);
313
+ j = nextJ;
314
+ continue;
315
+ }
316
+ if (isPunct(x, '*')) {
317
+ rec.nsLocal = tokens[j + 2]?.value ?? null;
318
+ j += 3;
319
+ continue;
320
+ }
321
+ if (x.type === 'id' && !rec.defaultLocal) rec.defaultLocal = { local: x.value, line: x.line };
322
+ j++;
323
+ }
324
+ if (tokens[j + 1]?.type === 'str') {
325
+ rec.spec = tokens[j + 1].value;
326
+ j += 1;
327
+ }
328
+ }
329
+ if (rec.spec === null) continue;
330
+ // import attributes: with { type: 'json' }
331
+ if ((isId(tokens[j + 1], 'with') || isId(tokens[j + 1], 'assert')) && isPunct(tokens[j + 2], '{')) {
332
+ j += 2;
333
+ while (j < tokens.length && !isPunct(tokens[j], '}')) j++;
334
+ }
335
+ exclude(i, j);
336
+ mod.imports.push(rec);
337
+ i = j;
338
+ continue;
339
+ }
340
+
341
+ if (t.value === 'export') {
342
+ const next = tokens[i + 1];
343
+ if (isPunct(next, '{')) {
344
+ const [specs, afterJ] = readSpecifiers(tokens, i + 1);
345
+ if (isId(tokens[afterJ], 'from') && tokens[afterJ + 1]?.type === 'str') {
346
+ mod.reExports.push({ spec: tokens[afterJ + 1].value, line: t.line, star: false, names: specs });
347
+ exclude(i, afterJ + 1);
348
+ i = afterJ + 1;
349
+ } else {
350
+ for (const s of specs) mod.exports.add(s.local === s.imported ? s.imported : s.local);
351
+ i = afterJ - 1;
352
+ }
353
+ continue;
354
+ }
355
+ if (isPunct(next, '*')) {
356
+ // export * from 'x' / export * as ns from 'x'
357
+ let j = i + 2;
358
+ let ns = null;
359
+ if (isId(tokens[j], 'as')) {
360
+ ns = tokens[j + 1]?.value ?? null;
361
+ j += 2;
362
+ }
363
+ if (isId(tokens[j], 'from') && tokens[j + 1]?.type === 'str') {
364
+ if (ns) mod.exports.add(ns);
365
+ mod.reExports.push({ spec: tokens[j + 1].value, line: t.line, star: !ns, names: [] });
366
+ exclude(i, j + 1);
367
+ i = j + 1;
368
+ }
369
+ continue;
370
+ }
371
+ if (isId(next, 'default')) {
372
+ mod.exports.add('default');
373
+ continue;
374
+ }
375
+ let j = i + 1;
376
+ if (isId(tokens[j], 'async')) j++;
377
+ if (isId(tokens[j], 'function')) {
378
+ j++;
379
+ if (isPunct(tokens[j], '*')) j++;
380
+ if (tokens[j]?.type === 'id') mod.exports.add(tokens[j].value);
381
+ continue;
382
+ }
383
+ if (isId(tokens[j], 'class')) {
384
+ if (tokens[j + 1]?.type === 'id') mod.exports.add(tokens[j + 1].value);
385
+ continue;
386
+ }
387
+ if (['const', 'let', 'var'].includes(tokens[j]?.value)) {
388
+ const name = tokens[j + 1];
389
+ if (name?.type === 'id') mod.exports.add(name.value);
390
+ else mod.openExports = true; // export const { a, b } = ... 这类,无法静态列全
391
+ continue;
392
+ }
393
+ }
394
+ }
395
+ return mod;
396
+ }
397
+
398
+ // ── 解析与校验 ────────────────────────────────────────────────────
399
+
400
+ const parsed = new Map();
401
+ function getModule(file) {
402
+ if (!parsed.has(file)) parsed.set(file, parseModule(readFileSync(file, 'utf8')));
403
+ return parsed.get(file);
404
+ }
405
+
406
+ const rel = (file) => relative(ROOT, file).split('\\').join('/');
407
+
408
+ /** 文件存在且大小写完全一致(最后一段);目录不算(ESM 不会自动补 index)。 */
409
+ function existsExact(file) {
410
+ try {
411
+ if (!existsSync(file) || !statSync(file).isFile()) return false;
412
+ return readdirSync(dirname(file)).includes(file.slice(dirname(file).length + 1));
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ const isScript = (file) => /\.(mjs|js)$/.test(file);
419
+
420
+ /** 模块导出的全部名字(沿 export * / export { } from 传递);`open` 表示列不全,不要据此判缺失。 */
421
+ function exportsOf(file, seen = new Set()) {
422
+ const out = { names: new Set(), open: false };
423
+ if (seen.has(file)) return out;
424
+ seen.add(file);
425
+ const mod = getModule(file);
426
+ for (const n of mod.exports) out.names.add(n);
427
+ if (mod.openExports) out.open = true;
428
+ for (const re of mod.reExports) {
429
+ if (!re.spec.startsWith('.')) {
430
+ out.open = true;
431
+ continue;
432
+ }
433
+ const target = resolve(dirname(file), re.spec);
434
+ if (re.star) {
435
+ if (!existsExact(target) || !isScript(target)) {
436
+ out.open = true;
437
+ continue;
438
+ }
439
+ const inner = exportsOf(target, seen);
440
+ for (const n of inner.names) if (n !== 'default') out.names.add(n);
441
+ if (inner.open) out.open = true;
442
+ } else {
443
+ for (const s of re.names) out.names.add(s.local);
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+
449
+ const builtinExports = new Map();
450
+ async function builtinNames(spec) {
451
+ if (!builtinExports.has(spec)) {
452
+ try {
453
+ builtinExports.set(spec, new Set(Object.keys(await import(spec))));
454
+ } catch {
455
+ builtinExports.set(spec, null);
456
+ }
457
+ }
458
+ return builtinExports.get(spec);
459
+ }
460
+ const isBuiltin = (spec) => spec.startsWith('node:') || builtinModules.includes(spec);
461
+
462
+ /** 检查一个说明符:路径存在、具名导出存在。`names` 为 [{imported, line}],`wantDefault` 表示要求有 default。 */
463
+ async function checkTarget(file, spec, line, names, wantDefault, problems) {
464
+ const where = `${rel(file)}:${line}`;
465
+ if (isBuiltin(spec)) {
466
+ const have = await builtinNames(spec);
467
+ if (!have) return;
468
+ for (const n of names) {
469
+ if (n.imported !== 'default' && !have.has(n.imported)) {
470
+ problems.push(`${rel(file)}:${n.line ?? line}: [exports] '${spec}' 没有导出 '${n.imported}'(当前 Node ${process.versions.node})`);
471
+ }
472
+ }
473
+ return;
474
+ }
475
+ if (!spec.startsWith('.')) return; // 没有第三方依赖;其它裸路径不归这里管
476
+ const target = resolve(dirname(file), spec);
477
+ if (!existsExact(target)) {
478
+ const why = existsSync(target) ? '存在但大小写不一致,或不是文件' : '不存在';
479
+ problems.push(`${where}: [resolve] 导入路径 '${spec}' ${why}`);
480
+ return;
481
+ }
482
+ if (!isScript(target)) return; // .json 等:只检查存在
483
+ const ex = exportsOf(target);
484
+ for (const n of names) {
485
+ if (!ex.open && !ex.names.has(n.imported)) {
486
+ problems.push(`${rel(file)}:${n.line ?? line}: [exports] '${spec}' 没有导出 '${n.imported}'`);
487
+ }
488
+ }
489
+ if (wantDefault && !ex.open && !ex.names.has('default')) {
490
+ problems.push(`${where}: [exports] '${spec}' 没有 default 导出`);
491
+ }
492
+ }
493
+
494
+ /** 具名/默认/命名空间导入里,没有被用到的绑定。 */
495
+ function unusedImports(file) {
496
+ const mod = getModule(file);
497
+ const used = new Set();
498
+ mod.tokens.forEach((t, k) => {
499
+ if (t.type !== 'id' || mod.excluded.has(k)) return;
500
+ const prev = mod.tokens[k - 1];
501
+ if (isPunct(prev, '.') || isPunct(prev, '?.')) return; // 属性访问不是对导入绑定的引用
502
+ used.add(t.value);
503
+ });
504
+ const problems = [];
505
+ for (const imp of mod.imports) {
506
+ for (const s of imp.names) {
507
+ if (!used.has(s.local)) problems.push(`${rel(file)}:${s.line}: [unused] 未使用的导入 '${s.local}'(来自 '${imp.spec}')`);
508
+ }
509
+ if (imp.defaultLocal && !used.has(imp.defaultLocal.local)) {
510
+ problems.push(`${rel(file)}:${imp.defaultLocal.line}: [unused] 未使用的默认导入 '${imp.defaultLocal.local}'(来自 '${imp.spec}')`);
511
+ }
512
+ if (imp.nsLocal && !used.has(imp.nsLocal)) {
513
+ problems.push(`${rel(file)}:${imp.line}: [unused] 未使用的命名空间导入 '${imp.nsLocal}'(来自 '${imp.spec}')`);
514
+ }
515
+ }
516
+ return problems;
517
+ }
518
+
519
+ // ── 入口 ──────────────────────────────────────────────────────────
520
+
521
+ function collectFiles() {
522
+ const out = [];
523
+ const walk = (dir) => {
524
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
525
+ if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
526
+ const full = join(dir, ent.name);
527
+ if (ent.isDirectory()) walk(full);
528
+ else if (ent.isFile() && ent.name.endsWith('.mjs')) out.push(full);
529
+ }
530
+ };
531
+ for (const d of DIRS) if (existsSync(join(ROOT, d))) walk(join(ROOT, d));
532
+ return out.sort();
533
+ }
534
+
535
+ /** `node --check`:只做语法检查,不执行。限制并发,避免在小机器上一次拉起几十个进程。 */
536
+ function syntaxCheck(files) {
537
+ const problems = [];
538
+ let next = 0;
539
+ const worker = async () => {
540
+ while (next < files.length) {
541
+ const file = files[next++];
542
+ await new Promise((done) => {
543
+ const child = spawn(process.execPath, ['--check', file], { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
544
+ let stderr = '';
545
+ child.stderr.on('data', (d) => (stderr += d));
546
+ child.on('close', (code) => {
547
+ if (code !== 0) {
548
+ const first = stderr.split('\n').find((l) => /Error/.test(l)) ?? stderr.split('\n')[0];
549
+ problems.push(`${rel(file)}: [syntax] ${first.trim()}`);
550
+ }
551
+ done();
552
+ });
553
+ });
554
+ }
555
+ };
556
+ return Promise.all(Array.from({ length: Math.min(CHECK_CONCURRENCY, files.length) }, worker)).then(() => problems);
557
+ }
558
+
559
+ async function main() {
560
+ const files = collectFiles();
561
+ const problems = [];
562
+
563
+ problems.push(...(await syntaxCheck(files)));
564
+
565
+ for (const file of files) {
566
+ const mod = getModule(file);
567
+ for (const imp of mod.imports) {
568
+ await checkTarget(file, imp.spec, imp.line, imp.names, Boolean(imp.defaultLocal), problems);
569
+ }
570
+ for (const re of mod.reExports) {
571
+ await checkTarget(file, re.spec, re.line, re.names, false, problems);
572
+ }
573
+ for (const dyn of mod.dynamicImports) {
574
+ await checkTarget(file, dyn.spec, dyn.line, dyn.names ?? [], false, problems);
575
+ }
576
+ problems.push(...unusedImports(file));
577
+ }
578
+
579
+ problems.sort();
580
+ for (const p of problems) console.log(p);
581
+ console.log(
582
+ problems.length
583
+ ? `\nlint: ${problems.length} 个问题(检查了 ${files.length} 个文件)`
584
+ : `lint: ${files.length} 个文件,语法 / 导入路径 / 具名导出 / 未使用导入均无问题`,
585
+ );
586
+ process.exit(problems.length ? 1 : 0);
587
+ }
588
+
589
+ // 只有直接运行时才执行;被别的脚本 import(复用 tokenize / parseModule)时不做任何事
590
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
591
+ main().catch((e) => {
592
+ console.error('lint 自身出错:', e);
593
+ process.exit(2);
594
+ });
595
+ }