@noob-stupid/dsh-plugin-console 0.5.33 → 0.5.34

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/lib/client.js CHANGED
@@ -208,6 +208,9 @@ window.__ModuleLoader__.load({
208
208
  riskyEnableConfirm: "确认强行启用",
209
209
  riskyEnableCancel: "取消",
210
210
  adoptableDetected: "检测到已适配:当前版本 {version} 已通过源码扫描,点「已适配,立即解锁」即可启用(不会自动开启)",
211
+ adoptableRescinded: "判据已更新:旧版扫描给出的「不兼容」结论已撤回(当前判据下源码扫描干净,版本不变也算)。点「已适配,立即解锁」即可启用(不会自动开启)",
212
+ compatRescindedTag: "判据已撤回·可解锁",
213
+ compatRescindedHint: "这条禁用是旧判据的误报:当前判据重算后不再判「不兼容」。点上方「已适配,立即解锁」即可启用;补丁由你确认后才改。",
211
214
  compatGateLabel: "兼容门",
212
215
  compatGateAutoDisable: "升级时自动禁用不适配插件",
213
216
  compatGateAutoDetect: "打开时自动检测已适配(仅提示)",
@@ -760,6 +763,9 @@ window.__ModuleLoader__.load({
760
763
  riskyEnableConfirm: "Force enable",
761
764
  riskyEnableCancel: "Cancel",
762
765
  adoptableDetected: "Now compatible: version {version} passed the source scan — click \"Adapted - unlock now\" to enable it (never auto-enabled)",
766
+ adoptableRescinded: "Criteria updated: the older scan's \"incompatible\" verdict has been withdrawn (the current criteria find the source clean even without a version change). Click \"Adapted - unlock now\" to enable it (never auto-enabled)",
767
+ compatRescindedTag: "Verdict withdrawn - unlockable",
768
+ compatRescindedHint: "This disable came from a false positive of the older criteria: recomputing with the current criteria no longer says \"incompatible\". Click \"Adapted - unlock now\" above; your patch is only changed after you confirm.",
763
769
  compatGateLabel: "Compat gate",
764
770
  compatGateAutoDisable: "Auto-disable incompatible plugins on upgrade",
765
771
  compatGateAutoDetect: "Detect adapted plugins on open (hint only)",
@@ -1231,9 +1237,12 @@ window.__ModuleLoader__.load({
1231
1237
  // 补丁与行状态零变化 ⇒ 用户观感"点了没反应"。
1232
1238
  // 现在只有**真的检测到已适配**(adoptable 是服务端 detectAdoptablePending 给出的对象)才出按钮;
1233
1239
  // 其余情况不给这个承诺,原因照旧由正文那行(pendingCompatHint + checkNote)如实展示。
1240
+ // 0.5.34 加法:`compatRescinded`(记录里的 fail 是旧判据的误报、已被当前判据撤回)同样给一键解锁入口 ——
1241
+ // 这类行**没有版本变化**,旧判据要求"版本变化"会让用户被永久钉在「待适配」(真机 dshmarket 就是这样)。
1242
+ // 判据本身没放宽:adoptable 仍是服务端算出来的对象(真 fail 的行根本拿不到它)。
1234
1243
  function canAdaptUnlock(entry) {
1235
1244
  return entry !== null && entry !== undefined
1236
- && entry.pendingCompat === true
1245
+ && (entry.pendingCompat === true || entry.compatRescinded === true)
1237
1246
  && entry.adoptable !== null && entry.adoptable !== undefined
1238
1247
  && typeof entry.adoptable === "object";
1239
1248
  }
@@ -3490,7 +3499,11 @@ window.__ModuleLoader__.load({
3490
3499
  entry.pendingCompat === true
3491
3500
  ? el("p", { className: styles.status, "data-pending": entry.adoptable ? "false" : "true" },
3492
3501
  entry.adoptable
3493
- ? t("adoptableDetected").replace("{version}", String(entry.adoptable.version ?? "?")).replace("{prev}", String(compatInfo?.version ?? "?"))
3502
+ ? (entry.adoptable.basis === "verdict-rescinded"
3503
+ // 0.5.34:旧判据的误报已被当前判据撤回(真机 dshmarket 现场)——如实说是"判据更新",
3504
+ // 不能说成"插件已适配"(那会让人以为作者发过新版)
3505
+ ? t("adoptableRescinded").replace("{version}", String(entry.adoptable.version ?? "?"))
3506
+ : t("adoptableDetected").replace("{version}", String(entry.adoptable.version ?? "?")).replace("{prev}", String(compatInfo?.version ?? "?")))
3494
3507
  : t("pendingCompatHint") + (compatInfo && compatInfo.checkNote ? ";已知校验:" + compatInfo.checkNote : ""))
3495
3508
  : null,
3496
3509
  meta !== null && meta.version ? el("div", { className: styles.rowTop },
@@ -3607,9 +3620,10 @@ window.__ModuleLoader__.load({
3607
3620
  type: "button",
3608
3621
  className: styles.toggle,
3609
3622
  disabled: !entry.toggleable || busy === entry.entryId || entry.pendingCompat === true,
3610
- title: entry.pendingCompat === true ? t("pendingCompatHint") : undefined,
3623
+ // 0.5.34:结论已被当前判据撤回的行不再显示「待适配」(旧判据的误报不许当事实展示)
3624
+ title: entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedHint") : t("pendingCompatHint")) : undefined,
3611
3625
  onClick: () => toggle(entry, !entry.enabled),
3612
- }, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? t("pendingCompatTag") : t(entry.enabled ? "off" : "on"))),
3626
+ }, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedTag") : t("pendingCompatTag")) : t(entry.enabled ? "off" : "on"))),
3613
3627
  el("button", {
3614
3628
  type: "button",
3615
3629
  className: styles.toggle,
@@ -0,0 +1,241 @@
1
+ // L1 · domain —— compat-verdict.js(适配门结论的**判据版本戳** + 过期结论的读取代发重算;0.5.34 加法)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
+ //
4
+ // ── 真机背景(2026-10-01,用户实测)────────────────────────────────────────────────
5
+ // `~/.dsh/plugin-console/compat-pending.json` 里 `dshmarket` 的记录仍是
6
+ // `check:"fail"` + `checkNote:"源码仍引用 0.1.2 起已删除的 dsh-settings API(…)——实际不兼容…"`
7
+ // —— 那是 **0.5.33 收紧判据之前**的误报结论(真机复核:现在 0 命中)。面板照旧把它当事实展示
8
+ // (「实际不兼容,启用会让整个服务启动崩溃」+「待适配」永久钉住),**持续误导用户**。
9
+ //
10
+ // ── 本模块只做两件事(**不放宽任何判据**)──────────────────────────────────────────
11
+ // ① 给结论打**判据版本戳**:记录里的 `scanVerdict.version` 标明"这条结论是哪一版判据算的"
12
+ // (戳来自 settings-api-scan.js 的 `SCAN_CRITERIA_VERSION`,判据改一次就要改它)。
13
+ // ② **读取代发**:读清单时若记录的戳落后于当前判据 → 用**当前判据只读重算**
14
+ // · 结论变了 → 回写(`check` / `checkNote` / `scanVerdict`,含 previous + rescinded + 来源证据)
15
+ // · 结论没变 → **零写盘**(幂等;同一条记录不会每次读都写盘)
16
+ // · 包解析不到 → 不猜:如实标注"无法复核"(保留旧结论,绝不假装成当前判据的结论)
17
+ // `fail` 永远不会因为"戳旧"就被改成 `pass` —— 只有**真的用当前判据重算**出非 fail 才算撤回;
18
+ // 真不兼容的(重算仍 fail)一个字都不动。
19
+ //
20
+ // 行数预算:独立模块,留在 600 行架构硬顶内。
21
+
22
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import { dshHome } from '../infra/paths.js'
25
+ import { SCAN_CRITERIA_VERSION } from './settings-api-scan.js'
26
+
27
+ /**
28
+ * 进程内重算备忘(纯性能,不影响判据):"结论未变 ⇒ 不写盘"意味着记录上的判据戳**一直是旧的**,
29
+ * 于是每次 `GET /state`(面板每隔几秒轮询一次)都会对同一批包重跑源码扫描(单包上限 120 文件 / 400KB)。
30
+ * 键含包路径 + 版本 + package.json 的 mtime:包一变(升级/改源码)就重算,包没变就复用。
31
+ */
32
+ const VERDICT_MEMO = new Map()
33
+ const MEMO_LIMIT = 200
34
+
35
+ /** 判据版本戳(当前判据的版本;判据变了就跟着 settings-api-scan.js 一起变)。 */
36
+ const SCANNER_VERSION = SCAN_CRITERIA_VERSION
37
+
38
+ /** 适配门清单文件(原在 compat.js;0.5.34 搬到本模块,供"读取代发"一处收口)。 */
39
+ const compatPendingFile = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
40
+
41
+ /**
42
+ * 「这条禁用记录是我们自己写进去的」判据(纯函数)。
43
+ * 只有这类记录才允许在**误报被撤回**时给用户一键解锁入口 —— 用户手写的禁用块不在此列。
44
+ */
45
+ const SELF_INFLICTED_SOURCES = new Set([
46
+ 'preflight-disabled-before-upgrade', // 框架升级前的预扫(dshmarket 就是这条)
47
+ 'boot-quarantine', // 启动失败隔离
48
+ 'auto-preflight', // 指纹变化自动预检
49
+ ])
50
+ function isSelfInflictedSource(source) {
51
+ return typeof source === 'string' && SELF_INFLICTED_SOURCES.has(source)
52
+ }
53
+
54
+ /** 记录上的判据戳(缺失 = 旧时代结论:必须重算)。 */
55
+ function stampOf(record) {
56
+ const s = record?.scanVerdict
57
+ if (s === null || typeof s !== 'object') return { version: null, decision: null, reason: null, at: null, rescinded: false, evidence: null }
58
+ return {
59
+ version: typeof s.version === 'string' ? s.version : null,
60
+ decision: typeof s.decision === 'string' ? s.decision : null,
61
+ reason: typeof s.reason === 'string' ? s.reason : null,
62
+ at: Number.isFinite(s.at) ? s.at : null,
63
+ rescinded: s.rescinded === true,
64
+ evidence: typeof s.evidence === 'string' ? s.evidence : null,
65
+ }
66
+ }
67
+
68
+ /** 这条记录的结论是不是"旧判据算的"(纯函数)。 */
69
+ function isStaleVerdict(record, version = SCANNER_VERSION) {
70
+ return stampOf(record).version !== version
71
+ }
72
+
73
+ /**
74
+ * 纯判据:给一组记录算出「哪些结论过期、用当前判据重算后是什么」(IO 全由 `recompute` 注入)。
75
+ * `recompute(record)` → `{ decision, reason }`;解析不到包时返回 `{ unresolved: '原因' }`。
76
+ * 返回每条的 `{ rowId, status, decision, reason, storedDecision, storedNote, rescinded, evidence }`,
77
+ * status ∈ fresh(戳已是最新,无需重算)/ updated(结论变了 → 调用方回写)/ unchanged(结论没变 → **零写盘**)
78
+ * / unresolved(复核不了 → 如实标注,保留旧结论)。
79
+ */
80
+ function planVerdictRefresh(records, { version = SCANNER_VERSION, recompute } = {}) {
81
+ const out = []
82
+ for (const rec of (Array.isArray(records) ? records : [])) {
83
+ if (rec === null || typeof rec !== 'object' || typeof rec.rowId !== 'string' || rec.rowId === '') continue
84
+ const storedDecision = typeof rec.check === 'string' && rec.check !== '' ? rec.check : null
85
+ const storedNote = typeof rec.checkNote === 'string' ? rec.checkNote : null
86
+ const stamp = stampOf(rec)
87
+ if (stamp.version === version) {
88
+ out.push({ rowId: rec.rowId, status: 'fresh', decision: storedDecision, reason: stamp.reason ?? storedNote, storedDecision, storedNote, rescinded: stamp.rescinded, evidence: stamp.evidence, version })
89
+ continue
90
+ }
91
+ // 没给重算器(或明确要求只读)→ 不猜:标成"过期、未复核"
92
+ if (typeof recompute !== 'function') {
93
+ out.push({ rowId: rec.rowId, status: 'stale', decision: storedDecision, reason: storedNote, storedDecision, storedNote, rescinded: false, evidence: null, version: null })
94
+ continue
95
+ }
96
+ let next = null
97
+ try {
98
+ next = recompute(rec)
99
+ } catch (error) {
100
+ next = { unresolved: String(error?.message ?? error) }
101
+ }
102
+ if (next === null || next === undefined) next = { unresolved: '重算没有返回结论' }
103
+ if (typeof next.unresolved === 'string' && next.unresolved !== '') {
104
+ out.push({ rowId: rec.rowId, status: 'unresolved', decision: storedDecision, reason: storedNote, storedDecision, storedNote, rescinded: false, evidence: null, version: null, unresolved: next.unresolved })
105
+ continue
106
+ }
107
+ const decision = typeof next.decision === 'string' && next.decision !== '' ? next.decision : 'unknown'
108
+ const reason = typeof next.reason === 'string' ? next.reason : null
109
+ out.push({
110
+ rowId: rec.rowId,
111
+ status: decision === storedDecision ? 'unchanged' : 'updated',
112
+ decision,
113
+ reason,
114
+ storedDecision,
115
+ storedNote,
116
+ // 「旧判据判 fail,当前判据不再 fail」= 误报被撤回(这是唯一允许解除"待适配"的情形)
117
+ rescinded: storedDecision === 'fail' && decision !== 'fail',
118
+ evidence: isSelfInflictedSource(rec.source) ? String(rec.source) : null,
119
+ version,
120
+ })
121
+ }
122
+ return out
123
+ }
124
+
125
+ /**
126
+ * 读取代发(IO 版):把 `planVerdictRefresh` 的结论落到记录上,**结论变了才写盘**(写后读回核实)。
127
+ * `deps`:`{ resolvePkg, readPkg, check, writeText, readText, now }` —— 全部可注入(离线测试)。
128
+ * 返回 `{ version, entries, updated, unchanged, unresolved, fresh, wrote, writeError, file }`。
129
+ */
130
+ function refreshCompatPendingVerdicts({ pending, baseDir, file = null, deps = {} } = {}) {
131
+ const {
132
+ resolvePkg, readPkg, check, now = () => Date.now(), stat = statSync,
133
+ readText = (p) => readFileSync(p, 'utf8'), writeText = (p, data) => writeFileSync(p, data, 'utf8'),
134
+ memo = VERDICT_MEMO,
135
+ } = deps
136
+ const target = file ?? compatPendingFile()
137
+ const records = Array.isArray(pending?.pending) ? pending.pending : []
138
+ const fwVer = typeof pending?.frameworkVersion === 'string' ? pending.frameworkVersion : null
139
+ const recompute = (rec) => {
140
+ if (typeof rec.moduleName !== 'string' || rec.moduleName === '') return { unresolved: '记录里没有 moduleName(无法定位已装包)' }
141
+ if (typeof resolvePkg !== 'function' || typeof readPkg !== 'function' || typeof check !== 'function') return { unresolved: '读取代发缺少解析器(内部接线问题)' }
142
+ let pkgPath = null
143
+ try { pkgPath = resolvePkg(rec.moduleName, baseDir) } catch (error) { return { unresolved: '解析包失败:' + String(error?.message ?? error) } }
144
+ if (pkgPath === null || pkgPath === undefined) return { unresolved: `本机解析不到 ${rec.moduleName}` }
145
+ let pkg = null
146
+ try { pkg = readPkg(pkgPath) } catch (error) { return { unresolved: '读取包信息失败:' + String(error?.message ?? error) } }
147
+ if (pkg === null || pkg === undefined) return { unresolved: '读取包信息失败' }
148
+ const mtimeMs = (() => { try { return stat(pkgPath).mtimeMs } catch { return 0 } })()
149
+ const key = `${rec.rowId}|${pkgPath}|${pkg.version ?? ''}|${mtimeMs}|${SCANNER_VERSION}`
150
+ if (memo instanceof Map && memo.has(key)) return memo.get(key)
151
+ const verdict = check(pkg, fwVer ?? '?', dirname(pkgPath))
152
+ const out = { decision: verdict?.decision ?? 'unknown', reason: verdict?.reason ?? null }
153
+ if (memo instanceof Map) {
154
+ if (memo.size >= MEMO_LIMIT) memo.clear()
155
+ memo.set(key, out)
156
+ }
157
+ return out
158
+ }
159
+ const entries = planVerdictRefresh(records, { recompute })
160
+ const byRow = new Map(entries.map((e) => [e.rowId, e]))
161
+ const updated = []
162
+ const at = now()
163
+ for (const rec of records) {
164
+ const e = byRow.get(rec?.rowId)
165
+ if (e === undefined || e.status !== 'updated') continue
166
+ const previous = { decision: e.storedDecision, note: e.storedNote }
167
+ rec.check = e.decision
168
+ rec.checkNote = e.reason
169
+ rec.scanVerdict = { version: SCANNER_VERSION, decision: e.decision, reason: e.reason, at, previous, rescinded: e.rescinded, evidence: e.evidence }
170
+ rec.verdictUpdatedAt = at
171
+ updated.push({ rowId: e.rowId, from: e.storedDecision, to: e.decision, rescinded: e.rescinded, evidence: e.evidence })
172
+ }
173
+ const result = {
174
+ version: SCANNER_VERSION,
175
+ file: target,
176
+ entries,
177
+ updated,
178
+ unchanged: entries.filter((e) => e.status === 'unchanged').map((e) => e.rowId),
179
+ unresolved: entries.filter((e) => e.status === 'unresolved').map((e) => ({ rowId: e.rowId, reason: e.unresolved })),
180
+ fresh: entries.filter((e) => e.status === 'fresh').map((e) => e.rowId),
181
+ wrote: false,
182
+ writeError: null,
183
+ }
184
+ if (updated.length === 0) return result // ② 结论未变(或无可重算)→ **零写盘**
185
+ const payload = { ...pending, pending: records, updatedAt: new Date(at).toISOString() }
186
+ try {
187
+ mkdirSync(dirname(target), { recursive: true })
188
+ writeText(target, JSON.stringify(payload, null, 2), 'utf8')
189
+ } catch (error) {
190
+ result.writeError = '回写适配门清单失败:' + String(error?.message ?? error)
191
+ return result
192
+ }
193
+ // 读回核实:只有真的落盘且内容一致才算写成功(绝不谎报"已更正")
194
+ try {
195
+ const back = JSON.parse(readText(target))
196
+ const rec = (back?.pending ?? []).find((p) => p.rowId === updated[0].rowId)
197
+ const ok = rec !== undefined && rec.check === updated[0].to && stampOf(rec).version === SCANNER_VERSION
198
+ if (!ok) result.writeError = '回写后读回内容与期望不一致(结论没真正落盘)'
199
+ } catch (error) {
200
+ result.writeError = '回写后读回失败:' + String(error?.message ?? error)
201
+ }
202
+ result.wrote = result.writeError === null
203
+ return result
204
+ }
205
+
206
+ /**
207
+ * 一条记录对外的"结论视图"(面板/门控摘要用):当前判据下的结论 + 是否过期 + 是否已撤回。
208
+ * `entry` = `refreshCompatPendingVerdicts()` 的 `entries` 里对应那条(可省 → 只看记录上的判据戳)。
209
+ */
210
+ function verdictView(record, entry = null) {
211
+ const stamp = stampOf(record)
212
+ const status = entry?.status ?? (stamp.version === SCANNER_VERSION ? 'fresh' : 'stale')
213
+ const evidence = entry?.evidence ?? stamp.evidence ?? (isSelfInflictedSource(record?.source) ? String(record.source) : null)
214
+ if (status === 'updated') {
215
+ return { decision: entry.decision, reason: entry.reason, version: entry.version, stale: false, rescinded: entry.rescinded === true, evidence, unresolved: null, status, previous: { decision: entry.storedDecision, note: entry.storedNote } }
216
+ }
217
+ if (status === 'fresh') {
218
+ return { decision: stamp.decision ?? (typeof record?.check === 'string' ? record.check : null), reason: stamp.reason ?? (typeof record?.checkNote === 'string' ? record.checkNote : null), version: stamp.version, stale: false, rescinded: stamp.rescinded, evidence, unresolved: null, status }
219
+ }
220
+ if (status === 'unchanged') {
221
+ // 结论与旧判据一致(已用当前判据复核过,但**不写盘**):结论可信,判据戳仍是旧的
222
+ return { decision: entry.decision, reason: entry.reason, version: entry.version, stale: true, rescinded: false, evidence, unresolved: null, status }
223
+ }
224
+ if (status === 'unresolved') {
225
+ return { decision: entry.decision, reason: entry.reason, version: null, stale: true, rescinded: false, evidence: null, unresolved: entry.unresolved, status }
226
+ }
227
+ // 'stale':本轮没有重算(没给基准/只读)→ 如实标"旧判据、未复核"
228
+ return { decision: stamp.decision ?? (typeof record?.check === 'string' ? record.check : null), reason: stamp.reason ?? (typeof record?.checkNote === 'string' ? record.checkNote : null), version: stamp.version, stale: true, rescinded: stamp.rescinded, evidence, unresolved: null, status: 'stale' }
229
+ }
230
+
231
+ export {
232
+ SCANNER_VERSION,
233
+ SELF_INFLICTED_SOURCES,
234
+ compatPendingFile,
235
+ isSelfInflictedSource,
236
+ isStaleVerdict,
237
+ planVerdictRefresh,
238
+ refreshCompatPendingVerdicts,
239
+ stampOf,
240
+ verdictView,
241
+ }
@@ -13,6 +13,8 @@ import { listEntries } from './runtime.js'
13
13
  import { fetchJsonUrl } from '../infra/http.js'
14
14
  import { dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
15
15
  import { semverRangeMatch, semverRangeMatchLoose } from '../infra/semver.js'
16
+ // 0.5.34 加法:适配门结论的**判据版本戳** + 过期结论的读取代发重算(见该模块头注释)。
17
+ import { SCANNER_VERSION, compatPendingFile, isSelfInflictedSource, planVerdictRefresh, refreshCompatPendingVerdicts, verdictView } from './compat-verdict.js'
16
18
  // 0.5.33 改错:已删除 API 的源码扫描搬进独立 domain 模块(判据收紧为"真的从 dsh-settings 绑定进来")。
17
19
  // 这三个名字继续由本模块导出,对外契约不变。
18
20
  import { REMOVED_SETTINGS_SYMBOLS, referencesRemovedSymbol, scanSettingsApiUsage } from './settings-api-scan.js'
@@ -82,8 +84,6 @@ function checkPluginFrameworkCompat(pkg, frameworkVersion, pkgDir = null) {
82
84
  // 支持 0.1.x 系列(0.1.0 / 0.1.1 等);0.2 / 1.0 等破坏性大版本才标记不支持
83
85
  const SUPPORTED_WEB_APP_PATTERN = /^0\.1\.\d+/u
84
86
 
85
- const COMPAT_PENDING_FILE = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
86
-
87
87
  /**
88
88
  * 启用前冒烟检查(服务永不崩机制):在独立子进程中动态 import 插件主模块,
89
89
  * 捕获 loader 会遇到的 import/resolution 错误(SyntaxError、缺失导出、模块缺失)。
@@ -126,7 +126,49 @@ function probePluginImport(moduleName, profileDir) {
126
126
  })
127
127
  }
128
128
 
129
- /** 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 null。 */
129
+ /** 适配门清单文件(0.5.34 起由 compat-verdict.js 定义,供"读取代发"一处收口)。 */
130
+ const COMPAT_PENDING_FILE = compatPendingFile
131
+
132
+ /**
133
+ * 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 `{ pending: null, ... }`。
134
+ *
135
+ * 0.5.34 加法(**读取代发**):给 `baseDir`(当前 profile 目录)时,对**判据戳落后**的记录
136
+ * 用当前判据只读重算:结论变了才回写(幂等:结论不变**零写盘**),解析不到包就如实标注"无法复核"。
137
+ * 真不兼容的(重算仍 fail)一个字都不动 —— 这条只解决"过期结论仍当事实展示",不放宽任何判据。
138
+ * 返回 `{ pending, refresh, verdicts }`:`verdicts` = rowId → 结论视图(面板/门控摘要用)。
139
+ */
140
+ function readCompatPendingWithVerdicts({ baseDir = null, deps = {} } = {}) {
141
+ let raw = null
142
+ try { raw = JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return { pending: null, refresh: null, verdicts: new Map() } }
143
+ if (raw === null || typeof raw !== 'object') return { pending: null, refresh: null, verdicts: new Map() }
144
+ let refresh = null
145
+ if (baseDir !== null && baseDir !== undefined && Array.isArray(raw.pending) && raw.pending.length > 0) {
146
+ try {
147
+ refresh = refreshCompatPendingVerdicts({
148
+ pending: raw,
149
+ baseDir,
150
+ deps: {
151
+ resolvePkg: (name, base) => resolvePackageJson(name, base),
152
+ readPkg: (p) => JSON.parse(readFileSync(p, 'utf8')),
153
+ check: checkPluginFrameworkCompat,
154
+ ...deps,
155
+ },
156
+ })
157
+ } catch (error) {
158
+ // 复核本身出问题也必须如实回报(不静默):结论按记录原样展示,附上失败原因
159
+ refresh = { version: SCANNER_VERSION, entries: [], updated: [], unchanged: [], unresolved: [], fresh: [], wrote: false, writeError: '读取代发重算失败:' + String(error?.message ?? error), file: COMPAT_PENDING_FILE() }
160
+ }
161
+ }
162
+ const entries = Array.isArray(refresh?.entries) ? refresh.entries : planVerdictRefresh(raw.pending)
163
+ const verdicts = new Map()
164
+ for (const rec of raw.pending) {
165
+ if (rec === null || typeof rec !== 'object' || typeof rec.rowId !== 'string') continue
166
+ verdicts.set(rec.rowId, verdictView(rec, entries.find((e) => e.rowId === rec.rowId) ?? null))
167
+ }
168
+ return { pending: raw, refresh, verdicts }
169
+ }
170
+
171
+ /** 读取清单(原契约不变:只读、不做复核)。需要"读取代发"的路径用 readCompatPendingWithVerdicts。 */
130
172
  function readCompatPending() {
131
173
  try { return JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return null }
132
174
  }
@@ -283,15 +325,18 @@ async function detectCompat(baseUrl) {
283
325
  }
284
326
 
285
327
  /** 待适配行的「现在是否已适配」检测(只提示,不自动解锁——用户定案:我点才开)。
286
- * 返回 Map<rowId, {version, check, note}>:仅当版本已变化且源码扫描不再 fail 时才算可解锁。 */
328
+ * 返回 Map<rowId, {version, check, note, basis}>:可解锁有两种**互不放松**的来路 ——
329
+ * ① `version-changed`:插件真的更新过(版本 ≠ 记录版本)且源码扫描不再 fail(原判据,不变);
330
+ * ② `verdict-rescinded`(0.5.34 加法):记录里的 `fail` 是**旧判据的误报**,当前判据重算后不再 fail。
331
+ * 真不兼容(重算仍 fail)**一个都不放行**;②也不需要"版本变化"——否则用户会被永久钉在「待适配」。 */
287
332
  function detectAdoptablePending(ports) {
288
333
  const out = new Map()
289
- const pending = readCompatPending()
334
+ let profileDir = null
335
+ try { profileDir = dirname(findPatchPath(ports)) } catch { return out }
336
+ const { pending, verdicts } = readCompatPendingWithVerdicts({ baseDir: profileDir })
290
337
  if (pending === null) return out
291
338
  const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : null
292
339
  if (fwVer === null) return out
293
- let profileDir = null
294
- try { profileDir = dirname(findPatchPath(ports)) } catch { return out }
295
340
  for (const p of (pending.pending ?? []).filter((x) => (x.status ?? 'pending') === 'pending')) {
296
341
  try {
297
342
  const pkgPath = resolvePackageJson(p.moduleName, profileDir)
@@ -300,9 +345,18 @@ function detectAdoptablePending(ports) {
300
345
  const version = typeof pkg.version === 'string' ? pkg.version : null
301
346
  const changed = version !== null && version !== p.version
302
347
  const check = checkPluginFrameworkCompat(pkg, fwVer, dirname(pkgPath))
303
- if (changed && check.decision !== 'fail') {
304
- out.set(p.rowId, { version, check: check.decision, note: check.reason ?? null })
305
- }
348
+ if (check.decision === 'fail') continue
349
+ const view = verdicts.get(p.rowId) ?? null
350
+ const rescinded = view?.rescinded === true
351
+ if (!changed && !rescinded) continue
352
+ out.set(p.rowId, {
353
+ version,
354
+ check: check.decision,
355
+ note: check.reason ?? null,
356
+ basis: changed ? 'version-changed' : 'verdict-rescinded',
357
+ previousVerdict: view?.storedDecision ?? null,
358
+ evidence: view?.evidence ?? null,
359
+ })
306
360
  } catch {}
307
361
  }
308
362
  return out
@@ -317,8 +371,11 @@ function detectAdoptablePending(ports) {
317
371
  *
318
372
  * pending —— 当前被拦下/待解锁的行(含原因、来源、是否已通过源码扫描可解锁)
319
373
  * adopted —— 曾经被拦、现已适配解锁的行(让用户能核对"到底动过哪些行")
374
+ *
375
+ * 0.5.34 加法:每行多带 `verdict`(当前判据下的结论视图:`stale` / `rescinded` / `unresolved`)——
376
+ * 旧判据的结论不许再被当成事实展示;**复核不了**(包解析不到)的行在 `note` 上如实标注。
320
377
  */
321
- function gatingSummary(ports, compatPending, ctxEntries = null) {
378
+ function gatingSummary(ports, compatPending, ctxEntries = null, verdicts = null) {
322
379
  const rec = compatPending ?? { frameworkVersion: null, upgradeFrom: null, pending: [] }
323
380
  const all = Array.isArray(rec.pending) ? rec.pending : []
324
381
  let info = new Map()
@@ -329,11 +386,14 @@ function gatingSummary(ports, compatPending, ctxEntries = null) {
329
386
  for (const e of Array.isArray(ctxEntries) ? ctxEntries : []) {
330
387
  if (typeof e?.rowId === 'string' && e.rowId !== '') byRow.set(e.rowId, e)
331
388
  }
389
+ const byVerdict = verdicts instanceof Map ? verdicts : new Map()
332
390
  const row = (p) => {
333
391
  const meta = info.get(p.rowId) ?? null
334
392
  const entry = byRow.get(p.rowId) ?? null
335
393
  const moduleName = p.moduleName ?? meta?.moduleName ?? entry?.moduleName ?? null
336
394
  const name = moduleName ?? p.rowId ?? '(未知行)'
395
+ const verdict = byVerdict.get(p.rowId) ?? verdictView(p)
396
+ const note = p.checkNote ?? null
337
397
  return {
338
398
  rowId: p.rowId ?? null,
339
399
  name,
@@ -342,12 +402,16 @@ function gatingSummary(ports, compatPending, ctxEntries = null) {
342
402
  version: p.version ?? meta?.version ?? entry?.version ?? null,
343
403
  status: p.status ?? 'pending',
344
404
  check: p.check ?? null,
345
- note: p.checkNote ?? null,
405
+ // 复核不了的行:**如实标注**"旧判据结论、未复核"(不许继续当事实;也不猜新结论)
406
+ note: verdict.unresolved === null || verdict.unresolved === undefined
407
+ ? note
408
+ : `${note ?? ''}(旧判据结论,未能复核:${verdict.unresolved})`,
346
409
  source: p.source ?? null,
347
410
  adoptedAt: p.adoptedAt ?? null,
348
411
  forcedAt: p.forcedAt ?? null,
349
412
  adoptedBy: p.adoptedBy ?? null,
350
413
  enabled: entry === null ? null : entry.enabled === true,
414
+ verdict,
351
415
  }
352
416
  }
353
417
  const isPending = (p) => (p.status ?? 'pending') === 'pending'
@@ -391,9 +455,10 @@ function rowIdModuleMap(ports) {
391
455
  /**
392
456
  * 框架升级适配门:安装/更新完成后自动校验兼容性,
393
457
  * 通过(版本已变化 + 未在声明/依赖层面明确不兼容)则移除补丁禁用块并解锁启用。
458
+ * 0.5.34:走**读取代发**(`profileDir` 已知)—— 顺手把过期判据的结论复核/回写,避免旧误报继续挂在这里。
394
459
  */
395
460
  async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patchPath, ports }) {
396
- const pending = readCompatPending()
461
+ const { pending } = readCompatPendingWithVerdicts({ baseDir: profileDir })
397
462
  if (!pending || !Array.isArray(pending.pending)) return { ran: false }
398
463
  const interested = new Set([packageName, ...(syncedNames ?? [])].filter((n) => n !== null))
399
464
  const targets = pending.pending.filter((p) => (p.status ?? 'pending') === 'pending' && interested.has(p.moduleName))
@@ -438,7 +503,7 @@ async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patc
438
503
  /** AI 赋能框架适配预检:查 registry 最新版声明 + 兼容门清单,给出权威说明。 */
439
504
  async function frameworkCompatReportFor(source, ports, profileDir) {
440
505
  const fwVer = currentFrameworkVersion(ports)
441
- const pending = readCompatPending()
506
+ const { pending, verdicts } = readCompatPendingWithVerdicts({ baseDir: profileDir })
442
507
  const norm = String(source ?? '').trim()
443
508
  const isNpm = norm.startsWith('@') || !norm.includes('/')
444
509
  const installed = (() => {
@@ -448,7 +513,10 @@ async function frameworkCompatReportFor(source, ports, profileDir) {
448
513
  return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
449
514
  } catch { return null }
450
515
  })()
451
- const pendHits = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && p.moduleName === norm)
516
+ // 命中行带上当前判据的结论视图(`fail` 仍然是 fail;过期/复核不了会如实标注)
517
+ const pendHits = (pending?.pending ?? [])
518
+ .filter((p) => (p.status ?? 'pending') === 'pending' && p.moduleName === norm)
519
+ .map((p) => ({ ...p, verdict: verdicts.get(p.rowId) ?? verdictView(p) }))
452
520
  if (!isNpm) {
453
521
  return {
454
522
  kind: 'repo', frameworkVersion: fwVer, packageName: null, latest: null, check: null,
@@ -487,4 +555,4 @@ async function frameworkCompatReportFor(source, ports, profileDir) {
487
555
  summary: parts.length > 0 ? parts.join(';') : `框架 ${fwVer},无待适配记录`,
488
556
  }
489
557
  }
490
- export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_ACK_FILE, COMPAT_GATE_DEFAULTS, NOTICE_MODES, DEFAULT_NOTICE_MODE, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, writeCompatPending, readCompatGate, writeCompatGate, readCompatAck, writeCompatAck, resolveNoticeState, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
558
+ export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_ACK_FILE, COMPAT_GATE_DEFAULTS, NOTICE_MODES, DEFAULT_NOTICE_MODE, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, readCompatPendingWithVerdicts, writeCompatPending, verdictView, SCANNER_VERSION, readCompatGate, writeCompatGate, readCompatAck, writeCompatAck, resolveNoticeState, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
@@ -5,15 +5,36 @@
5
5
  //
6
6
  // 三条安全约定(对齐仓库既有纪律):
7
7
  // ① **一律先改名备份**:顶层旧目录 → `<name>.stale-<ver>-<ts>`,绝不直接删(可回滚)。
8
- // ② 重建 junction 只在**目标确实存在**时做:目标从 `dsh` junction 的同级目录推(运行树的真实位置);
9
- // 推不到或目标不存在就只改名,并如实记进 `relinkSkipped`(不假装成功)。
8
+ // ② 改名与重建 junction **原子化**(0.5.34 改错,见下);重建失败 → 立刻把改名改回去。
10
9
  // ③ 框架备份只删 `fw-tree/<ts>` 目录,**绝不碰** `dsh-package-backup` 与目录下的快照文件。
10
+ //
11
+ // ── 0.5.34 改错(**真机停机事故 2026-10-01**:web 服务进程 exit code=1,起不来)──────────────
12
+ // 现场:框架树顶层 `@deepseek-ai/cordis` 被改名成 `cordis.stale-4.0.4-<ts>`,**但没重建 junction**
13
+ // ⇒ 启动包 `dsh-app-boot` 的 `import '@deepseek-ai/cordis'` 解析失败 ⇒ 服务进程退出。
14
+ // 真根因(`git blame` 定位到旧 `:77` 改名 / 旧 `:92` 本应重建):重建链接只在
15
+ // 「`dsh` junction 推得出运行树 **且** 目标存在」时才做,其余分支(`no-running-scope` /
16
+ // `target-missing`)**只改名不重建**;真机上顶层 `dsh` 是**真实目录**而非 junction
17
+ // ⇒ `runningScopeDir = null` ⇒ 10 条包全部落进这条"只改名"的支路,且只在 `relinkSkipped` 里
18
+ // 留一行 JSON(面板不留痕、用户看不到),框架就再也起不来了。
19
+ // 现在(四条硬规则,全部有离线测试钉死):
20
+ // ① **改名后立刻重建 junction**;重建失败 → **立刻回滚改名**(改回原名)→ 记 `failed` 并写明原因。
21
+ // `relink:false`(显式关闭重建)不再"只改名"——那种模式正是事故本体,一律不改名并如实记 `relink-off`。
22
+ // ② 找不到任何可重建目标(多基准候选全不存在)→ **连名字都不动**(先探目标、再改名),记
23
+ // `relinkSkipped`(带原因 + 试过哪些基准)—— 宁可什么都不做,也不让框架顶层少一个包名。
24
+ // ③ 整批结束后做一次**框架可解析性验证**(至少 `@deepseek-ai/cordis` + `@deepseek-ai/dsh-app-boot`,
25
+ // 多基准:框架树顶层 + `.pnpm/node_modules`;解析结果必须落在框架树内)→ **验证失败则把本轮所有
26
+ // 改名回滚**(恢复到操作前状态)并返回失败原因。这条就是"防止把服务搞到起不来"的硬闸。
27
+ // ④ **绝不删除**任何 `.stale-*` 备份;**幂等**:`.stale-*` 不再参与判据(见 framework-residuals.js),
28
+ // 已建好的 junction 与同 stamp 的备份名都会让重复运行零改名、零新建链接。
29
+ // 返回值:`renamed`(**留在原地**的改名,含 target/via)/ `relinked` / `relinkSkipped`(含原因)/
30
+ // `rolledBack`(含哪些与为什么)/ `verify`(多基准解析验证结果)/ `failed`(可读原因,绝不静默吞)。
11
31
 
12
- import { existsSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, symlinkSync } from 'node:fs'
13
- import { dirname, join } from 'node:path'
32
+ import { createRequire } from 'node:module'
33
+ import { existsSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
34
+ import { basename, dirname, join, resolve, sep } from 'node:path'
14
35
  import { removeDirVerifiedAsync } from '../infra/fsx.js'
15
36
  import { dshHome, resolvePackageJson } from '../infra/paths.js'
16
- import { classifyTopLevel, planBackupRetention } from './framework-residuals.js'
37
+ import { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, planBackupRetention, relinkTargetCandidates } from './framework-residuals.js'
17
38
 
18
39
  /** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
19
40
  function dirSizeBytes(dir) {
@@ -39,18 +60,142 @@ function isLinkEntry(full, dirent, readLink = readlinkSync) {
39
60
  try { readLink(full); return true } catch { return false }
40
61
  }
41
62
 
63
+ /** 删掉一个**链接**(Windows 的 junction 用 rmdir 拆;符号链接用 unlink 兜底)。绝不删真实目录。 */
64
+ function removeLinkSafe(full, deps = {}) {
65
+ const { rmdir = rmdirSync, unlink = unlinkSync } = deps
66
+ const errors = []
67
+ try { rmdir(full); return { ok: true, via: 'rmdir' } } catch (error) { errors.push(String(error?.message ?? error)) }
68
+ try { unlink(full); return { ok: true, via: 'unlink' } } catch (error) { errors.push(String(error?.message ?? error)) }
69
+ return { ok: false, error: errors.join(' / ') }
70
+ }
71
+
72
+ function errText(error) {
73
+ return String(error?.message ?? error)
74
+ }
75
+
76
+ /**
77
+ * 把「改名 → 重建链接」这一步回滚:先拆掉本轮建的链接(若已建),再把备份名改回原名。
78
+ * 返回 `{ ok, problem }`;**失败必须带可读原因**(回滚失败是最严重的一类,绝不能假装成功)。
79
+ */
80
+ function rollbackRename({ full, backupFull, linkAtName = false, deps = {} } = {}) {
81
+ const { exists = existsSync, rename = renameSync, readLink = readlinkSync, removeLink = removeLinkSafe } = deps
82
+ const problems = []
83
+ let occupied = false
84
+ try { occupied = exists(full) } catch (error) { problems.push('检查原位置失败:' + errText(error)) }
85
+ if (occupied) {
86
+ // 原位置此刻只可能是**本轮刚建的链接**(原名一刻前才被改名让出)。
87
+ // 真实目录一律不动 —— 宁可留下链接并如实报错,也绝不误删用户数据。
88
+ let linkNow = linkAtName
89
+ if (!linkNow) { try { readLink(full); linkNow = true } catch { linkNow = false } }
90
+ if (!linkNow) problems.push('原位置出现了非链接目录,拒绝覆盖')
91
+ else {
92
+ const rm = removeLink(full)
93
+ if (rm?.ok !== true) problems.push('拆除本轮链接失败:' + String(rm?.error ?? '未知'))
94
+ }
95
+ }
96
+ if (problems.length === 0) {
97
+ try { rename(backupFull, full) } catch (error) { problems.push('改回原名失败:' + errText(error)) }
98
+ }
99
+ return { ok: problems.length === 0, problem: problems.join(';') }
100
+ }
101
+
102
+ /** 解析一条残留该重建到哪个真实目录(多基准;第一个存在的候选胜出),或如实说明为什么不能重建。 */
103
+ function resolveRelinkTarget({ fwRoot, scopeName, name, version, runningScopeDir, deps = {} } = {}) {
104
+ const { exists = existsSync, readdir = readdirSync } = deps
105
+ const storeHoistDir = fwRoot === null ? null : join(fwRoot, '.pnpm', 'node_modules')
106
+ const tried = []
107
+ for (const candidate of relinkTargetCandidates({ runningScopeDir, storeHoistDir, scopeName, name })) {
108
+ tried.push({ path: candidate.path, via: candidate.via })
109
+ let hit = false
110
+ try { hit = exists(candidate.path) } catch { hit = false }
111
+ if (hit) return { target: candidate.path, via: candidate.via, tried }
112
+ }
113
+ // 兜底:pnpm 虚拟 store 里的实体本体 `<scope>+<name>@<version>*`(版本必须**与刚改名的那份相同**,
114
+ // 绝不借机换版本;同版本多个 hash 时按目录名排序取第一个,确定性且语义等价)。
115
+ const storeDir = fwRoot === null ? null : join(fwRoot, '.pnpm')
116
+ if (storeDir !== null && version !== null && version !== undefined && String(version) !== '') {
117
+ const prefix = `${scopeName}+${name}@${version}`
118
+ let dirs = []
119
+ try { dirs = readdir(storeDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === prefix || d.name.startsWith(prefix + '_'))).map((d) => d.name).sort() } catch (error) { tried.push({ path: join(storeDir, prefix + '*'), via: 'pnpm-store-entity', error: errText(error) }) }
120
+ for (const dir of dirs) {
121
+ const candidate = join(storeDir, dir, 'node_modules', scopeName, name)
122
+ tried.push({ path: candidate, via: 'pnpm-store-entity' })
123
+ let hit = false
124
+ try { hit = exists(candidate) } catch { hit = false }
125
+ if (hit) return { target: candidate, via: 'pnpm-store-entity', tried }
126
+ }
127
+ }
128
+ return { target: null, via: null, tried }
129
+ }
130
+
131
+ /**
132
+ * 框架**可解析性**验证(多基准,只读):从「框架树顶层」与「`.pnpm/node_modules`」两个基准分别解析
133
+ * 关键启动包,且要求解析结果**落在框架树内**(否则是从上层别处的同名包,等于框架自己还是解析不到)。
134
+ * 默认用 `createRequire(base).resolve(name)` 真解析;`deps.resolveFrom` 可注入(离线测试用)。
135
+ */
136
+ function verifyFrameworkTreeResolvable({ fwRoot, packages = FRAMEWORK_BOOT_PACKAGES, deps = {} } = {}) {
137
+ const { resolveFrom = (baseFile, name) => createRequire(baseFile).resolve(name), real = realpathSync } = deps
138
+ const treeRoot = fwRoot === null || fwRoot === undefined ? null : dirname(fwRoot)
139
+ const bases = treeRoot === null ? [] : [
140
+ { id: 'framework-top', dir: treeRoot, file: join(treeRoot, 'package.json') },
141
+ { id: 'pnpm-store', dir: join(fwRoot, '.pnpm', 'node_modules'), file: join(fwRoot, '.pnpm', 'node_modules', 'package.json') },
142
+ ]
143
+ const norm = (p) => {
144
+ try { return real(p) } catch { return resolve(p) }
145
+ }
146
+ const inside = (child, parent) => {
147
+ const c = norm(child)
148
+ const p = norm(parent)
149
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep)
150
+ }
151
+ const checks = []
152
+ for (const base of bases) {
153
+ for (const short of packages) {
154
+ const name = short.includes('/') ? short : `@deepseek-ai/${short}`
155
+ try {
156
+ const resolved = resolveFrom(base.file, name)
157
+ if (typeof resolved !== 'string' || resolved === '') throw new Error('解析器没有返回路径')
158
+ if (treeRoot !== null && !inside(resolved, treeRoot)) {
159
+ checks.push({ base: base.id, name, ok: false, path: resolved, error: `解析到框架树之外(${resolved})——框架自己仍然解析不到它` })
160
+ continue
161
+ }
162
+ checks.push({ base: base.id, name, ok: true, path: resolved, error: null })
163
+ } catch (error) {
164
+ checks.push({ base: base.id, name, ok: false, path: null, error: errText(error) })
165
+ }
166
+ }
167
+ }
168
+ const bad = checks.filter((c) => c.ok !== true)
169
+ const ok = bases.length > 0 && bad.length === 0
170
+ return {
171
+ ok,
172
+ phase: 'post',
173
+ bases: bases.map((b) => ({ id: b.id, dir: b.dir })),
174
+ packages: packages.map((s) => (s.includes('/') ? s : `@deepseek-ai/${s}`)),
175
+ checks,
176
+ error: ok ? null : (bases.length === 0
177
+ ? '拿不到框架树根(无法验证可解析性)'
178
+ : `框架可解析性验证失败:${bad.slice(0, 3).map((c) => `${c.name}@${c.base}(${c.error})`).join(';')}${bad.length > 3 ? ` 等 ${bad.length} 条` : ''}`),
179
+ }
180
+ }
181
+
42
182
  /**
43
- * A 档:顶层投影里的旧版本真实目录 → 改名备份(+ 可选重建 junction)。
44
- * `topScopeDir` = `<frameworkRoot>/node_modules/@deepseek-ai`;`dshLinkPath` = 同目录下的 `dsh`(运行树入口)。
183
+ * A 档:顶层投影里的旧版本真实目录 → 改名备份(+ **立刻**重建 junction;失败即回滚)。
184
+ * `topScopeDir` = `<fwRoot>/<scope>`(如 `…/node_modules/@deepseek-ai`);`dshLinkPath` = 同目录下的 `dsh`。
185
+ * 可选 `frameworkRoot`(默认 `dirname(topScopeDir)`)、`verifyPackages`(默认启动关键包)。
45
186
  */
46
- async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, deps = {} } = {}) {
187
+ async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, frameworkRoot = null, verifyPackages = null, deps = {} } = {}) {
47
188
  const {
48
189
  exists = existsSync, readdir = readdirSync, readText = (f) => readFileSync(f, 'utf8'),
49
190
  rename = renameSync, link = symlinkSync, readLink = readlinkSync,
191
+ resolveFrom = (baseFile, name) => createRequire(baseFile).resolve(name),
50
192
  } = deps
193
+ const empty = { renamed: [], relinked: [], relinkSkipped: [], rolledBack: [], failed: [], verify: null }
51
194
  if (topScopeDir === null || topScopeDir === undefined || !exists(topScopeDir)) {
52
- return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)', renamed: [], relinked: [], relinkSkipped: [], failed: [] }
195
+ return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)', ...empty }
53
196
  }
197
+ const fwRoot = frameworkRoot ?? dirname(topScopeDir)
198
+ const scopeName = basename(topScopeDir)
54
199
  const entries = []
55
200
  for (const dirent of readdir(topScopeDir, { withFileTypes: true })) {
56
201
  const full = join(topScopeDir, dirent.name)
@@ -72,40 +217,96 @@ async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, running
72
217
  const renamed = []
73
218
  const relinked = []
74
219
  const relinkSkipped = []
220
+ const rolledBack = []
75
221
  const failed = []
76
- for (const item of (dryRun === true ? [] : classified.stale)) {
222
+ const hints = []
223
+ if (runningScopeDir === null && dshLinkPath !== null) hints.push('顶层 `' + basename(dshLinkPath) + '` 不是 junction(推不出运行树)——重建链接改用 .pnpm 多基准候选')
224
+ const io = { exists, rename, readLink }
225
+
226
+ for (const item of classified.stale) {
227
+ if (dryRun === true) break // 只出 planned(函数尾部统一填),一个字节都不动
228
+ // 显式关闭重建(relink:false):**一律不改名**。旧行为是"只改名不重建",那正是 2026-10-01 停机事故本体。
229
+ if (relink !== true) {
230
+ relinkSkipped.push({ name: item.name, version: item.version, reason: 'relink-off', detail: '调用方显式关闭重建链接(relink:false):改名后无法指回真实包体,故不改名(旧行为会只改名 → 框架解析不到包)' })
231
+ continue
232
+ }
77
233
  const backupName = item.name + '.stale-' + item.version + '-' + ts
234
+ const backupFull = join(topScopeDir, backupName)
235
+ const full = join(topScopeDir, item.name)
236
+ let backupExists = false
237
+ try { backupExists = exists(backupFull) } catch { backupExists = false }
238
+ if (backupExists) {
239
+ // 幂等护栏:同一个 stamp 的备份已经在 → 说明这一条早就改过名了,重复运行不产生新改名。
240
+ relinkSkipped.push({ name: item.name, version: item.version, reason: 'backup-exists', detail: `备份 ${backupName} 已存在 —— 疑似重复运行,本次不改名、不改链接` })
241
+ continue
242
+ }
243
+ // **先探目标再改名**:找不到可指回的真实包体时连名字都不动(少一个"名字短暂空缺"的窗口)。
244
+ const resolvedTarget = resolveRelinkTarget({ fwRoot, scopeName, name: item.name, version: item.version, runningScopeDir, deps: { exists, readdir } })
245
+ if (resolvedTarget.target === null) {
246
+ relinkSkipped.push({ name: item.name, version: item.version, reason: 'no-target', detail: '多基准候选全不存在,无法重建链接 —— 不改名(判不准就不动)', tried: resolvedTarget.tried })
247
+ continue
248
+ }
78
249
  try {
79
- rename(join(topScopeDir, item.name), join(topScopeDir, backupName))
250
+ rename(full, backupFull)
80
251
  } catch (error) {
81
- failed.push({ name: item.name, error: String(error?.message ?? error) })
252
+ failed.push({ name: item.name, error: '改名备份失败:' + errText(error) })
82
253
  continue
83
254
  }
84
- renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null })
85
- if (relink !== true) { relinkSkipped.push({ name: item.name, reason: 'relink-off' }); continue }
86
- if (runningScopeDir === null) { relinkSkipped.push({ name: item.name, reason: 'no-running-scope' }); continue }
87
- const target = join(runningScopeDir, item.name)
88
- let targetExists = false
89
- try { targetExists = exists(target) } catch { targetExists = false }
90
- if (!targetExists) { relinkSkipped.push({ name: item.name, reason: 'target-missing' }); continue }
91
255
  try {
92
- link(target, join(topScopeDir, item.name), 'junction')
93
- relinked.push({ name: item.name, target })
256
+ link(resolvedTarget.target, full, 'junction')
94
257
  } catch (error) {
95
- failed.push({ name: item.name, error: '已改名但重建链接失败:' + String(error?.message ?? error) })
258
+ const rb = rollbackRename({ full, backupFull, deps: io })
259
+ const why = `重建链接失败(目标 ${resolvedTarget.target}):${errText(error)}`
260
+ if (rb.ok) {
261
+ rolledBack.push({ name: item.name, version: item.version, backup: backupName, reason: 'relink-failed' })
262
+ failed.push({ name: item.name, error: `${why};已把改名回滚(现场未变)` })
263
+ } else {
264
+ failed.push({ name: item.name, error: `${why};**回滚改名也失败**(现场可能不一致):${rb.problem}` })
265
+ }
266
+ continue
96
267
  }
268
+ renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null, target: resolvedTarget.target, via: resolvedTarget.via })
269
+ relinked.push({ name: item.name, target: resolvedTarget.target, via: resolvedTarget.via })
97
270
  }
271
+
272
+ // ③ 整批验证(硬闸):只在**本轮真的改过名**时才具回滚意义;没改过就只如实报告当前状态。
273
+ const verify = verifyFrameworkTreeResolvable({ fwRoot, packages: verifyPackages ?? FRAMEWORK_BOOT_PACKAGES, deps: { resolveFrom } })
274
+ verify.phase = dryRun === true ? 'pre' : 'post'
275
+ if (verify.ok !== true && renamed.length > 0) {
276
+ const all = [...renamed].reverse()
277
+ renamed.length = 0
278
+ relinked.length = 0
279
+ for (const item of all) {
280
+ const rb = rollbackRename({ full: join(topScopeDir, item.name), backupFull: join(topScopeDir, item.backup), linkAtName: true, deps: io })
281
+ if (rb.ok) rolledBack.push({ name: item.name, version: item.version, backup: item.backup, reason: 'verify-failed' })
282
+ else failed.push({ name: item.name, error: `验证失败后的回滚未完成(现场可能不一致):${rb.problem}` })
283
+ }
284
+ verify.rolledBackAll = true
285
+ verify.error = `${verify.error} → 已把本轮全部 ${all.length} 条改名回滚(恢复到操作前状态)`
286
+ } else if (verify.ok !== true) {
287
+ verify.rolledBackAll = false
288
+ hints.push('本轮没有改名(dryRun / relink-off):验证结果只反映**操作前**的现场状态,未触发回滚')
289
+ }
290
+ if (relinkSkipped.some((s) => s.reason === 'no-target')) hints.push(`有 ${relinkSkipped.filter((s) => s.reason === 'no-target').length} 条找不到可指回的真实包体:**连名字都没动**(判不准就不动)`)
291
+ if (rolledBack.length > 0) hints.push(`本轮回滚 ${rolledBack.length} 条(原因见 rolledBack)`)
292
+ const ok = failed.length === 0 && verify.ok !== false
98
293
  return {
99
- ok: failed.length === 0,
294
+ ok,
100
295
  renamed,
101
296
  relinked,
102
297
  relinkSkipped,
298
+ rolledBack,
103
299
  failed,
300
+ hints,
104
301
  staleCount: classified.stale.length,
105
302
  sameVersionCount: classified.sameVersion.length,
106
303
  missingVersion: classified.missingVersion,
107
304
  dryRun,
108
305
  planned: classified.stale.map((s) => ({ name: s.name, version: s.version, sizeBytes: s.sizeBytes ?? null })),
306
+ verify,
307
+ error: ok ? null : (failed.length > 0
308
+ ? `有 ${failed.length} 条没能安全落地:${failed.slice(0, 3).map((f) => `${f.name}(${f.error})`).join(';')}`
309
+ : String(verify.error ?? '未知失败')),
109
310
  }
110
311
  }
111
312
 
@@ -222,4 +423,4 @@ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
222
423
  return [raw?.checkpointDir, raw?.cliBackupDir].filter((p) => typeof p === 'string' && p !== '')
223
424
  } catch { return [] }
224
425
  }
225
- export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout }
426
+ export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, removeLinkSafe, reportFrameworkEntities, resolveFrameworkLayout, verifyFrameworkTreeResolvable }
@@ -8,15 +8,40 @@
8
8
  // 而回滚只需要**回滚点引用的那一条** + **最近 N 条**。
9
9
  //
10
10
  // 本模块只做纯判据(IO 全由调用方注入),动作在 routes/plugins.js 的 clean-residuals scope 里:
11
- // · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"(同版本/链接一律不动 —— 宁可少动)
11
+ // · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"(同版本/链接/备份一律不动 —— 宁可少动)
12
12
  // · `planBackupRetention` → 保留「回滚点那一条 ∪ 最近 N 条」,其余进删除清单 + 可释放字节
13
+ // · `relinkTargetCandidates` → 改名之后该把 junction 指回哪里(多基准候选,按优先级)
14
+ // · `isStaleBackupName` → 备份目录名判据(幂等:备份绝不能再被当成残留改名)
13
15
  //
14
16
  // 行数预算:独立模块,留在 600 行架构硬顶内。
15
17
 
18
+ import { join } from 'node:path'
19
+
20
+ /**
21
+ * `.stale-*` 备份目录名判据(**幂等必需**)。
22
+ *
23
+ * 动作层把旧目录改名成 `<name>.stale-<version>-<ts>`;那些备份里**还带着 package.json**
24
+ * (version = 被备份的那个版本),所以不排除的话:
25
+ * 第二次运行 → 备份自己又被判成"旧版本残留" → 改成 `<...>.stale-<v>-<ts2>`(越堆越深),
26
+ * 而且会给这个根本不存在的名字建 junction。旧行为正是如此(真机 `cordis.stale-4.0.4-<ts>` 一眼可见)。
27
+ * 误判方向是**保守**的:真有一个叫 `foo.stale-bar` 的包时我们只会"不动它",不做危险动作。
28
+ */
29
+ const STALE_BACKUP_RE = /\.stale-/u
30
+ function isStaleBackupName(name) {
31
+ return typeof name === 'string' && STALE_BACKUP_RE.test(name)
32
+ }
33
+
34
+ /**
35
+ * 框架**启动关键包**(相对 scope 目录名):整批改名结束后必须仍能解析,否则全量回滚。
36
+ * 这两个就是 2026-10-01 停机事故现场:`dsh-app-boot` 顶层 `import '@deepseek-ai/cordis'` 解析不到
37
+ * ⇒ web 服务进程 exit code=1。判据只认"能解析"这件事,与版本号无关。
38
+ */
39
+ const FRAMEWORK_BOOT_PACKAGES = ['cordis', 'dsh-app-boot']
40
+
16
41
  /**
17
42
  * 顶层条目分类(纯函数)。
18
43
  * `entries` 形如 `[{ name, version, isLink, sizeBytes }]`;`isLink` 为真表示已被 junction/符号链接接管。
19
- * **保守取舍**:同版本保留;缺版本(读不到 package.json)保留并单列 —— 判不准就不动。
44
+ * **保守取舍**:同版本保留;缺版本(读不到 package.json)保留并单列;`.stale-*` 备份一律不碰 —— 判不准就不动。
20
45
  */
21
46
  function classifyTopLevel(entries, { runningVersion = null } = {}) {
22
47
  const stale = []
@@ -26,6 +51,7 @@ function classifyTopLevel(entries, { runningVersion = null } = {}) {
26
51
  if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name === '') continue
27
52
  if (entry.name.startsWith('.')) continue
28
53
  if (entry.isLink === true) continue
54
+ if (isStaleBackupName(entry.name)) continue // 备份(含备份的备份)永不参与判断 —— 见 isStaleBackupName
29
55
  const version = entry.version === undefined || entry.version === null ? '' : String(entry.version)
30
56
  if (version === '') { missingVersion.push({ name: entry.name, sizeBytes: entry.sizeBytes ?? null }); continue }
31
57
  if (runningVersion !== null && version === String(runningVersion)) { sameVersion.push({ name: entry.name, version }); continue }
@@ -34,6 +60,29 @@ function classifyTopLevel(entries, { runningVersion = null } = {}) {
34
60
  return { stale, sameVersion, missingVersion }
35
61
  }
36
62
 
63
+ /**
64
+ * 改名之后 junction 该指向哪里(纯函数:只**列候选**,存不存在由动作层问 IO)。
65
+ *
66
+ * 多基准(真机实证:2026-10-01 事故里 `dsh` 顶层是**真实目录**而不是 junction,
67
+ * 老判据只从 `dsh` junction 推运行树 → `runningScopeDir = null` → 10 条包全部"只改名、没重建"):
68
+ * ① `runningScopeDir/<name>` —— `dsh` 是 junction 时的运行树同级目录(老语义,保留)
69
+ * ② `<fwRoot>/.pnpm/node_modules/<scope>/<name>` —— pnpm store 的 hoisted 投影
70
+ * (真机 10 条手工补回的 junction 指的正是这里,逐条读过 Target)
71
+ * ③ `<fwRoot>/.pnpm/node_modules/<name>` —— 非作用域名的同款投影(防御性兜底)
72
+ * 顺序即优先级:先运行树(框架自己正在用的那份),再 store 投影。返回 `[{ path, via }]`。
73
+ */
74
+ function relinkTargetCandidates({ runningScopeDir = null, storeHoistDir = null, scopeName = null, name = null } = {}) {
75
+ if (typeof name !== 'string' || name === '') return []
76
+ const out = []
77
+ if (typeof runningScopeDir === 'string' && runningScopeDir !== '') out.push({ path: join(runningScopeDir, name), via: 'running-scope' })
78
+ if (typeof storeHoistDir === 'string' && storeHoistDir !== '') {
79
+ if (typeof scopeName === 'string' && scopeName !== '') out.push({ path: join(storeHoistDir, scopeName, name), via: 'pnpm-hoist' })
80
+ out.push({ path: join(storeHoistDir, name), via: 'pnpm-hoist-flat' })
81
+ }
82
+ const seen = new Set()
83
+ return out.filter((c) => (seen.has(c.path) ? false : (seen.add(c.path), true)))
84
+ }
85
+
37
86
  /**
38
87
  * 备份保留策略(纯函数):保留集合 = 「回滚点引用的路径」∪「按 mtime 最近的 N 条」。
39
88
  * `keep` 非法/缺失时按 1 处理(最小保留)。返回 `{ kept, remove, freedBytes }`,
@@ -56,4 +105,4 @@ function planBackupRetention(snapshots, { referenced = [], keep = 1 } = {}) {
56
105
  return { kept, remove, freedBytes }
57
106
  }
58
107
 
59
- export { classifyTopLevel, planBackupRetention }
108
+ export { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, isStaleBackupName, planBackupRetention, relinkTargetCandidates }
@@ -34,6 +34,21 @@ import { join } from 'node:path'
34
34
  /** 0.1.2-rc.1 起被官方删除的 dsh-settings API(命中即"实际不兼容")。 */
35
35
  const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
36
36
 
37
+ /**
38
+ * **判据版本戳**(0.5.34 加法):判据一变就必须改这个字符串。
39
+ *
40
+ * 为什么需要它(真机背景 2026-10-01):`compat-pending.json` 里的结论是"当时的判据"写下的,
41
+ * 但面板把它当**事实**一直展示:`dshmarket` 记录里压着
42
+ * `check:"fail"` + `checkNote:"源码仍引用 0.1.2 起已删除的 dsh-settings API…"` ——
43
+ * 那是 0.5.33 收紧判据**之前**的误报结论(真机复核:现在 0 命中),
44
+ * 于是面板持续显示"实际不兼容,启用会让整个服务启动崩溃",把用户永久钉在「待适配」。
45
+ * 有了版本戳,"这条结论是哪一版判据算的"就可以被后续读取代发判读 → 过期即重算(见 compat-verdict.js)。
46
+ *
47
+ * v1 = 0.3.35 的「标识符边界 + 排除局部定义」(挡不住注释里的名字与对象键上的名字)
48
+ * v2 = 0.5.33 的「真的从 `@deepseek-ai/dsh-settings` 绑定进来」(本文件实现)
49
+ */
50
+ const SCAN_CRITERIA_VERSION = 'dsh-settings-scan/2'
51
+
37
52
  /** 这些 API 原本来自哪个包(只有真的从它绑定进来的名字才算引用)。 */
38
53
  const SETTINGS_MODULE = '@deepseek-ai/dsh-settings'
39
54
 
@@ -248,4 +263,4 @@ function scanSettingsApiUsage(pkgDir) {
248
263
  return found
249
264
  }
250
265
 
251
- export { REMOVED_SETTINGS_SYMBOLS, SETTINGS_MODULE, maskLiterals, settingsApiBindings, settingsApiReferences, referencesRemovedSymbol, scanSettingsApiUsage }
266
+ export { REMOVED_SETTINGS_SYMBOLS, SCAN_CRITERIA_VERSION, SETTINGS_MODULE, maskLiterals, settingsApiBindings, settingsApiReferences, referencesRemovedSymbol, scanSettingsApiUsage }
@@ -4,7 +4,7 @@
4
4
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
- import { checkPluginFrameworkCompat, probePluginImport, readCompatPending, rowIdModuleMap, writeCompatPending } from '../domain/compat.js'
7
+ import { checkPluginFrameworkCompat, probePluginImport, readCompatPendingWithVerdicts, rowIdModuleMap, writeCompatPending } from '../domain/compat.js'
8
8
  import { cleanFrameworkBackups, cleanFrameworkTopLevel, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout } from '../domain/framework-cleanup.js'
9
9
  import { currentFrameworkVersion } from '../domain/framework.js'
10
10
  import { pnpmRemove } from '../domain/install-job.js'
@@ -78,7 +78,8 @@ async function routeToggle(req, res, rc) {
78
78
  // 前端弹风险提示确认框;带 confirmRisky:true 才放行)。
79
79
  // 注意下面还有一条**硬**门禁:启用前的 import 冒烟检查——那条是事实性崩溃(模块根本加载不了),
80
80
  // 不允许覆盖(否则下次启动必崩,与「服务永不崩」冲突)。
81
- const compatPending = readCompatPending()
81
+ // 0.5.34:走**读取代发**(判据戳落后的结论先用当前判据复核/回写)⇒ 判定依据是最新的;软禁闸门本身一个字没放宽
82
+ const compatPending = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
82
83
  const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
83
84
  if (pend) {
84
85
  if (body.confirmRisky !== true) {
@@ -97,9 +98,9 @@ async function routeToggle(req, res, rc) {
97
98
  return
98
99
  }
99
100
  try {
100
- const next = readCompatPending()
101
- const rec = (next?.pending ?? []).find((p) => p.rowId === rowId)
102
- if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(next) }
101
+ // 复核(读取代发)已经作用在 compatPending 上,直接用同一份对象写回,避免重复读+重复扫描
102
+ const rec = (compatPending?.pending ?? []).find((p) => p.rowId === rowId)
103
+ if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(compatPending) }
103
104
  } catch {}
104
105
  }
105
106
  }
@@ -126,7 +127,7 @@ async function routeToggle(req, res, rc) {
126
127
  // 用户实测就是这样(5 行)。check / checkNote / riskyApprovedAt 一律保留供事后查。
127
128
  if (enabled) {
128
129
  try {
129
- const list = readCompatPending()
130
+ const list = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
130
131
  const meta = rowIdModuleMap(ctx).get(rowId) ?? null
131
132
  if (list !== null && markPendingAdopted(list, rowId, 'manual-enable', meta)) {
132
133
  list.updatedAt = new Date().toISOString()
@@ -304,14 +305,14 @@ async function routeAdaptUnlock(req, res, rc) {
304
305
  sendError(res, 400, 'rowId 无效')
305
306
  return
306
307
  }
307
- const pending = readCompatPending()
308
+ const patchPath = findPatchPath(ctx)
309
+ const profileDir = dirname(patchPath)
310
+ const { pending, verdicts } = readCompatPendingWithVerdicts({ baseDir: profileDir }) // 0.5.34:读取代发
308
311
  const entry = (pending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
309
312
  if (entry === undefined) {
310
313
  sendError(res, 404, '该行不在适配门待适配清单中(或已解锁)')
311
314
  return
312
315
  }
313
- const patchPath = findPatchPath(ctx)
314
- const profileDir = dirname(patchPath)
315
316
  let pkg = null
316
317
  let pkgDir = null
317
318
  let version = null
@@ -327,11 +328,13 @@ async function routeAdaptUnlock(req, res, rc) {
327
328
  const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
328
329
  const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
329
330
  if (check.decision === 'fail') {
330
- // 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」——旧文案只有一句"适配校验未通过",
331
- // 用户在界面上根本不知道下一步该做什么(而按钮此前又只在 adoptable 时出现,容易变成对不上的承诺)。
331
+ // 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」(旧文案只有一句"适配校验未通过",用户不知道下一步
332
+ // 该做什么);0.5.34:真不兼容的行**一个字都不放宽** —— 仍然 409、仍然零改动(补丁与清单逐字节不变)。
332
333
  sendError(res, 409, `适配校验未通过:${String(check.reason ?? '适配校验未通过')}。出路:先点「检测更新」把它更新到兼容当前框架 ${fwVer} 的版本(更新完成会自动重跑源码扫描,通过即解锁);若它已不再需要,可在补丁里删掉/禁用这一行。本次未改动补丁与适配门清单。`)
333
334
  return
334
335
  }
336
+ // 可解锁的两种来路(判据都不放松):① 真的更新过(版本变化)② 旧判据的 fail 已被当前判据撤回
337
+ const view = verdicts.get(rowId) ?? null, rescinded = view?.rescinded === true, changed = version !== null && version !== entry.version
335
338
  await removeDisableBlock(patchPath, rowId)
336
339
  entry.status = 'adopted'
337
340
  entry.adoptedAt = Date.now()
@@ -339,8 +342,9 @@ async function routeAdaptUnlock(req, res, rc) {
339
342
  entry.adoptedFramework = fwVer
340
343
  entry.check = check.decision
341
344
  entry.checkNote = check.reason ?? null
345
+ entry.adoptedBasis = changed ? 'version-changed' : (rescinded ? 'verdict-rescinded' : 'scan-pass')
342
346
  writeCompatPending(pending)
343
- sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null })
347
+ sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null, basis: entry.adoptedBasis, previousVerdict: rescinded ? (view?.previous?.decision ?? 'fail') : null, evidence: rescinded ? (view?.evidence ?? null) : null })
344
348
  return
345
349
  }
346
350
 
@@ -357,7 +361,7 @@ async function routeAdaptUnlockAll(req, res, rc) {
357
361
  sendError(res, 400, 'root 无效')
358
362
  return
359
363
  }
360
- const pending = readCompatPending()
364
+ const pending = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
361
365
  const patchPath = findPatchPath(ctx)
362
366
  const profileDir = dirname(patchPath)
363
367
  // v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
@@ -525,6 +529,13 @@ async function routeCleanResiduals(req, res, rc) {
525
529
  extra.dryRun = dryRun
526
530
  }
527
531
  const payload = { ok: failed.length === 0, removed, kept, failed, count: removed.length, ...extra }
532
+ // 0.5.34 加法:框架档的**硬闸**结果必须如实浮到面板层(旧行为:改名了却没重建链接,只在 JSON 里留一行)
533
+ const fw = extra.framework
534
+ if (fw && (fw.ok === false || fw.verify?.ok === false)) {
535
+ const rb = (fw.rolledBack ?? []).length > 0 ? `;已回滚 ${fw.rolledBack.length} 条(${fw.rolledBack.map((r) => `${r.name}:${r.reason}`).join('、')})` : ''
536
+ sendJson(res, 200, { ...payload, ok: false, error: `${fw.verify?.ok === false ? `框架可解析性验证未通过(${fw.verify.error ?? '未知'})` : `有 ${fw.failed.length} 条没能安全落地`}${rb};详情见 framework 字段。` })
537
+ return
538
+ }
528
539
  if (failed.length > 0) {
529
540
  const detail = failed.slice(0, 3).map((f) => `${f.name}(${f.error ?? '未知原因'})`).join(';')
530
541
  const keptNote = kept.length > 0 ? `;已在用插件 ${kept.length} 个(保留不删)` : ''
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { readFileSync, existsSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
- import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPending } from '../domain/compat.js'
6
+ import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPendingWithVerdicts } from '../domain/compat.js'
7
7
  import { compUiUrl, findComponents } from '../domain/components.js'
8
8
  import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
9
9
  import { peerVetoFor } from '../domain/peer-veto.js'
@@ -48,7 +48,11 @@ async function routeStateGet(req, res, rc) {
48
48
  }
49
49
  const extraRows = await readExtraBundleRows(dirname(patchPath))
50
50
  const profileDir = dirname(patchPath)
51
- const compatPending = readCompatPending()
51
+ // 0.5.34 加法(**读取代发**):给 profileDir ⇒ 判据戳落后的记录用当前判据只读重算,
52
+ // 结论变了才回写(幂等:结论不变**零写盘**)。真不兼容的重算仍 fail,一个字都不动。
53
+ const pendingState = readCompatPendingWithVerdicts({ baseDir: profileDir })
54
+ const compatPending = pendingState.pending
55
+ const compatVerdicts = pendingState.verdicts
52
56
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
53
57
  // 兼容门总开关 + 自动检测(只提示不自动开)
54
58
  const compatGate = readCompatGate()
@@ -72,6 +76,7 @@ async function routeStateGet(req, res, rc) {
72
76
  if (!disabledByPatch && entry.fiberPhase === null && vetoFrameworkVersion !== null) {
73
77
  try { veto = peerVetoFor({ moduleName: entry.moduleName, profileDir, frameworkVersion: vetoFrameworkVersion }) } catch { veto = null }
74
78
  }
79
+ const verdict = compatVerdicts.get(entry.rowId) ?? null
75
80
  return {
76
81
  ...entry,
77
82
  userDisabled: disabledByPatch,
@@ -83,7 +88,13 @@ async function routeStateGet(req, res, rc) {
83
88
  // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
84
89
  // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
85
90
  pendingCompat: compatPendingRows.has(entry.rowId) && disabledByPatch,
91
+ // 0.5.34 加法:记录的 `fail` 若已被**当前判据**撤回(旧误报),面板据此不再说「待适配」,
92
+ // 改说「判据已撤回·可解锁」并给一键解锁入口(判据本身一个字都没放宽:真 fail 仍 fail)
93
+ compatRescinded: compatPendingRows.has(entry.rowId) && verdict?.rescinded === true,
94
+ // 结论视图:当前判据下的结论 + 是否过期/撤回/复核不了(面板要如实展示,不许把旧结论当事实)
95
+ compatVerdict: verdict,
86
96
  // 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
97
+ // 或**旧判据的误报已被撤回**(0.5.34:不再要求"版本变化",否则用户被永久钉住)
87
98
  adoptable: adoptable.get(entry.rowId) ?? null,
88
99
  // 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
89
100
  veto,
@@ -111,7 +122,7 @@ async function routeStateGet(req, res, rc) {
111
122
  const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
112
123
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
113
124
  } catch {}
114
- sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
125
+ sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries, compatVerdicts), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null } }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
115
126
  return
116
127
  }
117
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.33",
3
+ "version": "0.5.34",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",