dsh-plugin-compat-check 0.1.2 → 0.1.3

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,11 +1,13 @@
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.3** 的实现;是否已发布及 npm latest 以[发布记录](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md)与registry为准。安装公开版本(发布前不能用源码测试代替公开包验收):
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
 
11
13
  ## 从源码构建安装
@@ -20,17 +22,19 @@ dsh plugin --profile <临时profile> add file:<生成的tgz绝对路径>
20
22
 
21
23
  prepack复制权威skill、同步单源报告契约的宿主/客户端产物,并暂存共享kernel和MIT LICENSE,postpack清理kernel。固定运行时依赖为 `@babel/parser@7.29.9` 与 `semver@7.7.3`,支持 Node `^22.19.0 || >=24`。源码裸复制或file安装不保证prepack运行,应使用完整仓库开发布局或预打包tarball。
22
24
 
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。
25
+ npm产物**不带CLI bin、安装检查shell或probe入口**。CLI和安装检查从仓库使用;仓库源码现提供`--issue-report`默认脱敏Markdown导出,不读写基线、保留原退出码,须预览后自行分享,该功能不在npm包内。本插件不能做安装前检查。仓库使用说明:[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
26
 
25
- 0.1.2修正发布状态文案并统一版本标记;判定算法与0.1.1一致。规则/API目录仍基于0.2.0-rc.2,不把新版接入测试当作全面适配。
27
+ 0.1.3增加目标版本声明适配、rc.2目录与卸载清理;历史0.1.2只修正文案和版本,算法与0.1.1一致;0.1.3改变了新版DSH声明判定。
26
28
 
27
29
  ## 判定与边界
28
30
 
31
+ 2026-10-18从候选完整tgz在私有rc.2宿主和真实Chrome验证:“更多 → 设置 → 内置插件 → 兼容性”可见标签和报告;禁用后报告路由消失,启用后恢复且标签唯一。59714e7中10月17日标签未显示的历史记录本次未复现,原因未定;不把这次局部UI验收当作所有业务功能认证。
32
+
29
33
  当前profile在宿主进程内采样服务描述符,不执行getter;未知访问器保留未知,并纳入能可靠映射的loader FAILED/PENDING。查询其它profile只用静态证据,不借用当前宿主服务。Babel AST扫描不执行目标源码,分析运行在有时间和内存上限的worker中;动态调用和跨函数数据流仍有限。
30
34
 
31
- `certainty`为certain或suspected;过滤、守卫、弃权是独立去处。省略的probe键表示未采样,不表示服务缺失。现代观测匹配profile/DSH版本且默认24小时过期;旧报告标记未绑定。声明不能代替API实测,官方peer冲突以推断影响呈现,当前实现仍按0.2.0-rc.2把engines.dsh作建议处理。0.2.1-rc.1宿主已优先按该字段判断,声明有误报/漏报风险,应核对实际宿主结果;本补丁未改判定算法。
35
+ `certainty`为certain或suspected;过滤、守卫、弃权另列。省略probe键为未采样,现代观测绑定profile/DSH版本且默认24小时过期,旧报告标记未绑定。0.1.3对DSH≥0.2.1-rc.1优先自有engines.dsh,缺失时回退官方peer;旧目标保持旧规则。声明冲突/非法字段的影响为推断,不冒充实测加载失败。只读profile独立compatibility.json,按插件名、插件精确版本及DSH精确版本匹配;无效记录整条忽略并限制覆盖,不可读/超64KiB则豁免未观测,不自动写入豁免。
32
36
 
33
- 转述时保留 `impactBasis=inferred`(影响为推断,非实测)、coverage限制和vacuous结果。API发现通常有file:line,loader/元数据发现可能没有源码位置。客户端服务列为待界面核验项;独立浏览器开发工具不属于本面板,且不验证业务交互。
37
+ 按已知目标选目录:DSH≥0.2.1-rc.2用rc.2快照(host98/client8),其余/未知用原0.2.0-rc.2快照(host91/client8);未来版本未经认证。转述保留 `impactBasis=inferred`、coverage限制和vacuous。API发现通常有file:line,loader/声明事实可能无位置;声明获准不证明API或业务成功。客户端待界面核验;到货工具与新增的独立业务队列均不属于本面板。仓库业务队列覆盖固定dshmarket的备注/收藏,以及dsh-better-sidebar的保存/预览HTTP路径、编码/换行保留及失败后恢复;对照仅在浏览器注入POST503,业务标签不进入兼容性判定,结果不认证其它路径或原插件有缺陷。
34
38
 
35
39
  插件体检不安装包、不调用外部命令或联网;会启动分析worker,并默认写自己的schema2基线:`$DSH_HOME/dsh-plugin-compat-check/baseline.<profile>.<path-hash>.<mode>.json`。模式分离、锁内比较、原子替换;源码扫描不完整不推进基线。默认不输出共享loader诊断;诊断需显式私有DSC_OUT_DIR。若需完全禁止基线读写,请使用仓库CLI的 `--no-baseline`。
36
40
 
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;
@@ -1076,7 +1100,7 @@ function registerTool(ctx, state) {
1076
1100
  log('D1 tool skipped: scoped ctx has no tools.register');
1077
1101
  return;
1078
1102
  }
1079
- scope.tools.register({
1103
+ const dispose = scope.tools.register({
1080
1104
  name: 'compat_check',
1081
1105
  description:
1082
1106
  'Check whether the DSH plugins installed in this profile are actually compatible with the '
@@ -1150,6 +1174,7 @@ function registerTool(ctx, state) {
1150
1174
  },
1151
1175
  });
1152
1176
  log('D1 tool registered: compat_check');
1177
+ return dispose;
1153
1178
  } catch (error) {
1154
1179
  log(`WARN D1 tool registration failed: ${errText(error)}`);
1155
1180
  }
@@ -1260,7 +1285,7 @@ function registerSkill(ctx) {
1260
1285
  path: skill.file,
1261
1286
  resourceBase: { kind: 'directory', path: path.dirname(skill.file) },
1262
1287
  };
1263
- skillCtx.skills.registerProvider(() => ({
1288
+ const dispose = skillCtx.skills.registerProvider(() => ({
1264
1289
  name: SKILL_PROVIDER,
1265
1290
  list: () => Promise.resolve([summary]),
1266
1291
  get: () => Promise.resolve({
@@ -1274,6 +1299,7 @@ function registerSkill(ctx) {
1274
1299
  }),
1275
1300
  }));
1276
1301
  log(`SKILL registered: ${SKILL_NAME} <- ${skill.file}`);
1302
+ return dispose;
1277
1303
  } catch (error) {
1278
1304
  log(`SKILL registration failed: ${errText(error)}`);
1279
1305
  }
@@ -1289,6 +1315,8 @@ export function apply(ctx) {
1289
1315
  log(`HOST-HALF-ACTIVATED pid=${process.pid} node=${process.version}`);
1290
1316
 
1291
1317
  const state = {
1318
+ disposed: false,
1319
+ scanTimers: new Set(),
1292
1320
  applyAt: new Date().toISOString(),
1293
1321
  loader: undefined,
1294
1322
  loaderVia: null,
@@ -1342,7 +1370,7 @@ export function apply(ctx) {
1342
1370
  if (typeof ctx.inject === 'function') {
1343
1371
  ctx.inject(['webServer'], (scope) => {
1344
1372
  try {
1345
- scope.webServer.register({
1373
+ const dispose = scope.webServer.register({
1346
1374
  name: 'dsh-plugin-compat-check',
1347
1375
  kind: 'exact',
1348
1376
  path: '/plugin-compat-check/report',
@@ -1371,6 +1399,7 @@ export function apply(ctx) {
1371
1399
  },
1372
1400
  });
1373
1401
  log('C2 route registered: GET /plugin-compat-check/report');
1402
+ return dispose;
1374
1403
  } catch (error) {
1375
1404
  log(`WARN C2 route registration failed: ${errText(error)}`);
1376
1405
  }
@@ -1431,14 +1460,18 @@ export function apply(ctx) {
1431
1460
  // one can safely wait for it and the report file is never written twice
1432
1461
  // concurrently.
1433
1462
  const schedulePass = (index) => {
1463
+ if(state.disposed)return;
1434
1464
  const previous = index === 0 ? 0 : SCAN_DELAYS[index - 1];
1435
1465
  const delay = Math.max(0, SCAN_DELAYS[index] - previous);
1436
1466
  const timer = setTimeout(() => {
1467
+ state.scanTimers.delete(timer);
1468
+ if(state.disposed)return;
1437
1469
  const pass = index + 1;
1438
1470
  try {
1439
1471
  scan(ctx, state, pass)
1440
1472
  .catch((error) => log(`ERROR scan#${pass} failed: ${errText(error)}`))
1441
1473
  .finally(() => {
1474
+ if(state.disposed)return;
1442
1475
  if (pass < SCAN_DELAYS.length) schedulePass(index + 1);
1443
1476
  else finish();
1444
1477
  });
@@ -1448,6 +1481,7 @@ export function apply(ctx) {
1448
1481
  }, delay);
1449
1482
  // unref: a scan timer must never keep the host process alive.
1450
1483
  if (timer && typeof timer.unref === 'function') timer.unref();
1484
+ state.scanTimers.add(timer);
1451
1485
  };
1452
1486
  schedulePass(0);
1453
1487
 
@@ -1465,6 +1499,7 @@ export function apply(ctx) {
1465
1499
  // 的清理顺手取消掉了 —— 启动自检从未运行、且**没有任何报错**。
1466
1500
  // 一个"看起来做了其实没做"的定时器,比没有更糟。
1467
1501
  state.runAutoCheck = () => {
1502
+ if(state.disposed)return;
1468
1503
  if (state.autoCheckStarted) return; // 幂等:只跑一次
1469
1504
  state.autoCheckStarted = true;
1470
1505
  try {
@@ -1503,12 +1538,21 @@ export function apply(ctx) {
1503
1538
 
1504
1539
  // Manual re-scan handle for tooling/experiments (no host service needed).
1505
1540
  try {
1506
- globalThis.__DSH_COMPAT_CHECK__ = {
1541
+ const handle = {
1507
1542
  rescan: () => scan(ctx, state, 0),
1508
1543
  reportPath: OUT_JSON,
1509
1544
  // 面板用的那份结论(同一个函数,所以界面与工具不可能给出不同答案)
1510
1545
  compatReport: (c) => computeReport(state, c || ctx),
1511
1546
  };
1547
+ globalThis.__DSH_COMPAT_CHECK__ = handle;
1548
+ if(typeof ctx.effect==='function')ctx.effect(()=>()=>{
1549
+ state.disposed=true;
1550
+ for(const timer of state.scanTimers)clearTimeout(timer);
1551
+ state.scanTimers.clear();
1552
+ clearTimeout(state.fallbackTimer);
1553
+ state.disposeInjectProbe?.();state.disposeInjectProbe=null;
1554
+ if(globalThis.__DSH_COMPAT_CHECK__===handle)delete globalThis.__DSH_COMPAT_CHECK__;
1555
+ },'compat-check: generation cleanup');
1512
1556
  } catch {
1513
1557
  /* 忽略 */
1514
1558
  }
@@ -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),
@@ -0,0 +1,69 @@
1
+ /** Read independent profile grants without importing or changing target plugins. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import semver from "semver";
5
+
6
+ const exact = (value) => {
7
+ const parsed = typeof value === "string" ? semver.parse(value) : null;
8
+ return (
9
+ parsed !== null &&
10
+ value ===
11
+ parsed.version + (parsed.build.length ? "+" + parsed.build.join(".") : "")
12
+ );
13
+ };
14
+ export function readProfileCompatibility(nodeModules) {
15
+ const file = path.join(path.dirname(nodeModules), "compatibility.json");
16
+ const unavailable = (note) => ({
17
+ file,
18
+ exemptions: undefined,
19
+ notes: [note],
20
+ });
21
+ let fd;
22
+ try {
23
+ fd = fs.openSync(
24
+ file,
25
+ fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0),
26
+ );
27
+ if (!fs.fstatSync(fd).isFile())
28
+ return unavailable("compatibility.json 不是普通文件,精确豁免未观测");
29
+ const buffer = Buffer.alloc(65537),
30
+ length = fs.readSync(fd, buffer, 0, buffer.length, 0);
31
+ if (length > 65536)
32
+ return unavailable(
33
+ "compatibility.json 超过64KiB读取预算,精确豁免未观测",
34
+ );
35
+ const value = JSON.parse(buffer.subarray(0, length).toString("utf8"));
36
+ if (value === null || typeof value !== "object" || Array.isArray(value))
37
+ return unavailable(
38
+ "compatibility.json 必须为精确版本豁免对象,记录未采用",
39
+ );
40
+ const exemptions = Object.create(null),
41
+ notes = [];
42
+ for (const [key, versions] of Object.entries(value)) {
43
+ const i = key.lastIndexOf("@");
44
+ if (
45
+ i <= 0 ||
46
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(
47
+ key.slice(0, i),
48
+ ) ||
49
+ !exact(key.slice(i + 1)) ||
50
+ !Array.isArray(versions) ||
51
+ !versions.every(exact)
52
+ ) {
53
+ notes.push("compatibility.json 含无效的精确版本豁免记录,已忽略");
54
+ continue;
55
+ }
56
+ Object.defineProperty(exemptions, key, {
57
+ value: versions,
58
+ enumerable: true,
59
+ });
60
+ }
61
+ return { file, exemptions, notes };
62
+ } catch (error) {
63
+ return error.code === "ENOENT"
64
+ ? { file, exemptions: {}, notes: [] }
65
+ : unavailable("compatibility.json 不可读或JSON无效,精确豁免未观测");
66
+ } finally {
67
+ if (fd !== undefined) fs.closeSync(fd);
68
+ }
69
+ }
@@ -99,8 +99,8 @@ export function methodRule(rules, service, method, side = "host") {
99
99
  return null;
100
100
  return rule;
101
101
  }
102
- export function declarationOf(p, version) {
103
- return declaredSupport(p, version);
102
+ export function declarationOf(p, version, options) {
103
+ return declaredSupport(p, version, options);
104
104
  }
105
105
  export function buildVerdicts({
106
106
  report,
@@ -110,6 +110,8 @@ export function buildVerdicts({
110
110
  dshVersion = null,
111
111
  entryStates = [],
112
112
  observationTarget = {},
113
+ compatibilityExemptions,
114
+ compatibilityNotes = [],
113
115
  }) {
114
116
  const observation = normalizeObservation(hostSurface, {
115
117
  dshVersion,
@@ -146,6 +148,7 @@ export function buildVerdicts({
146
148
  const resolved = Object.create(null);
147
149
  const limitations = [
148
150
  ...observation.notes,
151
+ ...compatibilityNotes,
149
152
  ...(p.diagnostics || []).map((d) => `${d.kind}: ${d.file ?? ""}`),
150
153
  ];
151
154
  let verified = 0;
@@ -422,14 +425,32 @@ export function buildVerdicts({
422
425
  (s) => methods.includes(s.method) || protectedCall(s),
423
426
  ).length;
424
427
  }
425
- const declaration = declarationOf(p, dshVersion);
428
+ const declaration = declarationOf(p, dshVersion, {
429
+ exemptions: compatibilityExemptions,
430
+ });
426
431
  const rejectedPeers =
432
+ declaration?.rejected ??
427
433
  declaration?.raw?.peers?.filter(
428
434
  (r) => r.source === "peerDependencies" && !r.compatible,
429
- ) || [];
430
- if (rejectedPeers.length) {
435
+ ) ??
436
+ [];
437
+ const modern = declaration?.policy === "engines-first";
438
+ if (modern && rejectedPeers.length && declaration.exempted === true)
439
+ limitations.push(
440
+ "声明范围未满足,但存在此插件与目标DSH的精确版本豁免;加载许可不证明API或业务兼容",
441
+ );
442
+ if (
443
+ (rejectedPeers.length || declaration?.invalid) &&
444
+ declaration?.exempted !== true
445
+ ) {
431
446
  limitations.push(
432
- "官方 peer 范围不匹配;未读取 exact-version 豁免,不能断言实际加载失败",
447
+ modern
448
+ ? declaration.invalid
449
+ ? declaration.invalid
450
+ : "声明范围不匹配;精确版本豁免" +
451
+ (declaration.exempted === null ? "未观测" : "未授予") +
452
+ ",未实测加载结果"
453
+ : "官方 peer 范围不匹配;未读取 exact-version 豁免,不能断言实际加载失败",
433
454
  );
434
455
  add({
435
456
  category: CATEGORY.DECLARED_INCOMPATIBLE,
@@ -439,18 +460,31 @@ export function buildVerdicts({
439
460
  method: null,
440
461
  location: null,
441
462
  severity: "degraded",
442
- impact:
443
- "官方 peer 声明不支持当前 DSH;请确认版本范围与精确版本豁免,API 存在不等于完整兼容",
444
- basis: rejectedPeers
445
- .map((r) => `${r.name}: ${r.range},当前 ${dshVersion}`)
446
- .join("; "),
463
+ impact: modern
464
+ ? "按目标DSH声明规则未获放行;须核对精确豁免及实际加载,API存在不等于完整兼容"
465
+ : "官方 peer 声明不支持当前 DSH;请确认版本范围与精确版本豁免,API 存在不等于完整兼容",
466
+ basis:
467
+ declaration.invalid ||
468
+ rejectedPeers
469
+ .map((r) => `${r.name}: ${r.range},当前 ${dshVersion}`)
470
+ .join("; "),
447
471
  evidence: {
448
472
  runtimeVersion: dshVersion,
449
473
  peers: rejectedPeers,
450
- exemption: "unobserved",
474
+ exemption: modern
475
+ ? declaration.exempted === null
476
+ ? "unobserved"
477
+ : "not-granted"
478
+ : "unobserved",
479
+ policy: declaration.policy ?? "legacy-peers",
480
+ invalid: declaration.invalid ?? null,
451
481
  },
452
482
  ruleSource: "declaration",
453
- ruleRef: "package.peerDependencies",
483
+ ruleRef: declaration.invalid
484
+ ? "package.declaration"
485
+ : rejectedPeers.some((r) => r.source === "engines.dsh")
486
+ ? "package.engines.dsh"
487
+ : "package.peerDependencies",
454
488
  });
455
489
  }
456
490
  if (declaration?.claimsCompatible && findings.length) {
@@ -29,7 +29,7 @@
29
29
  */
30
30
 
31
31
  /** 语义化版本。改动它会同时影响三个入口的 `--version` 与报告 JSON 的 `toolVersion`。 */
32
- export const TOOL_VERSION = '0.1.2';
32
+ export const TOOL_VERSION = '0.1.3';
33
33
 
34
34
  /** 人类可读的一行,给 `--version` 用 */
35
35
  export function versionLine() {
@@ -1,18 +1,18 @@
1
1
  # 严重度规则表与维护
2
2
 
3
- 适用于0.1.2,判定算法与0.1.1一致。当前契约见 [Plan现行契约](../docs/Plan.md),验证见 [自动统计](../docs/current-state.json)。本页后半保留2026-10-11旧提取器的规则依据;其中表格不是当前覆盖率,也不是当前运行时真值。
3
+ 适用于0.1.3;公开版本以registry和Plan发布记录为准。当前契约见 [Plan](../docs/Plan.md),验证见 [自动统计](../docs/current-state.json)。后半保留2026-10-11旧提取器的依据,历史表不是当前覆盖率或运行时真值。
4
4
 
5
5
  ## 文件与运行时消费者
6
6
 
7
7
  | 文件 | 用途 |
8
8
  |---|---|
9
9
  | severity.json | 方法严重度/影响文案;引擎读取services下的方法规则,并与代码内默认档合用 |
10
- | dsh-api-catalog.json | 指定DSH版本生成的宿主/客户端契约目录;不证明当次运行时提供某方法 |
10
+ | dsh-api-catalog.json / dsh-api-catalog-0.2.1-rc.2.json | 原0.2.0-rc.2(host91/client8)与新rc.2(host98/client8)契约快照;DSH≥0.2.1-rc.2选新目录,其余/未知选旧目录,CLI显式覆盖优先;未来版本未认证 |
11
11
  | usage-inventory.json | 带日期/版本/profile及完整性标记的AST库存快照;由coverage显式刷新,不保证与后续提取器重扫同数 |
12
12
 
13
13
  严重度由 `lib/verdict.mjs` 的确定性逻辑、缺失证据、方法规则及默认档共同决定,不由LLM决定。方法规则的severity/impact参与判定与输出,layer控制端别:方法自身优先、服务layer回退。summary/basis/evidence/knownGoneIn/needsReview等用于解释与维护;knownExtractionArtifacts是历史反例登记,不是自动过滤黑名单。
14
14
 
15
- 0.2.1-rc.1宿主改为优先按engines.dsh判断;本版尚未同步该逻辑,已复现声明误报与漏报,规则/API目录也未更新为新版。新版接入验证不代表这些判定已适配,实施顺序见Plan后续。
15
+ 0.1.3按DSH目标分流声明:从0.2.1-rc.1起自有engines.dsh优先,缺失才回退全部官方peer;旧目标保留旧行为。非法/空范围、预发布和peer workspace别名均有回归;engines中的workspace不获放行。独立compatibility.json仅按插件/运行时精确版本豁免,非法字段不能靠豁免绕过;无效记录忽略并保留覆盖限制。
16
16
 
17
17
  ## 当前证据与严重度
18
18
 
@@ -21,15 +21,19 @@
21
21
  | 可映射loader条目FAILED/PENDING | fatal,影响依据measured;禁用、LOADING、无法映射不冒充同一事实 |
22
22
  | 宿主硬依赖且明确absent | 可判fatal;仅catalog缺项不冒充运行时缺失 |
23
23
  | 非硬依赖服务明确absent,或有足够方法证据 | 按规则/默认档给feature-dead或degraded;用户影响通常是inferred |
24
- | 官方peer范围冲突 | declared-incompatible,degraded及推断影响;不是实测加载失败;当前内核沿用0.2.0-rc.2,将engines.dsh作建议处理 |
24
+ | 官方声明冲突或非法字段 | declared-incompatible,degraded及推断影响;不冒充实测加载失败;有效精确豁免可消除范围冲突,但保留限制且不证明业务成功 |
25
25
  | 未采样、读取失败、访问器未知、源码不完整 | 保留覆盖限制;不以省略键认定不存在 |
26
26
 
27
27
  方法规则中的degraded应有具体降级/回退依据;peer声明冲突的degraded是引擎针对声明风险的独立默认,不代表观察到回退。方法缺失但没有语义规则时会明确“影响面未知”。声明provide只是候选提供方,不证明已激活,不能遮住完整的present方法观测。
28
28
 
29
29
  宿主、客户端及shared调用点必须分开。客户端方法不能拿宿主probe判缺失;同名服务也不保证同一语义。对源码归属不确定的Context/别名保留suspected。certainty与impactBasis分别表达证据强度和影响是否实测。
30
30
 
31
+ 仓库独立业务队列将手审的功能调用区间内API风险与真实备注/收藏及侧栏保存/预览结果对照,保持业务、loader标签及预测隔离;不把整个插件的其它发现挪给本功能。正常/HTTP503故障组分别计分,限定样本不能改变生产报告中的certainty或impactBasis,也不是新增严重度规则的充分证据。复现见[Plan](../docs/Plan.md#52-日常检查与真实集成)。
32
+
31
33
  ## 如何更新
32
34
 
35
+ ASAR读取器已修正数据起点为8+headerSize,并校验头长、边界、完整读取、UTF-8/JSON及归档SHA256。2026-10-18与Electron原生ASAR读取逐字节及首尾交叉校验后生成独立rc.2目录,旧目录未改。新host增加8项、移除invariants;subagents移除start/startContinuable/drainContinuableChildren/drainContinuableDescendants,增加startActivation/drainChildren/drainDescendants/waitForChildren;其它方法差异和源摘要见current-state。client方法集合未变。旧方法规则保留其原调用语义;不把新方法当同义重命名,也不只凭目录变化新增严重度,未知影响继续回退。
36
+
33
37
  先读调用点和目标版本的真实描述符/官方源码,再修改规则,添加行为回归并审查快照差异。原needsReview已在2026-10-15逐条复核,决定与证据保留于severity.json的needsReview.reviewed/evidence及 [Plan](../docs/Plan.md)。
34
38
 
35
39
  `tools/coverage.mjs` 从AST调用点按host/client/shared分端计数,规则命中与默认影响、类别发现分列。**knownExtractionArtifacts只保留历史反例,不过滤现在的真实调用**;纯客户端规则不用于宿主同名方法。probe须为现代格式并绑定本目标/版本/时间,不能借别的profile的面。写库存要求显式或观测到的合法semver版本,非法版本退出3且不覆盖旧文件。