dsh-plugin-compat-check 0.1.3 → 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.
Files changed (45) hide show
  1. package/README.md +36 -20
  2. package/THIRD_PARTY_NOTICES.md +139 -0
  3. package/index.js +288 -55
  4. package/kernel/lib/author-ci-output.mjs +188 -0
  5. package/kernel/lib/author-ci-policy.mjs +285 -0
  6. package/kernel/lib/catalog.mjs +38 -0
  7. package/kernel/lib/contracts.mjs +88 -0
  8. package/kernel/lib/dependency-graph.mjs +650 -0
  9. package/kernel/lib/extract.mjs +14 -0
  10. package/kernel/lib/finding-guidance.mjs +72 -0
  11. package/kernel/lib/finding-review-store.mjs +245 -0
  12. package/kernel/lib/finding-review.mjs +338 -0
  13. package/kernel/lib/functional-health-contract.mjs +337 -0
  14. package/kernel/lib/issue-report.mjs +217 -0
  15. package/kernel/lib/observation.mjs +19 -9
  16. package/kernel/lib/owned-process.mjs +287 -0
  17. package/kernel/lib/report-identity-worker.mjs +153 -0
  18. package/kernel/lib/report-identity.mjs +15 -0
  19. package/kernel/lib/report-service.mjs +196 -11
  20. package/kernel/lib/report.mjs +76 -11
  21. package/kernel/lib/source-analysis.mjs +24 -6
  22. package/kernel/lib/upgrade-capture-worker.mjs +20 -0
  23. package/kernel/lib/upgrade-cleanup-worker.mjs +28 -0
  24. package/kernel/lib/upgrade-contract.mjs +471 -0
  25. package/kernel/lib/upgrade-input.mjs +309 -0
  26. package/kernel/lib/upgrade-preview.mjs +256 -0
  27. package/kernel/lib/upgrade-worker.mjs +50 -0
  28. package/kernel/lib/verdict.mjs +8 -1
  29. package/kernel/lib/version.mjs +1 -1
  30. package/kernel/lib/worker-analysis.mjs +16 -1
  31. package/kernel/lib/workflow-panels.mjs +706 -0
  32. package/kernel/rules/README.md +8 -6
  33. package/lib/client.js +2974 -69
  34. package/lib/dependency-graph.js +651 -0
  35. package/lib/finding-guidance.js +73 -0
  36. package/lib/finding-review.js +339 -0
  37. package/lib/functional-health-contract.js +338 -0
  38. package/lib/report-contract.js +88 -0
  39. package/lib/upgrade-contract.js +472 -0
  40. package/lib/workflow-panels.js +707 -0
  41. package/package.json +5 -3
  42. package/scripts/prepublish-check.mjs +28 -13
  43. package/scripts/stage-contract.mjs +91 -29
  44. package/scripts/stage-kernel.mjs +4 -3
  45. package/skill/SKILL.md +92 -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
- * 把结论交给面板 —— **F2 的缓存策略集中在这里**。
790
- *
791
- * ## 策略:stale-while-revalidate(先给旧的,后台再刷)
792
- *
793
- * 为什么不是"到点就重算":一次体检 0.5–1.5 秒(desktop profile 21 个插件)。
794
- * 让面板的请求**等**这 1.5 秒,用户看到的是转圈;而结论晚 1.5 秒到完全无所谓 ——
795
- * 这份数据说的是"你装的插件跟这版 DSH 兼不兼容",它不是秒级指标。
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 kernel=resolveKernelDir();
810
- if(!kernel)return failureEnvelope('找不到判定内核');
811
- const {ReportService}=await import(pathToFileURL(join(kernel,'lib','report-service.mjs')).href);
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});
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
- const key=options.profile?`profile:${options.profile}`:'current';
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||{});
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 {catalogPathFor}=await import(pathToFileURL(join(kernel,'lib','catalog.mjs')).href);
892
- const catalogPath = catalogPathFor(kernel,resolveDshVersion());
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 catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
895
- const rules = JSON.parse(readFileSync(rulesPath, 'utf8'));
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&&path.resolve(currentTarget.dir)===path.resolve(nm.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
- baseline=store.baselineTransaction(bfile,snap,{advance:extracted.report.scanComplete});
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;
@@ -1000,8 +1050,12 @@ function isTrustedRequest(req) {
1000
1050
  const host = String((req.headers && (req.headers.host || req.headers.Host)) || '');
1001
1051
  const origin = String((req.headers && (req.headers.origin || req.headers.Origin)) || '');
1002
1052
  const hostOk = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
1003
- if (!origin) return hostOk;
1004
- return hostOk && /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin);
1053
+ if (!hostOk) return false;
1054
+ const protocol = req.socket?.encrypted === true ? 'https:' : 'http:';
1055
+ const expectedOrigin = new URL(`${protocol}//${host}`).origin;
1056
+ if (!origin) return true;
1057
+ if ( !/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin)) return false;
1058
+ return new URL(origin).origin === expectedOrigin;
1005
1059
  } catch {
1006
1060
  return false;
1007
1061
  }
@@ -1030,8 +1084,9 @@ function renderForModel(compat) {
1030
1084
  const L = [];
1031
1085
  const s = r.summary || {};
1032
1086
  L.push(`结论:可用 ${s.ok || 0} 部分可用 ${s.partial || 0} 不兼容 ${s.broken || 0}`
1033
- + (s.vacuousOk ? `(其中 ${s.vacuousOk} 个「可用」是空洞的:该插件没有用过任何 DSH 服务,因此没有可检查的东西)` : ''));
1087
+ + (s.vacuousOk ? `(其中 ${s.vacuousOk} 个「可用」没有已验证调用,不等于业务通过)` : ''));
1034
1088
  L.push(`工具版本:${compat.toolVersion || '未知'}`);
1089
+ L.push(...catalogLines(r.catalog));
1035
1090
  for(const note of r.observation?.notes||[])L.push(`采样说明:${note}`);
1036
1091
  if(s.limited)L.push(`证据覆盖有限:${s.limited} 个插件尚有未验证部分`);
1037
1092
  L.push(`体检对象:${compat.nodeModules}(${compat.nodeModulesWhy})`);
@@ -1048,19 +1103,22 @@ function renderForModel(compat) {
1048
1103
  for (const name of names) {
1049
1104
  const p = r.plugins[name];
1050
1105
  const label = { broken: '不兼容', partial: '部分可用', unknown: '未知', ok: '可用' }[p.verdict] || p.verdict;
1051
- 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}`);}
1052
1108
  for (const f of p.findings || []) {
1053
1109
  const api = f.service ? (f.method ? `${f.service}.${f.method}` : f.service) : '(包声明)';
1054
1110
  const loc = f.location ? `${f.location.file}:${f.location.line}` : '—';
1055
1111
  const cat = {
1056
1112
  'host-service-missing': '服务缺失', 'host-method-missing': '方法缺失',
1057
- 'unprovided-dependency': '无人提供的依赖', 'declaration-mismatch': '声明与实测不符',
1113
+ 'unprovided-dependency': '依赖服务待确认', 'declaration-mismatch': '声明与实测不符',
1114
+ 'client-entry-missing': '客户端入口不可交付', 'host-entry-inactive': '宿主条目未激活',
1058
1115
  'declared-incompatible':'版本声明不支持',
1059
1116
  }[f.category] || f.category;
1060
1117
  const sev = { fatal: '致命', 'feature-dead': '功能失效', degraded: '降级' }[f.severity] || f.severity;
1061
- L.push(` ${sev} / ${cat}${f.certainty === 'certain' ? '' : ' [疑似]'} ${api} @ ${loc}`);
1062
- 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}`);
1063
1120
  if(f.basis)L.push(` 依据:${f.basis}`);
1121
+ for(const step of nextSteps(f))L.push(` 下一步:${step}`);
1064
1122
  }
1065
1123
  const cs = p.clientSide || [];
1066
1124
  if (cs.length) L.push(` 客户端半边(命令行测不到,需界面验证):${cs.map((c) => c.service).join('、')}`);
@@ -1070,11 +1128,14 @@ function renderForModel(compat) {
1070
1128
  }
1071
1129
 
1072
1130
  L.push('');
1073
- L.push('读这个结果时注意两点:');
1131
+ L.push('确定(certain)表示当前证据足以支持该条发现,可来自明确静态事实或合格运行时观测;疑似(suspected)表示当前证据不足以确定该条发现。');
1132
+ L.push('impactBasis=measured/inferred 分别表示影响有对应实测依据/影响由证据推断;确定发现不等于影响已实测。未观测不等于缺失。');
1133
+ L.push('没有已验证调用仍可能存在服务依赖或客户端功能;调用核验和覆盖完整均不等于业务验收通过。');
1134
+ L.push('下一步是核实建议;本工具不会自动安装、升级或修改配置与源码。');
1135
+ L.push('读这个结果时注意:');
1074
1136
  L.push(' 1) 「可用」不等于"功能验证过"。它只说明本轮未发现冲突;请同时看证据覆盖与未验证项。'
1075
1137
  + '插件仍然可能因为自身逻辑、配置或第三方依赖而出问题。');
1076
- L.push(' 2) 「客户端半边」的那些服务默认报告不验证 —— 它们活在浏览器里,'
1077
- + '只能靠"界面上有没有出现该插件的 UI"来验证。别把"没报错"当成"没问题"。');
1138
+ L.push(' 2) 客户端功能需在浏览器实际操作并核验结果;元素出现和没有报错均不等于业务成功。');
1078
1139
  return L.join('\n');
1079
1140
  } catch (error) {
1080
1141
  return `渲染结论时出错(工具的问题):${errText(error)}`;
@@ -1082,12 +1143,163 @@ function renderForModel(compat) {
1082
1143
  }
1083
1144
 
1084
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
+
1085
1297
  const TOOL_SERVICES = ['tools', 'agents'];
1086
1298
 
1087
1299
  function toolResult(compat,text){
1088
1300
  const s=compat.report?.summary||{};
1089
- 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,
1090
- 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};
1091
1303
  }
1092
1304
 
1093
1305
  function registerTool(ctx, state) {
@@ -1103,18 +1315,23 @@ function registerTool(ctx, state) {
1103
1315
  const dispose = scope.tools.register({
1104
1316
  name: 'compat_check',
1105
1317
  description:
1106
- 'Check whether the DSH plugins installed in this profile are actually compatible with the '
1107
- + 'current DSH version, and report the exact file:line and reason for anything that is not. '
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. '
1108
1320
  + 'Use it when the user asks whether a plugin still works, why a feature disappeared after '
1109
- + 'updating DSH, or whether something they installed is safe to keep. It reads the installed '
1321
+ + 'updating DSH, or what compatibility evidence is available for an installed plugin. It reads the installed '
1110
1322
  + 'profile; it does not install anything or check a plugin that is not yet installed. '
1111
- + 'Findings are labelled "certain" (backed by the live runtime surface) or "suspected" '
1112
- + '(inferred from the official API catalogue, which is a contract catalogue and not the '
1113
- + 'runtime surface) — report that distinction to the user instead of flattening it.',
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.',
1114
1330
  parameters: {
1115
1331
  type: 'object',
1116
1332
  additionalProperties: false,
1117
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.'},
1118
1335
  profile: {
1119
1336
  type: 'string',
1120
1337
  description:
@@ -1139,9 +1356,11 @@ function registerTool(ctx, state) {
1139
1356
  error: {oneOf:[{type:'string'},{type:'null'}]},
1140
1357
  exitCode: {type:'integer',enum:[0,1,2,3]},
1141
1358
  toolVersion: {oneOf:[{type:'string'},{type:'null'}]},
1142
- report: {type:'object',additionalProperties:true},
1359
+ report: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1360
+ upgradePreview: {type:'object',additionalProperties:true},
1143
1361
  baseline: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1144
1362
  cache: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1363
+ reviews: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1145
1364
  summary: { type: 'string' },
1146
1365
  nodeModules: { type: 'string' },
1147
1366
  hasRuntimeSurface: { type: 'boolean' },
@@ -1163,6 +1382,9 @@ function registerTool(ctx, state) {
1163
1382
  const wanted = args && typeof args.profile === 'string' && args.profile.trim() !== ''
1164
1383
  ? args.profile.trim()
1165
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
+ }
1166
1388
  const compat = await serveReport(state,ctx,false,wanted?{profile:wanted}:{});
1167
1389
  const text = renderForModel(compat);
1168
1390
  return toolResult(compat,text);
@@ -1330,9 +1552,10 @@ export function apply(ctx) {
1330
1552
  // 更重要的是**启动时就先跑一次**(见下面的预热),这样用户点开面板是即时的。
1331
1553
  lastReport: null,
1332
1554
  lastReportAt: 0,
1333
- computing: null,
1334
1555
  /** 缓存有效期。到点不清空,而是**先返回旧的、后台再刷新** —— 见下面的 stale-while-revalidate。 */
1335
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)),
1336
1559
  /** 启动后多久自动跑第一次(默认落在最后一次 loader 扫描之后) */
1337
1560
  autoRunDelayMs: toPositiveInt(process.env.DSC_AUTORUN_MS, (SCAN_DELAYS[SCAN_DELAYS.length - 1] || 4000) + 2000),
1338
1561
  autoRunTimer: null,
@@ -1390,7 +1613,9 @@ export function apply(ctx) {
1390
1613
  'content-type': 'application/json; charset=utf-8',
1391
1614
  'cache-control': 'no-store',
1392
1615
  });
1393
- 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));
1394
1619
  } catch (error) {
1395
1620
  // 连处理器里都不抛:把错误变成一条可读的 JSON,而不是 500 空响应。
1396
1621
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
@@ -1413,7 +1638,9 @@ export function apply(ctx) {
1413
1638
 
1414
1639
  // E1:注册随包的 skill。**失败只留日志,绝不抛** —— 见文件顶部硬规矩。
1415
1640
  try {
1416
- registerSkill(ctx);
1641
+ registerFindingReviewRoute(ctx, state);
1642
+ registerUpgradeRoute(ctx, state);
1643
+ registerSkill(ctx);
1417
1644
  } catch (error) {
1418
1645
  log(`WARN SKILL registration threw: ${errText(error)}`);
1419
1646
  }
@@ -1547,6 +1774,12 @@ export function apply(ctx) {
1547
1774
  globalThis.__DSH_COMPAT_CHECK__ = handle;
1548
1775
  if(typeof ctx.effect==='function')ctx.effect(()=>()=>{
1549
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();
1550
1783
  for(const timer of state.scanTimers)clearTimeout(timer);
1551
1784
  state.scanTimers.clear();
1552
1785
  clearTimeout(state.fallbackTimer);