dsh-plugin-compat-check 0.1.4 → 0.1.5
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 +37 -17
- package/THIRD_PARTY_NOTICES.md +139 -0
- package/index.js +282 -53
- package/kernel/lib/author-ci-output.mjs +188 -0
- package/kernel/lib/author-ci-policy.mjs +285 -0
- package/kernel/lib/catalog.mjs +38 -0
- package/kernel/lib/contracts.mjs +88 -0
- package/kernel/lib/dependency-graph.mjs +650 -0
- package/kernel/lib/extract.mjs +14 -0
- package/kernel/lib/finding-guidance.mjs +72 -0
- package/kernel/lib/finding-review-store.mjs +245 -0
- package/kernel/lib/finding-review.mjs +338 -0
- package/kernel/lib/functional-health-contract.mjs +337 -0
- package/kernel/lib/issue-report.mjs +217 -0
- package/kernel/lib/observation.mjs +19 -9
- package/kernel/lib/report-identity-worker.mjs +153 -0
- package/kernel/lib/report-identity.mjs +15 -0
- package/kernel/lib/report-service.mjs +196 -11
- package/kernel/lib/report.mjs +76 -11
- package/kernel/lib/source-analysis.mjs +24 -6
- package/kernel/lib/upgrade-capture-worker.mjs +20 -0
- package/kernel/lib/upgrade-cleanup-worker.mjs +28 -0
- package/kernel/lib/upgrade-contract.mjs +471 -0
- package/kernel/lib/upgrade-input.mjs +309 -0
- package/kernel/lib/upgrade-preview.mjs +256 -0
- package/kernel/lib/upgrade-worker.mjs +50 -0
- package/kernel/lib/version.mjs +1 -1
- package/kernel/lib/worker-analysis.mjs +16 -1
- package/kernel/lib/workflow-panels.mjs +706 -0
- package/kernel/rules/README.md +5 -5
- package/lib/client.js +2974 -69
- package/lib/dependency-graph.js +651 -0
- package/lib/finding-guidance.js +73 -0
- package/lib/finding-review.js +339 -0
- package/lib/functional-health-contract.js +338 -0
- package/lib/report-contract.js +88 -0
- package/lib/upgrade-contract.js +472 -0
- package/lib/workflow-panels.js +707 -0
- package/package.json +3 -2
- package/scripts/prepublish-check.mjs +28 -13
- package/scripts/stage-contract.mjs +91 -29
- package/scripts/stage-kernel.mjs +3 -2
- package/skill/SKILL.md +90 -39
package/index.js
CHANGED
|
@@ -21,14 +21,17 @@
|
|
|
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, realpathSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync, promises as fsPromises } 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';
|
|
28
28
|
import { createRequire as __createRequire } from 'node:module';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
29
30
|
|
|
30
31
|
import {loaderObservations,privateDiagnostic} from './lib/host-resources.js';
|
|
31
|
-
import {assertEnvelope,failureEnvelope} from './lib/report-contract.js';
|
|
32
|
+
import {assertEnvelope,failureEnvelope,catalogLines} from './lib/report-contract.js';
|
|
33
|
+
import {nextSteps} from './lib/finding-guidance.js';
|
|
34
|
+
import {createUpgradeFailure as upgradeFailure, SUPPORTED_PREVIEW_VERSIONS} from './lib/upgrade-contract.js';
|
|
32
35
|
|
|
33
36
|
export const name = 'dsh-plugin-compat-check';
|
|
34
37
|
|
|
@@ -786,33 +789,67 @@ function resolveNodeModules(requestedProfile = null) {
|
|
|
786
789
|
}
|
|
787
790
|
|
|
788
791
|
/**
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
* 所以:有缓存就先给,同时在后台刷新的。**响应速度与正确性都不牺牲**。
|
|
797
|
-
*
|
|
798
|
-
* 三种情况:
|
|
799
|
-
* - `?refresh=1` 或没有缓存 → **同步算**(用户明确要新的,或者根本没有旧的)
|
|
800
|
-
* - 缓存在有效期内 → 直接给
|
|
801
|
-
* - 缓存过期 → 先给旧的,并触发一次后台刷新(同一时刻只跑一个)
|
|
802
|
-
*
|
|
803
|
-
* 并发安全:`state.computing` 保存"正在跑的那一个 promise",所以十个面板请求
|
|
804
|
-
* 同时到达也只会算一次 —— 否则它们会各自算一遍,白烧 1.5 秒 × 10。
|
|
792
|
+
* Ordinary report cache is scoped to the canonical profile and DSH context.
|
|
793
|
+
* Verified loader/manager events trigger coalesced identity checks; a bounded
|
|
794
|
+
* worker watchdog also notices same-path source and judgment-input changes.
|
|
795
|
+
* Valid reports remain visible while a replacement generation runs. Cold and
|
|
796
|
+
* forced requests await a current generation; failed refreshes retain the
|
|
797
|
+
* previous report with explicit stale/error metadata. The TTL still refreshes
|
|
798
|
+
* time-dependent observations even when the sampled identity is unchanged.
|
|
805
799
|
*/
|
|
806
800
|
async function serveReport(state, ctx, force, options = {}) {
|
|
807
801
|
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
|
|
802
|
+
const kernel=resolveKernelDir(),nm=resolveNodeModules(options.profile || null);
|
|
803
|
+
if(!kernel)return failureEnvelope('找不到判定内核');
|
|
804
|
+
if(!nm.dir){
|
|
805
|
+
// Keep computeReport's version identity on target-resolution failures.
|
|
806
|
+
const out=await computeReport(state,ctx,options);
|
|
807
|
+
return failureEnvelope(out.error,out);
|
|
808
|
+
}
|
|
809
|
+
const canonical=await fsPromises.realpath(nm.dir),version=resolveDshVersion();
|
|
810
|
+
const context={nodeModules:canonical,observationProfile:path.resolve(nm.dir),kernel:await fsPromises.realpath(kernel),dshVersion:version,probe:process.env.DSC_PROBE_REPORT || null};
|
|
808
811
|
if(!state.reportService){
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
812
|
+
const [{ReportService},{reportIdentity},{catalogPathFor},{sampleServices}]=await Promise.all([
|
|
813
|
+
import(pathToFileURL(join(kernel,'lib','report-service.mjs')).href),
|
|
814
|
+
import(pathToFileURL(join(kernel,'lib','report-identity.mjs')).href),
|
|
815
|
+
import(pathToFileURL(join(kernel,'lib','catalog.mjs')).href),
|
|
816
|
+
import(pathToFileURL(join(kernel,'lib','observation.mjs')).href),
|
|
817
|
+
]);
|
|
818
|
+
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
|
|
819
|
+
if(!state.reportService)state.reportService=new ReportService(async (options,guard)=>{
|
|
820
|
+
const out=await computeReport(state,ctx,{...options,_cacheGuard:guard});
|
|
821
|
+
return out.ok?assertEnvelope(out):failureEnvelope(out.error,out);
|
|
822
|
+
},{ttlMs:state.cacheTtlMs,watchMs:state.cacheWatchMs,identity:async options=>{
|
|
823
|
+
const scope=options._cacheContext,current=resolveNodeModules(),currentVersion=resolveDshVersion();
|
|
824
|
+
const active=current.dir?await fsPromises.realpath(current.dir):null;
|
|
825
|
+
const requestedTarget=await fsPromises.realpath(options._nodeModules.dir);
|
|
826
|
+
const identity=await reportIdentity({nodeModules:scope.nodeModules,context:{...scope,currentVersion,active,requestedTarget,probe:process.env.DSC_PROBE_REPORT || null},files:[join(scope.kernel,'rules','severity.json'),catalogPathFor(scope.kernel,currentVersion),process.env.DSC_PROBE_REPORT,join(path.dirname(options._nodeModules.dir),'compatibility.json')]});
|
|
827
|
+
const sameHost=active===scope.nodeModules;
|
|
828
|
+
const itemListing=sameHost?await (async()=>{try{const listing=await ctx.get?.('pluginManager',false)?.list?.();return (Array.isArray(listing)?listing:listing?.items||listing?.plugins||[]).map(i=>({id:i.id??i.entryId??i.slotId,packageName:i.packageName??i.package??i.name}));}catch{return [];}})():[];
|
|
829
|
+
const loader=sameHost?loaderObservations(ctx,identity.manifests,itemListing):null;
|
|
830
|
+
const observation=sameHost?sampleServices(ctx,[...identity.services,...(options._cacheInputs.services || [])],{profileId:scope.nodeModules,dshVersion:currentVersion}):null;
|
|
831
|
+
// Sampling timestamps are evidence, not a changing service identity.
|
|
832
|
+
return JSON.stringify([identity.digest,loader,observation?.root || null]);
|
|
833
|
+
}});
|
|
813
834
|
}
|
|
814
|
-
|
|
815
|
-
|
|
835
|
+
if(!state.cacheEventDisposers && typeof ctx.on==='function'){
|
|
836
|
+
state.cacheEventDisposers=[];
|
|
837
|
+
// Verified in the bundled loader and plugin-manager emit sites; the
|
|
838
|
+
// bounded watchdog also catches source edits and hosts without ctx.on.
|
|
839
|
+
for(const event of ['plugin-manager/changed','plugin-manager/install-state','loader/entry-init','loader/partial-dispose','loader/volatile-update']){
|
|
840
|
+
try{const off=ctx.on(event,()=>state.reportService?.notify());if(typeof off==='function')state.cacheEventDisposers.push(off);}catch{}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const key=JSON.stringify(context);
|
|
844
|
+
state.reportInputs ||= new Map();
|
|
845
|
+
if(!state.reportInputs.has(key))state.reportInputs.set(key,{services:[]});
|
|
846
|
+
const inputs=state.reportInputs.get(key);
|
|
847
|
+
// Canonicalize cache identity only: preserve the requested path for the
|
|
848
|
+
// existing exact probe/profile binding and the report's business target.
|
|
849
|
+
const out=await state.reportService.get(key,{...options,_cacheContext:context,_cacheInputs:inputs,_nodeModules:{dir:nm.dir,why:nm.why}},force);
|
|
850
|
+
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
|
|
851
|
+
if (!out?.ok) return failureEnvelope(out?.error,out||{});
|
|
852
|
+
return attachFindingReviews(state,assertEnvelope(out),key,kernel);
|
|
816
853
|
}
|
|
817
854
|
|
|
818
855
|
/**
|
|
@@ -860,6 +897,7 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
860
897
|
};
|
|
861
898
|
try {
|
|
862
899
|
const kernel = resolveKernelDir();
|
|
900
|
+
if(options._cacheContext && (options._cacheContext.dshVersion!==resolveDshVersion() || options._cacheContext.probe!==(process.env.DSC_PROBE_REPORT || null) || options._cacheContext.nodeModules!==(await fsPromises.realpath(options._nodeModules.dir)) || options._cacheContext.kernel!==(kernel && await fsPromises.realpath(kernel))))return failureEnvelope('报告上下文已变化,等待目标报告刷新');
|
|
863
901
|
out.kernelDir = kernel;
|
|
864
902
|
// 版本要**尽早**拿到:连"找不到内核"这条错误路径上,用户也需要知道
|
|
865
903
|
// 是哪一版工具报的这个错。`version.mjs` 只在有内核时才读得到 ——
|
|
@@ -877,7 +915,7 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
877
915
|
+ '插件形态下它们是同仓库的相邻目录;发布形态请设 DSC_KERNEL。';
|
|
878
916
|
return out;
|
|
879
917
|
}
|
|
880
|
-
const nm = resolveNodeModules(options.profile || null);
|
|
918
|
+
const nm = options._nodeModules || resolveNodeModules(options.profile || null);
|
|
881
919
|
out.nodeModules = nm.dir;
|
|
882
920
|
out.nodeModulesWhy = nm.why;
|
|
883
921
|
if (!nm.dir) {
|
|
@@ -888,11 +926,12 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
888
926
|
const { extractInWorker } = await import(pathToFileURL(join(kernel, 'lib', 'worker-analysis.mjs')).href);
|
|
889
927
|
const { buildVerdicts } = await import(pathToFileURL(join(kernel, 'lib', 'verdict.mjs')).href);
|
|
890
928
|
|
|
891
|
-
const {
|
|
892
|
-
const catalogPath =
|
|
929
|
+
const {loadCatalog}=await import(pathToFileURL(join(kernel,'lib','catalog.mjs')).href);
|
|
930
|
+
const {file:catalogPath,catalog,evidence:catalogEvidence} = loadCatalog(kernel,resolveDshVersion());
|
|
893
931
|
const rulesPath = join(kernel, 'rules', 'severity.json');
|
|
894
|
-
const
|
|
895
|
-
const rules = JSON.parse(
|
|
932
|
+
const rulesBytes = readFileSync(rulesPath);
|
|
933
|
+
const rules = JSON.parse(rulesBytes);
|
|
934
|
+
out.analysisIdentity={rulesSha256:createHash('sha256').update(rulesBytes).digest('hex'),catalogSha256:catalogEvidence.sha256};
|
|
896
935
|
|
|
897
936
|
// 运行时面:宿主半边**自己就在进程里**,所以它能拿到比探针更准的东西。
|
|
898
937
|
// 第一版先读探针报告(如果外层给了路径),没有就退回目录 —— 并如实标注。
|
|
@@ -911,9 +950,10 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
911
950
|
|
|
912
951
|
const extracted = await extractInWorker(nm.dir, { catalog });
|
|
913
952
|
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
|
|
953
|
+
if(options._cacheInputs)options._cacheInputs.services=extracted.report.services;
|
|
914
954
|
const { sampleServices } = await import(pathToFileURL(join(kernel,'lib','observation.mjs')).href);
|
|
915
955
|
const currentTarget=resolveNodeModules();
|
|
916
|
-
const sameHost=currentTarget.dir&&
|
|
956
|
+
const sameHost=currentTarget.dir&&(await fsPromises.realpath(currentTarget.dir))===(await fsPromises.realpath(nm.dir));
|
|
917
957
|
if(!hostSurface && sameHost){
|
|
918
958
|
hostSurface=sampleServices(ctx,extracted.report.services,{profileId:nm.dir,dshVersion:resolveDshVersion()});
|
|
919
959
|
surfaceWhy='当前宿主进程内采集的服务结构;未采集或读取失败不等于缺失';
|
|
@@ -940,7 +980,7 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
940
980
|
// ── C4:基线差分 ──
|
|
941
981
|
// 用**与 CLI 完全相同**的 lib/baseline.mjs。面板说"变差了"而命令行说"没变"
|
|
942
982
|
// 这种分叉在结构上就不可能发生。
|
|
943
|
-
let baseline = null;
|
|
983
|
+
let baseline = null, finalizeBaseline = null;
|
|
944
984
|
try {
|
|
945
985
|
const { snapshotForBaseline, diffAgainstBaseline, badgeFor } =
|
|
946
986
|
await import(pathToFileURL(join(kernel, 'lib', 'baseline.mjs')).href);
|
|
@@ -949,7 +989,8 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
949
989
|
const snap = snapshotForBaseline(verdict, resolveDshVersion(),identity);
|
|
950
990
|
const bfile = store.baselinePath(nm.dir,verdict.observation.mode);
|
|
951
991
|
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
|
|
952
|
-
|
|
992
|
+
finalizeBaseline=()=>store.baselineTransaction(bfile,snap,{advance:extracted.report.scanComplete});
|
|
993
|
+
if(!options._cacheGuard)baseline=finalizeBaseline();
|
|
953
994
|
} catch (error) {
|
|
954
995
|
baseline = { error: errText(error), note: '基线差分没算成(不影响本次结论)' };
|
|
955
996
|
}
|
|
@@ -980,8 +1021,17 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
980
1021
|
}
|
|
981
1022
|
|
|
982
1023
|
const {toJSON}=await import(pathToFileURL(join(kernel,'lib','report.mjs')).href);
|
|
1024
|
+
const {renderIssueReport}=await import(pathToFileURL(join(kernel,'lib','issue-report.mjs')).href);
|
|
983
1025
|
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
|
|
984
|
-
const canonical=toJSON({error:null,extract:extracted,verdict,baseline,inputs:{nodeModules:nm.dir,hasRuntimeSurface:verdict.observation.usable,catalog:catalogPath,rules:rulesPath}});
|
|
1026
|
+
const canonical=toJSON({error:null,extract:extracted,verdict,entryStates:loaderState.rows,baseline,catalog:catalogEvidence,inputs:{nodeModules:nm.dir,hasRuntimeSurface:verdict.observation.usable,catalog:catalogPath,rules:rulesPath}});
|
|
1027
|
+
if(options._cacheGuard && finalizeBaseline)options._cacheGuard.commit(()=>{
|
|
1028
|
+
baseline=finalizeBaseline();
|
|
1029
|
+
canonical.baseline=baseline;
|
|
1030
|
+
out.baseline=baseline;
|
|
1031
|
+
out.report.baseline=baseline;
|
|
1032
|
+
out.issueReport=renderIssueReport(canonical);
|
|
1033
|
+
});
|
|
1034
|
+
out.issueReport=renderIssueReport(canonical);
|
|
985
1035
|
out.baseline = canonical.baseline;
|
|
986
1036
|
out.dshVersion = canonical.dshVersion;
|
|
987
1037
|
out.exitCode = canonical.exitCode;
|
|
@@ -1034,8 +1084,9 @@ function renderForModel(compat) {
|
|
|
1034
1084
|
const L = [];
|
|
1035
1085
|
const s = r.summary || {};
|
|
1036
1086
|
L.push(`结论:可用 ${s.ok || 0} 部分可用 ${s.partial || 0} 不兼容 ${s.broken || 0}`
|
|
1037
|
-
+ (s.vacuousOk ? `(其中 ${s.vacuousOk}
|
|
1087
|
+
+ (s.vacuousOk ? `(其中 ${s.vacuousOk} 个「可用」没有已验证调用,不等于业务通过)` : ''));
|
|
1038
1088
|
L.push(`工具版本:${compat.toolVersion || '未知'}`);
|
|
1089
|
+
L.push(...catalogLines(r.catalog));
|
|
1039
1090
|
for(const note of r.observation?.notes||[])L.push(`采样说明:${note}`);
|
|
1040
1091
|
if(s.limited)L.push(`证据覆盖有限:${s.limited} 个插件尚有未验证部分`);
|
|
1041
1092
|
L.push(`体检对象:${compat.nodeModules}(${compat.nodeModulesWhy})`);
|
|
@@ -1052,19 +1103,22 @@ function renderForModel(compat) {
|
|
|
1052
1103
|
for (const name of names) {
|
|
1053
1104
|
const p = r.plugins[name];
|
|
1054
1105
|
const label = { broken: '不兼容', partial: '部分可用', unknown: '未知', ok: '可用' }[p.verdict] || p.verdict;
|
|
1055
|
-
L.push(`${label} ${name}@${p.version || '?'}${p.vacuous ? ' [
|
|
1106
|
+
L.push(`${label} ${name}@${p.version || '?'}${p.vacuous ? ' [无已验证调用]' : ''}`);
|
|
1107
|
+
if(p.coverage){L.push(` 证据覆盖:${p.coverage.status==='complete'?'完整':'有限'};已验证调用 ${p.coverage.verifiedCalls}`);for(const note of p.coverage.limitations)L.push(` 覆盖限制:${note}`);}
|
|
1056
1108
|
for (const f of p.findings || []) {
|
|
1057
1109
|
const api = f.service ? (f.method ? `${f.service}.${f.method}` : f.service) : '(包声明)';
|
|
1058
1110
|
const loc = f.location ? `${f.location.file}:${f.location.line}` : '—';
|
|
1059
1111
|
const cat = {
|
|
1060
1112
|
'host-service-missing': '服务缺失', 'host-method-missing': '方法缺失',
|
|
1061
|
-
'unprovided-dependency': '
|
|
1113
|
+
'unprovided-dependency': '依赖服务待确认', 'declaration-mismatch': '声明与实测不符',
|
|
1114
|
+
'client-entry-missing': '客户端入口不可交付', 'host-entry-inactive': '宿主条目未激活',
|
|
1062
1115
|
'declared-incompatible':'版本声明不支持',
|
|
1063
1116
|
}[f.category] || f.category;
|
|
1064
1117
|
const sev = { fatal: '致命', 'feature-dead': '功能失效', degraded: '降级' }[f.severity] || f.severity;
|
|
1065
|
-
L.push(` ${sev} / ${cat}${f.certainty === 'certain' ? '' : ' [疑似]'} ${api} @ ${loc}`);
|
|
1066
|
-
if (f.impact) L.push(` 影响${f.impactBasis==='inferred'?'(推断,非实测)':''}:${f.impact}`);
|
|
1118
|
+
L.push(` ${sev} / ${cat}${f.certainty === 'certain' ? ' [确定]' : ' [疑似]'} ${api} @ ${loc}`);
|
|
1119
|
+
if (f.impact) L.push(` 影响${f.impactBasis==='measured'?'(实测)':f.impactBasis==='inferred'?'(推断,非实测)':'(依据未提供)'}:${f.impact}`);
|
|
1067
1120
|
if(f.basis)L.push(` 依据:${f.basis}`);
|
|
1121
|
+
for(const step of nextSteps(f))L.push(` 下一步:${step}`);
|
|
1068
1122
|
}
|
|
1069
1123
|
const cs = p.clientSide || [];
|
|
1070
1124
|
if (cs.length) L.push(` 客户端半边(命令行测不到,需界面验证):${cs.map((c) => c.service).join('、')}`);
|
|
@@ -1074,11 +1128,14 @@ function renderForModel(compat) {
|
|
|
1074
1128
|
}
|
|
1075
1129
|
|
|
1076
1130
|
L.push('');
|
|
1077
|
-
L.push('
|
|
1131
|
+
L.push('确定(certain)表示当前证据足以支持该条发现,可来自明确静态事实或合格运行时观测;疑似(suspected)表示当前证据不足以确定该条发现。');
|
|
1132
|
+
L.push('impactBasis=measured/inferred 分别表示影响有对应实测依据/影响由证据推断;确定发现不等于影响已实测。未观测不等于缺失。');
|
|
1133
|
+
L.push('没有已验证调用仍可能存在服务依赖或客户端功能;调用核验和覆盖完整均不等于业务验收通过。');
|
|
1134
|
+
L.push('下一步是核实建议;本工具不会自动安装、升级或修改配置与源码。');
|
|
1135
|
+
L.push('读这个结果时注意:');
|
|
1078
1136
|
L.push(' 1) 「可用」不等于"功能验证过"。它只说明本轮未发现冲突;请同时看证据覆盖与未验证项。'
|
|
1079
1137
|
+ '插件仍然可能因为自身逻辑、配置或第三方依赖而出问题。');
|
|
1080
|
-
L.push(' 2)
|
|
1081
|
-
+ '只能靠"界面上有没有出现该插件的 UI"来验证。别把"没报错"当成"没问题"。');
|
|
1138
|
+
L.push(' 2) 客户端功能需在浏览器实际操作并核验结果;元素出现和没有报错均不等于业务成功。');
|
|
1082
1139
|
return L.join('\n');
|
|
1083
1140
|
} catch (error) {
|
|
1084
1141
|
return `渲染结论时出错(工具的问题):${errText(error)}`;
|
|
@@ -1086,12 +1143,163 @@ function renderForModel(compat) {
|
|
|
1086
1143
|
}
|
|
1087
1144
|
|
|
1088
1145
|
/** 工具的服务集合:`tools`;`agents` 用来把相对路径解析到会话工作目录。 */
|
|
1146
|
+
/** Static version comparisons never enter the ordinary report cache or baseline path. */
|
|
1147
|
+
function unavailableUpgrade(error, options = {}) {
|
|
1148
|
+
let toolVersion = 'unknown';
|
|
1149
|
+
try { toolVersion = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch {}
|
|
1150
|
+
return upgradeFailure(error, {toolVersion, currentVersion:resolveDshVersion(), targetVersion:options.targetVersion, ...options});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function serveUpgradePreview(state, options = {}) {
|
|
1154
|
+
const currentVersion = resolveDshVersion(), targetVersion = options.targetVersion || null;
|
|
1155
|
+
const failure = error => unavailableUpgrade(error,{currentVersion,targetVersion});
|
|
1156
|
+
try {
|
|
1157
|
+
if (state.disposed) return failure('Plugin generation disposed / 已卸载');
|
|
1158
|
+
if (options.currentVersion && options.currentVersion !== currentVersion)
|
|
1159
|
+
return failure('当前 DSH 版本已变化,请刷新普通报告后重新预检');
|
|
1160
|
+
if (options.invalidRequest) return failure(options.invalidRequest);
|
|
1161
|
+
if (!SUPPORTED_PREVIEW_VERSIONS.includes(currentVersion) || !SUPPORTED_PREVIEW_VERSIONS.includes(targetVersion))
|
|
1162
|
+
return unavailableUpgrade('版本未认证:仅支持有明确 API 目录的精确版本 '+SUPPORTED_PREVIEW_VERSIONS.join('、'),{currentVersion,targetVersion,status:'unsupported-version'});
|
|
1163
|
+
const kernel = resolveKernelDir();
|
|
1164
|
+
if (!kernel) return failure('找不到升级预检内核');
|
|
1165
|
+
const engine = await import(pathToFileURL(join(kernel, 'lib/upgrade-preview.mjs')).href);
|
|
1166
|
+
if (state.disposed) return failure('Plugin generation disposed / 已卸载');
|
|
1167
|
+
const nm = resolveNodeModules(options.profile || null);
|
|
1168
|
+
const canonical = nm.dir && await fsPromises.realpath(nm.dir);
|
|
1169
|
+
if (state.disposed) return failure('Plugin generation disposed / 已卸载');
|
|
1170
|
+
const key = JSON.stringify([canonical, currentVersion, targetVersion]);
|
|
1171
|
+
if (state.upgradeFlight && state.upgradeFlight.key !== key)
|
|
1172
|
+
return failure('另一目标的升级预检正在运行,请稍后重试');
|
|
1173
|
+
let flight = state.upgradeFlight;
|
|
1174
|
+
if (!flight) {
|
|
1175
|
+
const controller = new AbortController();
|
|
1176
|
+
flight = {key, controller, promise: engine.runUpgradePreview({nm:nm.dir, currentVersion, targetVersion},{signal:controller.signal})};
|
|
1177
|
+
state.upgradeFlight = flight;
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
const result = await flight.promise;
|
|
1181
|
+
if (!state.disposed) return result;
|
|
1182
|
+
return failure([result.ok ? null : result.error, 'Plugin generation disposed / 已卸载'].filter(Boolean).join(';'));
|
|
1183
|
+
} finally {
|
|
1184
|
+
if (state.upgradeFlight === flight) state.upgradeFlight = null;
|
|
1185
|
+
}
|
|
1186
|
+
} catch (error) {return failure(errText(error));}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function upgradeToolResult(state, options) {
|
|
1190
|
+
let preview, text;
|
|
1191
|
+
try {
|
|
1192
|
+
const kernel = resolveKernelDir();
|
|
1193
|
+
if (!kernel) throw Error('找不到升级预检内核');
|
|
1194
|
+
const {renderUpgradePreview} = await import(pathToFileURL(join(kernel, 'lib/upgrade-preview.mjs')).href);
|
|
1195
|
+
preview = await serveUpgradePreview(state, options);
|
|
1196
|
+
text = renderUpgradePreview(preview);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
preview = unavailableUpgrade(errText(error),{targetVersion:options.targetVersion});
|
|
1199
|
+
text = '升级预检无法比较:'+preview.error+';静态预测不等于目标版本业务通过。';
|
|
1200
|
+
}
|
|
1201
|
+
return {schemaVersion:1,kind:'plugin-compat-check/tool-result',ok:preview.ok,error:preview.error,exitCode:preview.exitCode,toolVersion:preview.toolVersion,
|
|
1202
|
+
report:null,baseline:null,cache:null,nodeModules:'',hasRuntimeSurface:false,
|
|
1203
|
+
summary:preview.ok?`静态升级预检:新增 ${preview.summary.added} / 变重 ${preview.summary.worsened};不等于目标版本业务通过`:'升级预检无法比较',
|
|
1204
|
+
text,upgradePreview:preview};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function registerUpgradeRoute(ctx, state) {
|
|
1208
|
+
if (typeof ctx.inject !== 'function') return;
|
|
1209
|
+
ctx.inject(['webServer'], scope => {
|
|
1210
|
+
try {
|
|
1211
|
+
return scope.webServer.register({name:'dsh-plugin-compat-check/upgrade-preview',kind:'exact',path:'/plugin-compat-check/upgrade-preview',handler:async(req,res)=>{
|
|
1212
|
+
const send=(status,body)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(body));};
|
|
1213
|
+
if (!isTrustedRequest(req)) return send(403,{error:'refused: untrusted request'});
|
|
1214
|
+
if (req.method !== 'GET') return send(405,{error:'升级预检仅支持 GET'});
|
|
1215
|
+
try {
|
|
1216
|
+
const url=new URL(req.url||'/', 'http://127.0.0.1');
|
|
1217
|
+
const invalid=(req.url||'').length>2048 || [...url.searchParams.keys()].some(k=>!['targetVersion','currentVersion'].includes(k));
|
|
1218
|
+
const out=await serveUpgradePreview(state,{targetVersion:url.searchParams.get('targetVersion'),currentVersion:url.searchParams.get('currentVersion'),invalidRequest:invalid?'升级预检请求参数无效':null});
|
|
1219
|
+
send(200,out);
|
|
1220
|
+
} catch (error) {send(500,{error:'升级预检无法完成,请在本机核对插件与宿主版本'});}
|
|
1221
|
+
}});
|
|
1222
|
+
} catch (error) {log('Upgrade preview route registration failed: '+errText(error));}
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Human review is an independently loaded sidecar, never a verdict override. */
|
|
1227
|
+
async function attachFindingReviews(state,out,key,kernel) {
|
|
1228
|
+
try {
|
|
1229
|
+
const store=await import(pathToFileURL(join(kernel,'lib','finding-review-store.mjs')).href);
|
|
1230
|
+
const inputIdentity=state.reportService.identityForReport(key,out.report);
|
|
1231
|
+
if(typeof inputIdentity!=='string')throw new Error('当前报告缺少可绑定的分析输入身份');
|
|
1232
|
+
const sourceIdentity=createHash('sha256').update(inputIdentity).digest('hex');
|
|
1233
|
+
const context=await store.reviewContext(out.report,{nodeModules:out.nodeModules,sourceIdentity,rulesIdentity:out.analysisIdentity?.rulesSha256,catalogIdentity:out.report.catalog?.sha256,toolVersion:out.toolVersion});
|
|
1234
|
+
const reviews=await store.readReviews(context);
|
|
1235
|
+
if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载',out);
|
|
1236
|
+
state.reviewTargets ||= new Map();
|
|
1237
|
+
state.reviewTargets.set(context.contextToken,{context,key,report:out.report,nodeModules:out.nodeModules,store});
|
|
1238
|
+
while(state.reviewTargets.size>32)state.reviewTargets.delete(state.reviewTargets.keys().next().value);
|
|
1239
|
+
return {...out,reviews};
|
|
1240
|
+
} catch(error) {
|
|
1241
|
+
return {...out,reviews:{schemaVersion:1,contextToken:null,revision:null,items:[],history:[],error:'人工处置记录不可用:'+errText(error)}};
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function readReviewRequest(req,signal) {
|
|
1246
|
+
return new Promise((resolve,reject)=>{
|
|
1247
|
+
const maxBytes=16384,chunks=[];let bytes=0,settled=false;
|
|
1248
|
+
const finish=(error,value)=>{
|
|
1249
|
+
if(settled)return;settled=true;clearTimeout(timer);
|
|
1250
|
+
req.removeListener('data',data);req.removeListener('end',end);req.removeListener('error',errorEvent);req.removeListener('aborted',abort);
|
|
1251
|
+
signal.removeEventListener('abort',abort);
|
|
1252
|
+
if(error){req.pause?.();reject(error);}else resolve(value);
|
|
1253
|
+
};
|
|
1254
|
+
const invalid=(message,status)=>Object.assign(new Error(message),{status});
|
|
1255
|
+
const data=chunk=>{const buffer=Buffer.from(chunk);bytes+=buffer.length;if(bytes>maxBytes)return finish(invalid('人工处置请求超过大小限制',413));chunks.push(buffer);};
|
|
1256
|
+
const end=()=>{try{finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks))));}catch{finish(invalid('人工处置请求不是有效JSON',400));}};
|
|
1257
|
+
const errorEvent=()=>finish(invalid('人工处置请求读取失败',400));
|
|
1258
|
+
const abort=()=>finish(invalid('人工处置请求已取消或插件已卸载',409));
|
|
1259
|
+
const timer=setTimeout(()=>finish(invalid('人工处置请求读取超时',408)),5000);timer.unref?.();
|
|
1260
|
+
req.on('data',data);req.once('end',end);req.once('error',errorEvent);req.once('aborted',abort);signal.addEventListener('abort',abort,{once:true});
|
|
1261
|
+
if(signal.aborted)abort();
|
|
1262
|
+
const length=req.headers?.['content-length'];
|
|
1263
|
+
if(length!==undefined&&(!/^\d+$/.test(String(length))||Number(length)>maxBytes))finish(invalid('人工处置请求超过大小限制',413));
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
function registerFindingReviewRoute(ctx,state) {
|
|
1267
|
+
if(typeof ctx.inject!=='function')return;
|
|
1268
|
+
ctx.inject(['webServer'],scope=>{
|
|
1269
|
+
try{return scope.webServer.register({name:'dsh-plugin-compat-check/reviews',kind:'exact',path:'/plugin-compat-check/reviews',handler:async(req,res)=>{
|
|
1270
|
+
const send=(status,body)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(body));};
|
|
1271
|
+
if(!isTrustedRequest(req))return send(403,{ok:false,error:'refused: untrusted request'});
|
|
1272
|
+
if(req.method!=='POST')return send(405,{ok:false,error:'人工处置仅支持POST'});
|
|
1273
|
+
if(!/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type']||''))return send(415,{ok:false,error:'人工处置需要application/json'});
|
|
1274
|
+
if(state.disposed)return send(409,{ok:false,error:'Plugin generation disposed / 已卸载'});
|
|
1275
|
+
const controller=new AbortController();state.reviewRequests ||= new Set();state.reviewRequests.add(controller);
|
|
1276
|
+
try {
|
|
1277
|
+
const body=await readReviewRequest(req,controller.signal);
|
|
1278
|
+
const target=state.reviewTargets?.get(body?.contextToken);
|
|
1279
|
+
if(!target) return send(409,{ok:false,error:'报告已变化,请重新检查后核实此发现'});
|
|
1280
|
+
const canCommit=async()=>{
|
|
1281
|
+
if(state.disposed||controller.signal.aborted)return false;
|
|
1282
|
+
const current=resolveNodeModules();
|
|
1283
|
+
if(!current.dir||path.resolve(current.dir)!==path.resolve(target.nodeModules))return false;
|
|
1284
|
+
return state.reportService.isCurrent(target.key,target.report);
|
|
1285
|
+
};
|
|
1286
|
+
if(!await canCommit())return send(409,{ok:false,error:'体检输入已变化,请重新检查后核实此发现'});
|
|
1287
|
+
const reviews=await target.store.saveReview(target.context,body,{canCommit});
|
|
1288
|
+
send(200,{ok:true,reviews});
|
|
1289
|
+
} catch(error) {
|
|
1290
|
+
const status=error.status||(error.code==='REVIEW_STALE'||error.code==='REVIEW_BUSY'?409:error.code==='REVIEW_STORAGE'?422:400);
|
|
1291
|
+
send(status,{ok:false,error:errText(error)});
|
|
1292
|
+
} finally {state.reviewRequests.delete(controller);}
|
|
1293
|
+
}});}catch(error){log('Review route registration failed: '+errText(error));}
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1089
1297
|
const TOOL_SERVICES = ['tools', 'agents'];
|
|
1090
1298
|
|
|
1091
1299
|
function toolResult(compat,text){
|
|
1092
1300
|
const s=compat.report?.summary||{};
|
|
1093
|
-
return {schemaVersion:1,kind:'plugin-compat-check/tool-result',ok:compat.ok,error:compat.error,toolVersion:compat.toolVersion,exitCode:compat.exitCode,report:compat.report,baseline:compat.baseline,cache:compat.cache||null,
|
|
1094
|
-
summary:compat.ok?`可用 ${s.ok||0} / 部分可用 ${s.partial||0} / 不兼容 ${s.broken||0}`:'无法判定',nodeModules:compat.nodeModules||'',hasRuntimeSurface:!!compat.report?.hasRuntimeSurface,text};
|
|
1301
|
+
return {schemaVersion:1,kind:'plugin-compat-check/tool-result',ok:compat.ok,error:compat.error,toolVersion:compat.toolVersion,exitCode:compat.exitCode,report:compat.report,baseline:compat.baseline,cache:compat.cache||null,reviews:compat.reviews||null,
|
|
1302
|
+
summary:compat.ok?`可用 ${s.ok||0} / 部分可用 ${s.partial||0} / 不兼容 ${s.broken||0};覆盖有限 ${s.limited||0};无已验证调用 ${s.vacuousOk||0}`:'无法判定',nodeModules:compat.nodeModules||'',hasRuntimeSurface:!!compat.report?.hasRuntimeSurface,text};
|
|
1095
1303
|
}
|
|
1096
1304
|
|
|
1097
1305
|
function registerTool(ctx, state) {
|
|
@@ -1107,18 +1315,23 @@ function registerTool(ctx, state) {
|
|
|
1107
1315
|
const dispose = scope.tools.register({
|
|
1108
1316
|
name: 'compat_check',
|
|
1109
1317
|
description:
|
|
1110
|
-
'
|
|
1111
|
-
+ 'current DSH version,
|
|
1318
|
+
'Assess compatibility risks for the DSH plugins installed in this profile against the '
|
|
1319
|
+
+ 'current DSH version, reporting evidence, coverage limits and file:line locations where available. '
|
|
1112
1320
|
+ 'Use it when the user asks whether a plugin still works, why a feature disappeared after '
|
|
1113
|
-
+ 'updating DSH, or
|
|
1321
|
+
+ 'updating DSH, or what compatibility evidence is available for an installed plugin. It reads the installed '
|
|
1114
1322
|
+ 'profile; it does not install anything or check a plugin that is not yet installed. '
|
|
1115
|
-
+ '
|
|
1116
|
-
+ '
|
|
1117
|
-
+ '
|
|
1323
|
+
+ 'Certainty is separate from impact evidence: certain means supported by explicit static facts or qualified runtime observations; '
|
|
1324
|
+
+ 'suspected means insufficient evidence to establish that finding, even when some runtime observations exist. '
|
|
1325
|
+
+ 'impactBasis=measured requires corresponding impact/state observations; inferred means an inferred impact, not a measured business outcome. '
|
|
1326
|
+
+ 'vacuous means no verified calls in an otherwise ok result; service dependencies or unverified client features may still exist. '
|
|
1327
|
+
+ 'Preserve coverage limits: unobserved does not mean absent, and no findings or verified calls do not certify business behavior. '
|
|
1328
|
+
+ 'The official API catalogue is a contract catalogue, not a runtime observation. '
|
|
1329
|
+
+ 'Provide upgradeTo for a separate static pre-upgrade comparison against an exact supported DSH version. It reuses no live observations, cache or baseline, performs no upgrade and does not certify future business behavior.',
|
|
1118
1330
|
parameters: {
|
|
1119
1331
|
type: 'object',
|
|
1120
1332
|
additionalProperties: false,
|
|
1121
1333
|
properties: {
|
|
1334
|
+
upgradeTo: {type:'string',description:'Optional exact target DSH version for static upgrade preflight; known catalogues: 0.2.0-rc.2 and 0.2.1-rc.2. Unknown versions are uncertified.'},
|
|
1122
1335
|
profile: {
|
|
1123
1336
|
type: 'string',
|
|
1124
1337
|
description:
|
|
@@ -1143,9 +1356,11 @@ function registerTool(ctx, state) {
|
|
|
1143
1356
|
error: {oneOf:[{type:'string'},{type:'null'}]},
|
|
1144
1357
|
exitCode: {type:'integer',enum:[0,1,2,3]},
|
|
1145
1358
|
toolVersion: {oneOf:[{type:'string'},{type:'null'}]},
|
|
1146
|
-
report: {type:'object',additionalProperties:true},
|
|
1359
|
+
report: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
|
|
1360
|
+
upgradePreview: {type:'object',additionalProperties:true},
|
|
1147
1361
|
baseline: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
|
|
1148
1362
|
cache: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
|
|
1363
|
+
reviews: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
|
|
1149
1364
|
summary: { type: 'string' },
|
|
1150
1365
|
nodeModules: { type: 'string' },
|
|
1151
1366
|
hasRuntimeSurface: { type: 'boolean' },
|
|
@@ -1167,6 +1382,9 @@ function registerTool(ctx, state) {
|
|
|
1167
1382
|
const wanted = args && typeof args.profile === 'string' && args.profile.trim() !== ''
|
|
1168
1383
|
? args.profile.trim()
|
|
1169
1384
|
: null;
|
|
1385
|
+
if (args && Object.prototype.hasOwnProperty.call(args,'upgradeTo')) {
|
|
1386
|
+
return await upgradeToolResult(state,{profile:wanted,targetVersion:args.upgradeTo,invalidRequest:typeof args.upgradeTo !== 'string'?'upgradeTo 必须为确切 DSH 版本':null});
|
|
1387
|
+
}
|
|
1170
1388
|
const compat = await serveReport(state,ctx,false,wanted?{profile:wanted}:{});
|
|
1171
1389
|
const text = renderForModel(compat);
|
|
1172
1390
|
return toolResult(compat,text);
|
|
@@ -1334,9 +1552,10 @@ export function apply(ctx) {
|
|
|
1334
1552
|
// 更重要的是**启动时就先跑一次**(见下面的预热),这样用户点开面板是即时的。
|
|
1335
1553
|
lastReport: null,
|
|
1336
1554
|
lastReportAt: 0,
|
|
1337
|
-
computing: null,
|
|
1338
1555
|
/** 缓存有效期。到点不清空,而是**先返回旧的、后台再刷新** —— 见下面的 stale-while-revalidate。 */
|
|
1339
1556
|
cacheTtlMs: toPositiveInt(process.env.DSC_CACHE_TTL_MS, 5 * 60 * 1000),
|
|
1557
|
+
// A caller may shorten the observation interval; never enlarge its default.
|
|
1558
|
+
cacheWatchMs: Math.min(5000,toPositiveInt(process.env.DSC_CACHE_WATCH_MS,5000)),
|
|
1340
1559
|
/** 启动后多久自动跑第一次(默认落在最后一次 loader 扫描之后) */
|
|
1341
1560
|
autoRunDelayMs: toPositiveInt(process.env.DSC_AUTORUN_MS, (SCAN_DELAYS[SCAN_DELAYS.length - 1] || 4000) + 2000),
|
|
1342
1561
|
autoRunTimer: null,
|
|
@@ -1394,7 +1613,9 @@ export function apply(ctx) {
|
|
|
1394
1613
|
'content-type': 'application/json; charset=utf-8',
|
|
1395
1614
|
'cache-control': 'no-store',
|
|
1396
1615
|
});
|
|
1397
|
-
res.end(JSON.stringify(out
|
|
1616
|
+
res.end(JSON.stringify(url.searchParams.get('metadata')==='1' && out.ok ? {
|
|
1617
|
+
kind:'plugin-compat-check/report-metadata',ok:true,computedAt:out.computedAt,nodeModules:out.nodeModules,dshVersion:out.report.dshVersion,cache:out.cache || null,reviewsRevision:out.reviews?.revision || null,reviewsError:out.reviews?.error || null,reviewsContextToken:out.reviews?.contextToken || null,
|
|
1618
|
+
} : out));
|
|
1398
1619
|
} catch (error) {
|
|
1399
1620
|
// 连处理器里都不抛:把错误变成一条可读的 JSON,而不是 500 空响应。
|
|
1400
1621
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -1417,7 +1638,9 @@ export function apply(ctx) {
|
|
|
1417
1638
|
|
|
1418
1639
|
// E1:注册随包的 skill。**失败只留日志,绝不抛** —— 见文件顶部硬规矩。
|
|
1419
1640
|
try {
|
|
1420
|
-
|
|
1641
|
+
registerFindingReviewRoute(ctx, state);
|
|
1642
|
+
registerUpgradeRoute(ctx, state);
|
|
1643
|
+
registerSkill(ctx);
|
|
1421
1644
|
} catch (error) {
|
|
1422
1645
|
log(`WARN SKILL registration threw: ${errText(error)}`);
|
|
1423
1646
|
}
|
|
@@ -1551,6 +1774,12 @@ export function apply(ctx) {
|
|
|
1551
1774
|
globalThis.__DSH_COMPAT_CHECK__ = handle;
|
|
1552
1775
|
if(typeof ctx.effect==='function')ctx.effect(()=>()=>{
|
|
1553
1776
|
state.disposed=true;
|
|
1777
|
+
for (const request of state.reviewRequests || []) request.abort();
|
|
1778
|
+
state.reviewTargets?.clear();
|
|
1779
|
+
state.reportService?.dispose();
|
|
1780
|
+
for(const off of state.cacheEventDisposers || []){try{off();}catch{}}
|
|
1781
|
+
state.cacheEventDisposers=[];
|
|
1782
|
+
state.upgradeFlight?.controller?.abort();
|
|
1554
1783
|
for(const timer of state.scanTimers)clearTimeout(timer);
|
|
1555
1784
|
state.scanTimers.clear();
|
|
1556
1785
|
clearTimeout(state.fallbackTimer);
|