dsh-plugin-compat-check 0.1.1 → 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 +12 -6
- package/index.js +52 -8
- package/kernel/lib/catalog.mjs +16 -0
- package/kernel/lib/declarations.mjs +100 -1
- package/kernel/lib/extract.mjs +5 -0
- package/kernel/lib/profile-compatibility.mjs +69 -0
- package/kernel/lib/verdict.mjs +47 -13
- package/kernel/lib/version.mjs +1 -1
- package/kernel/rules/README.md +9 -3
- package/kernel/rules/dsh-api-catalog-0.2.1-rc.2.json +743 -0
- package/package.json +1 -1
- package/skill/SKILL.md +20 -4
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# dsh-plugin-compat-check
|
|
2
2
|
|
|
3
|
-
检查**已经安装的 DSH 插件**与当前 DSH
|
|
3
|
+
检查**已经安装的 DSH 插件**与当前 DSH 版本的兼容性,提供源码位置、运行时证据与覆盖限制。包含兼容性标签、插件详情发现/角标、`compat_check` 对话工具和skill provider。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+

|
|
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
|
|
10
|
+
dsh plugin --profile <profile> add dsh-plugin-compat-check
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
## 从源码构建安装
|
|
@@ -20,15 +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
|
|
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。
|
|
26
|
+
|
|
27
|
+
0.1.3增加目标版本声明适配、rc.2目录与卸载清理;历史0.1.2只修正文案和版本,算法与0.1.1一致;0.1.3改变了新版DSH声明判定。
|
|
24
28
|
|
|
25
29
|
## 判定与边界
|
|
26
30
|
|
|
31
|
+
2026-10-18从候选完整tgz在私有rc.2宿主和真实Chrome验证:“更多 → 设置 → 内置插件 → 兼容性”可见标签和报告;禁用后报告路由消失,启用后恢复且标签唯一。59714e7中10月17日标签未显示的历史记录本次未复现,原因未定;不把这次局部UI验收当作所有业务功能认证。
|
|
32
|
+
|
|
27
33
|
当前profile在宿主进程内采样服务描述符,不执行getter;未知访问器保留未知,并纳入能可靠映射的loader FAILED/PENDING。查询其它profile只用静态证据,不借用当前宿主服务。Babel AST扫描不执行目标源码,分析运行在有时间和内存上限的worker中;动态调用和跨函数数据流仍有限。
|
|
28
34
|
|
|
29
|
-
`certainty`为certain或suspected
|
|
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则豁免未观测,不自动写入豁免。
|
|
30
36
|
|
|
31
|
-
|
|
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,业务标签不进入兼容性判定,结果不认证其它路径或原插件有缺陷。
|
|
32
38
|
|
|
33
39
|
插件体检不安装包、不调用外部命令或联网;会启动分析worker,并默认写自己的schema2基线:`$DSH_HOME/dsh-plugin-compat-check/baseline.<profile>.<path-hash>.<mode>.json`。模式分离、锁内比较、原子替换;源码扫描不完整不推进基线。默认不输出共享loader诊断;诊断需显式私有DSC_OUT_DIR。若需完全禁止基线读写,请使用仓库CLI的 `--no-baseline`。
|
|
34
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/kernel/lib/extract.mjs
CHANGED
|
@@ -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
|
+
}
|
package/kernel/lib/verdict.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
.
|
|
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:
|
|
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:
|
|
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) {
|
package/kernel/lib/version.mjs
CHANGED
package/kernel/rules/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# 严重度规则表与维护
|
|
2
2
|
|
|
3
|
-
适用于0.1.
|
|
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 |
|
|
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.1.3按DSH目标分流声明:从0.2.1-rc.1起自有engines.dsh优先,缺失才回退全部官方peer;旧目标保留旧行为。非法/空范围、预发布和peer workspace别名均有回归;engines中的workspace不获放行。独立compatibility.json仅按插件/运行时精确版本豁免,非法字段不能靠豁免绕过;无效记录忽略并保留覆盖限制。
|
|
16
|
+
|
|
15
17
|
## 当前证据与严重度
|
|
16
18
|
|
|
17
19
|
| 情形 | 处理 |
|
|
@@ -19,15 +21,19 @@
|
|
|
19
21
|
| 可映射loader条目FAILED/PENDING | fatal,影响依据measured;禁用、LOADING、无法映射不冒充同一事实 |
|
|
20
22
|
| 宿主硬依赖且明确absent | 可判fatal;仅catalog缺项不冒充运行时缺失 |
|
|
21
23
|
| 非硬依赖服务明确absent,或有足够方法证据 | 按规则/默认档给feature-dead或degraded;用户影响通常是inferred |
|
|
22
|
-
|
|
|
24
|
+
| 官方声明冲突或非法字段 | declared-incompatible,degraded及推断影响;不冒充实测加载失败;有效精确豁免可消除范围冲突,但保留限制且不证明业务成功 |
|
|
23
25
|
| 未采样、读取失败、访问器未知、源码不完整 | 保留覆盖限制;不以省略键认定不存在 |
|
|
24
26
|
|
|
25
27
|
方法规则中的degraded应有具体降级/回退依据;peer声明冲突的degraded是引擎针对声明风险的独立默认,不代表观察到回退。方法缺失但没有语义规则时会明确“影响面未知”。声明provide只是候选提供方,不证明已激活,不能遮住完整的present方法观测。
|
|
26
28
|
|
|
27
29
|
宿主、客户端及shared调用点必须分开。客户端方法不能拿宿主probe判缺失;同名服务也不保证同一语义。对源码归属不确定的Context/别名保留suspected。certainty与impactBasis分别表达证据强度和影响是否实测。
|
|
28
30
|
|
|
31
|
+
仓库独立业务队列将手审的功能调用区间内API风险与真实备注/收藏及侧栏保存/预览结果对照,保持业务、loader标签及预测隔离;不把整个插件的其它发现挪给本功能。正常/HTTP503故障组分别计分,限定样本不能改变生产报告中的certainty或impactBasis,也不是新增严重度规则的充分证据。复现见[Plan](../docs/Plan.md#52-日常检查与真实集成)。
|
|
32
|
+
|
|
29
33
|
## 如何更新
|
|
30
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
|
+
|
|
31
37
|
先读调用点和目标版本的真实描述符/官方源码,再修改规则,添加行为回归并审查快照差异。原needsReview已在2026-10-15逐条复核,决定与证据保留于severity.json的needsReview.reviewed/evidence及 [Plan](../docs/Plan.md)。
|
|
32
38
|
|
|
33
39
|
`tools/coverage.mjs` 从AST调用点按host/client/shared分端计数,规则命中与默认影响、类别发现分列。**knownExtractionArtifacts只保留历史反例,不过滤现在的真实调用**;纯客户端规则不用于宿主同名方法。probe须为现代格式并绑定本目标/版本/时间,不能借别的profile的面。写库存要求显式或观测到的合法semver版本,非法版本退出3且不覆盖旧文件。
|