@moonquake2004/dsh-security 0.1.6 → 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.
@@ -11,22 +11,53 @@
11
11
  * release-compat 半边永远拿不到数据;现改走 GitHub contents API 列目录并取最新文件。
12
12
  * - bug 雷达不再硬编码 weekly-2026-08-15.md,自动取 docs/ 下最新的 weekly-*.md。
13
13
  * - 数据源不可用返回 skip 而不是伪装通过。
14
+ *
15
+ * 复审修复(docs/ecosystem-audit-2026-09.md §2 EXT-ECO-1 / §3(c) / §4.4)——**加陈旧度护栏 + 杜绝静默降级**:
16
+ *
17
+ * 1. 陈旧度护栏。这是第三方数据源:不在 npm 上、无 license、单一维护者,周报随时可能停更。
18
+ * 旧实现只要「拿得到内容且没有关键词命中」就判 pass —— 一份三个月前的周报同样会通过,
19
+ * 读者无法区分「生态健康」与「数据源已经死了」。现在从文件名/正文解析数据源日期,
20
+ * 最新数据源超过 STALE_AFTER_DAYS 天 → 显式 skip 并说明天数,绝不判 pass。
21
+ *
22
+ * 2. 命名/布局漂移 = skip。旧实现的 release-compat 文件名正则
23
+ * `/^release-compat-\d[\d-]*\.md$/` 与仓库实际布局不符(见 PICK_RELEASE_COMPAT 处的注释),
24
+ * 于是那半边**每一轮都静默返回 null**,只靠周报半边给出 pass —— 典型的静默降级。
25
+ * 现在:目录里找不到任何符合已知命名的文件 → 显式 skip(说明实际看到了什么文件名),
26
+ * 而不是悄悄少查一半。
27
+ *
28
+ * 3. 缺一不可。两个数据源(release-compat 报告 + weekly 周报)任意一个读不到或认不出,
29
+ * 都判 **skip + 原因**,而不是「用剩下那个给一个绿色」——半覆盖的 pass 与全没查在报告里看不出区别。
30
+ * 只有两个数据源都新鲜可读时才给出 pass/fail,因此绿色必然意味着这次确实把两侧都查了。
14
31
  */
15
32
 
16
33
  import { Severity } from '../protocol/severity.mjs';
17
34
  import { CheckPhase } from '../protocol/phase.mjs';
18
- import { skip } from '../protocol/check.mjs';
35
+ import { skip, fail, pass } from '../protocol/check.mjs';
36
+
37
+ export const ECOSYSTEM_ID = 'EXT-ECO-1';
19
38
 
20
39
  const REPO_DOCS_API = 'https://api.github.com/repos/zoahdev/dsh-ecosystem/contents/docs';
21
40
  const RAW_BASE = 'https://raw.githubusercontent.com/zoahdev/dsh-ecosystem/main/docs';
22
41
 
42
+ /** 数据源陈旧阈值(天)。周报是周更,给 4 周余量;超过即认为数据源已停更。 */
43
+ export const STALE_AFTER_DAYS = 30;
44
+
45
+ const DAY_MS = 24 * 60 * 60 * 1000;
46
+
23
47
  const GH_HEADERS = {
24
48
  'User-Agent': 'dsh-security',
25
49
  'Accept': 'application/vnd.github+json',
26
50
  };
27
51
 
28
- async function ghFetch(url, accept) {
29
- const response = await fetch(url, {
52
+ /** 数据源状态 */
53
+ const STATE = Object.freeze({
54
+ OK: 'ok', // 取到了文档
55
+ UNREACHABLE: 'unreachable', // 网络/API 失败,或取到空文档
56
+ NO_FILE: 'no-file', // 目录读到了,但没有符合已知命名的文件(契约/布局漂移)
57
+ });
58
+
59
+ async function ghFetch(fetchImpl, url, accept) {
60
+ const response = await fetchImpl(url, {
30
61
  headers: { ...GH_HEADERS, ...(accept ? { Accept: accept } : {}) },
31
62
  signal: AbortSignal.timeout(10000),
32
63
  });
@@ -35,93 +66,246 @@ async function ghFetch(url, accept) {
35
66
  }
36
67
 
37
68
  /** 列 docs/<sub> 目录下的 .md 文件名(GitHub contents API),按名称倒序 */
38
- async function listMarkdownFiles(sub = '') {
69
+ async function listMarkdownFiles(fetchImpl, sub = '') {
39
70
  try {
40
- const response = await ghFetch(sub ? `${REPO_DOCS_API}/${sub}` : REPO_DOCS_API);
41
- if (!response || !response.ok) return null;
71
+ const response = await ghFetch(fetchImpl, sub ? `${REPO_DOCS_API}/${sub}` : REPO_DOCS_API);
72
+ if (!response) return { ok: false, names: [] };
42
73
  const entries = await response.json();
43
- if (!Array.isArray(entries)) return null;
44
- return entries
45
- .filter(e => e.type === 'file' && e.name.endsWith('.md'))
74
+ if (!Array.isArray(entries)) return { ok: false, names: [] };
75
+ const names = entries
76
+ .filter(e => e.type === 'file' && typeof e.name === 'string' && e.name.endsWith('.md'))
46
77
  .map(e => e.name)
47
78
  .sort()
48
79
  .reverse();
49
- } catch { return null; }
80
+ return { ok: true, names };
81
+ } catch {
82
+ return { ok: false, names: [] };
83
+ }
50
84
  }
51
85
 
52
- async function fetchRaw(sub, name) {
86
+ async function fetchRaw(fetchImpl, sub, name) {
53
87
  try {
54
- const response = await ghFetch(`${RAW_BASE}/${sub ? sub + '/' : ''}${name}`);
55
- if (!response || !response.ok) return null;
56
- return await response.text();
88
+ const response = await ghFetch(fetchImpl, `${RAW_BASE}/${sub ? sub + '/' : ''}${name}`);
89
+ if (!response) return null;
90
+ const text = await response.text();
91
+ return typeof text === 'string' && text.trim() !== '' ? text : null;
57
92
  } catch { return null; }
58
93
  }
59
94
 
60
95
  /**
61
- * 获取最新发布兼容性报告
96
+ * 从文件名里解析日期(weekly-2026-08-22.md → 2026-08-22Z)。
97
+ * @param {string} name
98
+ * @returns {Date|null}
62
99
  */
63
- async function fetchReleaseCompat() {
64
- const names = await listMarkdownFiles('release-compat');
65
- if (!names || names.length === 0) return null;
66
- const latest = names.find(n => /^release-compat-\d[\d-]*\.md$/.test(n));
67
- if (!latest) return null;
68
- return fetchRaw('release-compat', latest);
100
+ export function parseDateFromName(name) {
101
+ const matched = /(\d{4})-(\d{2})-(\d{2})/.exec(String(name ?? ''));
102
+ if (!matched) return null;
103
+ const date = new Date(`${matched[1]}-${matched[2]}-${matched[3]}T00:00:00Z`);
104
+ return Number.isNaN(date.getTime()) ? null : date;
69
105
  }
70
106
 
71
107
  /**
72
- * 获取最新一期 bug 雷达周报
108
+ * 数据源日期:优先文件名里的日期,其次正文里的生成时间
109
+ * (release-compat 报告正文首部有 `Generated 2026-09-10T04:50:58.248Z`)。
110
+ * @param {string} name
111
+ * @param {string} text
112
+ * @returns {Date|null}
73
113
  */
74
- async function fetchBugRadar() {
75
- const names = await listMarkdownFiles('');
76
- if (!names) return null;
77
- const latest = names.find(n => /^weekly-\d{4}-\d{2}-\d{2}\.md$/.test(n));
78
- if (!latest) return null;
79
- return fetchRaw('', latest);
114
+ export function docDate(name, text) {
115
+ const fromName = parseDateFromName(name);
116
+ if (fromName) return fromName;
117
+ const matched = /Generated\s+(\d{4})-(\d{2})-(\d{2})/i.exec(String(text ?? ''));
118
+ if (!matched) return null;
119
+ const date = new Date(`${matched[1]}-${matched[2]}-${matched[3]}T00:00:00Z`);
120
+ return Number.isNaN(date.getTime()) ? null : date;
80
121
  }
81
122
 
82
- export async function runCheck(profileDir) {
83
- const id = 'EXT-ECO-1';
123
+ /** 解析 `<major>.<minor>.<patch>[-prerelease].md` 形式的版本文件名 */
124
+ function parseVersionName(name) {
125
+ const matched = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\.md$/.exec(String(name ?? ''));
126
+ if (!matched) return null;
127
+ return { major: Number(matched[1]), minor: Number(matched[2]), patch: Number(matched[3]), pre: matched[4] ?? '' };
128
+ }
84
129
 
85
- const [releaseNotes, bugRadar] = await Promise.all([fetchReleaseCompat(), fetchBugRadar()]);
130
+ function compareVersionDesc(a, b) {
131
+ if (a.major !== b.major) return b.major - a.major;
132
+ if (a.minor !== b.minor) return b.minor - a.minor;
133
+ if (a.patch !== b.patch) return b.patch - a.patch;
134
+ // 预发布串:非空 > 空(rc 高于正式版内的同号)——这里按字符串倒序即可,命名空间很窄
135
+ return String(b.pre).localeCompare(String(a.pre));
136
+ }
86
137
 
87
- if (!releaseNotes && !bugRadar) {
88
- return skip(id, Severity.LOW, 'dsh-ecosystem 数据源不可达(离线或仓库无数据),跳过生态兼容性检查');
138
+ /**
139
+ * 选最新的发布兼容性报告文件名。
140
+ *
141
+ * 实测布局(2026-09-11 经 GitHub contents API 核实):`docs/release-compat/<version>.md`
142
+ * (0.1.0-rc.6.md、0.1.5-rc.1.md …,正文首行为 `# Release compatibility report — 0.1.5-rc.1`)。
143
+ * 审计报告记的是 `release-compat-<date>.md`,两种命名都接受:优先版本命名的(按版本序),
144
+ * 其次才是旧的日期命名。都不匹配 → 返回 null,由调用方升级为带原因的 skip。
145
+ */
146
+ export function pickReleaseCompatName(names) {
147
+ const versioned = (names ?? [])
148
+ .map(name => ({ name, version: parseVersionName(name) }))
149
+ .filter(entry => entry.version)
150
+ .sort((a, b) => compareVersionDesc(a.version, b.version));
151
+ if (versioned.length > 0) return versioned[0].name;
152
+
153
+ const legacy = (names ?? []).filter(name => /^release-compat-\d[\d-]*\.md$/.test(name)).sort().reverse();
154
+ return legacy[0] ?? null;
155
+ }
156
+
157
+ /** 选最新的 bug 雷达周报文件名(weekly-YYYY-MM-DD.md) */
158
+ export function pickWeeklyName(names) {
159
+ return (names ?? []).filter(name => /^weekly-\d{4}-\d{2}-\d{2}\.md$/.test(name)).sort().reverse()[0] ?? null;
160
+ }
161
+
162
+ /**
163
+ * 取发布兼容性报告。
164
+ * @returns {Promise<{label: string, state: string, name?: string, text?: string, detail?: string}>}
165
+ */
166
+ async function fetchReleaseCompat(fetchImpl) {
167
+ const label = 'release-compat';
168
+ const listed = await listMarkdownFiles(fetchImpl, 'release-compat');
169
+ if (!listed.ok) return { label, state: STATE.UNREACHABLE, detail: 'GitHub contents API 请求失败' };
170
+ const name = pickReleaseCompatName(listed.names);
171
+ if (!name) {
172
+ return {
173
+ label,
174
+ state: STATE.NO_FILE,
175
+ detail: `docs/release-compat/ 下没有符合 <version>.md 或 release-compat-<date>.md 的文件(实际:${listed.names.join('、') || '空目录'})`,
176
+ };
177
+ }
178
+ const text = await fetchRaw(fetchImpl, 'release-compat', name);
179
+ if (!text) return { label, state: STATE.UNREACHABLE, name, detail: `raw 取 ${name} 失败或内容为空` };
180
+ return { label, state: STATE.OK, name, text };
181
+ }
182
+
183
+ /**
184
+ * 取最新一期 bug 雷达周报。
185
+ * @returns {Promise<{label: string, state: string, name?: string, text?: string, detail?: string}>}
186
+ */
187
+ async function fetchBugRadar(fetchImpl) {
188
+ const label = 'bug-radar(weekly)';
189
+ const listed = await listMarkdownFiles(fetchImpl, '');
190
+ if (!listed.ok) return { label, state: STATE.UNREACHABLE, detail: 'GitHub contents API 请求失败' };
191
+ const name = pickWeeklyName(listed.names);
192
+ if (!name) {
193
+ return {
194
+ label,
195
+ state: STATE.NO_FILE,
196
+ detail: `docs/ 下没有 weekly-YYYY-MM-DD.md(实际 .md:${listed.names.join('、') || '空目录'})`,
197
+ };
198
+ }
199
+ const text = await fetchRaw(fetchImpl, '', name);
200
+ if (!text) return { label, state: STATE.UNREACHABLE, name, detail: `raw 取 ${name} 失败或内容为空` };
201
+ return { label, state: STATE.OK, name, text };
202
+ }
203
+
204
+ /**
205
+ * 依据已取到的数据源给出结论(纯函数,便于单测)。
206
+ *
207
+ * @param {{release: object|null, radar: object|null, now?: Date}} input
208
+ * @returns {import('../protocol/check.mjs').SecurityCheckResult}
209
+ */
210
+ export function evaluateSources({ release, radar, now = new Date() }) {
211
+ const sources = [release, radar].filter(Boolean);
212
+
213
+ if (sources.length === 0) {
214
+ return skip(ECOSYSTEM_ID, Severity.LOW, 'dsh-ecosystem 没有任何可用数据源,跳过生态兼容性检查');
215
+ }
216
+
217
+ // 两个数据源缺一不可:任何一个读不到(不可达)或认不出(命名/布局漂移),
218
+ // 都是「这次没真正查完」→ 显式 skip 并逐条给出原因。
219
+ // (旧实现只靠周报半边就给 pass:release-compat 的文件名正则与仓库实际布局不符,
220
+ // 那半边每轮静默返回 null —— 少查一半却仍显示绿色,正是要消除的 false green。)
221
+ const unavailable = sources.filter(s => s.state !== STATE.OK);
222
+ if (unavailable.length > 0) {
223
+ const reasons = unavailable.map(s => {
224
+ const kind = s.state === STATE.NO_FILE ? '命名/布局与预期不符' : '数据源不可达';
225
+ return `${s.label}(${kind}:${s.detail ?? '未知原因'})`;
226
+ }).join(';');
227
+ return skip(ECOSYSTEM_ID, Severity.LOW,
228
+ `dsh-ecosystem 无法完成生态兼容性检查:${reasons};跳过(不外推为通过)`);
229
+ }
230
+
231
+ const docs = sources;
232
+
233
+ // 陈旧度护栏:最新数据源太旧 → skip(不能拿一份停更许久的周报判 pass)
234
+ const dated = docs
235
+ .map(doc => ({ ...doc, date: docDate(doc.name, doc.text) }))
236
+ .filter(doc => doc.date);
237
+ if (dated.length === 0) {
238
+ return skip(ECOSYSTEM_ID, Severity.LOW,
239
+ `dsh-ecosystem 数据源中不含可解析日期(${docs.map(d => d.name).join('、')}),无法判断新鲜度,跳过生态兼容性检查`);
240
+ }
241
+ const newest = dated.reduce((a, b) => (a.date >= b.date ? a : b));
242
+ const ageDays = Math.floor((now.getTime() - newest.date.getTime()) / DAY_MS);
243
+ if (ageDays > STALE_AFTER_DAYS) {
244
+ return skip(ECOSYSTEM_ID, Severity.LOW,
245
+ `dsh-ecosystem 数据源陈旧:最新 ${newest.name} 生成于 ${newest.date.toISOString().slice(0, 10)},距今 ${ageDays} 天(阈值 ${STALE_AFTER_DAYS} 天),跳过生态兼容性检查`);
89
246
  }
90
247
 
91
248
  const issues = [];
92
249
 
93
250
  // 检查是否有已知的 breaking changes
94
- if (releaseNotes) {
95
- const breakingMatch = releaseNotes.match(/breaking|incompatible|migration/gi);
251
+ const releaseDoc = docs.find(d => d.label === 'release-compat');
252
+ if (releaseDoc) {
253
+ const breakingMatch = releaseDoc.text.match(/breaking|incompatible|migration/gi);
96
254
  if (breakingMatch && breakingMatch.length > 0) {
97
- issues.push({ type: 'breaking-changes', detail: `发布兼容性报告中发现 ${breakingMatch.length} 个 breaking change 提及` });
255
+ issues.push({ type: 'breaking-changes', detail: `${releaseDoc.name} 中发现 ${breakingMatch.length} 个 breaking change 提及` });
98
256
  }
99
257
  }
100
258
 
101
259
  // 检查是否有 critical bugs
102
- if (bugRadar) {
103
- const criticalMatch = bugRadar.match(/critical|CRITICAL|严重/gi);
260
+ const radarDoc = docs.find(d => d.label === 'bug-radar(weekly)');
261
+ if (radarDoc) {
262
+ const criticalMatch = radarDoc.text.match(/critical|CRITICAL|严重/gi);
104
263
  if (criticalMatch && criticalMatch.length > 0) {
105
- issues.push({ type: 'critical-bugs', detail: `Bug 雷达中发现 ${criticalMatch.length} 个 critical 级别问题` });
264
+ issues.push({ type: 'critical-bugs', detail: `${radarDoc.name} 中发现 ${criticalMatch.length} 个 critical 级别问题` });
106
265
  }
107
266
  }
108
267
 
268
+ const coverage = docs
269
+ .map(doc => {
270
+ const date = docDate(doc.name, doc.text);
271
+ const age = date ? `${Math.max(0, Math.floor((now.getTime() - date.getTime()) / DAY_MS))} 天前` : '日期未知';
272
+ return `${doc.name}(${age})`;
273
+ })
274
+ .join('、');
275
+
109
276
  if (issues.length === 0) {
110
- return { id, ok: true, severity: Severity.LOW, detail: 'dsh-ecosystem 生态兼容性检查通过' };
277
+ const result = pass(ECOSYSTEM_ID, Severity.LOW,
278
+ `dsh-ecosystem 生态兼容性检查通过(数据源:${coverage})`);
279
+ result.evidence = { sources: docs.map(d => d.name) };
280
+ return result;
111
281
  }
112
282
 
113
283
  const details = issues.map(i => `${i.type}: ${i.detail}`).join('\n');
114
- return {
115
- id,
116
- ok: false,
117
- severity: Severity.LOW,
118
- detail: `dsh-ecosystem 检测到 ${issues.length} 个生态关注点:\n${details}`,
119
- fix: '查看 dsh-ecosystem 周报获取最新生态状态',
120
- };
284
+ const result = fail(
285
+ ECOSYSTEM_ID,
286
+ Severity.LOW,
287
+ `dsh-ecosystem 检测到 ${issues.length} 个生态关注点:\n${details}`,
288
+ '查看 dsh-ecosystem 周报获取最新生态状态',
289
+ );
290
+ result.evidence = { sources: docs.map(d => d.name), issues };
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * @param {string} profileDir - 未使用(生态检查与 profile 无关),保留以匹配 registry 的调用约定
296
+ * @param {{fetchImpl?: typeof fetch, now?: Date}} [deps] - 测试注入点
297
+ */
298
+ export async function runCheck(profileDir, deps = {}) {
299
+ const fetchImpl = deps.fetchImpl ?? fetch;
300
+ const [release, radar] = await Promise.all([
301
+ fetchReleaseCompat(fetchImpl),
302
+ fetchBugRadar(fetchImpl),
303
+ ]);
304
+ return evaluateSources({ release, radar, now: deps.now ?? new Date() });
121
305
  }
122
306
 
123
307
  export const ecosystemCheck = {
124
- id: 'EXT-ECO-1',
308
+ id: ECOSYSTEM_ID,
125
309
  name: 'ecosystem-compat',
126
310
  severity: Severity.LOW,
127
311
  phase: CheckPhase.LIFECYCLE,
@@ -1,37 +1,85 @@
1
+ /**
2
+ * 外部集成注册表
3
+ *
4
+ * 复审修复(docs/ecosystem-audit-2026-09.md §2/§3(c)):旧实现只在 `isAvailable()` 为 true 时
5
+ * 才把检查注册进 registry —— 于是「第三方工具没装」在报告里**完全不可见**:没有任何 EXT-* 行,
6
+ * 读者无从区分「查过且没问题」和「压根没查」。这本身就是一种静默通过。
7
+ *
8
+ * 现在一律注册(ecosystem 本来就是无条件注册的),可用性交给各自的 runner:
9
+ * · 工具在 → 按真实契约执行,产出 pass/fail;
10
+ * · 工具不在、调用失败、输出形状不认识 → 返回**带原因的 skip**(见 protocol/check.mjs 的 skip 契约)。
11
+ * skip 不影响退出码,但会出现在报告里,说明「这一项没有真正执行,原因是 X」。
12
+ *
13
+ * 唯一的例外是 dsh-sandbox-audit:该集成已退役(工具从未发布到 npm、契约不符,覆盖域由离线
14
+ * 检查 SP3 承担,详见 ./sandbox-audit.mjs 头部的决策记录),因此不再注册,EXT-SA-1 不再出现在输出中。
15
+ */
16
+
17
+ import { Severity } from '../protocol/severity.mjs';
18
+ import { CheckPhase } from '../protocol/phase.mjs';
19
+ import { skip } from '../protocol/check.mjs';
20
+
1
21
  export { poisonGuardCheck, isAvailable as isPoisonGuardAvailable } from './poison-guard.mjs';
2
22
  export { sandboxAuditCheck, isAvailable as isSandboxAuditAvailable } from './sandbox-audit.mjs';
3
23
  export { ecosystemCheck } from './ecosystem.mjs';
4
24
  export { pluginReducerCheck, isAvailable as isPluginReducerAvailable } from './plugin-reducer.mjs';
5
25
 
6
26
  /**
7
- * 获取所有可用的外部集成检查
27
+ * 集成清单。`exportName` 是各模块导出的检查对象名。
28
+ * 加载失败(模块语法错误/导出被删)时不再静默丢弃,而是注册一个恒 skip 的占位检查,
29
+ * 让「这一项没跑成」在报告里可见。
8
30
  */
9
- export async function getAvailableIntegrations() {
10
- const integrations = [];
31
+ const INTEGRATION_SPECS = [
32
+ {
33
+ path: './poison-guard.mjs',
34
+ exportName: 'poisonGuardCheck',
35
+ fallback: { id: 'EXT-PG-1', name: 'poison-scan', severity: Severity.HIGH, phase: CheckPhase.POST_INSTALL, source: 'dsh-poison-guard' },
36
+ },
37
+ {
38
+ path: './ecosystem.mjs',
39
+ exportName: 'ecosystemCheck',
40
+ fallback: { id: 'EXT-ECO-1', name: 'ecosystem-compat', severity: Severity.LOW, phase: CheckPhase.LIFECYCLE, source: 'dsh-ecosystem' },
41
+ },
42
+ {
43
+ path: './plugin-reducer.mjs',
44
+ exportName: 'pluginReducerCheck',
45
+ fallback: { id: 'EXT-RED-1', name: 'plugin-reducer', severity: Severity.MEDIUM, phase: CheckPhase.LIFECYCLE, source: 'dsh-plugin-reducer' },
46
+ },
47
+ ];
11
48
 
12
- // dsh-poison-guard(需要安装)
13
- try {
14
- const mod = await import('./poison-guard.mjs');
15
- if (mod.isAvailable()) integrations.push(mod.poisonGuardCheck);
16
- } catch { /* skip */ }
17
-
18
- // dsh-sandbox-audit(需要安装)
19
- try {
20
- const mod = await import('./sandbox-audit.mjs');
21
- if (mod.isAvailable()) integrations.push(mod.sandboxAuditCheck);
22
- } catch { /* skip */ }
49
+ /** 模块加载失败时使用的占位检查:永远 skip,并带上加载失败原因 */
50
+ function brokenIntegration({ id, name, severity, phase, source }, reason) {
51
+ return {
52
+ id,
53
+ name,
54
+ severity,
55
+ phase,
56
+ description: `(集成模块加载失败)${source}`,
57
+ src: 'external',
58
+ source,
59
+ runner: async () => skip(id, severity, `${source} 集成模块加载失败,跳过:${reason}`),
60
+ };
61
+ }
23
62
 
24
- // dsh-ecosystem(总是可用,网络 API)
25
- try {
26
- const mod = await import('./ecosystem.mjs');
27
- integrations.push(mod.ecosystemCheck);
28
- } catch { /* skip */ }
63
+ /**
64
+ * 获取全部外部集成检查。
65
+ * 名字沿用(src/registry.mjs 依赖此导出),但语义已改为「全部集成,各自决定运行还是 skip」。
66
+ */
67
+ export async function getAvailableIntegrations() {
68
+ const integrations = [];
29
69
 
30
- // dsh-plugin-reducer(需要安装)
31
- try {
32
- const mod = await import('./plugin-reducer.mjs');
33
- if (mod.isAvailable()) integrations.push(mod.pluginReducerCheck);
34
- } catch { /* skip */ }
70
+ for (const spec of INTEGRATION_SPECS) {
71
+ try {
72
+ const mod = await import(spec.path);
73
+ const check = mod[spec.exportName];
74
+ if (check && typeof check === 'object' && check.id && typeof check.runner === 'function') {
75
+ integrations.push(check);
76
+ } else {
77
+ integrations.push(brokenIntegration(spec.fallback, `模块未导出 ${spec.exportName}`));
78
+ }
79
+ } catch (e) {
80
+ integrations.push(brokenIntegration(spec.fallback, String(e?.message ?? e).slice(0, 120)));
81
+ }
82
+ }
35
83
 
36
84
  return integrations;
37
85
  }