@noob-stupid/dsh-plugin-console 0.5.97 → 0.5.99
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 +368 -6
- package/lib/server/domain/compat-pending-records.js +355 -0
- package/lib/server/domain/compat-scan.js +106 -0
- package/lib/server/domain/framework-cleanup.js +63 -28
- package/lib/server/domain/framework-stale-repair.js +23 -8
- package/lib/server/domain/framework-stale-restore.js +215 -0
- package/lib/server/infra/paths.js +32 -3
- package/lib/server/routes/compat-scan.js +123 -0
- package/lib/server/routes/compat.js +90 -1
- package/lib/server/routes/index.js +19 -2
- package/lib/server/routes/state.js +31 -1
- package/package.json +1 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// L1 · domain —— compat-pending-records.js(「真待适配」判据的**唯一真源** + 记录删除的写盘纪律)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// ── 真机背景(2026-10-28,用户实测)────────────────────────────────────────────────
|
|
5
|
+
// `~/.dsh/plugin-console/compat-pending.json` 自 2026-10-04 之后就没再被写过,里面 24 条记录:
|
|
6
|
+
// · 20 条 `status:"adopted"`(已适配解锁 —— 历史,不是待办);
|
|
7
|
+
// · 3 条 `status:"pending"` 且 `moduleName: null`(web-ui-describe-image / web-ui-doctor /
|
|
8
|
+
// web-ui-better-sidebar)—— **对应的补丁行早已不存在**,永远不可能被适配;
|
|
9
|
+
// · 全部 `check:"unknown"`。
|
|
10
|
+
// 结果门控面板照旧把它们算成"待适配的行",用户看到"有 N 行待适配",其实**一行都没有**。
|
|
11
|
+
//
|
|
12
|
+
// 两处根因,本模块各治一处:
|
|
13
|
+
// ① **计数口径错**:数的是 `pending` 数组长度,而不是"status=pending 且该行仍在补丁里"。
|
|
14
|
+
// → `isActionablePending()` / `countPendingRecords()` 是唯一判据,路由与面板都只认它。
|
|
15
|
+
// ② **记录从不清理**:adopted 与"行已消失"的记录永久留在文件里。
|
|
16
|
+
// → `planForgetRecord()` / `planForgetStale()` + `applyPendingForget()` 提供删除(只删记录)。
|
|
17
|
+
//
|
|
18
|
+
// ── 三条硬边界(不许放宽)──────────────────────────────────────────────────────────
|
|
19
|
+
// · 「行是否存在」只取**补丁解析的既有产物**(`readPatchState()` 的 disables / forced / inserts,
|
|
20
|
+
// 见 domain/patch.js)—— 这里不另写一套正则,避免两套判据分叉(本仓已为分叉付过代价)。
|
|
21
|
+
// · 删除**只动 compat-pending.json**:不动插件、不动补丁、不动 bundles 清单。
|
|
22
|
+
// 「清理无效记录」只清 stale(status=pending 且行已不存在);真 pending 与 adopted **保留**。
|
|
23
|
+
// · 写盘:备份 → 原子写(临时文件 + rename)→ **读回核实** → 失败回滚;坏文件**拒绝覆盖**
|
|
24
|
+
// (那是用户的文件,覆盖等于丢数据),读侧则降级为空结构并如实报原因。
|
|
25
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { compatPendingFile } from './compat-verdict.js'
|
|
27
|
+
|
|
28
|
+
/** 适配门记录里"待办"这一侧的状态字(其它一切状态都算历史)。 */
|
|
29
|
+
const PENDING_STATUS = 'pending'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 当前补丁里**真实存在**的行 id 集合(唯一真源:`readPatchState()` 的解析结果)。
|
|
33
|
+
* 三个来源都是"这一行在补丁里有位置"的等价证据:
|
|
34
|
+
* inserts —— 本控制台插入的启用行;disables / forced —— 针对该行的禁用/强制覆盖块。
|
|
35
|
+
* @param {{disables?: string[], forced?: string[], inserts?: string[]}|null} patchState
|
|
36
|
+
* @returns {Set<string>}
|
|
37
|
+
*/
|
|
38
|
+
function liveRowIdsFromPatch(patchState) {
|
|
39
|
+
const out = new Set()
|
|
40
|
+
if (patchState === null || typeof patchState !== 'object') return out
|
|
41
|
+
for (const key of ['disables', 'forced', 'inserts']) {
|
|
42
|
+
const list = Array.isArray(patchState[key]) ? patchState[key] : []
|
|
43
|
+
for (const id of list) if (typeof id === 'string' && id !== '') out.add(id)
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusOf = (record) => (typeof record?.status === 'string' && record.status !== '' ? record.status : PENDING_STATUS)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 「真待适配」判据(**纯函数、唯一真源**):status 仍是 pending,**且**该行此刻还在补丁里。
|
|
52
|
+
* 行已不存在 ⇒ 这条记录永远等不到"适配"(它的行都不在了),所以它不是待办。
|
|
53
|
+
*/
|
|
54
|
+
function isActionablePending(record, liveRowIds) {
|
|
55
|
+
if (record === null || typeof record !== 'object') return false
|
|
56
|
+
if (statusOf(record) !== PENDING_STATUS) return false
|
|
57
|
+
if (typeof record.rowId !== 'string' || record.rowId === '') return false
|
|
58
|
+
return liveRowIds instanceof Set && liveRowIds.has(record.rowId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 「失效记录」判据:记录写着待适配,但它的补丁行已经不在了(用户眼前那条"删不掉的待适配")。
|
|
63
|
+
* 注意:adopted 记录**不算** stale —— 那是历史,用户明确要求保留、只在历史区展示。
|
|
64
|
+
*/
|
|
65
|
+
function isStalePending(record, liveRowIds) {
|
|
66
|
+
if (record === null || typeof record !== 'object') return false
|
|
67
|
+
if (statusOf(record) !== PENDING_STATUS) return false
|
|
68
|
+
if (typeof record.rowId !== 'string' || record.rowId === '') return false
|
|
69
|
+
return !(liveRowIds instanceof Set) || !liveRowIds.has(record.rowId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 计数(面板/接口只认这三个数):真待适配 / 已失效 / 历史已适配。
|
|
74
|
+
* @returns {{ totalCount: number, actionableCount: number, staleCount: number, adoptedCount: number }}
|
|
75
|
+
*/
|
|
76
|
+
function countPendingRecords(records, liveRowIds) {
|
|
77
|
+
const list = Array.isArray(records) ? records : []
|
|
78
|
+
let actionableCount = 0
|
|
79
|
+
let staleCount = 0
|
|
80
|
+
let adoptedCount = 0
|
|
81
|
+
for (const rec of list) {
|
|
82
|
+
if (rec === null || typeof rec !== 'object') continue
|
|
83
|
+
if (statusOf(rec) !== PENDING_STATUS) { adoptedCount += 1; continue }
|
|
84
|
+
if (isActionablePending(rec, liveRowIds)) actionableCount += 1
|
|
85
|
+
else staleCount += 1
|
|
86
|
+
}
|
|
87
|
+
return { totalCount: list.length, actionableCount, staleCount, adoptedCount }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 给门控明细行补两个**只增不改**的字段(面板据此如实标注"该行已不存在"并给删除入口):
|
|
92
|
+
* rowMissing —— 这一行的 rowId 已不在当前补丁里(null/空 rowId 也算 missing:无所指)
|
|
93
|
+
* actionable —— 与 `isActionablePending` 同一个判据(pending 且行还在)
|
|
94
|
+
*/
|
|
95
|
+
function annotateGateRows(rows, liveRowIds) {
|
|
96
|
+
const list = Array.isArray(rows) ? rows : []
|
|
97
|
+
return list.map((row) => {
|
|
98
|
+
const hasRow = typeof row?.rowId === 'string' && row.rowId !== '' && liveRowIds instanceof Set && liveRowIds.has(row.rowId)
|
|
99
|
+
return { ...row, rowMissing: !hasRow, actionable: isActionablePending(row, liveRowIds) }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读记录文件(**只读**)。坏文件/空文件按"没有内容"处理,但**把原因如实带出来**:
|
|
105
|
+
* 调用方(写动作)据此拒绝覆盖,界面据此如实显示"读不出来,所以没有可清理的记录"。
|
|
106
|
+
* @returns {{ file: string, exists: boolean, corrupt: boolean, raw: object|null, records: object[], error: string|null }}
|
|
107
|
+
*/
|
|
108
|
+
function readPendingRecords(file = compatPendingFile()) {
|
|
109
|
+
const empty = { file, exists: false, corrupt: false, raw: null, records: [], error: null }
|
|
110
|
+
if (typeof file !== 'string' || file === '') return { ...empty, corrupt: true, error: '拿不到记录文件路径' }
|
|
111
|
+
if (!existsSync(file)) return empty
|
|
112
|
+
let text = null
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(file, 'utf8')
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ...empty, exists: true, corrupt: true, error: `读取失败:${String(error?.message ?? error)}` }
|
|
117
|
+
}
|
|
118
|
+
// 空文件(含只有空白 / BOM 的)按**没有内容**处理:它不是坏文件 ⇒ 读侧降级为空结构,
|
|
119
|
+
// 写侧照旧"没有可删的记录"(绝不为了删一条记录就把空文件改写成别的形状)。
|
|
120
|
+
if (text.replace(/^\uFEFF/u, '').trim() === '') return { ...empty, exists: true, empty: true, raw: null, records: [] }
|
|
121
|
+
let raw = null
|
|
122
|
+
try {
|
|
123
|
+
raw = JSON.parse(text)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { ...empty, exists: true, corrupt: true, error: `不是合法 JSON:${String(error?.message ?? error)}` }
|
|
126
|
+
}
|
|
127
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
128
|
+
return { ...empty, exists: true, corrupt: true, error: '内容不是 JSON 对象' }
|
|
129
|
+
}
|
|
130
|
+
const records = (Array.isArray(raw.pending) ? raw.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
131
|
+
return { file, exists: true, corrupt: false, raw, records, error: null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 写动作的公共前置校验:坏文件**拒绝覆盖**(保住用户数据),并给出可读原因。 */
|
|
135
|
+
function refuseIfUnsafe(read) {
|
|
136
|
+
if (read.exists !== true) return { ok: false, status: 404, error: `${read.file} 不存在(没有可删的记录)` }
|
|
137
|
+
if (read.corrupt === true) {
|
|
138
|
+
return { ok: false, status: 409, error: `${read.file} 读不出来(${read.error ?? '格式非法'});为保住你的数据,本动作已停止(请先手工修好或删掉它)` }
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 规划「删掉某一条记录」(**纯函数,不写盘**)。
|
|
145
|
+
* 不存在的 rowId ⇒ 404 + 明确原因(绝不静默成功)。
|
|
146
|
+
*/
|
|
147
|
+
function planForgetRecord({ read, rowId } = {}) {
|
|
148
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, removedRowIds: [], keptRowIds: [] }
|
|
149
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
150
|
+
const refused = refuseIfUnsafe(read)
|
|
151
|
+
if (refused !== null) return { ...base, ...refused }
|
|
152
|
+
if (typeof rowId !== 'string' || rowId === '') return { ...base, error: '缺少 rowId(要删哪一条?)' }
|
|
153
|
+
const index = read.records.findIndex((r) => r.rowId === rowId)
|
|
154
|
+
if (index < 0) return { ...base, status: 404, error: `没有 rowId 为 ${rowId} 的记录(文件未改动)` }
|
|
155
|
+
const kept = read.records.filter((_, i) => i !== index)
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
status: 200,
|
|
159
|
+
error: null,
|
|
160
|
+
file: read.file,
|
|
161
|
+
next: { ...read.raw, pending: kept },
|
|
162
|
+
changed: true,
|
|
163
|
+
removedRowIds: [rowId],
|
|
164
|
+
keptRowIds: kept.map((r) => r.rowId).filter((id) => typeof id === 'string'),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 规划「清掉所有失效记录」(**纯函数,不写盘**):只删 status=pending 且行已不存在的;
|
|
170
|
+
* 真 pending 与 adopted 一条都不动。没有可清的 ⇒ `changed:false`(**零写盘**,不是错误)。
|
|
171
|
+
*/
|
|
172
|
+
function planForgetStale({ read, liveRowIds } = {}) {
|
|
173
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, removedRowIds: [], keptRowIds: [], staleCount: 0 }
|
|
174
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
175
|
+
const refused = refuseIfUnsafe(read)
|
|
176
|
+
if (refused !== null) return { ...base, ...refused }
|
|
177
|
+
const stale = read.records.filter((r) => isStalePending(r, liveRowIds))
|
|
178
|
+
if (stale.length === 0) {
|
|
179
|
+
return { ...base, ok: true, status: 200, changed: false, keptRowIds: read.records.map((r) => r.rowId).filter((id) => typeof id === 'string'), staleCount: 0 }
|
|
180
|
+
}
|
|
181
|
+
const staleIds = new Set(stale.map((r) => r.rowId))
|
|
182
|
+
const kept = read.records.filter((r) => !staleIds.has(r.rowId))
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 200,
|
|
186
|
+
error: null,
|
|
187
|
+
file: read.file,
|
|
188
|
+
next: { ...read.raw, pending: kept },
|
|
189
|
+
changed: true,
|
|
190
|
+
removedRowIds: [...staleIds],
|
|
191
|
+
keptRowIds: kept.map((r) => r.rowId).filter((id) => typeof id === 'string'),
|
|
192
|
+
staleCount: stale.length,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 执行写入:备份 → 原子写(临时文件 + rename)→ **读回核实** → 任何一步不过就回滚并如实报错。
|
|
198
|
+
* `deps` 全是注入缝(写失败 / 读回不符的单测靠它,生产路径不传 ⇒ 真实 fs)。
|
|
199
|
+
* @returns 计划的字段 + `{ backup, verified, rolledBack }`
|
|
200
|
+
*/
|
|
201
|
+
function applyPendingForget(plan, deps = {}) {
|
|
202
|
+
if (plan === null || typeof plan !== 'object' || plan.ok !== true || plan.changed !== true) return plan
|
|
203
|
+
const io = {
|
|
204
|
+
copy: deps.copyFileSync ?? copyFileSync,
|
|
205
|
+
write: deps.writeFileSync ?? writeFileSync,
|
|
206
|
+
rename: deps.renameSync ?? renameSync,
|
|
207
|
+
remove: deps.unlinkSync ?? unlinkSync,
|
|
208
|
+
exists: deps.existsSync ?? existsSync,
|
|
209
|
+
read: deps.readFileSync ?? readFileSync,
|
|
210
|
+
}
|
|
211
|
+
const { file, next } = plan
|
|
212
|
+
const stamp = Date.now()
|
|
213
|
+
const backup = `${file}.bak-forget-${stamp}`
|
|
214
|
+
const tmp = `${file}.tmp-forget-${stamp}`
|
|
215
|
+
const rollback = (extra) => {
|
|
216
|
+
try { if (io.exists(backup)) io.copy(backup, file) } catch {}
|
|
217
|
+
try { if (io.exists(tmp)) io.remove(tmp) } catch {}
|
|
218
|
+
return { ...plan, ...extra, backup, verified: false, rolledBack: io.exists(backup) }
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
if (io.exists(file)) io.copy(file, backup)
|
|
222
|
+
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
223
|
+
io.rename(tmp, file)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return rollback({ ok: false, status: 500, error: `写入 ${file} 失败:${String(error?.message ?? error)}(已回滚,文件保持原样)` })
|
|
226
|
+
}
|
|
227
|
+
// 读回核实:删掉的那条必须真的不在、保留的每一条必须还在 —— "写了但没生效"是最坏的一种假绿
|
|
228
|
+
let back = null
|
|
229
|
+
try {
|
|
230
|
+
back = JSON.parse(io.read(file, 'utf8'))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return rollback({ ok: false, status: 500, error: `写入后读回失败:${String(error?.message ?? error)}(已回滚)` })
|
|
233
|
+
}
|
|
234
|
+
const rows = (Array.isArray(back?.pending) ? back.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
235
|
+
const stillThere = plan.removedRowIds.filter((id) => rows.some((r) => r.rowId === id))
|
|
236
|
+
const lost = plan.keptRowIds.filter((id) => !rows.some((r) => r.rowId === id))
|
|
237
|
+
if (stillThere.length > 0 || lost.length > 0) {
|
|
238
|
+
const why = stillThere.length > 0 ? `要删的记录仍在(${stillThere.join('、')})` : `保留的记录丢了(${lost.slice(0, 5).join('、')})`
|
|
239
|
+
return rollback({ ok: false, status: 500, error: `写入后读回内容与期望不一致(${why}),已回滚` })
|
|
240
|
+
}
|
|
241
|
+
return { ...plan, backup, verified: true, rolledBack: false }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 规划一次「把扫描结论并进记录」(**纯函数,不写盘**;0.5.100 加法)。
|
|
246
|
+
*
|
|
247
|
+
* 语义(只加不删):
|
|
248
|
+
* · 传入的记录按 `rowId` **合并**进 `pending` 数组:已有则覆盖结论字段(并保持**同一条记录**的位置),
|
|
249
|
+
* 没有则追加;其余记录(含历史 adopted、别人的记录)**一个字都不动**;
|
|
250
|
+
* · 只接受 `status`/`check`/`checkNote`/`version`/`moduleName`/`source`/`scanVerdict` 这些结论字段的更新;
|
|
251
|
+
* · 额外写一条 `lastScan` 摘要(`{ at, frameworkVersion, checked, pass, fail, unknown, skipped, source }`)——
|
|
252
|
+
* 它让面板能说"上次检查了几个、结果如何",而不是让 0 条记录看起来像"全绿";
|
|
253
|
+
* · 没有变化时 `changed:false` ⇒ 调用方**零写盘**(幂等)。
|
|
254
|
+
*/
|
|
255
|
+
function planScanMerge({ read, records, lastScan, frameworkVersion = null } = {}) {
|
|
256
|
+
const base = { ok: false, status: 400, error: null, file: read?.file ?? null, next: null, changed: false, added: [], updated: [], lastScan: lastScan ?? null }
|
|
257
|
+
if (read === null || typeof read !== 'object') return { ...base, error: '内部错误:没有读到记录文件' }
|
|
258
|
+
if (read.exists === true && read.corrupt === true) {
|
|
259
|
+
return { ...base, status: 409, error: `${read.file} 读不出来(${read.error ?? '格式非法'});为保住你的数据,本次扫描结果**没有写盘**(请先手工修好或删掉它)` }
|
|
260
|
+
}
|
|
261
|
+
const incoming = (Array.isArray(records) ? records : []).filter((r) => r !== null && typeof r === 'object' && typeof r.rowId === 'string' && r.rowId !== '')
|
|
262
|
+
const raw = read.raw === null || typeof read.raw !== 'object' ? { pending: [] } : read.raw
|
|
263
|
+
const current = (Array.isArray(raw.pending) ? raw.pending : []).filter((r) => r !== null && typeof r === 'object')
|
|
264
|
+
const next = { ...raw, pending: [...current] }
|
|
265
|
+
const added = []
|
|
266
|
+
const updated = []
|
|
267
|
+
// 「结论」= 只有这些字段变了才算真的变了;`scannedAt` 之类的时间戳**不算**变化 ——
|
|
268
|
+
// 否则每点一次「检查」都会重写一遍文件(也就每次留一份备份),幂等就名存实亡了。
|
|
269
|
+
const conclusionKey = (r) => JSON.stringify([
|
|
270
|
+
r?.status ?? null, r?.check ?? null, r?.checkNote ?? null, r?.version ?? null,
|
|
271
|
+
r?.moduleName ?? null, r?.source ?? null, r?.frameworkVersion ?? null,
|
|
272
|
+
r?.scanVerdict?.version ?? null, r?.scanVerdict?.decision ?? null,
|
|
273
|
+
])
|
|
274
|
+
for (const rec of incoming) {
|
|
275
|
+
const at = next.pending.findIndex((p) => p?.rowId === rec.rowId)
|
|
276
|
+
if (at < 0) {
|
|
277
|
+
next.pending.push({ ...rec })
|
|
278
|
+
added.push(rec.rowId)
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
const before = next.pending[at]
|
|
282
|
+
if (conclusionKey(before) === conclusionKey(rec)) continue // 结论没变 ⇒ 这条记录一个字都不动
|
|
283
|
+
next.pending[at] = { ...before, ...rec }
|
|
284
|
+
updated.push(rec.rowId)
|
|
285
|
+
}
|
|
286
|
+
if (typeof frameworkVersion === 'string' && frameworkVersion !== '') next.frameworkVersion = frameworkVersion
|
|
287
|
+
// lastScan 摘要:只在**摘要本身**变了(或还没有摘要)时才更新 —— 时间戳不算变化
|
|
288
|
+
if (lastScan !== null && lastScan !== undefined) {
|
|
289
|
+
const summaryKey = (s) => (s === null || s === undefined ? '' : JSON.stringify([s.checked, s.pass, s.fail, s.unknown, s.skipped, s.frameworkVersion, s.source]))
|
|
290
|
+
if (summaryKey(raw.lastScan) !== summaryKey(lastScan)) next.lastScan = lastScan
|
|
291
|
+
}
|
|
292
|
+
// 文件还不存在、也没有任何结论要写 ⇒ **连文件都不建**(零行可查时不留空壳文件)
|
|
293
|
+
if (read.exists !== true && added.length === 0 && updated.length === 0) {
|
|
294
|
+
return { ...base, ok: true, status: 200, next: raw, changed: false, added, updated }
|
|
295
|
+
}
|
|
296
|
+
const changed = JSON.stringify(next) !== JSON.stringify(raw)
|
|
297
|
+
return { ...base, ok: true, status: 200, next: changed ? next : raw, changed, added, updated }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 执行一次「整份记录的安全写入」:备份 → 原子写(临时文件 + rename)→ **读回核实** → 失败回滚。
|
|
302
|
+
* `verify(back)` 返回 `null` 表示核实通过,返回字符串表示为什么不通过(写进错误信息)。
|
|
303
|
+
* 与 `applyPendingForget` 同一套纪律,只是判据由调用方给(扫描合并与删除要核实的东西不一样)。
|
|
304
|
+
*/
|
|
305
|
+
function applyPendingWrite({ file, next, verify = null, tag = 'scan', deps = {} } = {}) {
|
|
306
|
+
const plan = { ok: true, status: 200, error: null, file: typeof file === 'string' ? file : null, changed: true, backup: null, verified: false, rolledBack: false }
|
|
307
|
+
if (plan.file === null || next === null || typeof next !== 'object') {
|
|
308
|
+
return { ...plan, ok: false, status: 400, error: '内部错误:缺少目标文件或内容' }
|
|
309
|
+
}
|
|
310
|
+
const io = {
|
|
311
|
+
copy: deps.copyFileSync ?? copyFileSync,
|
|
312
|
+
write: deps.writeFileSync ?? writeFileSync,
|
|
313
|
+
rename: deps.renameSync ?? renameSync,
|
|
314
|
+
remove: deps.unlinkSync ?? unlinkSync,
|
|
315
|
+
exists: deps.existsSync ?? existsSync,
|
|
316
|
+
read: deps.readFileSync ?? readFileSync,
|
|
317
|
+
}
|
|
318
|
+
const stamp = Date.now()
|
|
319
|
+
const backup = `${plan.file}.bak-${tag}-${stamp}`
|
|
320
|
+
const tmp = `${plan.file}.tmp-${tag}-${stamp}`
|
|
321
|
+
const rollback = (extra) => {
|
|
322
|
+
try { if (io.exists(backup)) io.copy(backup, plan.file) } catch {}
|
|
323
|
+
try { if (io.exists(tmp)) io.remove(tmp) } catch {}
|
|
324
|
+
return { ...plan, ...extra, backup, verified: false, rolledBack: io.exists(backup) }
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
if (io.exists(plan.file)) io.copy(plan.file, backup)
|
|
328
|
+
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
329
|
+
io.rename(tmp, plan.file)
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return rollback({ ok: false, status: 500, error: `写入 ${plan.file} 失败:${String(error?.message ?? error)}(已回滚,文件保持原样)` })
|
|
332
|
+
}
|
|
333
|
+
let back = null
|
|
334
|
+
try { back = JSON.parse(io.read(plan.file, 'utf8')) } catch (error) {
|
|
335
|
+
return rollback({ ok: false, status: 500, error: `写入后读回失败:${String(error?.message ?? error)}(已回滚)` })
|
|
336
|
+
}
|
|
337
|
+
const why = typeof verify === 'function' ? verify(back) : null
|
|
338
|
+
if (why !== null && why !== undefined) return rollback({ ok: false, status: 500, error: `写入后读回内容与期望不一致(${String(why)}),已回滚` })
|
|
339
|
+
return { ...plan, backup, verified: true, rolledBack: false }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export {
|
|
343
|
+
PENDING_STATUS,
|
|
344
|
+
liveRowIdsFromPatch,
|
|
345
|
+
isActionablePending,
|
|
346
|
+
isStalePending,
|
|
347
|
+
countPendingRecords,
|
|
348
|
+
annotateGateRows,
|
|
349
|
+
readPendingRecords,
|
|
350
|
+
planForgetRecord,
|
|
351
|
+
planForgetStale,
|
|
352
|
+
applyPendingForget,
|
|
353
|
+
planScanMerge,
|
|
354
|
+
applyPendingWrite,
|
|
355
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// L1 · domain —— compat-scan.js(门控的「按需检查」:对当前补丁里的行跑一次适配判定;0.5.100 加法)
|
|
2
|
+
//
|
|
3
|
+
// ── 为什么需要(用户 2026-11-02 原话:「我们一般情况下都不预检吧」)──────────────────────────
|
|
4
|
+
// compat-pending 只在**框架升级前预扫**与**启动失败隔离**时写入。平时门控里一条记录都没有,
|
|
5
|
+
// 而"0 条"会被误读成"全部兼容" —— 这不是事实,只是**没人裁决过**。
|
|
6
|
+
// 这个模块给门控一个「现在查一下」的入口:对当前补丁里的行逐行跑判定,把结论如实记下来。
|
|
7
|
+
//
|
|
8
|
+
// ── 三条硬约束(一条都不许放宽)──────────────────────────────────────────────────────
|
|
9
|
+
// ① **判据只有一份**:复用 `checkPluginFrameworkCompat`(框架升级预扫那条唯一链路里的判定函数,
|
|
10
|
+
// 含"源码里是否还引用已删除的 dsh-settings API"这条硬判据)。这里不新增任何兼容判据。
|
|
11
|
+
// ② **只记录结论,绝不禁用任何行**:禁用是用户的动作(`/toggle`)。本模块不 import
|
|
12
|
+
// `disableEntry`、不碰补丁文件 —— 扫描前后补丁必须逐字节不变。
|
|
13
|
+
// ③ **只把判为不适配(fail)的行写进记录**:pass / unknown 只进统计。
|
|
14
|
+
// 为什么不能全写:`status:'pending'` 的记录会让 `/toggle` 对该行走"软禁"确认流程(409 +
|
|
15
|
+
// 风险确认),把通过的行也写进去等于凭空给用户加一道墙 —— 那是行为变更,不是记录。
|
|
16
|
+
//
|
|
17
|
+
// 读/写 compat-pending.json 的纪律在 domain/compat-pending-records.js(备份 → 原子写 → 读回核实 → 回滚)。
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { dirname } from 'node:path'
|
|
20
|
+
import { resolvePackageJson } from '../infra/paths.js'
|
|
21
|
+
import { SCANNER_VERSION } from './compat-verdict.js'
|
|
22
|
+
import { checkPluginFrameworkCompat, isFrameworkOwnedPackage } from './compat.js'
|
|
23
|
+
|
|
24
|
+
/** 记录里标注的来源(面板/日志靠它区分"这次是谁裁决的")。 */
|
|
25
|
+
const SCAN_SOURCE = 'manual-scan'
|
|
26
|
+
/** 控制台自己永不参与扫描(它要是被判 fail,用户连面板都打不开)。 */
|
|
27
|
+
const SELF_ROW_ID = 'plugin-console'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 对**当前 loader 行**逐行跑一次适配判定(纯读:不写盘、不禁用、不改补丁)。
|
|
31
|
+
*
|
|
32
|
+
* @param {object} p
|
|
33
|
+
* @param {Array<object>} p.entries loader 条目(`listEntries()` 的结果)
|
|
34
|
+
* @param {string} p.profileDir profile 目录(解析已装包用)
|
|
35
|
+
* @param {string|null} p.frameworkVersion 当前框架版本(拿不到 => 全部 unknown,如实标注)
|
|
36
|
+
* @param {object} [p.deps] 注入缝:`resolvePkg` / `readPkg`
|
|
37
|
+
* @returns {{ ok: boolean, results: Array<object>, checked: number, pass: number, fail: number, unknown: number,
|
|
38
|
+
* skipped: Array<{rowId: string|null, reason: string}>, frameworkVersion: string|null, error: string|null }}
|
|
39
|
+
*/
|
|
40
|
+
function planCurrentScan({ entries, profileDir, frameworkVersion = null, deps = {} } = {}) {
|
|
41
|
+
const resolvePkg = deps.resolvePkg ?? resolvePackageJson
|
|
42
|
+
const readPkg = deps.readPkg ?? ((p) => JSON.parse(readFileSync(p, 'utf8')))
|
|
43
|
+
const results = []
|
|
44
|
+
const skipped = []
|
|
45
|
+
const fwVer = typeof frameworkVersion === 'string' && frameworkVersion !== '' ? frameworkVersion : null
|
|
46
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
47
|
+
const rowId = typeof entry?.rowId === 'string' ? entry.rowId : ''
|
|
48
|
+
if (rowId === '') { skipped.push({ rowId: null, reason: 'no-row-id' }); continue }
|
|
49
|
+
if (rowId === SELF_ROW_ID) { skipped.push({ rowId, reason: 'console-self' }); continue }
|
|
50
|
+
const moduleName = typeof entry?.moduleName === 'string' ? entry.moduleName : ''
|
|
51
|
+
let pkgPath = null
|
|
52
|
+
try { pkgPath = moduleName === '' ? null : resolvePkg(moduleName, profileDir) } catch { pkgPath = null }
|
|
53
|
+
if (pkgPath === null) { skipped.push({ rowId, reason: 'package-unreadable', moduleName }); continue }
|
|
54
|
+
let pkg = null
|
|
55
|
+
try { pkg = readPkg(pkgPath) } catch { pkg = null }
|
|
56
|
+
if (pkg === null || typeof pkg !== 'object') { skipped.push({ rowId, reason: 'package-unreadable', moduleName }); continue }
|
|
57
|
+
const pkgDir = dirname(pkgPath)
|
|
58
|
+
let check = null
|
|
59
|
+
if (fwVer === null) check = { decision: 'unknown', reason: '拿不到当前框架版本(无法判定,不是"兼容")' }
|
|
60
|
+
else {
|
|
61
|
+
try { check = checkPluginFrameworkCompat(pkg, fwVer, pkgDir) } catch (error) { check = { decision: 'unknown', reason: '判定出错:' + String(error?.message ?? error) } }
|
|
62
|
+
}
|
|
63
|
+
const decision = check?.decision === 'pass' || check?.decision === 'fail' ? check.decision : 'unknown'
|
|
64
|
+
results.push({
|
|
65
|
+
rowId,
|
|
66
|
+
moduleName: moduleName === '' ? null : moduleName,
|
|
67
|
+
version: typeof pkg.version === 'string' ? pkg.version : null,
|
|
68
|
+
decision,
|
|
69
|
+
reason: check?.reason ?? null,
|
|
70
|
+
frameworkOwned: isFrameworkOwnedPackage(pkgDir, profileDir),
|
|
71
|
+
enabled: entry?.enabled === true,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
const count = (d) => results.filter((r) => r.decision === d).length
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
results,
|
|
78
|
+
checked: results.length,
|
|
79
|
+
pass: count('pass'),
|
|
80
|
+
fail: count('fail'),
|
|
81
|
+
unknown: count('unknown'),
|
|
82
|
+
skipped,
|
|
83
|
+
frameworkVersion: fwVer,
|
|
84
|
+
error: fwVer === null ? '拿不到当前框架版本:本次所有行都只能判成"无法判定"' : null,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 要写进 compat-pending 的记录(**只有 fail**;pass/unknown 只进统计,见模块头注释 ③)。 */
|
|
89
|
+
function recordsFromScan(scan, { at = Date.now() } = {}) {
|
|
90
|
+
const list = Array.isArray(scan?.results) ? scan.results : []
|
|
91
|
+
return list.filter((r) => r.decision === 'fail').map((r) => ({
|
|
92
|
+
rowId: r.rowId,
|
|
93
|
+
moduleName: r.moduleName,
|
|
94
|
+
version: r.version,
|
|
95
|
+
status: 'pending',
|
|
96
|
+
check: 'fail',
|
|
97
|
+
checkNote: r.reason,
|
|
98
|
+
source: SCAN_SOURCE,
|
|
99
|
+
scannedAt: at,
|
|
100
|
+
frameworkVersion: scan?.frameworkVersion ?? null,
|
|
101
|
+
// 判据戳:这次用的就是**当前**判据(SCANNER_VERSION),所以"读取代发"不会把它当过期结论重算
|
|
102
|
+
scanVerdict: { version: SCANNER_VERSION, decision: 'fail', reason: r.reason, at, previous: null, rescinded: false, evidence: null },
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { SCAN_SOURCE, SELF_ROW_ID, planCurrentScan, recordsFromScan }
|
|
@@ -35,6 +35,8 @@ import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
|
35
35
|
import { removeDirVerifiedAsync } from '../infra/fsx.js'
|
|
36
36
|
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
37
37
|
import { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, planBackupRetention, relinkTargetCandidates } from './framework-residuals.js'
|
|
38
|
+
// 0.5.99 改错:改名残留能不能恢复的**判据**(可用 = 目录里有 package.json;候选包名必须与真名一致)
|
|
39
|
+
import { emptyShellName, isUsablePackageDir, planStaleRestore } from './framework-stale-restore.js'
|
|
38
40
|
|
|
39
41
|
/** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
|
|
40
42
|
function dirSizeBytes(dir) {
|
|
@@ -424,54 +426,87 @@ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
|
|
|
424
426
|
} catch { return [] }
|
|
425
427
|
}
|
|
426
428
|
/**
|
|
427
|
-
* 升级失败后的补偿:把 `<name>.stale-<ver>-<ts>`
|
|
429
|
+
* 升级失败后的补偿:把 `<name>.stale-<ver>-<ts>` **改回真名**。
|
|
428
430
|
*
|
|
429
431
|
* 背景(2026-10-28 真机):升级在「拉起」阶段失败,向上层报「自动回滚未生效」,而清扫步
|
|
430
432
|
* 已经把 10 个框架包改名成 `.stale-*`、真名处什么都没有。结果框架起不来:
|
|
431
433
|
* `Cannot find package '@deepseek-ai/cordis' imported from @deepseek-ai/dsh-app-boot`
|
|
432
434
|
* 那一刻用户只能手改目录名 —— 这个函数就是那条手改的自动化版本。
|
|
433
435
|
*
|
|
434
|
-
*
|
|
436
|
+
* ★ 0.5.99 改错(真机 2026-11-02,用户实测):旧判据是「真名**缺失**才恢复;真名存在就跳过」,
|
|
437
|
+
* 而现场恰恰是「真名存在但是**空壳**(没有 package.json),内容在 `.stale-*` 里」⇒ 被跳过 ⇒
|
|
438
|
+
* 框架一直解析不到包(`ERR_MODULE_NOT_FOUND`),用户只能手工把 178 个空壳恢复回来。
|
|
439
|
+
* 现在判据收敛到 domain/framework-stale-restore.js:**可用 = 目录里有 package.json**,
|
|
440
|
+
* 空壳在判据里与"缺失"同等对待;候选还必须 `package.json.name === <scope>/<real>`(防张冠李戴)。
|
|
441
|
+
* 空壳**挪成 `<real>.empty-<ts>`**(挪开不删,可回溯),恢复后**读回核实**,不成就回滚并如实报。
|
|
442
|
+
*
|
|
443
|
+
* 纪律(一条都没放宽):
|
|
435
444
|
* · **绝不删除**任何东西(只 rename);
|
|
436
|
-
* ·
|
|
437
|
-
* ·
|
|
438
|
-
* ·
|
|
445
|
+
* · 真名**可用**就跳过(宁可不动,也不覆盖可能更新的目录)—— 健康树上零动作(幂等);
|
|
446
|
+
* · 只看 `@deepseek-ai` 这一层(顶层 scope),不递归、不碰 `.pnpm` 内部;
|
|
447
|
+
* · 三类结果分开回报:`restored` / `noCandidate`(真缺内容,无可用候选)/ `failed`(名字不符、改名失败、读回不符)。
|
|
439
448
|
*/
|
|
440
|
-
function restoreStaleBackups({ scopeDir, deps = {} } = {}) {
|
|
449
|
+
function restoreStaleBackups({ scopeDir, stamp = null, deps = {} } = {}) {
|
|
441
450
|
const exists = deps.exists ?? existsSync
|
|
442
|
-
const readdir = deps.readdir ?? ((dir) => readdirSync(dir, { withFileTypes: true }))
|
|
443
451
|
const rename = deps.rename ?? renameSync
|
|
444
452
|
const restored = []
|
|
445
453
|
const skipped = []
|
|
454
|
+
const noCandidate = []
|
|
455
|
+
const emptied = []
|
|
446
456
|
const failed = []
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const missing =
|
|
451
|
-
return { ok: missing, restored, skipped, failed, problem: missing ? null :
|
|
457
|
+
const io = { lstat: deps.lstat, readText: deps.readText, readdir: deps.readdir }
|
|
458
|
+
const plan = planStaleRestore({ scopeDir, deps: io })
|
|
459
|
+
if (plan.problem !== null) {
|
|
460
|
+
const missing = typeof scopeDir === 'string' && scopeDir !== '' && !exists(scopeDir)
|
|
461
|
+
return { ok: missing, restored, skipped, noCandidate, emptied, failed, problem: missing ? null : plan.problem }
|
|
452
462
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
const source = join(scopeDir,
|
|
461
|
-
let
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
463
|
+
const ts = stamp === null ? String(Date.now()) : String(stamp)
|
|
464
|
+
for (const item of plan.items) {
|
|
465
|
+
const target = join(scopeDir, item.real)
|
|
466
|
+
if (item.action === 'skip') { skipped.push({ name: item.candidates[0], real: item.real, reason: 'real-name-exists' }); continue }
|
|
467
|
+
if (item.action === 'noCandidate') { noCandidate.push({ real: item.real, candidates: item.candidates, reason: item.reason, detail: item.detail, unusable: item.unusable ?? [] }); continue }
|
|
468
|
+
if (item.action === 'failed') { failed.push({ name: item.candidates[0] ?? item.real, real: item.real, reason: item.reason, error: item.detail }); continue }
|
|
469
|
+
// ① 空壳(存在但不可用)先挪开 —— **改个名,不删**;挪不动(或改名没生效)就整条不动
|
|
470
|
+
const source = join(scopeDir, item.staleName)
|
|
471
|
+
let emptyFull = null
|
|
472
|
+
if (item.realOccupied && item.realUsable !== true) {
|
|
473
|
+
const aside = join(scopeDir, emptyShellName(item.real, ts))
|
|
474
|
+
try {
|
|
475
|
+
rename(target, aside)
|
|
476
|
+
} catch (error) {
|
|
477
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'empty-shell-move-failed', error: `真名是空壳但挪不开(${errText(error)})—— 本次不动它` })
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
// 挪开这个动作也要核实:注入桩/文件系统异常下"调用成功但没生效"不能当成功(否则报告会编造 emptied)
|
|
481
|
+
if (exists(target) || !exists(aside)) {
|
|
482
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'empty-shell-move-failed', error: '把空壳挪成 .empty-* 没有生效(真名处仍有东西)—— 本次不动它' })
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
emptyFull = aside
|
|
466
486
|
}
|
|
487
|
+
// ② 候选 → 真名
|
|
467
488
|
try {
|
|
468
489
|
rename(source, target)
|
|
469
|
-
restored.push({ from: name, to: real })
|
|
470
490
|
} catch (error) {
|
|
471
|
-
|
|
491
|
+
// 挪开的空壳放回去(尽力而为;失败也要写在 failed 里,绝不静默)
|
|
492
|
+
let back = true
|
|
493
|
+
if (emptyFull !== null) { try { rename(emptyFull, target) } catch { back = false } }
|
|
494
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'rename-failed', error: `改回真名失败:${errText(error)}${emptyFull === null ? '' : (back ? '(空壳已放回原处)' : '(**空壳没能放回原处**,现场可能不一致)')}` })
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
// ③ 读回核实:真名目录里必须真的有 package.json("改了名"不等于"能解析")
|
|
498
|
+
if (isUsablePackageDir(target, io)) {
|
|
499
|
+
if (emptyFull !== null) emptied.push({ from: item.real, to: basename(emptyFull) })
|
|
500
|
+
restored.push({ from: item.staleName, to: item.real, emptied: emptyFull === null ? null : basename(emptyFull) })
|
|
501
|
+
continue
|
|
472
502
|
}
|
|
503
|
+
// ④ 核实不过:把候选改回去、空壳放回原处,如实报失败(绝不谎报已恢复)
|
|
504
|
+
let rolledBack = true
|
|
505
|
+
try { if (!exists(source)) rename(target, source) } catch { rolledBack = false }
|
|
506
|
+
if (emptyFull !== null) { try { rename(emptyFull, target) } catch { rolledBack = false } }
|
|
507
|
+
failed.push({ name: item.staleName, real: item.real, reason: 'verify-failed', error: `改名后读回核实不通过(${item.real} 里仍读不到 package.json)${rolledBack ? ';已回滚到操作前' : ';**回滚未完成**,现场可能不一致'}` })
|
|
473
508
|
}
|
|
474
|
-
return { ok: failed.length === 0, restored, skipped, failed, problem: null }
|
|
509
|
+
return { ok: failed.length === 0, restored, skipped, noCandidate, emptied, failed, problem: null }
|
|
475
510
|
}
|
|
476
511
|
|
|
477
512
|
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, removeLinkSafe, reportFrameworkEntities, resolveFrameworkLayout, restoreStaleBackups, verifyFrameworkTreeResolvable }
|
|
@@ -4,27 +4,42 @@
|
|
|
4
4
|
// 真名处什么都没有 ⇒ 框架起不来:`Cannot find package '@deepseek-ai/cordis'`。
|
|
5
5
|
// 当时只能手工改目录名;这里把那件事自动化,并**只返回人类可读的步骤**,
|
|
6
6
|
// 让调用方(routes/framework-upgrade.js)保持精简(那个文件有 700 行硬顶)。
|
|
7
|
+
//
|
|
8
|
+
// ★ 0.5.99 改错(真机 2026-11-02):「真名存在但只是空壳(没有 package.json)」以前被当成
|
|
9
|
+
// "已存在 ⇒ 跳过",于是坏状态一直留着。判据已收敛到 domain/framework-stale-restore.js
|
|
10
|
+
// (可用 = 目录里有 package.json;候选包名必须与真名一致)。本文件把三类结果**都**翻成
|
|
11
|
+
// 升级日志里的可读步骤:restored / noCandidate(真缺内容,无可用候选)/ failed。
|
|
7
12
|
import { join } from 'node:path'
|
|
8
13
|
import { restoreStaleBackups } from './framework-cleanup.js'
|
|
9
14
|
|
|
15
|
+
/** 一列名字最多列几个(升级日志是单行步骤,别把几百个包名全塞进去)。 */
|
|
16
|
+
const LIST_LIMIT = 8
|
|
17
|
+
const brief = (names) => (names.length <= LIST_LIMIT ? names.join('、') : `${names.slice(0, LIST_LIMIT).join('、')} 等 ${names.length} 个`)
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
|
-
* @returns {{ steps: string[], restored: Array<
|
|
20
|
+
* @returns {{ steps: string[], restored: Array<object>, emptied: Array<object>, noCandidate: Array<object>, failed: Array<object> }}
|
|
12
21
|
*/
|
|
13
|
-
function repairStaleRenameResidue({ fwRoot } = {}) {
|
|
22
|
+
function repairStaleRenameResidue({ fwRoot, deps = {} } = {}) {
|
|
14
23
|
const steps = []
|
|
15
|
-
|
|
24
|
+
const empty = { steps, restored: [], emptied: [], noCandidate: [], failed: [] }
|
|
25
|
+
if (typeof fwRoot !== 'string' || fwRoot === '') return empty
|
|
16
26
|
try {
|
|
17
|
-
const repair = restoreStaleBackups({ scopeDir: join(fwRoot, '@deepseek-ai') })
|
|
27
|
+
const repair = restoreStaleBackups({ scopeDir: join(fwRoot, '@deepseek-ai'), deps })
|
|
18
28
|
if (repair.restored.length > 0) {
|
|
19
|
-
steps.push(`修复上次失败留下的改名残留:${repair.restored.length} 个包已改回真名(${repair.restored.map((r) => r.to)
|
|
29
|
+
steps.push(`修复上次失败留下的改名残留:${repair.restored.length} 个包已改回真名(${brief(repair.restored.map((r) => r.to))})`
|
|
30
|
+
+ (Array.isArray(repair.emptied) && repair.emptied.length > 0 ? `;${repair.emptied.length} 个空壳已挪成 .empty-*(未删除)` : ''))
|
|
31
|
+
}
|
|
32
|
+
// ★ 0.5.99:真缺内容(真名不可用且没有可用候选)必须**说出来** —— 否则"框架解析不到包"在日志里无声无息
|
|
33
|
+
if (Array.isArray(repair.noCandidate) && repair.noCandidate.length > 0) {
|
|
34
|
+
steps.push(`有 ${repair.noCandidate.length} 个框架包真名不可用、且没有可用的 .stale-* 候选(真缺内容,需要重装/重新安装该版本):${brief(repair.noCandidate.map((n) => n.real))}`)
|
|
20
35
|
}
|
|
21
36
|
if (repair.failed.length > 0) {
|
|
22
|
-
steps.push(`改名残留修复未全部成功:${repair.failed.map((f) => f.real
|
|
37
|
+
steps.push(`改名残留修复未全部成功:${brief(repair.failed.map((f) => `${f.real}${f.reason ? `(${f.reason})` : ''}`))}(不阻塞升级)`)
|
|
23
38
|
}
|
|
24
|
-
return { steps, restored: repair.restored, failed: repair.failed }
|
|
39
|
+
return { steps, restored: repair.restored, emptied: repair.emptied ?? [], noCandidate: repair.noCandidate ?? [], failed: repair.failed }
|
|
25
40
|
} catch (error) {
|
|
26
41
|
steps.push(`改名残留修复失败(不阻塞升级):${error instanceof Error ? error.message : String(error)}`)
|
|
27
|
-
return {
|
|
42
|
+
return { ...empty, steps, failed: [{ error: String(error) }] }
|
|
28
43
|
}
|
|
29
44
|
}
|
|
30
45
|
|