dsh-plugin-t-expert 0.2.7 → 0.2.9

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
@@ -79,19 +79,29 @@ export async function loadDivisionLabels(zhRoot) {
79
79
  * 这样新增的分类不必改代码就能被扫描到。
80
80
  */
81
81
  export async function discoverDivisions(root) {
82
+ const report = { found: [], unreadable: [], rootError: undefined };
82
83
  let entries;
83
84
  try {
84
85
  entries = await readdir(root, { withFileTypes: true });
85
- } catch {
86
- return [];
86
+ } catch (error) {
87
+ report.rootError = error;
88
+ return report;
87
89
  }
88
- const found = [];
89
90
  for (const entry of entries) {
90
91
  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);
92
+ let files;
93
+ try {
94
+ files = await readdir(join(root, entry.name));
95
+ } catch (error) {
96
+ // 分区目录读不动 —— 过去这里 `.catch(() => [])` 把它当成「没有 .md 的空分区」丢掉,
97
+ // 于是该分区整批专家静默缺席(单个分区不可读即可让名册 316→244,见 D-18)。
98
+ report.unreadable.push({ division: entry.name, error });
99
+ continue;
100
+ }
101
+ if (files.some((name) => name.endsWith(".md"))) report.found.push(entry.name);
93
102
  }
94
- return found.sort();
103
+ report.found.sort();
104
+ return report;
95
105
  }
96
106
 
97
107
  const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
@@ -150,14 +160,17 @@ export function truncate(text, limit) {
150
160
  return value.length > limit ? `${value.slice(0, Math.max(0, limit - 1))}…` : value;
151
161
  }
152
162
 
153
- /** 递归收集目录下的 .md 文件(跳过符号链接,与 DSH 的扫描口径一致)。 */
163
+ /**
164
+ * 递归收集目录下的 .md 文件(跳过符号链接,与 DSH 的扫描口径一致)。
165
+ *
166
+ * 读目录失败**不再静默返回**:整个分区不可读时(EACCES 等)那批专家会**悄悄**从名册里消失
167
+ * ——实测单个分区不可读就能让 316 位掉到 244 位且零日志(D-18)。现在错误带路径抛给调用方,
168
+ * 由调用方逐分区记 warn,名册照旧返回其余可读的部分。
169
+ * @param dir - 要递归的目录。
170
+ * @param onFile - 每个 .md 文件的回调 `(fullPath, fileName)`。
171
+ */
154
172
  async function walkMarkdown(dir, onFile) {
155
- let entries;
156
- try {
157
- entries = await readdir(dir, { withFileTypes: true });
158
- } catch {
159
- return;
160
- }
173
+ const entries = await readdir(dir, { withFileTypes: true });
161
174
  entries.sort((a, b) => a.name.localeCompare(b.name));
162
175
  for (const entry of entries) {
163
176
  const full = join(dir, entry.name);
@@ -188,41 +201,90 @@ export async function loadCatalog(root, divisions, options = {}) {
188
201
  await assertDirectory(root);
189
202
  const zhRoot = typeof options.zhRoot === "string" && options.zhRoot !== "" ? options.zhRoot : undefined;
190
203
  const customRoot = typeof options.customRoot === "string" && options.customRoot !== "" ? options.customRoot : undefined;
204
+ /** 宿主 logger(可选):所有「本该说话却过去没说」的地方都走它,不再 console(N-3)。 */
205
+ const logger = options.logger;
206
+ const warn = (message) => logger?.warn?.(message);
191
207
  // 中文侧车目录:names.json / descriptions.json / <与 experts 同相对路径的 .md>。
192
208
  // 译文永不写进 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);
209
+ // 侧车缺失是**自洽的错配**(它由 seedData 播种、路径来自 Config.zhRoot),必须可观察:
210
+ // 过去这里静默变成「全英文名册」,面板与工具都不报错(D-5)。
211
+ // 侧车「可用」= 目录存在**且**至少有一个内容锚点(names/descriptions/divisions/manual/manual-bodies
212
+ // 或任一分区目录)。只看目录存在是不够的:存在但空(或只剩 .DS_Store)时 seedData 会跳过播种,
213
+ // 于是中文名/简介全空却什么都不说 —— 正是 D-5 要消灭的静默降级。
214
+ const sidecarDirExists = zhRoot === undefined ? true : await isDirectory(zhRoot);
215
+ const sidecarAnchor = zhRoot === undefined || !sidecarDirExists
216
+ ? undefined
217
+ : await firstExisting([
218
+ join(zhRoot, "names.json"),
219
+ join(zhRoot, "descriptions.json"),
220
+ join(zhRoot, "divisions.json"),
221
+ join(zhRoot, "manual.json"),
222
+ join(zhRoot, "manual-bodies"),
223
+ ]);
224
+ const sidecarPresent = zhRoot === undefined || (sidecarDirExists && sidecarAnchor !== undefined);
225
+ if (zhRoot !== undefined && !sidecarDirExists) {
226
+ warn(`[t-team] 中文侧车目录不存在:${zhRoot}(中文名/简介/分类标签会全部回退英文;跑一次首启播种或把 zhRoot 指到正确位置)`);
227
+ } else if (zhRoot !== undefined && !sidecarPresent) {
228
+ warn(`[t-team] 中文侧车目录存在但没有任何内容(缺少 names.json/descriptions.json 等):${zhRoot}`
229
+ + `(中文名/简介/分类标签会全部回退英文;删掉这个空目录让插件下次启动重新播种)`);
230
+ }
231
+ const zhNames = zhRoot === undefined || !sidecarPresent ? {} : await readJson(join(zhRoot, "names.json"));
232
+ const zhDescriptions = zhRoot === undefined || !sidecarPresent ? {} : await readJson(join(zhRoot, "descriptions.json"));
233
+ const divisionLabels = sidecarPresent ? await loadDivisionLabels(zhRoot) : {};
196
234
  // 自建分类的显示名表(键 = 声明过的自建分类);官方标签优先级更高,见文件末尾的合并处。
197
235
  const customLabels = await loadCustomLabels(customRoot);
198
236
  // 未显式配置分类时自动发现:新增分类无需改代码。
199
237
  // 发现结果一律按 isValidDivision 过一遍,**列表与扫描共用同一份过滤结果** ——
200
238
  // 否则目录名不合规时会出现「分区名在列表里、专家却一个都没扫到」的空分区(已实测)。
201
- const scanned = (divisions !== undefined && divisions.length > 0 ? divisions : await discoverDivisions(root))
239
+ // 显式配置了 divisions 就不自动发现;否则用发现结果,并把「读不动的分区」报出来(D-18)。
240
+ const discovery = divisions !== undefined && divisions.length > 0 ? undefined : await discoverDivisions(root);
241
+ if (discovery?.rootError !== undefined) {
242
+ warn(`[t-team] 无法列出专家根目录下的分区:${root}(${String(discovery.rootError)})`);
243
+ }
244
+ for (const item of discovery?.unreadable ?? []) {
245
+ warn(`[t-team] 分区目录无法读取,该分区专家已全部跳过:${join(root, item.division)}(${String(item.error)})`);
246
+ }
247
+ const scanned = (divisions !== undefined && divisions.length > 0 ? divisions : discovery.found)
202
248
  .filter(isValidDivision);
249
+ /** 读不动的分区(供 host 侧 snapshot 暴露,诊断用)。 */
250
+ const unreadableDivisions = (discovery?.unreadable ?? []).map((item) => item.division);
203
251
  const catalog = new Map();
204
252
 
205
253
  /** 解析一个 persona 文件并放进名册;自定义根的文件额外带 custom 标记与写回路径。 */
206
254
  async function ingest(fromRoot, division, filePath, fileName, isCustom) {
207
255
  const slug = fileName.slice(0, -3);
208
- if (!SLUG_PATTERN.test(slug)) return;
256
+ if (!SLUG_PATTERN.test(slug)) {
257
+ // 文件名不合规 → 这位专家不会进名册。过去完全无声(D-18 同类);降到 debug 是刻意的:
258
+ // 它属于「文件命名不合规」,不该在正常安装里刷 warn。
259
+ logger?.debug?.(`[t-team] 跳过文件名不合规的专家文件:${filePath}`);
260
+ return "skipped";
261
+ }
209
262
  let raw;
210
263
  try {
211
264
  raw = stripBom(await readFile(filePath, "utf8"));
212
- } catch {
213
- return;
265
+ } catch (error) {
266
+ // 读不到就是这位专家静默缺席的直接原因,必须可见(过去 `catch { return; }` 什么都不说)。
267
+ warn(`[t-team] 读不到专家文件,已跳过:${filePath}(${String(error)})`);
268
+ return "skipped";
214
269
  }
215
270
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
216
- if (match === null) return;
271
+ if (match === null) {
272
+ logger?.debug?.(`[t-team] 跳过没有 frontmatter 的专家文件:${filePath}`);
273
+ return "skipped";
274
+ }
217
275
  const meta = parseMetadata(match[1]);
218
- if (!meta.name || !meta.description) return;
276
+ if (!meta.name || !meta.description) {
277
+ // 缺 name/description 的专家**不会进名册**,而这是作者最常犯的错 —— 过去零信号。
278
+ warn(`[t-team] 专家文件缺 name 或 description,已跳过:${filePath}`);
279
+ return "skipped";
280
+ }
219
281
  if (catalog.has(slug)) {
220
282
  if (isCustom) {
221
283
  // 内置优先:自定义目录里出现同名 slug 时保留内置那位,不覆盖也不报冲突。
222
- console.warn(`[t-team] 自定义专家与内置专家同 slug,已忽略:${slug}(${filePath})`);
223
- return;
284
+ warn(`[t-team] 自定义专家与内置专家同 slug,已忽略:${slug}(${filePath})`);
285
+ return "skipped";
224
286
  }
225
- console.warn(`[t-team] slug 冲突,后者覆盖前者:${slug}`);
287
+ warn(`[t-team] slug 冲突,后者覆盖前者:${slug}`);
226
288
  }
227
289
  const entry = {
228
290
  slug,
@@ -246,19 +308,36 @@ export async function loadCatalog(root, divisions, options = {}) {
246
308
  catalog.set(slug, entry);
247
309
  }
248
310
 
249
- for (const division of scanned) {
250
- await walkMarkdown(join(root, division), (filePath, fileName) => ingest(root, division, filePath, fileName, false));
251
- }
311
+ /** 返回值用于把「跳过」计数汇总成一条 warn;逐条 debug 太吵,全静默又看不见。 */
312
+ let skipped = 0;
313
+ const walkInto = async (baseRoot, division, isCustom) => {
314
+ const dir = join(baseRoot, division);
315
+ if (!(await isDirectory(dir))) return;
316
+ try {
317
+ await walkMarkdown(dir, async (filePath, fileName) => {
318
+ if ((await ingest(baseRoot, division, filePath, fileName, isCustom)) === "skipped") skipped += 1;
319
+ });
320
+ } catch (error) {
321
+ // 分区读不动 = 这个分区下的专家整批缺席。这是「无声掉专家」的根源,必须逐分区报出来。
322
+ // 不抛错:其余分区仍然可用,名册照旧返回(降级但可观察)。
323
+ warn(`[t-team] 分区目录无法读取,该分区专家已全部跳过:${dir}(${String(error)})`);
324
+ }
325
+ };
326
+
327
+ for (const division of scanned) await walkInto(root, division, false);
252
328
 
253
329
  // 自建专家根:目录可以不存在(还没建过任何自建专家),分区同样自动发现。
254
330
  let customDivisions = [];
255
331
  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));
332
+ const customDiscovery = await discoverDivisions(customRoot);
333
+ for (const item of customDiscovery.unreadable) {
334
+ warn(`[t-team] 自建分区目录无法读取,该分区专家已全部跳过:${join(customRoot, item.division)}(${String(item.error)})`);
259
335
  }
336
+ customDivisions = customDiscovery.found.filter(isValidDivision);
337
+ for (const division of customDivisions) await walkInto(customRoot, division, true);
260
338
  }
261
339
 
340
+ if (skipped > 0) warn(`[t-team] 名册加载跳过了 ${skipped} 个专家文件(原因见上面的逐条日志)`);
262
341
  if (catalog.size === 0) throw new Error(`专家目录为空或没有可解析的专家:${root}`);
263
342
  markConflicts(catalog);
264
343
  catalog.divisions = [...scanned, ...customDivisions.filter((item) => !scanned.includes(item))];
@@ -267,6 +346,10 @@ export async function loadCatalog(root, divisions, options = {}) {
267
346
  catalog.customRoot = customRoot;
268
347
  // 自建分类的显示名表;它的**键**同时就是"声明过的自建分类"——允许空分类(目录里还没有专家)。
269
348
  catalog.customLabels = customLabels;
349
+ // 供 host 侧 snapshot() 暴露给面板:侧车缺了、有没有专家因此跳过。
350
+ catalog.sidecarPresent = sidecarPresent;
351
+ catalog.skippedFiles = skipped;
352
+ catalog.unreadableDivisions = unreadableDivisions;
270
353
  // 官方标签优先:官方分类的显示名跟随中文侧车,不允许被本机覆盖(2026-09-12 用户选定)。
271
354
  catalog.labels = { ...customLabels, ...divisionLabels };
272
355
  if (zhRoot !== undefined) {
@@ -293,6 +376,26 @@ async function readJson(path) {
293
376
  }
294
377
  }
295
378
 
379
+ /** 返回第一个存在的路径(侧车「有没有内容」的锚点探测)。 */
380
+ async function firstExisting(paths) {
381
+ for (const path of paths) {
382
+ if (await isPath(path)) return path;
383
+ }
384
+ return undefined;
385
+ }
386
+
387
+ /** 路径是否存在(文件或目录)。 */
388
+ async function isPath(path) {
389
+ const info = await stat(path).catch(() => undefined);
390
+ return info !== undefined;
391
+ }
392
+
393
+ /** 是否为目录(用于「侧车/分区是否存在」的存在性探测)。 */
394
+ async function isDirectory(path) {
395
+ const info = await stat(path).catch(() => undefined);
396
+ return info !== undefined && info.isDirectory();
397
+ }
398
+
296
399
  /** 是否为普通文件。 */
297
400
  async function isFile(path) {
298
401
  const info = await stat(path).catch(() => undefined);