@noob-stupid/dsh-plugin-console 0.5.32 → 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.
@@ -0,0 +1,266 @@
1
+ // L1 · domain —— settings-api-scan.js(「已删除 dsh-settings API」源码扫描;0.5.33 改错时从 compat.js 搬出)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
+ //
4
+ // 为什么单独成模块:判据从"源码里出现过这个名字"改成"这个名字**真的从
5
+ // `@deepseek-ai/dsh-settings` 绑定进来**"——要先剥注释/字符串字面量,再解析 import/require 的
6
+ // 绑定子句,逻辑比原先的按行子串匹配长得多,而 compat.js 已经 544 行(守卫上限 600)。
7
+ // 搬移保持对外契约不变:`REMOVED_SETTINGS_SYMBOLS` / `referencesRemovedSymbol` /
8
+ // `scanSettingsApiUsage` 三个名字照旧由 compat.js 导出。
9
+ //
10
+ // ── 0.5.33 改错(真机误报,证据充分)────────────────────────────────────────────────
11
+ // 现症:`dshmarket` 被判 `check:'fail'`(= 「源码仍引用 0.1.2 起已删除的 dsh-settings API」),
12
+ // 于是**框架升级预扫会把这个好插件自动禁用**。逐条核对它的三处命中:
13
+ // · dshmarket/lib/settings.js:45 / :53 / :73 —— 全在**注释**里;
14
+ // · dshmarket/lib/routes.js:3136 `settingsNamespace: settingsNamespaceState(),` —— 一个
15
+ // **HTTP 载荷对象的属性名**(值是自己的局部函数 `settingsNamespaceState`,与本包无关);
16
+ // · 该包对 `@deepseek-ai/dsh-settings` 的 **import 零命中**(它早已把那两个 helper 内联)。
17
+ // 旧判据(`text.includes(sym)` → v0.3.35 换成标识符边界 + 排除局部定义)挡不住这两类:
18
+ // 注释里的名字与对象键上的名字都不是"引用",但它们都在**代码行**上、也不是本地定义。
19
+ //
20
+ // 新判据(更严格,不放宽任何真引用):
21
+ // ① 先掩掉注释、字符串/模板字面量、正则字面量(等长空格替换,保留换行与列偏移);
22
+ // 唯一保留的是**模块说明符字符串**(`from 'x'` / `require('x')` / `import('x')`),
23
+ // 否则没法知道某个绑定来自哪个包。
24
+ // ② 只在**真的对这个包**的 import/require 绑定子句里取名字:
25
+ // `import { settingsNamespace } from '@deepseek-ai/dsh-settings'`、
26
+ // `import settingsNamespace from …`、`import * as s from …` 后的 `s.settingsNamespace`、
27
+ // `const { settingsNamespace } = require('@deepseek-ai/dsh-settings')`、
28
+ // `const { installSettingsSection } = await import('@deepseek-ai/dsh-settings')`。
29
+ // ③ 对象键、局部同名函数、别的模块的同名导入、注释与字符串里的名字 —— 一律不算。
30
+
31
+ import { readdirSync, readFileSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+
34
+ /** 0.1.2-rc.1 起被官方删除的 dsh-settings API(命中即"实际不兼容")。 */
35
+ const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
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
+
52
+ /** 这些 API 原本来自哪个包(只有真的从它绑定进来的名字才算引用)。 */
53
+ const SETTINGS_MODULE = '@deepseek-ai/dsh-settings'
54
+
55
+ const SPECIFIER_RE = `['"]${SETTINGS_MODULE.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')}['"]`
56
+
57
+ /**
58
+ * 把注释、字符串/模板字面量、正则字面量掩成**等长空格**(换行/回车保留,行号与列偏移不变)。
59
+ * `keepSpecifier` 为真时保留"模块说明符"字符串的原样内容(`from 'x'` / `require('x')` / `import('x')`)。
60
+ *
61
+ * 这是**掩码**而不是删除:后续判据仍然按整份源码做正则,位置对得上才好排查。
62
+ */
63
+ function maskLiterals(text, keepSpecifier) {
64
+ const src = String(text ?? '')
65
+ const out = src.split('')
66
+ const blank = (from, to) => {
67
+ for (let k = from; k < to; k += 1) if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '
68
+ }
69
+ const isWordChar = (ch) => ch !== undefined && /[A-Za-z0-9_$]/u.test(ch)
70
+ const wordBefore = (index) => {
71
+ let j = index
72
+ while (j > 0 && (src[j - 1] === ' ' || src[j - 1] === '\t')) j -= 1
73
+ let k = j
74
+ while (k > 0 && isWordChar(src[k - 1])) k -= 1
75
+ return src.slice(k, j)
76
+ }
77
+ const prevMeaningful = (index) => {
78
+ for (let k = index - 1; k >= 0; k -= 1) {
79
+ const ch = src[k]
80
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue
81
+ return ch
82
+ }
83
+ return ''
84
+ }
85
+ /** 当前位置若是 `word(` 的第一个实参,返回 `word`(用于 require(...) / import(...) 的说明符判定)。 */
86
+ const callWordBefore = (index) => {
87
+ let k = index - 1
88
+ while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
89
+ if (k < 0 || src[k] !== '(') return ''
90
+ k -= 1
91
+ while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
92
+ const end = k + 1
93
+ while (k >= 0 && isWordChar(src[k])) k -= 1
94
+ return src.slice(k + 1, end)
95
+ }
96
+ // 正则字面量的判定沿用仓库既有启发式(tests/test-architecture-guard.mjs 的 stripCode 同款):
97
+ // 前一个有效字符说明这里不可能是除法,或者前一个词是 return/typeof 这类关键字。
98
+ const REGEX_PREV = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '\n', ''])
99
+ const REGEX_KEYWORDS = new Set(['return', 'typeof', 'case', 'in', 'of', 'do', 'else', 'void', 'delete', 'new', 'instanceof', 'yield', 'await'])
100
+ let i = 0
101
+ while (i < src.length) {
102
+ const c = src[i]
103
+ const n = src[i + 1]
104
+ if (c === '/' && n === '/') {
105
+ const end = src.indexOf('\n', i)
106
+ const to = end < 0 ? src.length : end
107
+ blank(i, to)
108
+ i = to
109
+ continue
110
+ }
111
+ if (c === '/' && n === '*') {
112
+ const end = src.indexOf('*/', i + 2)
113
+ const to = end < 0 ? src.length : end + 2
114
+ blank(i, to)
115
+ i = to
116
+ continue
117
+ }
118
+ if (c === "'" || c === '"' || c === '`') {
119
+ let j = i + 1
120
+ while (j < src.length && src[j] !== c) {
121
+ if (src[j] === '\\') j += 1
122
+ j += 1
123
+ }
124
+ const end = j < src.length ? j + 1 : src.length
125
+ // 说明符字符串保留:紧跟在 `from` 之后,或作为 require(...) / import(...) 的第一个实参。
126
+ const asCallArgument = /^(?:require|import)$/u.test(callWordBefore(i))
127
+ const isSpecifier = keepSpecifier === true && c !== '`' && (wordBefore(i) === 'from' || asCallArgument)
128
+ if (!isSpecifier) blank(i + 1, end - 1)
129
+ i = end
130
+ continue
131
+ }
132
+ if (c === '/') {
133
+ const pm = prevMeaningful(i)
134
+ if (REGEX_PREV.has(pm) || REGEX_KEYWORDS.has(wordBefore(i))) {
135
+ let j = i + 1
136
+ let inClass = false
137
+ while (j < src.length) {
138
+ const ch = src[j]
139
+ if (ch === '\\') { j += 2; continue }
140
+ if (ch === '[') inClass = true
141
+ else if (ch === ']') inClass = false
142
+ else if (ch === '/' && !inClass) { j += 1; break }
143
+ else if (ch === '\n') break
144
+ j += 1
145
+ }
146
+ blank(i, j)
147
+ i = j
148
+ continue
149
+ }
150
+ }
151
+ i += 1
152
+ }
153
+ return out.join('')
154
+ }
155
+
156
+ /** 从一个绑定子句里取出**被导入的名字**(`a as b` 取 `a`;默认导入取它自己;`*` 交给命名空间那条路)。 */
157
+ function importedNamesOf(clause) {
158
+ const names = new Set()
159
+ const text = String(clause ?? '')
160
+ const brace = text.match(/\{([^}]*)\}/u)
161
+ if (brace !== null) {
162
+ for (const part of brace[1].split(',')) {
163
+ const seg = part.trim()
164
+ if (seg === '') continue
165
+ const imported = seg.split(/\s+as\s+/u)[0].trim()
166
+ if (/^[A-Za-z_$][\w$]*$/u.test(imported)) names.add(imported)
167
+ }
168
+ }
169
+ const rest = brace === null ? text : text.replace(brace[0], ' ')
170
+ for (const seg of rest.split(',')) {
171
+ const t = seg.trim()
172
+ if (t === '' || t === '*' || /\s/u.test(t)) continue
173
+ if (/^[A-Za-z_$][\w$]*$/u.test(t)) names.add(t)
174
+ }
175
+ return names
176
+ }
177
+
178
+ /**
179
+ * 从源码里取出「真的从 `@deepseek-ai/dsh-settings` 绑定进来的名字」。
180
+ * 返回 `{ named, namespaces }`:`named` 是直接绑定的符号,`namespaces` 是包级命名空间
181
+ * (`import * as s` / `const s = require(…)`),它们的成员访问 `s.settingsNamespace` 也算引用。
182
+ */
183
+ function settingsApiBindings(text) {
184
+ // 掩码只做一次:注释/字符串/正则变空格,模块说明符字符串保留 —— 判据据此认包。
185
+ const code = maskLiterals(text, true)
186
+ const named = new Set()
187
+ const namespaces = new Set()
188
+ // ① ESM 命名空间导入:`import * as s from '<mod>'`
189
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
190
+ namespaces.add(m[1])
191
+ }
192
+ // ② ESM 具名/默认导入:`import { a, b as c } from '<mod>'` / `import d from '<mod>'`
193
+ // 子句里不允许再出现 import/from,长度封顶 2000 —— 挡住"跨到上一条 import 语句"的假匹配。
194
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s+(?!\\*)((?:(?!\\bimport\\b|\\bfrom\\b)[\\s\\S]){0,2000}?)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
195
+ for (const name of importedNamesOf(m[1])) named.add(name)
196
+ }
197
+ // ③ CJS / 动态导入的解构绑定:`const { a } = require('<mod>')` / `= await import('<mod>')`
198
+ const callRe = `(?:require|import)\\s*\\(\\s*${SPECIFIER_RE}\\s*\\)`
199
+ for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
200
+ for (const name of importedNamesOf(`{${m[1]}}`)) named.add(name)
201
+ }
202
+ // ④ CJS / 动态导入的命名空间绑定:`const s = require('<mod>')`
203
+ for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
204
+ namespaces.add(m[1])
205
+ }
206
+ return { named, namespaces }
207
+ }
208
+
209
+ /** 命中的已删除符号列表(空数组 = 干净)。判据见文件头:只有真绑定/真成员访问才算。 */
210
+ function settingsApiReferences(text) {
211
+ const { named, namespaces } = settingsApiBindings(text)
212
+ const found = []
213
+ for (const sym of REMOVED_SETTINGS_SYMBOLS) {
214
+ if (named.has(sym)) { found.push(sym); continue }
215
+ for (const ns of namespaces) {
216
+ if (new RegExp(`(?<![\\w$.])${ns}\\s*\\.\\s*${sym}(?![\\w$])`, 'u').test(maskLiterals(text, true))) { found.push(sym); break }
217
+ }
218
+ }
219
+ return found
220
+ }
221
+
222
+ /** 源码是否**引用**了某个已删除符号(对外契约不变;判据已收紧到"真绑定/真成员访问")。 */
223
+ function referencesRemovedSymbol(text, sym) {
224
+ return settingsApiReferences(text).includes(sym)
225
+ }
226
+
227
+ /**
228
+ * 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用。
229
+ *
230
+ * 2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
231
+ * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
232
+ * = 整个服务启动崩溃。
233
+ *
234
+ * 扫描预算(未改):限深 3 层、上限 120 个文件、单文件读前 400KB。
235
+ */
236
+ function scanSettingsApiUsage(pkgDir) {
237
+ const found = []
238
+ const seen = new Set()
239
+ const walk = (dir, depth) => {
240
+ if (depth > 3) return
241
+ let entries = []
242
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
243
+ for (const entry of entries) {
244
+ const full = join(dir, entry.name)
245
+ if (entry.isDirectory()) {
246
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
247
+ walk(full, depth + 1)
248
+ continue
249
+ }
250
+ if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
251
+ if (seen.has(full)) continue
252
+ seen.add(full)
253
+ if (seen.size > 120) return
254
+ try {
255
+ const text = readFileSync(full, 'utf8').slice(0, 400000)
256
+ for (const sym of settingsApiReferences(text)) {
257
+ if (!found.includes(sym)) found.push(sym)
258
+ }
259
+ } catch {}
260
+ }
261
+ }
262
+ walk(pkgDir, 0)
263
+ return found
264
+ }
265
+
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,9 +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
- sendError(res, 409, `适配校验未通过:${check.reason}`)
331
+ // 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」(旧文案只有一句"适配校验未通过",用户不知道下一步
332
+ // 该做什么);0.5.34:真不兼容的行**一个字都不放宽** —— 仍然 409、仍然零改动(补丁与清单逐字节不变)。
333
+ sendError(res, 409, `适配校验未通过:${String(check.reason ?? '适配校验未通过')}。出路:先点「检测更新」把它更新到兼容当前框架 ${fwVer} 的版本(更新完成会自动重跑源码扫描,通过即解锁);若它已不再需要,可在补丁里删掉/禁用这一行。本次未改动补丁与适配门清单。`)
331
334
  return
332
335
  }
336
+ // 可解锁的两种来路(判据都不放松):① 真的更新过(版本变化)② 旧判据的 fail 已被当前判据撤回
337
+ const view = verdicts.get(rowId) ?? null, rescinded = view?.rescinded === true, changed = version !== null && version !== entry.version
333
338
  await removeDisableBlock(patchPath, rowId)
334
339
  entry.status = 'adopted'
335
340
  entry.adoptedAt = Date.now()
@@ -337,8 +342,9 @@ async function routeAdaptUnlock(req, res, rc) {
337
342
  entry.adoptedFramework = fwVer
338
343
  entry.check = check.decision
339
344
  entry.checkNote = check.reason ?? null
345
+ entry.adoptedBasis = changed ? 'version-changed' : (rescinded ? 'verdict-rescinded' : 'scan-pass')
340
346
  writeCompatPending(pending)
341
- 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 })
342
348
  return
343
349
  }
344
350
 
@@ -355,7 +361,7 @@ async function routeAdaptUnlockAll(req, res, rc) {
355
361
  sendError(res, 400, 'root 无效')
356
362
  return
357
363
  }
358
- const pending = readCompatPending()
364
+ const pending = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
359
365
  const patchPath = findPatchPath(ctx)
360
366
  const profileDir = dirname(patchPath)
361
367
  // v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
@@ -523,6 +529,13 @@ async function routeCleanResiduals(req, res, rc) {
523
529
  extra.dryRun = dryRun
524
530
  }
525
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
+ }
526
539
  if (failed.length > 0) {
527
540
  const detail = failed.slice(0, 3).map((f) => `${f.name}(${f.error ?? '未知原因'})`).join(';')
528
541
  const keptNote = kept.length > 0 ? `;已在用插件 ${kept.length} 个(保留不删)` : ''
@@ -3,9 +3,10 @@
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
- import { detectFrameworkUpgrade } from '../domain/framework.js'
8
+ import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
9
+ import { peerVetoFor } from '../domain/peer-veto.js'
9
10
  import { readExtraBundleRows } from '../domain/install-job.js'
10
11
  import { installJobView, readGithubAuth } from '../domain/install.js'
11
12
  import { readPluginDetails } from '../domain/market.js'
@@ -46,7 +47,12 @@ async function routeStateGet(req, res, rc) {
46
47
  setPatchHealReport(patchHeal)
47
48
  }
48
49
  const extraRows = await readExtraBundleRows(dirname(patchPath))
49
- const compatPending = readCompatPending()
50
+ const profileDir = dirname(patchPath)
51
+ // 0.5.34 加法(**读取代发**):给 profileDir ⇒ 判据戳落后的记录用当前判据只读重算,
52
+ // 结论变了才回写(幂等:结论不变**零写盘**)。真不兼容的重算仍 fail,一个字都不动。
53
+ const pendingState = readCompatPendingWithVerdicts({ baseDir: profileDir })
54
+ const compatPending = pendingState.pending
55
+ const compatVerdicts = pendingState.verdicts
50
56
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
51
57
  // 兼容门总开关 + 自动检测(只提示不自动开)
52
58
  const compatGate = readCompatGate()
@@ -58,11 +64,22 @@ async function routeStateGet(req, res, rc) {
58
64
  rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
59
65
  }
60
66
  } catch {}
67
+ // 0.5.33 加法 D-⑧(诊断缺口):**补丁要求启用、运行时却没挂载**(fiberPhase === null)的行,
68
+ // 本地复算框架启动时的 peer 预检,补出短句原因 + 出路。框架那一侧只在**内存里**把
69
+ // row.disabled 置 true(不写用户补丁)、原因只 process.stderr.write —— 面板此前一个字都看不出。
70
+ // 判据与 dsh-app-boot 的 evaluatePluginCompatibility / preflight 对齐(见 domain/peer-veto.js)。
71
+ const vetoFrameworkVersion = currentFrameworkVersion(ctx)
61
72
  const entries = listEntries(ctx).map((entry) => {
62
73
  const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
74
+ const disabledByPatch = patch.disables.includes(entry.rowId)
75
+ let veto = null
76
+ if (!disabledByPatch && entry.fiberPhase === null && vetoFrameworkVersion !== null) {
77
+ try { veto = peerVetoFor({ moduleName: entry.moduleName, profileDir, frameworkVersion: vetoFrameworkVersion }) } catch { veto = null }
78
+ }
79
+ const verdict = compatVerdicts.get(entry.rowId) ?? null
63
80
  return {
64
81
  ...entry,
65
- userDisabled: patch.disables.includes(entry.rowId),
82
+ userDisabled: disabledByPatch,
66
83
  userForced: patch.forced.includes(entry.rowId),
67
84
  extra: patch.inserts.includes(entry.rowId) || extraRows.has(entry.moduleName) || extraRows.has(entry.rowId),
68
85
  installDate: meta?.installDate ?? null,
@@ -70,9 +87,17 @@ async function routeStateGet(req, res, rc) {
70
87
  repository: meta?.repository ?? null,
71
88
  // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
72
89
  // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
73
- pendingCompat: compatPendingRows.has(entry.rowId) && patch.disables.includes(entry.rowId),
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,
74
96
  // 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
97
+ // 或**旧判据的误报已被撤回**(0.5.34:不再要求"版本变化",否则用户被永久钉住)
75
98
  adoptable: adoptable.get(entry.rowId) ?? null,
99
+ // 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
100
+ veto,
76
101
  }
77
102
  })
78
103
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
@@ -97,7 +122,7 @@ async function routeStateGet(req, res, rc) {
97
122
  const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
98
123
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
99
124
  } catch {}
100
- 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 })) })
101
126
  return
102
127
  }
103
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.32",
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",