@noob-stupid/dsh-plugin-console 0.5.14 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ // L1 · domain —— lockfile-health.js(live profile 的「依赖锁体检」与**用户显式触发**的 lock 重建)
2
+ //
3
+ // ── 为什么(2026-09-27,真问题,隔离环境已复现)────────────────────────────────
4
+ // web profile 的 `dsh plugin remove` / 任何一次 pnpm 全量解析都会失败,三条独立原因叠在一起:
5
+ // ① 声明的依赖 `dsh-github-login@0.1.0` 在 registry.npmmirror.com 与 registry.npmjs.org **双双 404**
6
+ // (`ERR_PNPM_FETCH_404`)——这是用户环境里"装不回来"的真因,**不能**靠改 lock 解决;
7
+ // ② `pnpm-lock.yaml` 陈旧残缺:importers 里写着 0.5.4/0.5.13、manifest 写 0.5.14,7 个依赖里缺 4 个
8
+ // (frozen-lockfile 下直接 `ERR_PNPM_OUTDATED_LOCKFILE`);
9
+ // ③ pnpm 的供应链闸 `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`(新发版本不足 24h)。
10
+ //
11
+ // ── 安全边界(写死,不许越线)─────────────────────────────────────────────────
12
+ // · 本模块**绝不**自动改用户 profile:体检(runLockfileCheck)纯只读;重建(runLockfileRepair)
13
+ // 必须由用户显式点/显式调用路由才会跑,且只跑 `pnpm install --lockfile-only`(只重写 lock,
14
+ // 不动 package.json、不动 node_modules)。
15
+ // · **绝不**为了"让重建成功"而绕过供应链闸,也绝不静默丢弃依赖:
16
+ // - 遇到 404 依赖 → **停下**,把包名如实列出来(`action: 'blocked'`),一个文件都不写;
17
+ // - supply-chain-age → 只提示"可自行 `--config.minimumReleaseAge=0` 显式绕过(有安全代价)"。
18
+ // · 命令参数由 `repairArgsFor()` 唯一产出(纯函数,单测直接断言 argv 里没有绕过开关)。
19
+ //
20
+ // 分层:本模块属于 L1 domain —— 不认识 cordis ctx,IO 一律以参数注入(便于离线单测)。
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { probeRegistryPackage } from './dep-source.js'
24
+ import { classifyInstallFailure, hintForKind } from './install-diagnose.js'
25
+ import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
26
+ import { semverRangeMatch } from '../infra/semver.js'
27
+
28
+ const MANIFEST_NAME = 'package.json'
29
+ const LOCKFILE_NAME = 'pnpm-lock.yaml'
30
+ const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
31
+ /** 供应链闸的窗口:pnpm 的 minimumReleaseAge 默认 24h(本模块只用它做**只读判定**,不复制 pnpm 的策略)。 */
32
+ const RELEASE_AGE_HOURS = 24
33
+ const PROBE_TIMEOUT_MS = 8000
34
+ const REPAIR_TIMEOUT_MS = 180000
35
+ const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies']
36
+
37
+ /** 重建 lock 的 argv(纯函数,唯一产出点):只重写 lock,不碰 node_modules;
38
+ * `--no-frozen-lockfile` 是必需的(我们的 pnpm env 带 CI=true,pnpm 在 CI 下默认 frozen-lockfile,
39
+ * 不加它就只会得到 `ERR_PNPM_OUTDATED_LOCKFILE` —— 正是要修的那个症状)。
40
+ * ⚠️ 这里**永远不会**出现 `--config.minimumReleaseAge=0` 这类绕过开关(临时 profile 真跑实测过)。 */
41
+ function repairArgsFor(registry) {
42
+ const reg = typeof registry === 'string' && registry.trim() !== '' ? registry.trim() : DEFAULT_REGISTRY
43
+ return ['install', '--lockfile-only', '--no-frozen-lockfile', '--registry', reg]
44
+ }
45
+
46
+ /** 读 profile 清单里的依赖声明(纯函数,容错):返回 { ok, error, deps: [{ name, spec, section }] }。 */
47
+ function readManifestDeps(manifestText) {
48
+ let pkg = null
49
+ try {
50
+ pkg = JSON.parse(typeof manifestText === 'string' ? manifestText : String(manifestText ?? ''))
51
+ } catch (error) {
52
+ return { ok: false, error: error instanceof Error ? error.message : String(error), deps: [] }
53
+ }
54
+ if (pkg === null || typeof pkg !== 'object') return { ok: false, error: 'package.json 不是对象', deps: [] }
55
+ const deps = []
56
+ for (const section of DEP_SECTIONS) {
57
+ const block = pkg[section]
58
+ if (block === null || typeof block !== 'object') continue
59
+ for (const [name, spec] of Object.entries(block)) {
60
+ if (typeof spec !== 'string') continue
61
+ deps.push({ name, spec, section })
62
+ }
63
+ }
64
+ return { ok: true, error: null, deps }
65
+ }
66
+
67
+ /** 解析 pnpm-lock.yaml 的 importers 段(纯函数,只认 pnpm v6/v9 的确定性形状):
68
+ * 返回 { present, parsed, importers: [{ name, specifier, version }] }。
69
+ * 为什么只认形状而不是上 YAML 解析器:本仓库零依赖(不能为读一个字段引入 yaml 包),
70
+ * 而 importers 段的缩进是 pnpm 写死的(section 4 空格、包名 6 空格、字段 8 空格)。 */
71
+ function parseLockImporters(lockText) {
72
+ const text = typeof lockText === 'string' ? lockText : String(lockText ?? '')
73
+ const lines = text.split(/\r?\n/u)
74
+ const start = lines.findIndex((l) => /^importers:\s*$/u.test(l))
75
+ if (start === -1) return { present: true, parsed: false, importers: [] }
76
+ const importers = []
77
+ let section = null
78
+ let current = null
79
+ for (let i = start + 1; i < lines.length; i += 1) {
80
+ const line = lines[i]
81
+ if (/^[A-Za-z]/u.test(line)) break // 下一个顶层段(packages: / settings:)
82
+ const sectionMatch = /^\s{2,6}([A-Za-z]+):\s*$/u.exec(line)
83
+ if (sectionMatch !== null && DEP_SECTIONS.includes(sectionMatch[1])) {
84
+ section = sectionMatch[1]
85
+ current = null
86
+ continue
87
+ }
88
+ const keyMatch = /^\s{6,}(?:'([^']+)'|"([^"]+)"|([^\s:'"][^\s:]*)):\s*$/u.exec(line)
89
+ if (keyMatch !== null && section !== null) {
90
+ current = { name: keyMatch[1] ?? keyMatch[2] ?? keyMatch[3], specifier: null, version: null }
91
+ importers.push(current)
92
+ continue
93
+ }
94
+ if (current !== null) {
95
+ const field = /^\s+(specifier|version):\s*(.+?)\s*$/u.exec(line)
96
+ if (field !== null) {
97
+ const value = field[2].replace(/^'|'$/gu, '')
98
+ if (field[1] === 'specifier') current.specifier = value
99
+ else current.version = value
100
+ continue
101
+ }
102
+ if (/^\s{6,}\S/u.test(line)) current = null // 同一段里的下一个包
103
+ }
104
+ }
105
+ return { present: true, parsed: importers.length > 0, importers }
106
+ }
107
+
108
+ /** 依赖是否满足 manifest 的 spec(纯函数,容错):
109
+ * · 版本范围(^ ~ >= …)→ semver 匹配;
110
+ * · 精确版本 → 字符串相等;
111
+ * · 非 registry 来源(link:/file:/git+/http…)→ 交给 specifier 相等判定,这里恒 true。 */
112
+ function specSatisfiedBy(spec, version) {
113
+ if (typeof spec !== 'string' || spec === '') return true
114
+ if (/^(?:link|file|workspace|portal|npm|git\+|git:|github:|https?:)/iu.test(spec)) return true
115
+ if (typeof version !== 'string' || version === '') return false
116
+ if (/^[\^~]?\d/u.test(spec) || /^(?:>=|>|=|<=|<)\s*\d/u.test(spec)) return semverRangeMatch(version, spec)
117
+ return version === spec
118
+ }
119
+
120
+ /**
121
+ * 清单 vs lock vs 磁盘的三方对账(**纯函数**,体检结论的唯一来源):
122
+ * · missing —— manifest 里有、lock importer 里没有(陈旧残缺);
123
+ * · specifierMismatch —— lock 记的 specifier 与 manifest 现在的 spec 不一致(改过范围没重装);
124
+ * · versionMismatch —— lock 钉住的版本不满足 manifest 的 spec(如 manifest 0.5.14 / lock 0.5.4);
125
+ * · drift —— node_modules 里实际装的版本 != lock 钉住的版本(装了但没写进 lock)。
126
+ * `installed` 由调用方读盘后传入(纯函数不做 IO)。 */
127
+ function diffLockfile({ manifestText, lockText, installed = {} } = {}) {
128
+ const manifest = readManifestDeps(manifestText)
129
+ const lock = lockText === null || lockText === undefined
130
+ ? { present: false, parsed: false, importers: [] }
131
+ : parseLockImporters(lockText)
132
+ const byName = new Map(lock.importers.map((entry) => [entry.name, entry]))
133
+ const deps = []
134
+ const missing = []
135
+ const specifierMismatch = []
136
+ const versionMismatch = []
137
+ const drift = []
138
+ for (const dep of manifest.deps) {
139
+ const locked = byName.get(dep.name) ?? null
140
+ if (locked === null) {
141
+ missing.push(dep.name)
142
+ deps.push({ name: dep.name, spec: dep.spec, section: dep.section, lockSpecifier: null, lockVersion: null, state: 'missing' })
143
+ continue
144
+ }
145
+ let state = 'ok'
146
+ if (locked.specifier !== null && locked.specifier !== dep.spec) {
147
+ specifierMismatch.push({ name: dep.name, spec: dep.spec, lockSpecifier: locked.specifier })
148
+ state = 'specifier-mismatch'
149
+ } else if (!specSatisfiedBy(dep.spec, locked.version)) {
150
+ versionMismatch.push({ name: dep.name, spec: dep.spec, lockVersion: locked.version })
151
+ state = 'version-mismatch'
152
+ }
153
+ const onDisk = installed[dep.name] ?? null
154
+ if (onDisk !== null && locked.version !== null && onDisk !== locked.version) {
155
+ drift.push({ name: dep.name, installed: onDisk, lockVersion: locked.version })
156
+ }
157
+ deps.push({ name: dep.name, spec: dep.spec, section: dep.section, lockSpecifier: locked.specifier, lockVersion: locked.version, state })
158
+ }
159
+ return {
160
+ manifestOk: manifest.ok,
161
+ manifestError: manifest.error,
162
+ lockfilePresent: lock.present,
163
+ lockfileParsed: lock.parsed,
164
+ deps,
165
+ missing,
166
+ specifierMismatch,
167
+ versionMismatch,
168
+ drift,
169
+ upToDate: manifest.ok && lock.present && lock.parsed
170
+ && missing.length === 0 && specifierMismatch.length === 0 && versionMismatch.length === 0,
171
+ }
172
+ }
173
+
174
+ /** registry 元数据里"刚发布不久"的版本(纯函数):只看 dist-tags.latest 的发布时间。
175
+ * 返回 [{ name, version, publishedAt, ageHours }];镜像没给 `time` 时返回 [](调用方口径:不判定)。 */
176
+ function freshReleases(name, meta, { now = Date.now(), hours = RELEASE_AGE_HOURS } = {}) {
177
+ const latest = meta !== null && typeof meta === 'object' && typeof meta['dist-tags']?.latest === 'string' ? meta['dist-tags'].latest : null
178
+ const time = meta !== null && typeof meta === 'object' && meta.time !== null && typeof meta.time === 'object' ? meta.time : null
179
+ if (latest === null || time === null || typeof time[latest] !== 'string') return []
180
+ const at = Date.parse(time[latest])
181
+ if (!Number.isFinite(at)) return []
182
+ const ageHours = (now - at) / 3600000
183
+ return ageHours >= 0 && ageHours < hours ? [{ name, version: latest, publishedAt: time[latest], ageHours: Math.round(ageHours * 10) / 10 }] : []
184
+ }
185
+
186
+ /** registry 探测结论 → 分类(纯函数):能从 tried 文本里认出 404 就点名 404,否则算"不可达"。 */
187
+ function probeVerdict(probe) {
188
+ const tried = Array.isArray(probe?.tried) ? probe.tried.join(' | ') : ''
189
+ if (/HTTP 404|Not Found|E404|is not in the npm registry/iu.test(tried)) return 'fetch-404'
190
+ return 'network-timeout'
191
+ }
192
+
193
+ /** manifest 的 spec 里能确定"就是这一个版本"时取出来(用于探测"这个版本在不在 registry 上")。 */
194
+ function exactVersionOf(spec) {
195
+ const m = /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/u.exec(String(spec ?? '').trim())
196
+ return m === null ? null : m[1]
197
+ }
198
+
199
+ /** 一句话总结(短句,面板用)。 */
200
+ function summarizeCheck(view) {
201
+ if (view.ok) return '依赖锁与清单一致,无需处理。'
202
+ const kinds = [...new Set(view.problems.map((p) => p.kind))]
203
+ const named = view.problems.flatMap((p) => p.packages).slice(0, 4)
204
+ return `发现 ${view.problems.length} 类问题(${kinds.join('、')})${named.length === 0 ? '' : `:${named.join('、')}`}`
205
+ }
206
+
207
+ /**
208
+ * 只读体检(**不写任何文件**)。
209
+ * IO 全部注入:probe(registry 探测,默认 probeRegistryPackage)、readFile、exists。
210
+ * 返回体检视图(HTTP 响应体同款):
211
+ * { ok, checkedAt, profileDir, registry, manifestDeps, lockfile, problems, packages404,
212
+ * outdated, supplyChainAge, repair, hint }
213
+ */
214
+ async function runLockfileCheck({
215
+ profileDir,
216
+ registries = [],
217
+ probe = probeRegistryPackage,
218
+ readFile = (p) => readFileSync(p, 'utf8'),
219
+ exists = existsSync,
220
+ now = Date.now(),
221
+ releaseAgeHours = RELEASE_AGE_HOURS,
222
+ } = {}) {
223
+ const regList = (Array.isArray(registries) ? registries : []).filter((r) => typeof r === 'string' && r.trim() !== '')
224
+ const probeList = regList.length > 0 ? regList : [DEFAULT_REGISTRY]
225
+ const registry = probeList[0]
226
+ const dir = typeof profileDir === 'string' && profileDir !== '' ? profileDir : null
227
+ const read = (name) => {
228
+ if (dir === null) return null
229
+ const file = join(dir, name)
230
+ try {
231
+ return exists(file) ? readFile(file) : null
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+ const manifestText = read(MANIFEST_NAME)
237
+ const lockText = read(LOCKFILE_NAME)
238
+ const installed = {}
239
+ if (dir !== null) {
240
+ for (const dep of readManifestDeps(manifestText ?? '').deps) {
241
+ const text = read(join('node_modules', ...dep.name.split('/'), MANIFEST_NAME))
242
+ if (text === null) continue
243
+ try {
244
+ const version = JSON.parse(text)?.version
245
+ if (typeof version === 'string') installed[dep.name] = version
246
+ } catch {}
247
+ }
248
+ }
249
+ const diff = diffLockfile({ manifestText: manifestText ?? '', lockText, installed })
250
+ const manifest = readManifestDeps(manifestText ?? '')
251
+
252
+ // ① 依赖能不能在 registry 上解析(404 要点名;不可达与 404 分开说)
253
+ const probes = await Promise.all((manifest.ok ? manifest.deps : []).map(async (dep) => {
254
+ const result = await probe(dep.name, probeList, {
255
+ version: exactVersionOf(dep.spec),
256
+ timeoutMs: PROBE_TIMEOUT_MS,
257
+ includeMeta: true,
258
+ }).catch((error) => ({ resolvable: false, hasVersion: false, latest: null, registry: null, tried: [String(error?.message ?? error)] }))
259
+ return { dep, result }
260
+ }))
261
+ const missing404 = []
262
+ const missingVersion = []
263
+ const unreachable = []
264
+ const supplyChainAge = []
265
+ for (const { dep, result } of probes) {
266
+ if (result.resolvable !== true) {
267
+ if (probeVerdict(result) === 'fetch-404') missing404.push(dep.name)
268
+ else unreachable.push(dep.name)
269
+ continue
270
+ }
271
+ if (result.hasVersion === false) missingVersion.push(`${dep.name}@${exactVersionOf(dep.spec) ?? '?'}`)
272
+ supplyChainAge.push(...freshReleases(dep.name, result.meta, { now, hours: releaseAgeHours }))
273
+ }
274
+
275
+ const problems = []
276
+ const name404 = [...missing404, ...missingVersion]
277
+ if (name404.length > 0) {
278
+ problems.push({ kind: 'fetch-404', hint: hintForKind('fetch-404', name404), packages: name404, note: 'registry 上解析不到这些依赖:修 lock 解决不了,必须先核对包名/版本或换源。' })
279
+ }
280
+ if (unreachable.length > 0) {
281
+ problems.push({
282
+ kind: 'network-timeout',
283
+ hint: '有依赖没能探测成功(registry 不可达或超时):先确认网络/镜像,再体检一次;本次不重建 lock。',
284
+ packages: unreachable,
285
+ note: null,
286
+ })
287
+ }
288
+ if (!diff.upToDate || diff.drift.length > 0) {
289
+ const names = [...new Set([...diff.missing, ...diff.specifierMismatch.map((x) => x.name), ...diff.versionMismatch.map((x) => x.name)])]
290
+ problems.push({
291
+ kind: 'lockfile-outdated',
292
+ hint: hintForKind('lockfile-outdated', names),
293
+ packages: names,
294
+ note: diff.lockfilePresent
295
+ ? `lock 与清单对不上:缺 ${diff.missing.length} 项、specifier 漂移 ${diff.specifierMismatch.length} 项、版本不满足 ${diff.versionMismatch.length} 项`
296
+ : 'pnpm-lock.yaml 不存在(任何 pnpm 全量解析都会重建它)',
297
+ })
298
+ }
299
+ if (supplyChainAge.length > 0) {
300
+ problems.push({
301
+ kind: 'supply-chain-age',
302
+ hint: hintForKind('supply-chain-age', supplyChainAge.map((x) => `${x.name}@${x.version}`)),
303
+ packages: supplyChainAge.map((x) => `${x.name}@${x.version}(${x.ageHours}h)`),
304
+ note: '只提示:重建 lock 时 pnpm 的 minimumReleaseAge 闸可能拦下这些"刚发布"的版本。',
305
+ })
306
+ }
307
+ const blockedBy = problems.filter((p) => p.kind === 'fetch-404' || p.kind === 'network-timeout').map((p) => p.kind)
308
+ const fixable = problems.some((p) => p.kind === 'lockfile-outdated')
309
+ const view = {
310
+ ok: problems.length === 0,
311
+ checkedAt: now,
312
+ profileDir: dir,
313
+ registry,
314
+ manifestDeps: manifest.deps.length,
315
+ lockfile: { present: diff.lockfilePresent, parsed: diff.lockfileParsed, entries: diff.deps.filter((d) => d.lockVersion !== null).length },
316
+ problems,
317
+ packages404: name404,
318
+ outdated: {
319
+ lockfileMissing: !diff.lockfilePresent,
320
+ missing: diff.missing,
321
+ specifierMismatch: diff.specifierMismatch,
322
+ versionMismatch: diff.versionMismatch,
323
+ drift: diff.drift,
324
+ },
325
+ supplyChainAge,
326
+ repair: {
327
+ applicable: fixable && blockedBy.length === 0,
328
+ blockedBy,
329
+ command: repairArgsFor(registry).join(' '),
330
+ },
331
+ hint: null,
332
+ }
333
+ view.hint = summarizeCheck(view)
334
+ return view
335
+ }
336
+
337
+ /** 体检视图 → 短句清单(面板只放短句;长文本走 hint/note)。 */
338
+ function problemLines(view) {
339
+ return (view?.problems ?? []).map((p) => ({ kind: p.kind, text: p.hint, packages: p.packages ?? [] }))
340
+ }
341
+
342
+ /**
343
+ * **用户显式触发**的 lock 重建(本模块唯一会写文件的地方,而且只让 pnpm 写 pnpm-lock.yaml):
344
+ * ① 先只读体检;发现 404 / 探测不到 → `action: 'blocked'`,**一个文件都不写**,如实列出包名;
345
+ * ② 否则跑 `pnpm install --lockfile-only --no-frozen-lockfile --registry <主源>`(argv 由 repairArgsFor 唯一产出);
346
+ * ③ 复检:lock 与清单一致才算成功(不谎报),否则 `action: 'partial'` 并留下剩余问题;
347
+ * ④ pnpm 报错时按 install-diagnose 的分类如实回报(supply-chain-age 只给"可自行显式绕过"的提示)。
348
+ * 返回 { ok, action, kind, hint, packages, before, after, command, stderrTail, ... }。
349
+ */
350
+ async function runLockfileRepair({
351
+ profileDir,
352
+ registries = [],
353
+ check = runLockfileCheck,
354
+ runPnpm = runPnpmWithFallback,
355
+ now = Date.now(),
356
+ deps = {},
357
+ } = {}) {
358
+ const registry = (Array.isArray(registries) ? registries : []).find((r) => typeof r === 'string' && r.trim() !== '') ?? DEFAULT_REGISTRY
359
+ const args = repairArgsFor(registry)
360
+ const base = { checkedAt: now, profileDir: profileDir ?? null, registry, command: args.join(' '), packages: [], stderrTail: null }
361
+ const before = await check({ profileDir, registries, now, ...(deps ?? {}) })
362
+ base.before = before.outdated
363
+ const notFound = before.problems.find((p) => p.kind === 'fetch-404') ?? null
364
+ if (notFound !== null) {
365
+ return {
366
+ ...base,
367
+ ok: false,
368
+ action: 'blocked',
369
+ kind: 'fetch-404',
370
+ hint: notFound.hint,
371
+ packages: notFound.packages,
372
+ after: before.outdated,
373
+ reason: '有依赖在 registry 上解析不到:重建 lock 必然失败,而且"跳过它"等于静默丢弃你的依赖 —— 已停下,未改任何文件。',
374
+ }
375
+ }
376
+ const unreachable = before.problems.find((p) => p.kind === 'network-timeout') ?? null
377
+ if (unreachable !== null) {
378
+ return { ...base, ok: false, action: 'blocked', kind: 'network-timeout', hint: unreachable.hint, packages: unreachable.packages, after: before.outdated, reason: 'registry 探测未全部成功:先解决网络/镜像再重建 lock。' }
379
+ }
380
+ if (before.repair.applicable !== true) {
381
+ return { ...base, ok: true, action: 'noop', kind: null, hint: before.hint, after: before.outdated, reason: '体检未发现需要重建的 lock 问题。' }
382
+ }
383
+ try {
384
+ await runPnpm(args, { execOpts: { cwd: profileDir, timeout: REPAIR_TIMEOUT_MS, windowsHide: true, maxBuffer: 8 * 1024 * 1024, env: buildPnpmEnv(registry) } })
385
+ } catch (error) {
386
+ const diagnosis = classifyInstallFailure(error?.message)
387
+ return {
388
+ ...base,
389
+ ok: false,
390
+ action: 'failed',
391
+ kind: diagnosis.kind,
392
+ hint: diagnosis.hint,
393
+ packages: diagnosis.packages,
394
+ after: before.outdated,
395
+ stderrTail: String(error?.message ?? error).slice(-600),
396
+ reason: 'pnpm 重建 lock 失败(原因见分类与原始输出)——未动 package.json / node_modules。',
397
+ }
398
+ }
399
+ const after = await check({ profileDir, registries, now: Date.now(), ...(deps ?? {}) })
400
+ const remaining = after.problems.filter((p) => p.kind === 'lockfile-outdated')
401
+ const repaired = after.outdated.missing.length === 0 && after.outdated.versionMismatch.length === 0 && after.outdated.specifierMismatch.length === 0 && after.lockfile.present
402
+ return {
403
+ ...base,
404
+ ok: repaired,
405
+ action: repaired ? 'repaired' : 'partial',
406
+ kind: repaired ? null : 'lockfile-outdated',
407
+ hint: repaired ? 'lock 已按清单重建(pnpm install --lockfile-only)。' : (remaining[0]?.hint ?? after.hint),
408
+ packages: repaired ? [] : [...new Set(remaining.flatMap((p) => p.packages))],
409
+ after: after.outdated,
410
+ reason: repaired ? '只重写了 pnpm-lock.yaml;package.json 与 node_modules 未改动。' : '重建后 lock 与清单仍有差距(详见 after)。',
411
+ }
412
+ }
413
+
414
+ export {
415
+ DEFAULT_REGISTRY,
416
+ LOCKFILE_NAME,
417
+ MANIFEST_NAME,
418
+ RELEASE_AGE_HOURS,
419
+ REPAIR_TIMEOUT_MS,
420
+ DEP_SECTIONS,
421
+ diffLockfile,
422
+ exactVersionOf,
423
+ freshReleases,
424
+ parseLockImporters,
425
+ problemLines,
426
+ probeVerdict,
427
+ readManifestDeps,
428
+ repairArgsFor,
429
+ runLockfileCheck,
430
+ runLockfileRepair,
431
+ specSatisfiedBy,
432
+ summarizeCheck,
433
+ }