dsh-plugin-t-expert 0.2.7 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/catalog.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * T专家 花名册:扫描固定专家目录,解析 frontmatter,按 slug 建立索引。
3
4
  *
@@ -9,6 +10,24 @@ import { createHash } from "node:crypto";
9
10
  import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
10
11
  import { dirname, join, relative } from "node:path";
11
12
 
13
+ /**
14
+ * 名册本体:`Map<slug, Expert>`,另外挂上扫描诊断与分类表。
15
+ *
16
+ * 之所以是 Map 加属性而不是 `{bySlug, ...}`:调用方(`lib/index.js`、测试)大量直接
17
+ * `catalog.get(slug)` / `catalog.size` / 迭代,保持 Map 形态的改动面最小。
18
+ * @typedef {Map<string, any> & {
19
+ * divisions: string[],
20
+ * rosterDivisions: string[],
21
+ * customDivisions: string[],
22
+ * customRoot: string | undefined,
23
+ * customLabels: Record<string, string>,
24
+ * sidecarPresent: boolean,
25
+ * skippedFiles: number,
26
+ * unreadableDivisions: string[],
27
+ * labels: Record<string, string>,
28
+ * }} CatalogMap
29
+ */
30
+
12
31
  /** 内置的分区目录名(= divisions.json 的键)。 */
13
32
  export const DEFAULT_DIVISIONS = [
14
33
  "academic",
@@ -79,19 +98,29 @@ export async function loadDivisionLabels(zhRoot) {
79
98
  * 这样新增的分类不必改代码就能被扫描到。
80
99
  */
81
100
  export async function discoverDivisions(root) {
101
+ const report = { found: [], unreadable: [], rootError: undefined };
82
102
  let entries;
83
103
  try {
84
104
  entries = await readdir(root, { withFileTypes: true });
85
- } catch {
86
- return [];
105
+ } catch (error) {
106
+ report.rootError = error;
107
+ return report;
87
108
  }
88
- const found = [];
89
109
  for (const entry of entries) {
90
110
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
91
- const files = await readdir(join(root, entry.name)).catch(() => []);
92
- if (files.some((name) => name.endsWith(".md"))) found.push(entry.name);
111
+ let files;
112
+ try {
113
+ files = await readdir(join(root, entry.name));
114
+ } catch (error) {
115
+ // 分区目录读不动 —— 过去这里 `.catch(() => [])` 把它当成「没有 .md 的空分区」丢掉,
116
+ // 于是该分区整批专家静默缺席(单个分区不可读即可让名册 316→244,见 D-18)。
117
+ report.unreadable.push({ division: entry.name, error });
118
+ continue;
119
+ }
120
+ if (files.some((name) => name.endsWith(".md"))) report.found.push(entry.name);
93
121
  }
94
- return found.sort();
122
+ report.found.sort();
123
+ return report;
95
124
  }
96
125
 
97
126
  const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
@@ -150,14 +179,17 @@ export function truncate(text, limit) {
150
179
  return value.length > limit ? `${value.slice(0, Math.max(0, limit - 1))}…` : value;
151
180
  }
152
181
 
153
- /** 递归收集目录下的 .md 文件(跳过符号链接,与 DSH 的扫描口径一致)。 */
182
+ /**
183
+ * 递归收集目录下的 .md 文件(跳过符号链接,与 DSH 的扫描口径一致)。
184
+ *
185
+ * 读目录失败**不再静默返回**:整个分区不可读时(EACCES 等)那批专家会**悄悄**从名册里消失
186
+ * ——实测单个分区不可读就能让 316 位掉到 244 位且零日志(D-18)。现在错误带路径抛给调用方,
187
+ * 由调用方逐分区记 warn,名册照旧返回其余可读的部分。
188
+ * @param dir - 要递归的目录。
189
+ * @param onFile - 每个 .md 文件的回调 `(fullPath, fileName)`。
190
+ */
154
191
  async function walkMarkdown(dir, onFile) {
155
- let entries;
156
- try {
157
- entries = await readdir(dir, { withFileTypes: true });
158
- } catch {
159
- return;
160
- }
192
+ const entries = await readdir(dir, { withFileTypes: true });
161
193
  entries.sort((a, b) => a.name.localeCompare(b.name));
162
194
  for (const entry of entries) {
163
195
  const full = join(dir, entry.name);
@@ -188,41 +220,91 @@ export async function loadCatalog(root, divisions, options = {}) {
188
220
  await assertDirectory(root);
189
221
  const zhRoot = typeof options.zhRoot === "string" && options.zhRoot !== "" ? options.zhRoot : undefined;
190
222
  const customRoot = typeof options.customRoot === "string" && options.customRoot !== "" ? options.customRoot : undefined;
223
+ /** 宿主 logger(可选):所有「本该说话却过去没说」的地方都走它,不再 console(N-3)。 */
224
+ const logger = options.logger;
225
+ const warn = (message) => logger?.warn?.(message);
191
226
  // 中文侧车目录:names.json / descriptions.json / <与 experts 同相对路径的 .md>。
192
227
  // 译文永不写进 experts(那是随包发布的只读名册),所以名册更新不受影响。
193
- const zhNames = zhRoot === undefined ? {} : await readJson(join(zhRoot, "names.json"));
194
- const zhDescriptions = zhRoot === undefined ? {} : await readJson(join(zhRoot, "descriptions.json"));
195
- const divisionLabels = await loadDivisionLabels(zhRoot);
228
+ // 侧车缺失是**自洽的错配**(它由 seedData 播种、路径来自 Config.zhRoot),必须可观察:
229
+ // 过去这里静默变成「全英文名册」,面板与工具都不报错(D-5)。
230
+ // 侧车「可用」= 目录存在**且**至少有一个内容锚点(names/descriptions/divisions/manual/manual-bodies
231
+ // 或任一分区目录)。只看目录存在是不够的:存在但空(或只剩 .DS_Store)时 seedData 会跳过播种,
232
+ // 于是中文名/简介全空却什么都不说 —— 正是 D-5 要消灭的静默降级。
233
+ const sidecarDirExists = zhRoot === undefined ? true : await isDirectory(zhRoot);
234
+ const sidecarAnchor = zhRoot === undefined || !sidecarDirExists
235
+ ? undefined
236
+ : await firstExisting([
237
+ join(zhRoot, "names.json"),
238
+ join(zhRoot, "descriptions.json"),
239
+ join(zhRoot, "divisions.json"),
240
+ join(zhRoot, "manual.json"),
241
+ join(zhRoot, "manual-bodies"),
242
+ ]);
243
+ const sidecarPresent = zhRoot === undefined || (sidecarDirExists && sidecarAnchor !== undefined);
244
+ if (zhRoot !== undefined && !sidecarDirExists) {
245
+ warn(`[t-team] 中文侧车目录不存在:${zhRoot}(中文名/简介/分类标签会全部回退英文;跑一次首启播种或把 zhRoot 指到正确位置)`);
246
+ } else if (zhRoot !== undefined && !sidecarPresent) {
247
+ warn(`[t-team] 中文侧车目录存在但没有任何内容(缺少 names.json/descriptions.json 等):${zhRoot}`
248
+ + `(中文名/简介/分类标签会全部回退英文;删掉这个空目录让插件下次启动重新播种)`);
249
+ }
250
+ const zhNames = zhRoot === undefined || !sidecarPresent ? {} : await readJson(join(zhRoot, "names.json"));
251
+ const zhDescriptions = zhRoot === undefined || !sidecarPresent ? {} : await readJson(join(zhRoot, "descriptions.json"));
252
+ const divisionLabels = sidecarPresent ? await loadDivisionLabels(zhRoot) : {};
196
253
  // 自建分类的显示名表(键 = 声明过的自建分类);官方标签优先级更高,见文件末尾的合并处。
197
254
  const customLabels = await loadCustomLabels(customRoot);
198
255
  // 未显式配置分类时自动发现:新增分类无需改代码。
199
256
  // 发现结果一律按 isValidDivision 过一遍,**列表与扫描共用同一份过滤结果** ——
200
257
  // 否则目录名不合规时会出现「分区名在列表里、专家却一个都没扫到」的空分区(已实测)。
201
- const scanned = (divisions !== undefined && divisions.length > 0 ? divisions : await discoverDivisions(root))
258
+ // 显式配置了 divisions 就不自动发现;否则用发现结果,并把「读不动的分区」报出来(D-18)。
259
+ const discovery = divisions !== undefined && divisions.length > 0 ? undefined : await discoverDivisions(root);
260
+ if (discovery?.rootError !== undefined) {
261
+ warn(`[t-team] 无法列出专家根目录下的分区:${root}(${String(discovery.rootError)})`);
262
+ }
263
+ for (const item of discovery?.unreadable ?? []) {
264
+ warn(`[t-team] 分区目录无法读取,该分区专家已全部跳过:${join(root, item.division)}(${String(item.error)})`);
265
+ }
266
+ const scanned = (divisions !== undefined && divisions.length > 0 ? divisions : discovery.found)
202
267
  .filter(isValidDivision);
203
- const catalog = new Map();
268
+ /** 读不动的分区(供 host snapshot 暴露,诊断用)。 */
269
+ const unreadableDivisions = (discovery?.unreadable ?? []).map((item) => item.division);
270
+ /** @type {CatalogMap} 名册本体 + 扫描诊断(见 CatalogMap 的各字段说明)。 */
271
+ const catalog = /** @type {CatalogMap} */ (new Map());
204
272
 
205
273
  /** 解析一个 persona 文件并放进名册;自定义根的文件额外带 custom 标记与写回路径。 */
206
274
  async function ingest(fromRoot, division, filePath, fileName, isCustom) {
207
275
  const slug = fileName.slice(0, -3);
208
- if (!SLUG_PATTERN.test(slug)) return;
276
+ if (!SLUG_PATTERN.test(slug)) {
277
+ // 文件名不合规 → 这位专家不会进名册。过去完全无声(D-18 同类);降到 debug 是刻意的:
278
+ // 它属于「文件命名不合规」,不该在正常安装里刷 warn。
279
+ logger?.debug?.(`[t-team] 跳过文件名不合规的专家文件:${filePath}`);
280
+ return "skipped";
281
+ }
209
282
  let raw;
210
283
  try {
211
284
  raw = stripBom(await readFile(filePath, "utf8"));
212
- } catch {
213
- return;
285
+ } catch (error) {
286
+ // 读不到就是这位专家静默缺席的直接原因,必须可见(过去 `catch { return; }` 什么都不说)。
287
+ warn(`[t-team] 读不到专家文件,已跳过:${filePath}(${String(error)})`);
288
+ return "skipped";
214
289
  }
215
290
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
216
- if (match === null) return;
291
+ if (match === null) {
292
+ logger?.debug?.(`[t-team] 跳过没有 frontmatter 的专家文件:${filePath}`);
293
+ return "skipped";
294
+ }
217
295
  const meta = parseMetadata(match[1]);
218
- if (!meta.name || !meta.description) return;
296
+ if (!meta.name || !meta.description) {
297
+ // 缺 name/description 的专家**不会进名册**,而这是作者最常犯的错 —— 过去零信号。
298
+ warn(`[t-team] 专家文件缺 name 或 description,已跳过:${filePath}`);
299
+ return "skipped";
300
+ }
219
301
  if (catalog.has(slug)) {
220
302
  if (isCustom) {
221
303
  // 内置优先:自定义目录里出现同名 slug 时保留内置那位,不覆盖也不报冲突。
222
- console.warn(`[t-team] 自定义专家与内置专家同 slug,已忽略:${slug}(${filePath})`);
223
- return;
304
+ warn(`[t-team] 自定义专家与内置专家同 slug,已忽略:${slug}(${filePath})`);
305
+ return "skipped";
224
306
  }
225
- console.warn(`[t-team] slug 冲突,后者覆盖前者:${slug}`);
307
+ warn(`[t-team] slug 冲突,后者覆盖前者:${slug}`);
226
308
  }
227
309
  const entry = {
228
310
  slug,
@@ -246,19 +328,36 @@ export async function loadCatalog(root, divisions, options = {}) {
246
328
  catalog.set(slug, entry);
247
329
  }
248
330
 
249
- for (const division of scanned) {
250
- await walkMarkdown(join(root, division), (filePath, fileName) => ingest(root, division, filePath, fileName, false));
251
- }
331
+ /** 返回值用于把「跳过」计数汇总成一条 warn;逐条 debug 太吵,全静默又看不见。 */
332
+ let skipped = 0;
333
+ const walkInto = async (baseRoot, division, isCustom) => {
334
+ const dir = join(baseRoot, division);
335
+ if (!(await isDirectory(dir))) return;
336
+ try {
337
+ await walkMarkdown(dir, async (filePath, fileName) => {
338
+ if ((await ingest(baseRoot, division, filePath, fileName, isCustom)) === "skipped") skipped += 1;
339
+ });
340
+ } catch (error) {
341
+ // 分区读不动 = 这个分区下的专家整批缺席。这是「无声掉专家」的根源,必须逐分区报出来。
342
+ // 不抛错:其余分区仍然可用,名册照旧返回(降级但可观察)。
343
+ warn(`[t-team] 分区目录无法读取,该分区专家已全部跳过:${dir}(${String(error)})`);
344
+ }
345
+ };
346
+
347
+ for (const division of scanned) await walkInto(root, division, false);
252
348
 
253
349
  // 自建专家根:目录可以不存在(还没建过任何自建专家),分区同样自动发现。
254
350
  let customDivisions = [];
255
351
  if (customRoot !== undefined) {
256
- customDivisions = (await discoverDivisions(customRoot)).filter(isValidDivision);
257
- for (const division of customDivisions) {
258
- await walkMarkdown(join(customRoot, division), (filePath, fileName) => ingest(customRoot, division, filePath, fileName, true));
352
+ const customDiscovery = await discoverDivisions(customRoot);
353
+ for (const item of customDiscovery.unreadable) {
354
+ warn(`[t-team] 自建分区目录无法读取,该分区专家已全部跳过:${join(customRoot, item.division)}(${String(item.error)})`);
259
355
  }
356
+ customDivisions = customDiscovery.found.filter(isValidDivision);
357
+ for (const division of customDivisions) await walkInto(customRoot, division, true);
260
358
  }
261
359
 
360
+ if (skipped > 0) warn(`[t-team] 名册加载跳过了 ${skipped} 个专家文件(原因见上面的逐条日志)`);
262
361
  if (catalog.size === 0) throw new Error(`专家目录为空或没有可解析的专家:${root}`);
263
362
  markConflicts(catalog);
264
363
  catalog.divisions = [...scanned, ...customDivisions.filter((item) => !scanned.includes(item))];
@@ -267,6 +366,10 @@ export async function loadCatalog(root, divisions, options = {}) {
267
366
  catalog.customRoot = customRoot;
268
367
  // 自建分类的显示名表;它的**键**同时就是"声明过的自建分类"——允许空分类(目录里还没有专家)。
269
368
  catalog.customLabels = customLabels;
369
+ // 供 host 侧 snapshot() 暴露给面板:侧车缺了、有没有专家因此跳过。
370
+ catalog.sidecarPresent = sidecarPresent;
371
+ catalog.skippedFiles = skipped;
372
+ catalog.unreadableDivisions = unreadableDivisions;
270
373
  // 官方标签优先:官方分类的显示名跟随中文侧车,不允许被本机覆盖(2026-09-12 用户选定)。
271
374
  catalog.labels = { ...customLabels, ...divisionLabels };
272
375
  if (zhRoot !== undefined) {
@@ -293,6 +396,26 @@ async function readJson(path) {
293
396
  }
294
397
  }
295
398
 
399
+ /** 返回第一个存在的路径(侧车「有没有内容」的锚点探测)。 */
400
+ async function firstExisting(paths) {
401
+ for (const path of paths) {
402
+ if (await isPath(path)) return path;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ /** 路径是否存在(文件或目录)。 */
408
+ async function isPath(path) {
409
+ const info = await stat(path).catch(() => undefined);
410
+ return info !== undefined;
411
+ }
412
+
413
+ /** 是否为目录(用于「侧车/分区是否存在」的存在性探测)。 */
414
+ async function isDirectory(path) {
415
+ const info = await stat(path).catch(() => undefined);
416
+ return info !== undefined && info.isDirectory();
417
+ }
418
+
296
419
  /** 是否为普通文件。 */
297
420
  async function isFile(path) {
298
421
  const info = await stat(path).catch(() => undefined);