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 +21 -21
- package/index.js +58 -10
- package/kernel/lib/catalog.mjs +16 -0
- package/kernel/lib/declarations.mjs +100 -1
- package/kernel/lib/extract.mjs +5 -0
- package/kernel/lib/owned-process.mjs +287 -0
- package/kernel/lib/profile-compatibility.mjs +69 -0
- package/kernel/lib/verdict.mjs +55 -14
- package/kernel/lib/version.mjs +1 -1
- package/kernel/rules/README.md +11 -5
- package/kernel/rules/dsh-api-catalog-0.2.1-rc.2.json +743 -0
- package/package.json +3 -2
- package/scripts/stage-kernel.mjs +1 -1
- package/skill/SKILL.md +22 -4
package/README.md
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
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.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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
`certainty
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
980
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|