@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.
- package/lib/client.js +516 -220
- package/lib/server/domain/compat-verdict.js +241 -0
- package/lib/server/domain/compat.js +84 -16
- package/lib/server/domain/framework-cleanup.js +225 -24
- package/lib/server/domain/framework-residuals.js +52 -3
- package/lib/server/domain/market-search.js +242 -0
- package/lib/server/domain/settings-api-scan.js +16 -1
- package/lib/server/routes/market.js +108 -35
- package/lib/server/routes/plugins.js +24 -13
- package/lib/server/routes/state.js +14 -3
- package/package.json +1 -1
|
@@ -5,15 +5,36 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 三条安全约定(对齐仓库既有纪律):
|
|
7
7
|
// ① **一律先改名备份**:顶层旧目录 → `<name>.stale-<ver>-<ts>`,绝不直接删(可回滚)。
|
|
8
|
-
// ②
|
|
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 {
|
|
13
|
-
import {
|
|
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 档:顶层投影里的旧版本真实目录 → 改名备份(+
|
|
44
|
-
* `topScopeDir` = `<
|
|
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/* 的真实位置)',
|
|
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
|
-
|
|
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(
|
|
250
|
+
rename(full, backupFull)
|
|
80
251
|
} catch (error) {
|
|
81
|
-
failed.push({ name: item.name, 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,
|
|
93
|
-
relinked.push({ name: item.name, target })
|
|
256
|
+
link(resolvedTarget.target, full, 'junction')
|
|
94
257
|
} catch (error) {
|
|
95
|
-
|
|
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
|
|
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 }
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// L1 · domain —— market-search.js(0.5.35:市场页搜索的**唯一一份排序判据** + 分页/截断语义)
|
|
2
|
+
//
|
|
3
|
+
// 真机缺陷(2026-10-03 用户实测,市场页输入他刚发布的仓库确切名字 `dsh-connection-card-host`):
|
|
4
|
+
// ① 结果里只有三条**无关的高星**条目(dsh-desktop ★29812 / awesome-dsh-plugin ★17621 /
|
|
5
|
+
// dsh-desktop ★11682,都带「聚合」角标),**他要的仓库既不在前排、往下也没有**;
|
|
6
|
+
// ② 下面**没有「加载更多」**,也就无从"往下看"。
|
|
7
|
+
// 定位结论(离线复现,见 tests/test-market-search.mjs):
|
|
8
|
+
// · 那三条来自 `lib/client.js` 的**本地索引模糊匹配**(500 条封顶的静态索引里没有这个新仓库;
|
|
9
|
+
// 索引匹配只按"子串 / 编辑距离 ≤2"打分,结果里全是恰好带 `dsh`、`host` 这类 token 的无关仓库),
|
|
10
|
+
// 且它选出来的 12 条**按"星数"排的结果**被当作最终顺序**直接 return** —— 活搜 GitHub
|
|
11
|
+
// (本来会把逐字同名的仓库排第一)根本没被执行;
|
|
12
|
+
// · 「加载更多」按钮的渲染条件是 `market.data.length >= 20`:三条命中 ⇒ 按钮不渲染;
|
|
13
|
+
// · 服务端 /search 还把 `page` **夹到 1..5**,翻到第 6 次开始原地返回第 5 页。
|
|
14
|
+
//
|
|
15
|
+
// ★ 用户定案(原话):「所有的搜索链,都应该以**名字相似为主排序**,然后才往下按 star 走」★
|
|
16
|
+
// 即**不是**"只把精确命中置顶",而是**名字相似度分层** —— 同一层内才比 star:
|
|
17
|
+
//
|
|
18
|
+
// Tier 0 full_name 逐字相等(owner/repo 或裸 repo)
|
|
19
|
+
// Tier 1 name 逐字相等(大小写不敏感)
|
|
20
|
+
// Tier 2 name 前缀命中(以查询词开头)
|
|
21
|
+
// Tier 3 name 含查询子串
|
|
22
|
+
// Tier 4 name 的 token **全部**命中(可乱序)
|
|
23
|
+
// Tier 5 description / topics 命中
|
|
24
|
+
// Tier 6 README 命中
|
|
25
|
+
// 其余 不匹配(排在最后,不参与前七层)
|
|
26
|
+
// 同 Tier 内:stars 降序 → 再同分:最近更新优先 → 最后 full_name 字典序(确定性兜底)
|
|
27
|
+
//
|
|
28
|
+
// 本模块只做**纯函数**(无网络、无落盘、无 ctx):查询词归一、分层判据、排序、分页/截断/合计。
|
|
29
|
+
// 「一处定义、处处复用」的落地方式(**双份实现必须逐字一致,由架构守卫钉死**):
|
|
30
|
+
// · 服务端:`lib/server/routes/market.js` 直接 import 本模块;
|
|
31
|
+
// · 浏览器:`lib/client.js` 是**单文件打包产物**(`window.__ModuleLoader__.load`),不能用 import,
|
|
32
|
+
// 所以那里的排序块是**逐字移植**的同一份代码,两端都用下面的提取标记圈住:
|
|
33
|
+
// // <<<market-rank:begin>>> … // <<<market-rank:end>>>
|
|
34
|
+
// `tests/test-market-rank-canonical.mjs` 断言两份**去缩进后逐字相等**(改一边必须改另一边),
|
|
35
|
+
// 架构守卫里同一断言也跑一遍。这样"唯一排序函数"是可验证的事实,而不是口头约定。
|
|
36
|
+
//
|
|
37
|
+
// 铁律:① 只调整**展示顺序**与**分页口径**,绝不改变"谁被搜出来"(过滤条件一处不动);
|
|
38
|
+
// ② 分页**幂等可重复**:同一查询同一页永远同一批条目(排序里必须有确定性 tiebreaker)。
|
|
39
|
+
|
|
40
|
+
// <<<market-rank:begin>>>
|
|
41
|
+
/** 排序层级(越小越靠前)。Tier 0-6 与用户定案逐条对应;NOT_MATCHED 只是"没有名字/描述命中"。 */
|
|
42
|
+
const RANK_TIER = Object.freeze({
|
|
43
|
+
FULL_NAME_EXACT: 0,
|
|
44
|
+
NAME_EXACT: 1,
|
|
45
|
+
NAME_PREFIX: 2,
|
|
46
|
+
NAME_SUBSTRING: 3,
|
|
47
|
+
NAME_TOKENS: 4,
|
|
48
|
+
DESCRIPTION_OR_TOPICS: 5,
|
|
49
|
+
README: 6,
|
|
50
|
+
NOT_MATCHED: 7,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** 归一化:小写 + 去掉尾部 `.git` / 首尾空白。所有判据都用它,避免大小写与 `.git` 差异漏判。 */
|
|
54
|
+
function normalizeSearchName(value) {
|
|
55
|
+
return String(value ?? '').trim().replace(/\.git$/iu, '').toLowerCase()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 查询词按分隔符切出的 token(≥2 字符,去重保序)——用于 Tier 4「token 全命中」判据。 */
|
|
59
|
+
function queryTokens(query) {
|
|
60
|
+
const q = normalizeSearchName(query)
|
|
61
|
+
if (q === '') return []
|
|
62
|
+
const out = []
|
|
63
|
+
for (const tk of q.split(/[\s\-_/.]+/u)) {
|
|
64
|
+
if (tk.length >= 2 && !out.includes(tk)) out.push(tk)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 条目的两个名字形态(都归一化):`full` = owner/repo,`short` = 裸名(缺 name 时从 full 取尾段)。 */
|
|
70
|
+
function itemNameForms(item) {
|
|
71
|
+
const full = normalizeSearchName(item?.fullName ?? item?.full_name)
|
|
72
|
+
const short = normalizeSearchName(item?.name) || (full === '' ? '' : (full.split('/').pop() ?? ''))
|
|
73
|
+
return { full, short }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 名字里按分隔符切出的 token(小写)。`dsh-connection-card-host` → [dsh, connection, card, host]。 */
|
|
77
|
+
function nameTokens(name) {
|
|
78
|
+
return String(name ?? '').toLowerCase().split(/[\s\-_/.]+/u).filter(Boolean)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** description / topics 是不是命中查询词(整词子串,大小写不敏感)。 */
|
|
82
|
+
function textHitsQuery(query, item) {
|
|
83
|
+
const q = normalizeSearchName(query)
|
|
84
|
+
if (q === '') return false
|
|
85
|
+
const desc = normalizeSearchName(item?.description)
|
|
86
|
+
if (desc !== '' && desc.includes(q)) return true
|
|
87
|
+
const topics = Array.isArray(item?.topics) ? item.topics : []
|
|
88
|
+
return topics.some((topic) => normalizeSearchName(topic).includes(q))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** README 命中:优先看条目**自带**的 readme 文本;没有就认标记位(服务端 in:readme 重查补进来的条目)。
|
|
92
|
+
* 标记位是"这条是从 README 检索面捞到的"的既成事实,不编造内容。 */
|
|
93
|
+
function readmeHitsQuery(query, item) {
|
|
94
|
+
const q = normalizeSearchName(query)
|
|
95
|
+
if (q === '') return false
|
|
96
|
+
const readme = normalizeSearchName(item?.readme)
|
|
97
|
+
if (readme !== '' && readme.includes(q)) return true
|
|
98
|
+
return item?.viaReadme === true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 名字相似度分层(唯一判据;越小越靠前)。owner/repo 查询只按 full_name 判 Tier 0,
|
|
102
|
+
* 避免"同名不同 owner"被当成逐字命中(与用户"输入确切名字要排第一"的预期一致)。 */
|
|
103
|
+
function rankTier(query, item) {
|
|
104
|
+
const q = normalizeSearchName(query)
|
|
105
|
+
if (q === '') return RANK_TIER.NOT_MATCHED
|
|
106
|
+
const { full, short } = itemNameForms(item)
|
|
107
|
+
const tokens = queryTokens(query)
|
|
108
|
+
const hasOwner = q.includes('/')
|
|
109
|
+
const target = short !== '' ? short : full
|
|
110
|
+
if (full !== '' && full === q) return RANK_TIER.FULL_NAME_EXACT
|
|
111
|
+
if (!hasOwner && short !== '' && short === q) return RANK_TIER.NAME_EXACT
|
|
112
|
+
if (target !== '' && target.startsWith(q)) return RANK_TIER.NAME_PREFIX
|
|
113
|
+
if (target !== '' && target.includes(q)) return RANK_TIER.NAME_SUBSTRING
|
|
114
|
+
if (tokens.length > 0) {
|
|
115
|
+
const owned = nameTokens(full !== '' ? full : short)
|
|
116
|
+
if (tokens.every((tk) => owned.includes(tk))) return RANK_TIER.NAME_TOKENS
|
|
117
|
+
}
|
|
118
|
+
if (textHitsQuery(query, item)) return RANK_TIER.DESCRIPTION_OR_TOPICS
|
|
119
|
+
if (readmeHitsQuery(query, item)) return RANK_TIER.README
|
|
120
|
+
return RANK_TIER.NOT_MATCHED
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 星数(缺省 0;非数字按 0 处理——索引/GitHub 都是外部数据,不能拿它当数字用)。 */
|
|
124
|
+
function starsOf(item) {
|
|
125
|
+
const n = item?.stars ?? item?.stargazers_count
|
|
126
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 更新时间戳(毫秒)。缺省/非法一律 0("没给时间"排最后;绝不编造"刚更新")。 */
|
|
130
|
+
function updatedAtOf(item) {
|
|
131
|
+
const raw = item?.updatedAt ?? item?.updated_at
|
|
132
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
|
133
|
+
if (typeof raw === 'string' && raw !== '') {
|
|
134
|
+
const parsed = Date.parse(raw)
|
|
135
|
+
if (Number.isFinite(parsed)) return parsed
|
|
136
|
+
}
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 确定性兜底 tiebreaker:同层同星同时间时按 full_name 字典序(分页不抖动)。 */
|
|
141
|
+
function nameOf(item) {
|
|
142
|
+
return normalizeSearchName(item?.fullName ?? item?.full_name ?? item?.name)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 统一比较器:Tier 0-6 升序 → stars 降序 → updatedAt 降序 → full_name 升序。
|
|
146
|
+
* 「名字相似为主、同层才比 star」的全部语义都在这一个函数里;所有搜索链只许调它。 */
|
|
147
|
+
function compareByRank(query) {
|
|
148
|
+
return (a, b) => {
|
|
149
|
+
const ta = rankTier(query, a)
|
|
150
|
+
const tb = rankTier(query, b)
|
|
151
|
+
if (ta !== tb) return ta - tb
|
|
152
|
+
const sa = starsOf(a)
|
|
153
|
+
const sb = starsOf(b)
|
|
154
|
+
if (sa !== sb) return sb - sa
|
|
155
|
+
const ua = updatedAtOf(a)
|
|
156
|
+
const ub = updatedAtOf(b)
|
|
157
|
+
if (ua !== ub) return ub - ua
|
|
158
|
+
const na = nameOf(a)
|
|
159
|
+
const nb = nameOf(b)
|
|
160
|
+
return na < nb ? -1 : (na > nb ? 1 : 0)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** **唯一入口**:按用户定案排序(返回**新数组**;条目对象一个字段都不动 —— 只改顺序不改内容)。
|
|
165
|
+
* 分页幂等的根本保证:同一数组同一 query 的排序结果逐位相同(比较器无随机、无时间依赖)。 */
|
|
166
|
+
function rankSearchResults(items, query) {
|
|
167
|
+
const list = Array.isArray(items) ? items.slice() : []
|
|
168
|
+
if (list.length < 2) return list
|
|
169
|
+
return list.sort(compareByRank(query))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 分层结果(UI 角标/说明与测试用):[{ tier, items }] 只含**真的命中**(Tier 0-6)的层,层序固定。 */
|
|
173
|
+
function rankTiers(items, query) {
|
|
174
|
+
const buckets = new Map()
|
|
175
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
176
|
+
const tier = rankTier(query, item)
|
|
177
|
+
if (tier > RANK_TIER.README) continue
|
|
178
|
+
if (!buckets.has(tier)) buckets.set(tier, [])
|
|
179
|
+
buckets.get(tier).push(item)
|
|
180
|
+
}
|
|
181
|
+
return [...buckets.keys()].sort((a, b) => a - b).map((tier) => ({ tier, items: buckets.get(tier) }))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 分页 + 截断口径(纯函数;GitHub search 的 `total_count` 与 `per_page` 都进这里):
|
|
186
|
+
* · shown 本次实际显示的条目数
|
|
187
|
+
* · total 数据源报的**总命中数**(未知传 null → UI 只说"已显示 M 条",绝不编造总数)
|
|
188
|
+
* · hasMore 还能不能往下翻(`page*perPage < total` **且没有翻过数据源硬上限**;总数为 null 时退化为"本页满页")
|
|
189
|
+
* · truncated 结果被截断了(总数 > 本次已取到的范围)——UI 必须**明说**,不许让用户以为"就是没有"
|
|
190
|
+
* · reason 截断的**可读原因**(GitHub API 最多 1000 条 / 索引封顶 / 无 total 但满页)
|
|
191
|
+
* 幂等:同一 (page, perPage, total) 永远同一结论。
|
|
192
|
+
*
|
|
193
|
+
* ★ 0.5.35 实测(真机 GitHub,2026-10-03):`per_page=20&page=51`(第 1001 条起)不是"返回空"而是
|
|
194
|
+
* **HTTP 422 `Only the first 1000 search results are available`** —— 也就是说越过上限再翻页必然报错。
|
|
195
|
+
* 所以 hasMore 在 `page*perPage >= min(total, cap)` 时必须为 false:按钮不给,用户就不会点出一个错误。
|
|
196
|
+
* 它也在标记块**之内**:浏览器那一半用同一份口径算「加载更多」与「共 N 条」。
|
|
197
|
+
*/
|
|
198
|
+
function buildPageEnvelope({ shown = 0, total = null, page = 1, perPage = 20, cap = 1000, capReason = null } = {}) {
|
|
199
|
+
const n = Number.isFinite(shown) && shown > 0 ? shown : 0
|
|
200
|
+
const p = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1
|
|
201
|
+
const size = Number.isFinite(perPage) && perPage > 0 ? Math.floor(perPage) : 20
|
|
202
|
+
const known = typeof total === 'number' && Number.isFinite(total) && total >= 0 ? Math.floor(total) : null
|
|
203
|
+
const capValue = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : Infinity
|
|
204
|
+
const fetched = p * size
|
|
205
|
+
// 数据源硬上限之内还能取到条数(GitHub search 只有前 1000 条;越过就是 422,不是空页)
|
|
206
|
+
const reachable = known === null ? Infinity : Math.min(known, capValue)
|
|
207
|
+
// hasMore 只由**总数与上限**决定(`shown > 0`):本页返回 0 条就是到底了,不许再让用户点空按钮,
|
|
208
|
+
// 也不许让用户点到必然 422 的那一页。
|
|
209
|
+
const hasMore = known === null ? n >= size : (n > 0 && fetched < reachable)
|
|
210
|
+
// 截断分两种,都必须让用户看见(而不是以为"就是没有"):
|
|
211
|
+
// ① 数据源硬上限(GitHub search 只给前 1000 条)→ 这是**始终**成立的事实,与翻到第几页无关;
|
|
212
|
+
// ② 本次只取到前 fetched 条(还有更多没取)→ 翻页可解。
|
|
213
|
+
const overCap = known !== null && known > cap
|
|
214
|
+
const truncated = overCap || (known === null ? n >= size : known > fetched)
|
|
215
|
+
let reason = null
|
|
216
|
+
if (overCap) reason = capReason !== null ? capReason : `数据源最多只返回前 ${cap} 条(命中 ${known} 条)`
|
|
217
|
+
else if (known === null && n >= size) reason = '数据源未返回总数(只能按每页满页判断还有更多)'
|
|
218
|
+
else if (known !== null && known > fetched) reason = `共 ${known} 条,本次只取到前 ${Math.min(fetched, known)} 条`
|
|
219
|
+
// 已经翻到/翻过硬上限:再说"可继续加载"就是骗人(GitHub 第 1001 条起是 422)
|
|
220
|
+
if (overCap && fetched >= cap) {
|
|
221
|
+
reason = capReason !== null ? capReason : `数据源最多只返回前 ${cap} 条(命中 ${known} 条):已翻到第 ${fetched} 条,无法再往下`
|
|
222
|
+
}
|
|
223
|
+
return { shown: n, total: known, page: p, perPage: size, hasMore, truncated, reason }
|
|
224
|
+
}
|
|
225
|
+
// <<<market-rank:end>>>
|
|
226
|
+
|
|
227
|
+
export {
|
|
228
|
+
RANK_TIER,
|
|
229
|
+
normalizeSearchName,
|
|
230
|
+
queryTokens,
|
|
231
|
+
itemNameForms,
|
|
232
|
+
nameTokens,
|
|
233
|
+
textHitsQuery,
|
|
234
|
+
readmeHitsQuery,
|
|
235
|
+
rankTier,
|
|
236
|
+
starsOf,
|
|
237
|
+
updatedAtOf,
|
|
238
|
+
compareByRank,
|
|
239
|
+
rankSearchResults,
|
|
240
|
+
rankTiers,
|
|
241
|
+
buildPageEnvelope,
|
|
242
|
+
}
|