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

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.
@@ -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 }
@@ -6,6 +6,7 @@ import { writeFile } from 'node:fs/promises'
6
6
  import { resolve } from 'node:path'
7
7
  import { readGithubAuth } from '../domain/install.js'
8
8
  import { enrichItems, fetchRepoPackage, fetchSubpackageNames, githubRepoInfo, hasDirectNameHit, normalizePlatformItems, searchNpmPackages, searchSubpackageItems } from '../domain/market.js'
9
+ import { buildPageEnvelope, rankSearchResults } from '../domain/market-search.js'
9
10
  import { SKILL_TOPICS, detectSkillRepo, fetchSkillMeta } from '../domain/skills.js'
10
11
  import { DEFAULT_SEARCH, gitCloneUrls, orderedRegistries, readGiteeConfig, readSources } from '../domain/sources.js'
11
12
  import { GITHUB_API, META_BUDGET_MS, curlJson, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
@@ -13,19 +14,48 @@ import { sendError, sendJson } from '../infra/httpd.js'
13
14
  import { marketIndexCacheFile } from '../infra/paths.js'
14
15
  import { marketIndexCache, setMarketIndexCache } from '../state.js'
15
16
 
17
+ /** 技能检索的 per_page:三 topic 合并去重后不一定还有 20 条,取少了下拉就白丢条目
18
+ * (旧行为每 topic 20 条再截前 20 ⇒ 点「加载更多」也翻不出新东西)。 */
19
+ const SKILL_TOPIC_PER_PAGE = 50
20
+ /** 技能池缓存:同一个查询的**同一份合并池**重复切片 ⇒ 翻页幂等(不重不漏)。
21
+ * 不进 state.js:这是路由层的短命缓存(与静态索引缓存不是一回事)。 */
22
+ const SKILL_POOL_TTL_MS = 300000
23
+ const skillPoolCache = new Map()
24
+ function skillPoolCacheGet(key) {
25
+ const hit = skillPoolCache.get(key)
26
+ if (hit === undefined) return null
27
+ if (Date.now() - hit.at > SKILL_POOL_TTL_MS) { skillPoolCache.delete(key); return null }
28
+ return hit.items
29
+ }
30
+ function skillPoolCacheSet(key, items) {
31
+ if (skillPoolCache.size >= 8) skillPoolCache.delete(skillPoolCache.keys().next().value)
32
+ skillPoolCache.set(key, { at: Date.now(), items })
33
+ }
34
+
16
35
  async function routeSearch(req, res, rc) {
17
36
  const DEFAULT_SEARCH = rc.deps.DEFAULT_SEARCH
37
+ // 网络取数的注入缝(默认就是真通道):测试要断言"排序/分页/失败路径"时喂固定数据,
38
+ // 免得把判据与 CI 稳定性绑在网络可用性上(与 routes/index.js 的 deps 同一种风格)。
39
+ const fetchJson = typeof rc.deps?.fetchJson === 'function' ? rc.deps.fetchJson : fetchJsonUrl
40
+ const searchRepos = typeof rc.deps?.githubJson === 'function' ? rc.deps.githubJson : githubJson
18
41
  const ctx = rc.ctx
19
42
  const pathname = rc.pathname
20
43
  const method = rc.method
21
44
  const body = rc.body
22
45
  const raw = typeof body.q === 'string' ? body.q.trim() : ''
23
46
  const query = raw === '' ? DEFAULT_SEARCH : raw
24
- const page = Math.max(Math.min(Number.parseInt(String(body.page), 10) || 1, 5), 1)
47
+ // 0.5.35 改错:旧代码把 page **夹到 1..5** —— 第 6 次「加载更多」起原地返回第 5 页,
48
+ // 用户看到的是"怎么点都不出新条目"(与本次"没有加载更多"同族的第二个截断层)。
49
+ // 现在只做合法性下限(≥1),真实可翻页性由 buildPageEnvelope 依据数据源的 total 如实回报。
50
+ const page = Math.max(Number.parseInt(String(body.page), 10) || 1, 1)
25
51
  const source = typeof body.source === 'string' && body.source !== '' ? body.source : 'github'
26
52
  const all = body.all === true
27
53
  const auth = readGithubAuth()
28
54
  let items = []
55
+ // 数据源报的**总命中数**(GitHub search 的 total_count;自定义源没有就留 null —— 绝不编造)
56
+ let total = null
57
+ // in:readme 重查那一层有没有被自己的 40 条上限截断(说清楚,别让用户以为"就是没有")
58
+ let readmeTruncated = false
29
59
  // 给前端的补充说明(如"代码搜索需登录")——放响应里,前端据此给提示,不改 items 结构
30
60
  const extraNotes = {}
31
61
  // 增量检索(前端浏览器直连成功时**并行**调用):只补「npm 包名映射 + in:readme 重查 + 代码搜索子包」,
@@ -36,7 +66,7 @@ async function routeSearch(req, res, rc) {
36
66
  if (raw !== '') {
37
67
  try { extra.push(...await searchNpmPackages(raw, orderedRegistries(readSources()), 3, auth.token)) } catch {}
38
68
  try {
39
- const again = await githubJson(
69
+ const again = await searchRepos(
40
70
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${raw} in:name,description,readme${all ? '' : ' topic:dsh-plugin'}`)}&sort=stars&order=desc&per_page=20&page=1`,
41
71
  req.signal,
42
72
  auth.token,
@@ -48,7 +78,8 @@ async function routeSearch(req, res, rc) {
48
78
  sendJson(res, 200, {
49
79
  ok: true,
50
80
  query: raw,
51
- items: extra,
81
+ // 增量结果同样过**唯一**排序函数:客户端拿到后只需合并,不需要(也不许)自己另排一份
82
+ items: rankSearchResults(extra, raw),
52
83
  extras: true,
53
84
  authenticated: auth.loggedIn,
54
85
  source: 'github',
@@ -63,11 +94,12 @@ async function routeSearch(req, res, rc) {
63
94
  const tasks = [
64
95
  (async () => {
65
96
  try {
66
- const data = await githubJson(
97
+ const data = await searchRepos(
67
98
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
68
99
  req.signal,
69
100
  auth.token,
70
101
  )
102
+ if (typeof data?.total_count === 'number') total = data.total_count
71
103
  return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', sourceName: 'GitHub' }))
72
104
  } catch {
73
105
  return []
@@ -76,7 +108,7 @@ async function routeSearch(req, res, rc) {
76
108
  ...sources.searchSources.filter((s) => s.type === 'custom').map((s) => (async () => {
77
109
  try {
78
110
  const url = s.url.replace('{q}', encodeURIComponent(query)).replace('{page}', String(page))
79
- const data = await fetchJsonUrl(url, 15000, s.headers ?? {})
111
+ const data = await fetchJson(url, 15000, s.headers ?? {})
80
112
  return normalizePlatformItems(data, 'main').map((item) => ({ ...item, source: s.id, sourceName: s.name }))
81
113
  } catch {
82
114
  return []
@@ -86,40 +118,63 @@ async function routeSearch(req, res, rc) {
86
118
  const results = await Promise.all(tasks)
87
119
  items = results.flat()
88
120
  items = await enrichItems(items)
89
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source: 'all', multi: true })
121
+ // 多源合并后**只排一次**(唯一排序函数):跨源条目按同一套名字分层,同层才比 star
122
+ items = rankSearchResults(items, raw)
123
+ sendJson(res, 200, {
124
+ ok: true, query, items, authenticated: auth.loggedIn, source: 'all', multi: true,
125
+ ...buildPageEnvelope({ shown: items.length, total, page }),
126
+ })
90
127
  return
91
128
  }
92
129
  if (body.skills === true) {
93
- // 技能模式搜索:agent-skills / claude-skills / dsh-skill 三 topic 并行检索后合并去重
94
- // (GitHub search 的 OR 语法优先级不可靠,分开查最稳),按 star 排序取前 20。
130
+ // 技能模式搜索:agent-skills / claude-skills / dsh-skill 三 topic 并行检索后合并去重。
131
+ // (GitHub search 的 OR 语法优先级不可靠,分开查最稳。)
132
+ // 0.5.35 改错:旧行为「取前 20 条」把合并池**截断**在 20 —— 客户端点「加载更多」请求下一页,
133
+ // 服务端仍从**每个 topic 的第 N 页**取 20 条再截前 20,去重后经常一条新的都没有(按钮白点)。
134
+ // 现在:合并池按唯一排序函数排序 + 去重,然后**照分页口径切片**;并集池按查询缓存,
135
+ // 同一查询的任何一页都从同一份池里切 ⇒ 翻页幂等(不重不漏)。
95
136
  if (source !== 'github') {
96
137
  sendError(res, 400, '技能搜索仅支持 GitHub 源')
97
138
  return
98
139
  }
99
140
  const keyword = raw === '' ? '' : `${raw} in:name,description,topics `
100
- const tasks = SKILL_TOPICS.map((topic) => (async () => {
101
- try {
102
- const data = await githubJson(
103
- `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${keyword}topic:${topic}`)}&sort=stars&order=desc&per_page=20&page=${page}`,
104
- req.signal,
105
- auth.token,
106
- )
107
- return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', skillTopics: [topic] }))
108
- } catch {
109
- return []
141
+ const poolKey = `${raw}\u0000${all ? '1' : '0'}`
142
+ let pool = skillPoolCacheGet(poolKey)
143
+ if (pool === null) {
144
+ const tasks = SKILL_TOPICS.map((topic) => (async () => {
145
+ try {
146
+ const data = await searchRepos(
147
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${keyword}topic:${topic}`)}&sort=stars&order=desc&per_page=${SKILL_TOPIC_PER_PAGE}&page=1`,
148
+ req.signal,
149
+ auth.token,
150
+ )
151
+ if (typeof data?.total_count === 'number') total = (total ?? 0) + data.total_count
152
+ return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', skillTopics: [topic] }))
153
+ } catch {
154
+ return []
155
+ }
156
+ })())
157
+ const merged = (await Promise.all(tasks)).flat()
158
+ const seen = new Set()
159
+ const deduped = []
160
+ for (const item of rankSearchResults(merged, raw)) {
161
+ if (seen.has(item.fullName)) continue
162
+ seen.add(item.fullName)
163
+ deduped.push(item)
110
164
  }
111
- })())
112
- const merged = (await Promise.all(tasks)).flat()
113
- const seen = new Set()
114
- items = []
115
- for (const item of merged.sort((a, b) => b.stars - a.stars)) {
116
- if (seen.has(item.fullName)) continue
117
- seen.add(item.fullName)
118
- items.push(item)
119
- if (items.length >= 20) break
165
+ pool = deduped
166
+ skillPoolCacheSet(poolKey, pool)
120
167
  }
168
+ const perPage = 20
169
+ const start = (page - 1) * perPage
170
+ items = pool.slice(start, start + perPage)
121
171
  items = await enrichItems(items)
122
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source: 'github', skills: true })
172
+ sendJson(res, 200, {
173
+ ok: true, query, items, authenticated: auth.loggedIn, source: 'github', skills: true,
174
+ // 技能池的真实总量(三 topic 并集去重后的条数),不是 GitHub 的 total_count 之和
175
+ // (三个 topic 之间有重叠,直接相加会把"共 N 条"报大)
176
+ ...buildPageEnvelope({ shown: items.length, total: pool.length, page, perPage }),
177
+ })
123
178
  return
124
179
  }
125
180
  if (source === 'gitee') {
@@ -140,13 +195,16 @@ async function routeSearch(req, res, rc) {
140
195
  // repo 已由 githubRepoInfo 校验;分段编码(只编码中文等非 ASCII,斜杠保留原样——
141
196
  // Gitee 服务器不认 %2F 编码的路径分隔,返回 404)
142
197
  const [owner, name] = repo.split('/')
143
- const data = await fetchJsonUrl(`https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${tokenQ}`)
198
+ const data = await fetchJson(`https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${tokenQ}`)
144
199
  items = normalizePlatformItems([data], 'master').map((item) => ({ ...item, source: 'gitee' }))
145
200
  } catch (error) {
146
201
  giteeError = error instanceof Error ? error.message : String(error)
147
202
  }
148
203
  }
149
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source, giteeNeedsLogin: false, directOnly: true, giteeError })
204
+ sendJson(res, 200, {
205
+ ok: true, query, items, authenticated: auth.loggedIn, source, giteeNeedsLogin: false, directOnly: true, giteeError,
206
+ ...buildPageEnvelope({ shown: items.length, total, page }),
207
+ })
150
208
  return
151
209
  } else if (source !== 'github') {
152
210
  // 自定义搜索源:URL 模板({q}/{page} 占位符),返回数组或 {items} 结构;支持配置的请求头
@@ -159,7 +217,7 @@ async function routeSearch(req, res, rc) {
159
217
  const url = custom.url
160
218
  .replace('{q}', encodeURIComponent(query))
161
219
  .replace('{page}', String(page))
162
- const data = await fetchJsonUrl(url, 15000, custom.headers ?? {})
220
+ const data = await fetchJson(url, 15000, custom.headers ?? {})
163
221
  items = normalizePlatformItems(data, 'main').map((item) => ({ ...item, source, sourceName: custom.name }))
164
222
  } else {
165
223
  // npm 包名搜索与 GitHub 仓库搜索**并行**:npm 侧要查 registry 搜索 + packument + 仓库元数据
@@ -167,12 +225,13 @@ async function routeSearch(req, res, rc) {
167
225
  const npmPromise = raw !== '' && !body.skills
168
226
  ? searchNpmPackages(raw, orderedRegistries(readSources()), 3, auth.token).catch(() => [])
169
227
  : Promise.resolve([])
170
- const data = await githubJson(
228
+ const data = await searchRepos(
171
229
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
172
230
  req.signal,
173
231
  auth.token,
174
232
  )
175
233
  items = normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github' }))
234
+ if (typeof data?.total_count === 'number') total = data.total_count
176
235
  items = await enrichItems(items)
177
236
  // monorepo 子包增强(OpenViking/examples/dsh-memory-plugin 等可按子包名搜到;
178
237
  // 代码搜索需登录,未登录时该函数返回空数组,见 domain/market.js 说明)
@@ -188,7 +247,7 @@ async function routeSearch(req, res, rc) {
188
247
  // 首屏没有"名字逐词命中"的条目时,用 in:name,description,readme 再查一次(未登录也能用)。
189
248
  if (raw !== '' && !body.skills && page === 1 && !hasDirectNameHit(items, raw)) {
190
249
  try {
191
- const again = await githubJson(
250
+ const again = await searchRepos(
192
251
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${raw} in:name,description,readme${all ? '' : ' topic:dsh-plugin'}`)}&sort=stars&order=desc&per_page=20&page=1`,
193
252
  req.signal,
194
253
  auth.token,
@@ -196,7 +255,9 @@ async function routeSearch(req, res, rc) {
196
255
  for (const it of normalizePlatformItems(again.items ?? [], 'main')) {
197
256
  if (items.some((x) => x.fullName === it.fullName)) continue
198
257
  items.push({ ...it, source: 'github', viaReadme: true })
199
- if (items.length >= 40) break
258
+ // 截断上限 40 是**既有**的检索面口径(不改"谁被搜出来"):只影响 in:readme 重查这一路,
259
+ // 且被截断这件事由下面的 envelope 如实告知(truncated/reason),不再静默。
260
+ if (items.length >= 40) { readmeTruncated = true; break }
200
261
  }
201
262
  } catch {}
202
263
  }
@@ -217,7 +278,19 @@ async function routeSearch(req, res, rc) {
217
278
  if (raw !== '' && !body.skills && !auth.loggedIn) extraNotes.codeSearchSkipped = true
218
279
  }
219
280
  items = items.filter((item) => item.fullName !== '')
220
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source, ...(extraNotes) })
281
+ // ★ 唯一一处排序 ★:所有搜索链(github 多路合并 / multi 多源 / custom / gitee)在**这里**统一
282
+ // 过同一个名字分层函数。上一版各分支自己排/自己截(skills 还截前 20),才会有"点两次结果不同"
283
+ // 与"高星无关仓库压过逐字命中"。
284
+ items = rankSearchResults(items, raw)
285
+ // 分页/截断如实回报:客户端据此决定「加载更多」是否可点、要不要明说"已截断"。
286
+ const envelope = buildPageEnvelope({ shown: items.length, total, page })
287
+ sendJson(res, 200, {
288
+ ok: true, query, items, authenticated: auth.loggedIn, source,
289
+ ...envelope,
290
+ // 自己那一层 40 条上限截断了 → 明说(否则用户以为"就是没有";翻页也解不了这一层)
291
+ ...(readmeTruncated ? { truncated: true, reason: envelope.reason !== null ? `${envelope.reason};另:README 重查层已取满 40 条` : 'README 重查层已取满 40 条(可换更精确的关键词缩小范围)' } : {}),
292
+ ...(extraNotes),
293
+ })
221
294
  return
222
295
  }
223
296
 
@@ -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.35",
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",