dsh-deps 0.0.1 → 0.1.0

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/deps.mjs ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * 依赖收集与快照归一:只读,不联网。
3
+ *
4
+ * 数据来源只有三样,全部由调用方提供:
5
+ * 1) package.json 的 manifest(对象);
6
+ * 2) 本地 registry 元数据快照 `{name: {latest, deprecated?, engines?, yanked?}}`;
7
+ * 3) 可选的 installed 快照 `{name: "1.2.3"}`(比如 lockfile 里解析出的实际版本)。
8
+ * 快照里没有的包 → 绝不猜 latest,一律 unknown。
9
+ */
10
+
11
+ import { parseVersion } from './version.mjs'
12
+
13
+ /** manifest 里会被收集的依赖段(按优先级排序)。 */
14
+ export const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
15
+
16
+ /**
17
+ * 从 manifest 收集依赖(同名只保留第一段,并记下全部段)。
18
+ * @param {object} manifest package.json 对象
19
+ * @returns {Array<{name:string, range:string, section:string, sections:string[], dev:boolean, optional:boolean, peer:boolean}>} 依赖清单
20
+ */
21
+ export function collectDependencies(manifest) {
22
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return []
23
+ const found = new Map()
24
+ for (const section of DEP_SECTIONS) {
25
+ const table = manifest[section]
26
+ if (table === null || typeof table !== 'object' || Array.isArray(table)) continue
27
+ for (const [name, range] of Object.entries(table)) {
28
+ if (typeof range !== 'string') continue
29
+ if (found.has(name)) {
30
+ found.get(name).sections.push(section)
31
+ continue
32
+ }
33
+ found.set(name, {
34
+ name,
35
+ range: range.trim(),
36
+ section,
37
+ sections: [section],
38
+ dev: section === 'devDependencies',
39
+ optional: section === 'optionalDependencies',
40
+ peer: section === 'peerDependencies',
41
+ })
42
+ }
43
+ }
44
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name))
45
+ }
46
+
47
+ /**
48
+ * 归一化 registry 快照;形状不对的条目记入 problems,而不是猜。
49
+ * @param {unknown} snapshot 快照
50
+ * @returns {{entries:Map<string,object>, problems:string[]}} 归一化结果
51
+ */
52
+ export function normalizeSnapshot(snapshot) {
53
+ const entries = new Map()
54
+ const problems = []
55
+ if (snapshot === null || snapshot === undefined) return { entries, problems: ['没有提供 registry 快照:所有依赖都会是 unknown'] }
56
+ if (typeof snapshot !== 'object' || Array.isArray(snapshot)) return { entries, problems: ['registry 快照不是对象'] }
57
+
58
+ for (const [name, raw] of Object.entries(snapshot)) {
59
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
60
+ problems.push(`${name} 的快照不是对象,已忽略`)
61
+ continue
62
+ }
63
+ const latest = parseVersion(raw.latest) ? raw.latest : null
64
+ if (raw.latest !== undefined && latest === null) problems.push(`${name}.latest 不是可解析的版本:${String(raw.latest)}`)
65
+ const engines = raw.engines && typeof raw.engines === 'object' && !Array.isArray(raw.engines) ? raw.engines : null
66
+ entries.set(name, {
67
+ latest,
68
+ deprecated: typeof raw.deprecated === 'string' ? raw.deprecated : raw.deprecated === true ? '已标记 deprecated' : null,
69
+ engines,
70
+ yankedVersions: normalizeYanked(raw.yanked),
71
+ hasEntry: true,
72
+ })
73
+ }
74
+ return { entries, problems }
75
+ }
76
+
77
+ /**
78
+ * 归一化 yanked 标记:`true` / 字符串 / 字符串数组。
79
+ * @param {unknown} yanked 标记
80
+ * @returns {string[]} 被 yanked 的版本列表(空数组表示没标)
81
+ */
82
+ function normalizeYanked(yanked) {
83
+ if (yanked === true) return ['*']
84
+ if (typeof yanked === 'string' && yanked.trim().length > 0) return [yanked.trim()]
85
+ if (Array.isArray(yanked)) return yanked.filter((item) => typeof item === 'string' && item.trim().length > 0).map((item) => item.trim())
86
+ return []
87
+ }
88
+
89
+ /**
90
+ * 归一化 installed 快照(实际安装版本)。
91
+ * @param {unknown} installed 快照
92
+ * @returns {{versions:Map<string,string>, problems:string[]}} 归一化结果
93
+ */
94
+ export function normalizeInstalled(installed) {
95
+ const versions = new Map()
96
+ const problems = []
97
+ if (installed === null || installed === undefined) return { versions, problems: [] }
98
+ if (typeof installed !== 'object' || Array.isArray(installed)) return { versions, problems: ['installed 快照不是对象'] }
99
+ for (const [name, version] of Object.entries(installed)) {
100
+ if (parseVersion(version)) versions.set(name, String(version))
101
+ else problems.push(`installed.${name} 不是可解析的版本:${String(version)}`)
102
+ }
103
+ return { versions, problems }
104
+ }
105
+
106
+ /**
107
+ * 统计「被依赖次数」:给多份 manifest(monorepo/工作区)时按声明计数;只给一份时每项计 1。
108
+ * @param {object[]} manifests manifest 列表(第一份视为根 manifest)
109
+ * @param {string[]} names 依赖名
110
+ * @returns {{counts:Map<string,number>, source:'manifests'|'single-manifest'}} 计数
111
+ */
112
+ export function countDependents(manifests, names) {
113
+ const counts = new Map(names.map((name) => [name, 0]))
114
+ const list = Array.isArray(manifests) && manifests.length > 0 ? manifests : []
115
+ if (list.length <= 1) {
116
+ for (const name of names) counts.set(name, 1)
117
+ return { counts, source: 'single-manifest' }
118
+ }
119
+ for (const manifest of list) {
120
+ if (manifest === null || typeof manifest !== 'object') continue
121
+ const seen = new Set()
122
+ for (const section of DEP_SECTIONS) {
123
+ const table = manifest[section]
124
+ if (table === null || typeof table !== 'object' || Array.isArray(table)) continue
125
+ for (const name of Object.keys(table)) {
126
+ if (counts.has(name) && !seen.has(name)) {
127
+ counts.set(name, counts.get(name) + 1)
128
+ seen.add(name)
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return { counts, source: 'manifests' }
134
+ }
135
+
136
+
137
+ /** 本地快照的形状(写进文档与 CLI `snapshot-shape`,避免调用方猜格式)。 */
138
+ export const SNAPSHOT_SHAPE = {
139
+ shape: '{ "<package>": { "latest": "<x.y.z>", "deprecated"?: string | true, "engines"?: { "node": "<range>" }, "yanked"?: true | string | string[] } }',
140
+ latest: '必填才能判断升级;缺了就是 unknown(不猜)',
141
+ deprecated: '可选;真值表示该包已废弃',
142
+ engines: '可选;只读不猜,声明了才判断与当前 Node 的兼容性',
143
+ yanked: '可选;true=全部版本被撤,字符串/数组=指定版本被撤',
144
+ }
145
+
146
+ /**
147
+ * 快照形状说明。
148
+ * @returns {object} 形状
149
+ */
150
+ export function describeSnapshotShape() {
151
+ return { ...SNAPSHOT_SHAPE }
152
+ }
package/lib/engine.mjs ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * dsh-deps 编排:依赖升级规划。plan / why / pin / audit。
3
+ *
4
+ * 只读、不联网、不跑 npm outdated:latest 只来自调用方给的本地快照 JSON。
5
+ * 快照里没有 → unknown(不猜)。
6
+ */
7
+
8
+ import { collectDependencies, normalizeSnapshot, normalizeInstalled, countDependents } from './deps.mjs'
9
+ import { classifyAll } from './classify.mjs'
10
+ import { buildPlan, auditReport, explainEntry, ORDER_RATIONALE, PHASES } from './plan.mjs'
11
+ import { buildPinSuggestions } from './pin.mjs'
12
+ import { DepsError } from './errors.mjs'
13
+
14
+ /** 包版本。 */
15
+ export const VERSION = '0.1.0'
16
+
17
+ /** 支持的动作。 */
18
+ export const ACTIONS = ['plan', 'why', 'pin', 'audit']
19
+
20
+ /** 固定能力边界声明:每次结果都带。 */
21
+ export const LIMITATIONS = [
22
+ '不联网、不跑 npm outdated、不读 node_modules:latest 只来自调用方给的本地快照 JSON;快照里没有就 unknown,不猜。',
23
+ '当前版本默认取「声明范围下界」,不是 lock 里的实际安装版本;要实际版本请传 installed 快照(本包不重复 dsh-lockfile 的漂移检查)。',
24
+ 'semver 是子集:支持 ^ ~ >= <= > < = *、x 范围、AND/OR、连字符;不支持 npm 别名、git/file/workspace 范围与 prerelease 特例,遇到就 unknown。',
25
+ '只做升级规划,不做依赖冲突、重复包、peer 求解(那是 dsh-pkg 的活),也不改 package.json / lockfile。',
26
+ '风险信号是静态判据(major / engines / deprecated / yanked / peer),不代表真实兼容性;升级前仍要跑测试。',
27
+ 'pin 只生成 diff 文本,不写文件;没有 installed 快照就不猜锁版本。',
28
+ ]
29
+
30
+ /**
31
+ * 解析输入 manifest。
32
+ * @param {object} options 选项
33
+ * @returns {object} manifest
34
+ */
35
+ function resolveManifest(options) {
36
+ if (options.manifest && typeof options.manifest === 'object' && !Array.isArray(options.manifest)) return options.manifest
37
+ if (options.packageJson && typeof options.packageJson === 'object' && !Array.isArray(options.packageJson)) return options.packageJson
38
+ if (typeof options.packageJsonText === 'string' && options.packageJsonText.trim().length > 0) {
39
+ try {
40
+ const parsed = JSON.parse(options.packageJsonText)
41
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('根不是对象')
42
+ return parsed
43
+ } catch (error) {
44
+ throw new DepsError('invalid_manifest', `package.json 文本不是合法 JSON:${error instanceof Error ? error.message : String(error)}`)
45
+ }
46
+ }
47
+ throw new DepsError('missing_manifest', '需要 manifest(package.json 解析后的对象)或 packageJsonText', {
48
+ hint: '插件里传 packageJson 对象;CLI 用 --package-json <路径>。',
49
+ })
50
+ }
51
+
52
+ /**
53
+ * 预处理:收集依赖 + 归一化快照 + 分类。
54
+ * @param {object} options 选项
55
+ * @returns {object} 上下文
56
+ */
57
+ function prepare(options) {
58
+ const manifest = resolveManifest(options)
59
+ const deps = collectDependencies(manifest)
60
+ const snapshot = normalizeSnapshot(options.snapshot)
61
+ const installed = normalizeInstalled(options.installed)
62
+ const problems = [...snapshot.problems, ...installed.problems]
63
+
64
+ const manifestsForCount = Array.isArray(options.manifests) && options.manifests.length > 0 ? options.manifests : [manifest]
65
+ const { counts, source } = countDependents(manifestsForCount, deps.map((dep) => dep.name))
66
+
67
+ // 调用方显式给的 dependents 覆盖自动计数
68
+ if (options.dependents && typeof options.dependents === 'object' && !Array.isArray(options.dependents)) {
69
+ for (const [name, value] of Object.entries(options.dependents)) {
70
+ const number = Number(value)
71
+ if (counts.has(name) && Number.isFinite(number) && number >= 0) counts.set(name, Math.trunc(number))
72
+ }
73
+ }
74
+
75
+ const nodeVersion = typeof options.nodeVersion === 'string' && options.nodeVersion.length > 0 ? options.nodeVersion : process.versions.node
76
+ const { entries } = classifyAll(manifest, { deps, snapshot: snapshot.entries, installed: installed.versions, dependents: counts, nodeVersion, problems })
77
+
78
+ return { manifest, entries, snapshot, installed, counts, countSource: source, nodeVersion, problems }
79
+ }
80
+
81
+ /**
82
+ * 处理一个动作。
83
+ * @param {string} action 动作
84
+ * @param {object} [options] 选项
85
+ * @returns {object} 结果(纯 JSON、只读标记)
86
+ */
87
+ export function runAction(action, options = {}) {
88
+ if (!ACTIONS.includes(action)) {
89
+ throw new DepsError('invalid_action', `不认识的动作 ${JSON.stringify(action)}`, { hint: `可选:${ACTIONS.join(' / ')}` })
90
+ }
91
+
92
+ const context = prepare(options)
93
+ const base = {
94
+ tool: 'dsh_deps',
95
+ version: VERSION,
96
+ readOnly: true,
97
+ wroteFiles: [],
98
+ limitations: [...LIMITATIONS],
99
+ }
100
+ const plan = buildPlan(context.entries, { countSource: context.countSource })
101
+
102
+ const common = {
103
+ ...base,
104
+ manifestName: typeof context.manifest.name === 'string' ? context.manifest.name : '',
105
+ nodeVersion: context.nodeVersion,
106
+ snapshotProvided: context.snapshot.entries.size > 0,
107
+ installedProvided: context.installed.versions.size > 0,
108
+ versionSourceNote: context.installed.versions.size > 0 ? '当前版本优先取 installed 快照' : '当前版本取声明范围下界(不是 lock 实际版本)',
109
+ entries: context.entries,
110
+ plan,
111
+ notes: [...context.problems],
112
+ rationale: ORDER_RATIONALE,
113
+ phaseOrder: [...PHASES],
114
+ }
115
+
116
+ if (action === 'plan') return { ...common, action: 'plan' }
117
+
118
+ if (action === 'why') {
119
+ const name = typeof options.name === 'string' ? options.name.trim() : ''
120
+ if (name.length === 0) throw new DepsError('missing_name', 'why 需要 name(要解释的依赖名)')
121
+ const explanation = explainEntry(name, context.entries, {
122
+ snapshot: context.snapshot.entries,
123
+ installed: context.installed.versions,
124
+ nodeVersion: context.nodeVersion,
125
+ manifest: context.manifest,
126
+ })
127
+ return { ...common, action: 'why', ...explanation }
128
+ }
129
+
130
+ if (action === 'pin') {
131
+ const pin = buildPinSuggestions(context.entries, { packageJsonText: options.packageJsonText })
132
+ return { ...common, action: 'pin', ...pin, notes: [...context.problems, ...pin.notes] }
133
+ }
134
+
135
+ // audit
136
+ const audit = auditReport(context.entries, plan, context.problems, {
137
+ nodeVersion: context.nodeVersion,
138
+ snapshotProvided: context.snapshot.entries.size > 0,
139
+ })
140
+ return { ...common, action: 'audit', audit }
141
+ }
142
+
143
+ export { describeSnapshotShape, SNAPSHOT_SHAPE } from './deps.mjs'
package/lib/errors.mjs ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 稳定错误码:按 code 分支,不靠猜文案。
3
+ *
4
+ * 「快照里没有 latest」不是异常,是正常结论(bump=unknown);
5
+ * 异常只留给「manifest 缺失/不合法 / 动作不认识 / 快照文件读不了」这类。
6
+ */
7
+
8
+ /** 本插件错误基类。 */
9
+ export class DepsError extends Error {
10
+ /**
11
+ * @param {string} code 稳定错误码
12
+ * @param {string} message 中文人话
13
+ * @param {{hint?:string, cause?:unknown}} [init] 附加
14
+ */
15
+ constructor(code, message, init = {}) {
16
+ super(message, init.cause === undefined ? undefined : { cause: init.cause })
17
+ this.name = 'DepsError'
18
+ this.code = code
19
+ this.hint = init.hint
20
+ }
21
+ }
22
+
23
+ /**
24
+ * 把任意异常转成结构化错误。
25
+ * @param {unknown} error 异常
26
+ * @returns {{code:string, message:string, hint?:string}} 结构化错误
27
+ */
28
+ export function describeError(error) {
29
+ if (error instanceof DepsError) return { code: error.code, message: error.message, hint: error.hint }
30
+ return { code: 'internal', message: error instanceof Error ? error.message : String(error) }
31
+ }
package/lib/format.mjs ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * 结果 → 文本:工具 render 与 CLI 共用。
3
+ *
4
+ * 排版口径:先结论,再建议顺序(每步带依据),最后是 unknown / blocked / pinned 的分流。
5
+ * 所有动作都只读,文本里不出现「已修改」这种话。
6
+ */
7
+
8
+ /**
9
+ * 渲染任意动作结果。
10
+ * @param {object} report 结果
11
+ * @param {{detail?:'brief'|'full'}} [options] 选项
12
+ * @returns {string} 文本
13
+ */
14
+ export function renderReport(report, options = {}) {
15
+ const full = (options.detail ?? 'full') === 'full'
16
+ const lines = [`dsh-deps ${report.version} · ${report.action}`]
17
+
18
+ switch (report.action) {
19
+ case 'plan': renderPlan(lines, report, full); break
20
+ case 'why': renderWhy(lines, report, full); break
21
+ case 'pin': renderPin(lines, report, full); break
22
+ case 'audit': renderAudit(lines, report, full); break
23
+ default: lines.push('(未知动作)')
24
+ }
25
+
26
+ if (full && Array.isArray(report.notes) && report.notes.length > 0) {
27
+ lines.push('')
28
+ lines.push('【说明】')
29
+ for (const note of report.notes) lines.push(`- ${note}`)
30
+ }
31
+ if (full && Array.isArray(report.limitations) && report.limitations.length > 0) {
32
+ lines.push('')
33
+ lines.push('【已知限制】')
34
+ for (const item of report.limitations) lines.push(`- ${item}`)
35
+ }
36
+ return lines.join('\n')
37
+ }
38
+
39
+ /**
40
+ * plan 渲染。
41
+ * @param {string[]} lines 输出行
42
+ * @param {object} report 结果
43
+ * @param {boolean} full 是否完整
44
+ * @returns {void}
45
+ */
46
+ function renderPlan(lines, report, full) {
47
+ const plan = report.plan
48
+ lines.push('')
49
+ lines.push(`【结论】共 ${plan.counts.total} 个依赖 | 可升 ${plan.counts.actionable} 个 | patch ${plan.counts.byBump.patch} / minor ${plan.counts.byBump.minor} / major ${plan.counts.byBump.major} | pinned ${plan.counts.byBump.pinned} | unknown ${plan.counts.byBump.unknown} | blocked ${plan.counts.byRisk.blocked}`)
50
+ if (full) lines.push(`理由:${plan.rationale}`)
51
+
52
+ if (plan.order.length === 0) {
53
+ lines.push('')
54
+ lines.push('没有需要按顺序执行的升级。')
55
+ } else {
56
+ lines.push('')
57
+ lines.push('【建议顺序】')
58
+ for (const item of plan.order) {
59
+ lines.push(`${item.step}. ${item.name} ${item.currentVersion} → ${item.latest}(${item.bump},risk=${item.risk},被依赖 ${item.dependents} 次)建议 ${item.suggested}`)
60
+ if (full) lines.push(` ${item.orderReason}`)
61
+ }
62
+ }
63
+
64
+ for (const [label, list] of [
65
+ ['被阻塞(不进队列)', plan.blocked],
66
+ ['未判定(不猜)', plan.unknown],
67
+ ['无需升级', plan.pinned],
68
+ ]) {
69
+ if (list.length === 0) continue
70
+ lines.push('')
71
+ lines.push(`【${label}】`)
72
+ for (const item of list.slice(0, 20)) lines.push(`- ${item.name}:${item.reason}${item.action ? ` → ${item.action}` : ''}`)
73
+ if (list.length > 20) lines.push(`- …还有 ${list.length - 20} 个`)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * why 渲染。
79
+ * @param {string[]} lines 输出行
80
+ * @param {object} report 结果
81
+ * @param {boolean} full 是否完整
82
+ * @returns {void}
83
+ */
84
+ function renderWhy(lines, report, full) {
85
+ lines.push('')
86
+ lines.push(`【结论】${report.conclusion}`)
87
+ if (!report.found) return
88
+ lines.push('')
89
+ lines.push('【判定依据】')
90
+ for (const check of report.checks) lines.push(`- ${check.id}:${JSON.stringify(check.value)} —— ${check.detail}`)
91
+ if (full && report.entry.signals.length > 0) {
92
+ lines.push('')
93
+ lines.push('【风险信号】')
94
+ for (const signal of report.entry.signals) lines.push(`- ${signal.signal}(${signal.severity}):${signal.detail} → ${signal.action}`)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * pin 渲染。
100
+ * @param {string[]} lines 输出行
101
+ * @param {object} report 结果
102
+ * @param {boolean} full 是否完整
103
+ * @returns {void}
104
+ */
105
+ function renderPin(lines, report, full) {
106
+ lines.push('')
107
+ lines.push(`【结论】${report.summary}`)
108
+ if (report.changes.length > 0) {
109
+ lines.push('')
110
+ lines.push('【收紧清单】')
111
+ for (const change of report.changes) lines.push(`- ${change.name}(${change.section}):${change.from} → ${change.to}(锁版本 ${change.locked})`)
112
+ lines.push('')
113
+ lines.push('【建议 diff(未写入,复制后自行应用)】')
114
+ lines.push(report.diff.trimEnd())
115
+ }
116
+ if (full && report.notes.length > 0) {
117
+ lines.push('')
118
+ lines.push('【跳过说明】')
119
+ for (const note of report.notes) lines.push(`- ${note}`)
120
+ }
121
+ }
122
+
123
+ /**
124
+ * audit 渲染。
125
+ * @param {string[]} lines 输出行
126
+ * @param {object} report 结果
127
+ * @param {boolean} full 是否完整
128
+ * @returns {void}
129
+ */
130
+ function renderAudit(lines, report, full) {
131
+ const audit = report.audit
132
+ lines.push('')
133
+ lines.push(`【结论 ${audit.verdict}】${audit.summary}`)
134
+ lines.push(`Node ${audit.nodeVersion ?? '(未知)'} | 快照${audit.snapshotProvided ? '已提供' : '未提供(所有 latest 都会 unknown)'}`)
135
+ lines.push('')
136
+ lines.push('【计数】' + Object.entries(audit.counts.byBump).map(([key, value]) => `${key} ${value}`).join(' | '))
137
+ const signals = Object.entries(audit.signals).sort((a, b) => b[1] - a[1])
138
+ if (signals.length > 0) {
139
+ lines.push('')
140
+ lines.push('【风险信号】')
141
+ for (const [signal, count] of signals) lines.push(`- ${signal} × ${count}`)
142
+ }
143
+ if (full && audit.recommendations.length > 0) {
144
+ lines.push('')
145
+ lines.push('【建议动作】')
146
+ for (const item of audit.recommendations) lines.push(`- ${item}`)
147
+ }
148
+ if (full && report.plan?.order?.length > 0) {
149
+ lines.push('')
150
+ lines.push('【建议顺序】')
151
+ for (const item of report.plan.order) lines.push(`${item.step}. ${item.name} ${item.currentVersion} → ${item.latest}(${item.bump})`)
152
+ }
153
+ }
154
+
155
+ /**
156
+ * JSON 渲染。
157
+ * @param {object} report 结果
158
+ * @returns {string} JSON
159
+ */
160
+ export function renderJson(report) {
161
+ return JSON.stringify(report, null, 2)
162
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 公开 API:plugin.mjs、CLI、别人的程序共用同一套实现。
3
+ */
4
+
5
+ export {
6
+ runAction, VERSION, ACTIONS, LIMITATIONS, describeSnapshotShape, SNAPSHOT_SHAPE,
7
+ } from './engine.mjs'
8
+
9
+ export {
10
+ parseVersion, compareVersions, versionEquals, parseRange, satisfiesRange, rangeFloor, bumpType, checkEngines,
11
+ SemverError, UNSUPPORTED_RANGE_PREFIXES,
12
+ } from './version.mjs'
13
+
14
+ export {
15
+ collectDependencies, normalizeSnapshot, normalizeInstalled, countDependents, DEP_SECTIONS,
16
+ } from './deps.mjs'
17
+
18
+ export {
19
+ classifyDependency, classifyAll, deriveRisk, suggestRange, BUMPS, RISKS, RISK_RANK, RISK_LABEL,
20
+ } from './classify.mjs'
21
+
22
+ export {
23
+ buildPlan, auditReport, explainEntry, PHASES, PHASE_LABEL, ORDER_RATIONALE,
24
+ } from './plan.mjs'
25
+
26
+ export { buildPinSuggestions } from './pin.mjs'
27
+
28
+ export { renderReport, renderJson } from './format.mjs'
29
+
30
+ export { DepsError, describeError } from './errors.mjs'
package/lib/pin.mjs ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * pin:把声明范围收紧到「已锁版本」的 patch 建议。
3
+ *
4
+ * 只生成 diff 文本,**不写任何文件**(返回 wroteFiles: [])。
5
+ * 没有 installed 快照的依赖不猜锁版本,列入 notes。
6
+ */
7
+
8
+ import { rangeFloor, satisfiesRange, parseVersion } from './version.mjs'
9
+ import { suggestRange } from './classify.mjs'
10
+
11
+ /**
12
+ * 生成 pin 建议。
13
+ * @param {object[]} entries 分类后的条目
14
+ * @param {{packageJsonText?:string}} [options] 选项
15
+ * @returns {object} 建议(含 diff 文本)
16
+ */
17
+ export function buildPinSuggestions(entries, options = {}) {
18
+ const changes = []
19
+ const notes = []
20
+
21
+ for (const entry of entries) {
22
+ if (entry.versionSource !== 'installed') {
23
+ notes.push(`${entry.name} 没有 installed 快照:无法给出 pin 建议(不猜锁版本)`)
24
+ continue
25
+ }
26
+ const floor = rangeFloor(entry.current)
27
+ const installed = entry.currentVersion
28
+ if (!floor || !parseVersion(installed)) {
29
+ notes.push(`${entry.name} 的范围 ${entry.current} 不可判定:跳过`)
30
+ continue
31
+ }
32
+ if (floor === installed) continue // 范围下界已经是锁版本,无需收紧
33
+ const satisfied = satisfiesRange(installed, entry.current)
34
+ if (!satisfied.supported) {
35
+ notes.push(`${entry.name} 的范围不受支持(${entry.current}):跳过`)
36
+ continue
37
+ }
38
+ if (!satisfied.satisfies) {
39
+ notes.push(`${entry.name} 的锁版本 ${installed} 不满足声明范围 ${entry.current}:这是 drift,不是 pin 能修的(交给 lockfile 检查)`)
40
+ continue
41
+ }
42
+ changes.push({
43
+ name: entry.name,
44
+ section: entry.section,
45
+ from: entry.current,
46
+ to: suggestRange(entry.current, installed),
47
+ locked: installed,
48
+ })
49
+ }
50
+
51
+ const diff = changes.length === 0
52
+ ? ''
53
+ : options.packageJsonText
54
+ ? diffAgainstText(options.packageJsonText, changes)
55
+ : syntheticDiff(changes)
56
+
57
+ return {
58
+ changes,
59
+ diff,
60
+ applied: false,
61
+ wroteFiles: [],
62
+ notes: notes.length > 0 ? notes : [],
63
+ summary: changes.length === 0
64
+ ? '没有可收紧的范围(要么没有 installed 快照,要么范围下界已经是锁版本)。'
65
+ : `建议收紧 ${changes.length} 个依赖的范围到已锁版本(仅 diff 文本,不写文件)。`,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 对真实 package.json 文本生成 unified diff(hunk 带行号)。
71
+ * @param {string} text 文件内容
72
+ * @param {Array<object>} changes 变更
73
+ * @returns {string} diff
74
+ */
75
+ function diffAgainstText(text, changes) {
76
+ const lines = text.split('\n')
77
+ const edits = []
78
+ for (const change of changes) {
79
+ const escaped = escapeRegExp(change.name)
80
+ const pattern = new RegExp(`^(\\s*)"${escaped}"(\\s*:\\s*)"${escapeRegExp(change.from)}"(\\s*,?\\s*)$`)
81
+ const index = lines.findIndex((line) => pattern.test(line))
82
+ if (index === -1) continue
83
+ edits.push({ index, before: lines[index], after: lines[index].replace(pattern, `$1"${change.name}"$2"${change.to}"$3`) })
84
+ }
85
+ if (edits.length === 0) return syntheticDiff(changes)
86
+ edits.sort((a, b) => a.index - b.index)
87
+
88
+ const hunks = []
89
+ let current = null
90
+ for (const edit of edits) {
91
+ const start = Math.max(0, edit.index - 1)
92
+ const end = Math.min(lines.length - 1, edit.index + 1)
93
+ if (current && start <= current.end + 1) {
94
+ current.edits.push(edit)
95
+ current.end = Math.max(current.end, end)
96
+ } else {
97
+ current = { start, end, edits: [edit] }
98
+ hunks.push(current)
99
+ }
100
+ }
101
+
102
+ const output = ['--- package.json', '+++ package.json (suggested, not written)']
103
+ for (const hunk of hunks) {
104
+ const count = hunk.end - hunk.start + 1
105
+ output.push(`@@ -${hunk.start + 1},${count} +${hunk.start + 1},${count} @@`)
106
+ const edited = new Map(hunk.edits.map((edit) => [edit.index, edit]))
107
+ for (let i = hunk.start; i <= hunk.end; i++) {
108
+ const edit = edited.get(i)
109
+ if (edit) {
110
+ output.push(`-${edit.before}`)
111
+ output.push(`+${edit.after}`)
112
+ } else {
113
+ output.push(` ${lines[i]}`)
114
+ }
115
+ }
116
+ }
117
+ return `${output.join('\n')}\n`
118
+ }
119
+
120
+ /**
121
+ * 没有原文时的合成 diff。
122
+ * @param {Array<object>} changes 变更
123
+ * @returns {string} diff
124
+ */
125
+ function syntheticDiff(changes) {
126
+ const output = ['--- package.json', '+++ package.json (suggested, not written)']
127
+ for (const change of changes) {
128
+ output.push(`@@ ${change.section} @@`)
129
+ output.push(`- "${change.name}": "${change.from}",`)
130
+ output.push(`+ "${change.name}": "${change.to}",`)
131
+ }
132
+ return `${output.join('\n')}\n`
133
+ }
134
+
135
+ /**
136
+ * 正则转义。
137
+ * @param {string} text 文本
138
+ * @returns {string} 转义结果
139
+ */
140
+ function escapeRegExp(text) {
141
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
142
+ }