dsh-plugin-compat-check 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -1,16 +1,22 @@
1
1
  # dsh-plugin-compat-check
2
2
 
3
- 检查**已经安装的 DSH 插件**与当前 DSH 版本的兼容性,提供源码位置、运行时证据与覆盖限制。包含兼容性面板、插件详情角标、`compat_check` 对话工具和 skill provider。
3
+ 检查**已经安装的 DSH 插件**与当前 DSH 版本的兼容性,提供源码位置、运行时证据与覆盖限制。包含兼容性标签、插件详情发现/角标、`compat_check` 对话工具和 skill provider。
4
4
 
5
- 本文件适用于 **0.1.2**。安装指定版本:
5
+ ![插件详情页:部分可用,含 file:line、影响与依据](https://raw.githubusercontent.com/lcy0121/dsh-plugin-compat-check/main/plugin/assets/compat-detail-partial.png)
6
+
7
+ 本说明对应 **0.1.4**,支持 Node `^22.19.0 || >=24`。公开版本及 latest 以 [npm](https://www.npmjs.com/package/dsh-plugin-compat-check) 为准,发布和验收记录见 [Plan](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md)。
6
8
 
7
9
  ```bash
8
- dsh plugin --profile <profile> add dsh-plugin-compat-check@0.1.2
10
+ dsh plugin --profile <profile> add dsh-plugin-compat-check
9
11
  ```
10
12
 
13
+ DSH 0.2.1-rc.2 入口:**更多 → 设置 → 内置插件 → 兼容性**。agent 可调用 `compat_check`;请选择实际 profile,不默认使用 desktop。
14
+
15
+ 0.1.4 补充必需 inject 未观测时的激活前提说明,修正 Origin 校验以比较协议、主机和有效端口(保留本机无 Origin 调用),并提供 `repository.directory=plugin`。仓库另增强了进程回收、Chrome 启动诊断及 Linux CI;浏览器开发工具不随 npm 插件交付。市场显示名与 1024 Store 采集问题须由相应上游修复,不因本包升级自动解决。
16
+
11
17
  ## 从源码构建安装
12
18
 
13
- 本仓库根为私有开发工作区,不声明DSH bundle。不要把根目录GitHub安装规格当成plugin子目录的安装方式。在仓库根运行 `npm ci --ignore-scripts`,然后:
19
+ 仓库根是 private 开发工作区,不是可激活的插件包。先在根运行 `npm ci --ignore-scripts`,然后:
14
20
 
15
21
  ```bash
16
22
  cd plugin
@@ -18,26 +24,20 @@ npm pack --pack-destination <已存在的绝对输出目录>
18
24
  dsh plugin --profile <临时profile> add file:<生成的tgz绝对路径>
19
25
  ```
20
26
 
21
- prepack复制权威skill、同步单源报告契约的宿主/客户端产物,并暂存共享kernel和MIT LICENSE,postpack清理kernel。固定运行时依赖为 `@babel/parser@7.29.9` 与 `semver@7.7.3`,支持 Node `^22.19.0 || >=24`。源码裸复制或file安装不保证prepack运行,应使用完整仓库开发布局或预打包tarball。
27
+ prepack 同步权威 skill/报告契约,暂存共享 kernel 和 MIT LICENSE,postpack 清理 kernel。运行时依赖固定为 `@babel/parser@7.29.9`、`semver@7.7.3`。不要将根 GitHub spec、裸 `plugin/` 复制或源码 file 安装等同完整 tgz。
22
28
 
23
- npm产物**不带CLI bin、安装检查shell或probe入口**。CLI和安装检查从仓库使用;本插件不能做安装前检查。仓库使用说明:[README](https://github.com/lcy0121/dsh-plugin-compat-check#readme)。版本说明与发布状态请查看[开发计划与验收记录](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md),本地路径为docs/Plan.md。
24
-
25
- 0.1.2修正发布状态文案并统一版本标记;判定算法与0.1.1一致。规则/API目录仍基于0.2.0-rc.2,不把新版接入测试当作全面适配。
29
+ npm 产物**没有 CLI bin、安装检查 shell 或 probe 入口**,不支持 `npx dsh-plugin-compat-check`,本面板不做安装前检查。这些入口及 `--issue-report` 默认脱敏 Markdown 导出从[仓库](https://github.com/lcy0121/dsh-plugin-compat-check#readme)使用。显式安装检查会联网、启动宿主并执行目标插件;临时 profile 不是 OS 沙箱。
26
30
 
27
31
  ## 判定与边界
28
32
 
29
- 当前profile在宿主进程内采样服务描述符,不执行getter;未知访问器保留未知,并纳入能可靠映射的loader FAILED/PENDING。查询其它profile只用静态证据,不借用当前宿主服务。Babel AST扫描不执行目标源码,分析运行在有时间和内存上限的worker中;动态调用和跨函数数据流仍有限。
30
-
31
- `certainty`为certain或suspected;过滤、守卫、弃权是独立去处。省略的probe键表示未采样,不表示服务缺失。现代观测匹配profile/DSH版本且默认24小时过期;旧报告标记未绑定。声明不能代替API实测,官方peer冲突以推断影响呈现,当前实现仍按0.2.0-rc.2把engines.dsh作建议处理。0.2.1-rc.1宿主已优先按该字段判断,声明有误报/漏报风险,应核对实际宿主结果;本补丁未改判定算法。
32
-
33
- 转述时保留 `impactBasis=inferred`(影响为推断,非实测)、coverage限制和vacuous结果。API发现通常有file:line,loader/元数据发现可能没有源码位置。客户端服务列为待界面核验项;独立浏览器开发工具不属于本面板,且不验证业务交互。
34
-
35
- 插件体检不安装包、不调用外部命令或联网;会启动分析worker,并默认写自己的schema2基线:`$DSH_HOME/dsh-plugin-compat-check/baseline.<profile>.<path-hash>.<mode>.json`。模式分离、锁内比较、原子替换;源码扫描不完整不推进基线。默认不输出共享loader诊断;诊断需显式私有DSC_OUT_DIR。若需完全禁止基线读写,请使用仓库CLI的 `--no-baseline`。
36
-
37
- 0.1.1的分阶段证据包括默认回归、独立tarball执行及2026-10-15的真实DSH0.2.0-rc.2/macOS arm64验收:工具、skill、面板、详情角标、基线及Chrome正负向对照。2026-10-16另在Ubuntu24.04.5 arm64 VM通过Node22.19.0/24.21.0、真实DSH/tgz/HTTP及资源边界验收。同日提交38fa4a0的[GitHub Actions](https://github.com/lcy0121/dsh-plugin-compat-check/actions/runs/38892527900)在Ubuntu x64通过Node22/24资源检查及联合18项检查(4项真实DSH、1项冻结pnpm安装、13项离线)。后续main提交49403d7的[CI](https://github.com/lcy0121/dsh-plugin-compat-check/actions/runs/38923778251)再次通过Node22/24发布前与资源检查、Linux真实集成。Windows按约定待验收;skill正文修订后的仓库验证不冒充旧现场正文验收。具体测试数量与证据见仓库docs/current-state.json、docs/Plan.md;不把历史测试数字作为当前版本的完整功能认证。
38
-
39
- 报告结构由仓库lib/contracts.mjs校验:HTTP envelope.report与compat_check.report均保留完整发现、覆盖、观测和基线;工具原summary/text字段保留。新面板拒绝损坏或不支持版本的负载,提示刷新和对齐版本。旧probe保持未绑定警告,旧schema1基线中性重建;内核现支持有界常量属性、函数参数/返回值、包内模块别名及透明Proxy;未知动态行为与超预算继续显示覆盖限制。具体字段与错误码见 [Plan契约记录](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md#23-报告结构与兼容策略)。
33
+ - 当前 profile 在宿主进程内采样服务描述符,不执行 getter,纳入可靠映射且启用的 loader FAILED/PENDING。其它 profile 仅用静态证据,不借用当前宿主服务;未知、未采样或省略键不等于缺失。
34
+ - Babel AST 扫描不执行目标源码,分析运行在有时间/内存上限的 worker 中;有界包内值流支持部分别名、参数/返回值和透明 Proxy,动态行为仍有限。必需 inject 的激活障碍与方法失效分别判断。
35
+ - 转述保留 `certainty`、`impactBasis`、`coverage` 和 `vacuous`。API 发现通常有 `file:line`,loader/声明事实可能无位置;声明放行、激活成功、条目到货和 ok 均不证明业务正常,客户端功能须另验收。
36
+ - 从 DSH 0.2.1-rc.1 起优先自有 `engines.dsh`,缺失才回退官方 peer,旧目标保留旧规则。声明冲突/非法字段是推断风险,不冒充实际加载失败。只读独立 `compatibility.json`,按插件名、插件精确版本、DSH 精确版本匹配豁免;无效/不可读/超64KiB保留限制,不自动授予,非法字段不可豁免。
37
+ - DSH≥0.2.1-rc.2 用新目录(host98/client8),其余/未知用旧0.2.0-rc.2目录(host91/client8);未来 DSH 未认证。rc.2 的真实 Chrome 验收覆盖导航、报告渲染、禁用后路由移除及重启用后恢复/标签唯一;不认证所有插件业务。
38
+ - 体检不装包、不调用外部命令或联网;默认写自身 schema2 基线:`$DSH_HOME/dsh-plugin-compat-check/baseline.<profile>.<path-hash>.<mode>.json`。模式隔离、锁内比较、原子替换,不完整扫描不推进。完全禁基线读写须用仓库 CLI 的 `--no-baseline`。
39
+ - HTTP envelope.report 和 `compat_check.report` 保留完整发现、覆盖、观测和基线,原 summary/text 保留;新面板拒绝损坏或不支持版本的负载。旧 probe 提示未绑定,旧 schema1 基线中性重建,不兼容报告变更须升 schema。
40
40
 
41
- ## License
41
+ 限定 macOS/Linux、真实 DSH/Chrome、独立 tgz 和工程验收的逐次版本及证据见 [current-state.json](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/current-state.json)。Windows 待真实验收。独立业务/模型度量属于仓库开发工具,不随 npm 分发、不进入产品判定,默认测试/CI不调用模型;固定样本不是生态准确率或完整模型质量认证。
42
42
 
43
- MIT。源码、问题反馈与仓库文档:[GitHub](https://github.com/lcy0121/dsh-plugin-compat-check)。
43
+ [完整契约与复现](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md#2-现行判定与集成契约) · [源码及问题反馈](https://github.com/lcy0121/dsh-plugin-compat-check) · [MIT License](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/LICENSE)
package/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // ${DSC_OUT_DIR}/loader-introspect.json (explicit owned private directory only)
22
22
  // plus structured console lines prefixed with [dsh-plugin-compat-check].
23
23
 
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
25
25
  import { homedir, tmpdir } from 'node:os';
26
26
  import path, { join } from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -462,6 +462,7 @@ function toEntryArray(value) {
462
462
  }
463
463
 
464
464
  async function scan(ctx, state, pass) {
465
+ if(state.disposed)return;
465
466
  const report = {
466
467
  schemaVersion: SCHEMA_VERSION,
467
468
  plugin: name,
@@ -550,6 +551,7 @@ async function scan(ctx, state, pass) {
550
551
  return true;
551
552
  });
552
553
 
554
+ if(state.disposed)return report;
553
555
  const written = writeReport(report);
554
556
  log(
555
557
  `scan#${pass}/${SCAN_DELAYS.length} loader-via=${via} entries=${report.entryCount} states=${safeJson(report.stateHistogram)} problems=${report.problems.length} report=${OUT_JSON}${written ? '' : ' (WRITE FAILED)'}`,
@@ -700,6 +702,18 @@ function resolveDshVersion() {
700
702
  }
701
703
  } catch { /* 继续 */ }
702
704
 
705
+ // npm prefixes need not end in /dsh/. Read only an official runtime's own
706
+ // manifest above the real entry; package.json need not be an exported subpath.
707
+ try {
708
+ let dir=path.dirname(path.resolve(realpathSync(process.argv[1])));
709
+ for(let depth=0;depth<32;depth++){
710
+ try{
711
+ const meta=JSON.parse(readFileSync(join(dir,'package.json'),'utf8'));
712
+ if(['@deepseek-ai/dsh','@deepseek-ai/dsh-desktop-host','@deepseek-ai/dsh-app-boot'].includes(meta.name)&&typeof meta.version==='string'&&meta.version)return meta.version;
713
+ }catch{}
714
+ const parent=path.dirname(dir);if(parent===dir)break;dir=parent;
715
+ }
716
+ }catch{}
703
717
  // ③ 都没有 → **null,不猜**(判定层会据此不产出声明不符的结论)
704
718
  return null;
705
719
  }
@@ -790,6 +804,7 @@ function resolveNodeModules(requestedProfile = null) {
790
804
  * 同时到达也只会算一次 —— 否则它们会各自算一遍,白烧 1.5 秒 × 10。
791
805
  */
792
806
  async function serveReport(state, ctx, force, options = {}) {
807
+ if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
793
808
  if(!state.reportService){
794
809
  const kernel=resolveKernelDir();
795
810
  if(!kernel)return failureEnvelope('找不到判定内核');
@@ -797,7 +812,7 @@ async function serveReport(state, ctx, force, options = {}) {
797
812
  if(!state.reportService)state.reportService=new ReportService(async options=>{const out=await computeReport(state,ctx,options);return out.ok?assertEnvelope(out):failureEnvelope(out.error,out);},{ttlMs:state.cacheTtlMs});
798
813
  }
799
814
  const key=options.profile?`profile:${options.profile}`:'current';
800
- const out=await state.reportService.get(key,options,force);return out?.ok?assertEnvelope(out):failureEnvelope(out?.error,out||{});
815
+ const out=await state.reportService.get(key,options,force);if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');return out?.ok?assertEnvelope(out):failureEnvelope(out?.error,out||{});
801
816
  }
802
817
 
803
818
  /**
@@ -817,13 +832,14 @@ function baselineFileFor(nmDir) {
817
832
  }
818
833
 
819
834
  /**
820
- * 跑一次体检。**同步、纯计算、不抛**。
835
+ * 跑一次有界体检;异步分析、可写自身基线,错误由报告返回。
821
836
  *
822
837
  * 为什么宿主半边要算而不是把原始数据丢给客户端:
823
838
  * - 客户端跑在浏览器里,读不了文件系统;
824
839
  * - 结论(五元组 + 确定性)是内核的产物,算出唯一一份才不会两边漂移。
825
840
  */
826
841
  async function computeReport(state, ctx, options = {}) {
842
+ if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
827
843
  const started = Date.now();
828
844
  const out = {
829
845
  schemaVersion: 1,
@@ -872,7 +888,8 @@ async function computeReport(state, ctx, options = {}) {
872
888
  const { extractInWorker } = await import(pathToFileURL(join(kernel, 'lib', 'worker-analysis.mjs')).href);
873
889
  const { buildVerdicts } = await import(pathToFileURL(join(kernel, 'lib', 'verdict.mjs')).href);
874
890
 
875
- const catalogPath = join(kernel, 'rules', 'dsh-api-catalog.json');
891
+ const {catalogPathFor}=await import(pathToFileURL(join(kernel,'lib','catalog.mjs')).href);
892
+ const catalogPath = catalogPathFor(kernel,resolveDshVersion());
876
893
  const rulesPath = join(kernel, 'rules', 'severity.json');
877
894
  const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
878
895
  const rules = JSON.parse(readFileSync(rulesPath, 'utf8'));
@@ -893,6 +910,7 @@ async function computeReport(state, ctx, options = {}) {
893
910
  out.surfaceWhy = surfaceWhy;
894
911
 
895
912
  const extracted = await extractInWorker(nm.dir, { catalog });
913
+ if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
896
914
  const { sampleServices } = await import(pathToFileURL(join(kernel,'lib','observation.mjs')).href);
897
915
  const currentTarget=resolveNodeModules();
898
916
  const sameHost=currentTarget.dir&&path.resolve(currentTarget.dir)===path.resolve(nm.dir);
@@ -905,6 +923,8 @@ async function computeReport(state, ctx, options = {}) {
905
923
  out.loaderCoverage=loaderState;
906
924
 
907
925
  out.surfaceWhy=surfaceWhy;
926
+ const {readProfileCompatibility}=await import(pathToFileURL(join(kernel,'lib','profile-compatibility.mjs')).href);
927
+ const compatibility=readProfileCompatibility(nm.dir);
908
928
  const verdict = buildVerdicts({
909
929
  report: extracted.report,
910
930
  hostSurface,
@@ -913,6 +933,8 @@ async function computeReport(state, ctx, options = {}) {
913
933
  dshVersion: resolveDshVersion(),
914
934
  entryStates: loaderState.rows,
915
935
  observationTarget: {profileId:nm.dir,maxAgeMs:24*60*60*1000},
936
+ compatibilityExemptions: compatibility.exemptions,
937
+ compatibilityNotes: compatibility.notes,
916
938
  });
917
939
 
918
940
  // ── C4:基线差分 ──
@@ -926,6 +948,7 @@ async function computeReport(state, ctx, options = {}) {
926
948
  const identity=store.baselineIdentity(nm.dir,verdict.observation.mode,rules,catalog);
927
949
  const snap = snapshotForBaseline(verdict, resolveDshVersion(),identity);
928
950
  const bfile = store.baselinePath(nm.dir,verdict.observation.mode);
951
+ if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
929
952
  baseline=store.baselineTransaction(bfile,snap,{advance:extracted.report.scanComplete});
930
953
  } catch (error) {
931
954
  baseline = { error: errText(error), note: '基线差分没算成(不影响本次结论)' };
@@ -957,6 +980,7 @@ async function computeReport(state, ctx, options = {}) {
957
980
  }
958
981
 
959
982
  const {toJSON}=await import(pathToFileURL(join(kernel,'lib','report.mjs')).href);
983
+ if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
960
984
  const canonical=toJSON({error:null,extract:extracted,verdict,baseline,inputs:{nodeModules:nm.dir,hasRuntimeSurface:verdict.observation.usable,catalog:catalogPath,rules:rulesPath}});
961
985
  out.baseline = canonical.baseline;
962
986
  out.dshVersion = canonical.dshVersion;
@@ -976,8 +1000,12 @@ function isTrustedRequest(req) {
976
1000
  const host = String((req.headers && (req.headers.host || req.headers.Host)) || '');
977
1001
  const origin = String((req.headers && (req.headers.origin || req.headers.Origin)) || '');
978
1002
  const hostOk = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
979
- if (!origin) return hostOk;
980
- return hostOk && /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin);
1003
+ if (!hostOk) return false;
1004
+ const protocol = req.socket?.encrypted === true ? 'https:' : 'http:';
1005
+ const expectedOrigin = new URL(`${protocol}//${host}`).origin;
1006
+ if (!origin) return true;
1007
+ if ( !/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin)) return false;
1008
+ return new URL(origin).origin === expectedOrigin;
981
1009
  } catch {
982
1010
  return false;
983
1011
  }
@@ -1076,7 +1104,7 @@ function registerTool(ctx, state) {
1076
1104
  log('D1 tool skipped: scoped ctx has no tools.register');
1077
1105
  return;
1078
1106
  }
1079
- scope.tools.register({
1107
+ const dispose = scope.tools.register({
1080
1108
  name: 'compat_check',
1081
1109
  description:
1082
1110
  'Check whether the DSH plugins installed in this profile are actually compatible with the '
@@ -1150,6 +1178,7 @@ function registerTool(ctx, state) {
1150
1178
  },
1151
1179
  });
1152
1180
  log('D1 tool registered: compat_check');
1181
+ return dispose;
1153
1182
  } catch (error) {
1154
1183
  log(`WARN D1 tool registration failed: ${errText(error)}`);
1155
1184
  }
@@ -1260,7 +1289,7 @@ function registerSkill(ctx) {
1260
1289
  path: skill.file,
1261
1290
  resourceBase: { kind: 'directory', path: path.dirname(skill.file) },
1262
1291
  };
1263
- skillCtx.skills.registerProvider(() => ({
1292
+ const dispose = skillCtx.skills.registerProvider(() => ({
1264
1293
  name: SKILL_PROVIDER,
1265
1294
  list: () => Promise.resolve([summary]),
1266
1295
  get: () => Promise.resolve({
@@ -1274,6 +1303,7 @@ function registerSkill(ctx) {
1274
1303
  }),
1275
1304
  }));
1276
1305
  log(`SKILL registered: ${SKILL_NAME} <- ${skill.file}`);
1306
+ return dispose;
1277
1307
  } catch (error) {
1278
1308
  log(`SKILL registration failed: ${errText(error)}`);
1279
1309
  }
@@ -1289,6 +1319,8 @@ export function apply(ctx) {
1289
1319
  log(`HOST-HALF-ACTIVATED pid=${process.pid} node=${process.version}`);
1290
1320
 
1291
1321
  const state = {
1322
+ disposed: false,
1323
+ scanTimers: new Set(),
1292
1324
  applyAt: new Date().toISOString(),
1293
1325
  loader: undefined,
1294
1326
  loaderVia: null,
@@ -1342,7 +1374,7 @@ export function apply(ctx) {
1342
1374
  if (typeof ctx.inject === 'function') {
1343
1375
  ctx.inject(['webServer'], (scope) => {
1344
1376
  try {
1345
- scope.webServer.register({
1377
+ const dispose = scope.webServer.register({
1346
1378
  name: 'dsh-plugin-compat-check',
1347
1379
  kind: 'exact',
1348
1380
  path: '/plugin-compat-check/report',
@@ -1371,6 +1403,7 @@ export function apply(ctx) {
1371
1403
  },
1372
1404
  });
1373
1405
  log('C2 route registered: GET /plugin-compat-check/report');
1406
+ return dispose;
1374
1407
  } catch (error) {
1375
1408
  log(`WARN C2 route registration failed: ${errText(error)}`);
1376
1409
  }
@@ -1431,14 +1464,18 @@ export function apply(ctx) {
1431
1464
  // one can safely wait for it and the report file is never written twice
1432
1465
  // concurrently.
1433
1466
  const schedulePass = (index) => {
1467
+ if(state.disposed)return;
1434
1468
  const previous = index === 0 ? 0 : SCAN_DELAYS[index - 1];
1435
1469
  const delay = Math.max(0, SCAN_DELAYS[index] - previous);
1436
1470
  const timer = setTimeout(() => {
1471
+ state.scanTimers.delete(timer);
1472
+ if(state.disposed)return;
1437
1473
  const pass = index + 1;
1438
1474
  try {
1439
1475
  scan(ctx, state, pass)
1440
1476
  .catch((error) => log(`ERROR scan#${pass} failed: ${errText(error)}`))
1441
1477
  .finally(() => {
1478
+ if(state.disposed)return;
1442
1479
  if (pass < SCAN_DELAYS.length) schedulePass(index + 1);
1443
1480
  else finish();
1444
1481
  });
@@ -1448,6 +1485,7 @@ export function apply(ctx) {
1448
1485
  }, delay);
1449
1486
  // unref: a scan timer must never keep the host process alive.
1450
1487
  if (timer && typeof timer.unref === 'function') timer.unref();
1488
+ state.scanTimers.add(timer);
1451
1489
  };
1452
1490
  schedulePass(0);
1453
1491
 
@@ -1465,6 +1503,7 @@ export function apply(ctx) {
1465
1503
  // 的清理顺手取消掉了 —— 启动自检从未运行、且**没有任何报错**。
1466
1504
  // 一个"看起来做了其实没做"的定时器,比没有更糟。
1467
1505
  state.runAutoCheck = () => {
1506
+ if(state.disposed)return;
1468
1507
  if (state.autoCheckStarted) return; // 幂等:只跑一次
1469
1508
  state.autoCheckStarted = true;
1470
1509
  try {
@@ -1503,12 +1542,21 @@ export function apply(ctx) {
1503
1542
 
1504
1543
  // Manual re-scan handle for tooling/experiments (no host service needed).
1505
1544
  try {
1506
- globalThis.__DSH_COMPAT_CHECK__ = {
1545
+ const handle = {
1507
1546
  rescan: () => scan(ctx, state, 0),
1508
1547
  reportPath: OUT_JSON,
1509
1548
  // 面板用的那份结论(同一个函数,所以界面与工具不可能给出不同答案)
1510
1549
  compatReport: (c) => computeReport(state, c || ctx),
1511
1550
  };
1551
+ globalThis.__DSH_COMPAT_CHECK__ = handle;
1552
+ if(typeof ctx.effect==='function')ctx.effect(()=>()=>{
1553
+ state.disposed=true;
1554
+ for(const timer of state.scanTimers)clearTimeout(timer);
1555
+ state.scanTimers.clear();
1556
+ clearTimeout(state.fallbackTimer);
1557
+ state.disposeInjectProbe?.();state.disposeInjectProbe=null;
1558
+ if(globalThis.__DSH_COMPAT_CHECK__===handle)delete globalThis.__DSH_COMPAT_CHECK__;
1559
+ },'compat-check: generation cleanup');
1512
1560
  } catch {
1513
1561
  /* 忽略 */
1514
1562
  }
@@ -0,0 +1,16 @@
1
+ /** Keep the frozen legacy catalogue available for older targets and overrides. */
2
+ import path from "node:path";
3
+ import semver from "semver";
4
+
5
+ export function catalogPathFor(root, version, override) {
6
+ if (override) return override;
7
+ const modern =
8
+ typeof version === "string" &&
9
+ semver.valid(version) &&
10
+ semver.gte(version, "0.2.1-rc.2");
11
+ return path.join(
12
+ root,
13
+ "rules",
14
+ modern ? "dsh-api-catalog-0.2.1-rc.2.json" : "dsh-api-catalog.json",
15
+ );
16
+ }
@@ -1,7 +1,7 @@
1
1
  import semver from "semver";
2
2
 
3
3
  /** DSH app-boot 0.2.0-rc.2 checks every official dsh peer, including prereleases. */
4
- export function declaredSupport(pkg, version) {
4
+ function legacySupport(pkg, version) {
5
5
  if (!version || !semver.valid(version)) return null;
6
6
  const requirements = [];
7
7
  for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
@@ -48,3 +48,102 @@ export function declaredSupport(pkg, version) {
48
48
  .map((r) => `Invalid declared range for ${r.name}`),
49
49
  };
50
50
  }
51
+
52
+ const own = (value, key) => value != null && Object.hasOwn(value, key);
53
+ const record = (value) =>
54
+ value !== null && typeof value === "object" && !Array.isArray(value);
55
+
56
+ /** Match the engines-first policy introduced by app-boot 0.2.1-rc.1.
57
+ * This predicts declaration acceptance, never plugin activation or behavior.
58
+ */
59
+ export function declaredSupport(pkg, version, { exemptions } = {}) {
60
+ if (!version || !semver.valid(version)) return null;
61
+ if (semver.lt(version, "0.2.1-rc.1")) return legacySupport(pkg, version);
62
+ const compatibility = pkg.compatibility;
63
+ pkg = pkg.declarationManifest ?? pkg;
64
+ const requirements = [];
65
+ let error = null;
66
+ if (!record(pkg)) return null;
67
+ const engines = own(pkg, "engines") ? pkg.engines : {};
68
+ if (!record(engines)) error = "Plugin manifest engines must be an object";
69
+ else if (own(engines, "dsh")) {
70
+ if (typeof engines.dsh !== "string")
71
+ error = "Plugin manifest engines.dsh must be a string";
72
+ else
73
+ requirements.push({
74
+ name: "dsh",
75
+ range: engines.dsh,
76
+ source: "engines.dsh",
77
+ });
78
+ } else if (own(pkg, "peerDependencies")) {
79
+ if (!record(pkg.peerDependencies))
80
+ error = "Plugin manifest peerDependencies must be an object";
81
+ else
82
+ for (const [name, range] of Object.entries(pkg.peerDependencies)) {
83
+ if (typeof range !== "string") {
84
+ error = "Plugin manifest peerDependencies ranges must be strings";
85
+ break;
86
+ }
87
+ if (name === "@deepseek-ai/dsh" || name.startsWith("@deepseek-ai/dsh-"))
88
+ requirements.push({ name, range, source: "peerDependencies" });
89
+ }
90
+ }
91
+ const checks = requirements.map((r) => {
92
+ const range =
93
+ r.source === "peerDependencies" &&
94
+ ["workspace:^", "workspace:~", "workspace:*"].includes(r.range)
95
+ ? version
96
+ : r.range;
97
+ return {
98
+ ...r,
99
+ valid: semver.validRange(range) !== null,
100
+ compatible:
101
+ range.trim() !== "" &&
102
+ semver.satisfies(version, range, { includePrerelease: true }),
103
+ };
104
+ });
105
+ const rejected = checks.filter((r) => !r.compatible);
106
+ if (
107
+ !error &&
108
+ rejected.length &&
109
+ ["name", "version"].some(
110
+ (key) =>
111
+ !own(pkg, key) || typeof pkg[key] !== "string" || !pkg[key].trim(),
112
+ )
113
+ )
114
+ error =
115
+ "Plugin manifest name/version must be non-empty strings when requirements are unmet";
116
+ const exact =
117
+ compatibility?.[version] ?? compatibility?.dshReleases?.[version];
118
+ const exactClaim =
119
+ exact === "compatible" ? true : exact === "incompatible" ? false : null;
120
+ if (!error && !checks.length && exactClaim === null) return null;
121
+ const grant = error
122
+ ? false
123
+ : exemptions === undefined
124
+ ? null
125
+ : own(exemptions, pkg.name + "@" + pkg.version) &&
126
+ Array.isArray(exemptions[pkg.name + "@" + pkg.version]) &&
127
+ exemptions[pkg.name + "@" + pkg.version].includes(version);
128
+ return {
129
+ claimsCompatible:
130
+ !error &&
131
+ (checks.length
132
+ ? checks.every((r) => r.compatible) && exactClaim !== false
133
+ : exactClaim),
134
+ policy: "engines-first",
135
+ invalid: error,
136
+ rejected,
137
+ exempted: grant,
138
+ raw: {
139
+ compatibility: compatibility ?? null,
140
+ engines: own(pkg, "engines") ? pkg.engines : null,
141
+ peers: checks,
142
+ },
143
+ notes: error
144
+ ? [error]
145
+ : checks
146
+ .filter((r) => !r.valid)
147
+ .map((r) => "Invalid declared range for " + r.name),
148
+ };
149
+ }
@@ -232,6 +232,11 @@ export function extractWithProvenance(nm, options = {}) {
232
232
  clientInject: client.inject ?? null,
233
233
  peerDependencies: j.peerDependencies ?? null,
234
234
  // 声明的兼容性口径(第 8 题:「声明与实测不符」这一类结论的输入)
235
+ declarationManifest: Object.fromEntries(
236
+ ["name", "version", "engines", "peerDependencies"]
237
+ .filter((key) => Object.hasOwn(j, key))
238
+ .map((key) => [key, j[key]]),
239
+ ),
235
240
  engines: j.engines ?? null,
236
241
  compatibility: dsh.compatibility ?? null,
237
242
  declaredClientEntry: declaredClientEntry(j),