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/LICENSE +21 -0
- package/README.md +138 -9
- package/cordis.patch.yml +12 -0
- package/dsh-plugin.naming.json +28 -0
- package/lib/classify.mjs +250 -0
- package/lib/cli.js +197 -0
- package/lib/deps.mjs +152 -0
- package/lib/engine.mjs +143 -0
- package/lib/errors.mjs +31 -0
- package/lib/format.mjs +162 -0
- package/lib/index.mjs +30 -0
- package/lib/pin.mjs +142 -0
- package/lib/plan.mjs +205 -0
- package/lib/version.mjs +270 -0
- package/package.json +35 -7
- package/plugin.mjs +156 -0
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 建议顺序 + 体检汇总 + why 解释。全部只读,不写文件。
|
|
3
|
+
*
|
|
4
|
+
* 排序判据(写死):
|
|
5
|
+
* 1) 先 patch,再 minor,最后 major;
|
|
6
|
+
* 2) 同组内按「被依赖次数」降序,次数相同按名字升序;
|
|
7
|
+
* 3) blocked(engines 不兼容 / 当前版本被 yanked)不进入升级队列;
|
|
8
|
+
* 4) unknown(快照缺失 / 范围不可判定)不猜、不排队,单列。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { RISK_RANK, RISK_LABEL, BUMPS } from './classify.mjs'
|
|
12
|
+
|
|
13
|
+
/** 信号严重度排序(选「最该先看」的建议动作用)。 */
|
|
14
|
+
const SEVERITY_RANK = { blocked: 4, high: 3, medium: 2, info: 1, unknown: 0 }
|
|
15
|
+
|
|
16
|
+
/** 升级阶段的先后(写死)。 */
|
|
17
|
+
export const PHASES = ['patch', 'minor', 'major']
|
|
18
|
+
|
|
19
|
+
/** 阶段说明。 */
|
|
20
|
+
export const PHASE_LABEL = { patch: 'patch(同 minor 内)', minor: 'minor(同 major 内)', major: 'major(跨大版本)' }
|
|
21
|
+
|
|
22
|
+
/** 排序理由(给报告与 README 共用)。 */
|
|
23
|
+
export const ORDER_RATIONALE = '先 patch 后 minor 再 major:小步先行,每一步都能单独测试与回滚,出问题容易二分定位;同组内按被依赖次数降序:被依赖越多,升级收益越大,也越早暴露兼容问题,先升它能让后续升级更安全。blocked 不进队列,unknown 不猜。'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 排序比较器。
|
|
27
|
+
* @param {object} a 条目 a
|
|
28
|
+
* @param {object} b 条目 b
|
|
29
|
+
* @returns {number} 比较结果
|
|
30
|
+
*/
|
|
31
|
+
function compareForOrder(a, b) {
|
|
32
|
+
const phase = PHASES.indexOf(a.bump) - PHASES.indexOf(b.bump)
|
|
33
|
+
if (phase !== 0) return phase
|
|
34
|
+
if (b.dependents !== a.dependents) return b.dependents - a.dependents
|
|
35
|
+
return a.name.localeCompare(b.name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 生成升级计划。
|
|
40
|
+
* @param {object[]} entries 分类后的条目
|
|
41
|
+
* @param {{countSource?:string}} [options] 选项
|
|
42
|
+
* @returns {object} 计划
|
|
43
|
+
*/
|
|
44
|
+
export function buildPlan(entries, options = {}) {
|
|
45
|
+
const actionable = entries.filter((entry) => PHASES.includes(entry.bump) && entry.risk !== 'blocked')
|
|
46
|
+
const blocked = entries.filter((entry) => entry.risk === 'blocked')
|
|
47
|
+
const unknown = entries.filter((entry) => entry.bump === 'unknown' && entry.risk !== 'blocked')
|
|
48
|
+
const pinned = entries.filter((entry) => entry.bump === 'pinned')
|
|
49
|
+
|
|
50
|
+
const sorted = actionable.slice().sort(compareForOrder)
|
|
51
|
+
const order = sorted.map((entry, index) => ({
|
|
52
|
+
step: index + 1,
|
|
53
|
+
name: entry.name,
|
|
54
|
+
bump: entry.bump,
|
|
55
|
+
risk: entry.risk,
|
|
56
|
+
current: entry.current,
|
|
57
|
+
currentVersion: entry.currentVersion,
|
|
58
|
+
latest: entry.latest,
|
|
59
|
+
suggested: entry.suggested,
|
|
60
|
+
dependents: entry.dependents,
|
|
61
|
+
group: entry.bump,
|
|
62
|
+
orderReason: `${entry.bump} 组第 ${sorted.filter((item) => item.bump === entry.bump).indexOf(entry) + 1} 个:被 ${entry.dependents} 个包依赖${entry.risk === 'high' ? ',但属于高风险升级,建议单独 PR' : ''}`,
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
const groups = {
|
|
66
|
+
patch: order.filter((item) => item.bump === 'patch'),
|
|
67
|
+
minor: order.filter((item) => item.bump === 'minor'),
|
|
68
|
+
major: order.filter((item) => item.bump === 'major'),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const counts = {
|
|
72
|
+
total: entries.length,
|
|
73
|
+
actionable: actionable.length,
|
|
74
|
+
byBump: Object.fromEntries(BUMPS.map((bump) => [bump, entries.filter((entry) => entry.bump === bump).length])),
|
|
75
|
+
byRisk: Object.fromEntries(Object.keys(RISK_RANK).map((risk) => [risk, entries.filter((entry) => entry.risk === risk).length])),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
order,
|
|
80
|
+
groups,
|
|
81
|
+
blocked: blocked.map(summarize),
|
|
82
|
+
unknown: unknown.map(summarize),
|
|
83
|
+
pinned: pinned.map(summarize),
|
|
84
|
+
rationale: ORDER_RATIONALE,
|
|
85
|
+
phaseOrder: [...PHASES],
|
|
86
|
+
counts,
|
|
87
|
+
countSource: options.countSource ?? 'single-manifest',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 报告里用的条目摘要(不带 signals 细节)。
|
|
93
|
+
* @param {object} entry 条目
|
|
94
|
+
* @returns {object} 摘要
|
|
95
|
+
*/
|
|
96
|
+
function summarize(entry) {
|
|
97
|
+
return {
|
|
98
|
+
name: entry.name,
|
|
99
|
+
bump: entry.bump,
|
|
100
|
+
risk: entry.risk,
|
|
101
|
+
current: entry.current,
|
|
102
|
+
currentVersion: entry.currentVersion,
|
|
103
|
+
latest: entry.latest,
|
|
104
|
+
dependents: entry.dependents,
|
|
105
|
+
reason: entry.reason,
|
|
106
|
+
action: (entry.signals.slice().sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]).find((signal) => signal.action)?.action) ?? null,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 一次性体检汇总。
|
|
112
|
+
* @param {object[]} entries 条目
|
|
113
|
+
* @param {object} plan 计划
|
|
114
|
+
* @param {object[]} problems 数据问题
|
|
115
|
+
* @param {{nodeVersion?:string, snapshotProvided?:boolean}} [context] 上下文
|
|
116
|
+
* @returns {object} 体检结果
|
|
117
|
+
*/
|
|
118
|
+
export function auditReport(entries, plan, problems = [], context = {}) {
|
|
119
|
+
const counts = plan.counts
|
|
120
|
+
const serious = entries.filter((entry) => entry.risk === 'high' || entry.risk === 'blocked')
|
|
121
|
+
const mi = entries.filter((entry) => entry.bump === 'minor')
|
|
122
|
+
const verdict = entries.length === 0 ? 'empty'
|
|
123
|
+
: serious.length > 0 ? 'risk'
|
|
124
|
+
: mi.length > 0 || counts.byBump.unknown > 0 ? 'warn'
|
|
125
|
+
: 'ok'
|
|
126
|
+
|
|
127
|
+
const signals = {}
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
for (const signal of entry.signals) signals[signal.signal] = (signals[signal.signal] ?? 0) + 1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
verdict,
|
|
134
|
+
counts,
|
|
135
|
+
signals,
|
|
136
|
+
nodeVersion: context.nodeVersion ?? null,
|
|
137
|
+
snapshotProvided: context.snapshotProvided === true,
|
|
138
|
+
summary: summarizeVerdict(verdict, counts, serious.length),
|
|
139
|
+
recommendations: recommendationsFor(entries, plan, problems),
|
|
140
|
+
problems,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 结论一句话。
|
|
146
|
+
* @param {string} verdict 结论
|
|
147
|
+
* @param {object} counts 计数
|
|
148
|
+
* @param {number} serious 高风险数
|
|
149
|
+
* @returns {string} 文案
|
|
150
|
+
*/
|
|
151
|
+
function summarizeVerdict(verdict, counts, serious) {
|
|
152
|
+
if (verdict === 'empty') return '没有依赖需要规划。'
|
|
153
|
+
if (verdict === 'risk') return `有 ${serious} 个高风险/被阻塞项:先处理它们,再按建议顺序升其余 ${counts.actionable} 个。`
|
|
154
|
+
if (verdict === 'warn') return `没有高危项,但有 ${counts.byBump.minor} 个 minor 或 ${counts.byBump.unknown} 个 unknown;按建议顺序小步升。`
|
|
155
|
+
return `只有低风险升级(${counts.actionable} 个可升),按建议顺序执行即可。`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 建议动作清单。
|
|
160
|
+
* @param {object[]} entries 条目
|
|
161
|
+
* @param {object} plan 计划
|
|
162
|
+
* @param {object[]} problems 数据问题
|
|
163
|
+
* @returns {string[]} 建议
|
|
164
|
+
*/
|
|
165
|
+
function recommendationsFor(entries, plan, problems) {
|
|
166
|
+
const list = []
|
|
167
|
+
if (plan.blocked.length > 0) list.push(`先处理 ${plan.blocked.length} 个被阻塞项(engines 不兼容 / 当前版本被 yanked):${plan.blocked.map((item) => item.name).join('、')}`)
|
|
168
|
+
if (plan.counts.byBump.unknown > 0) list.push(`补 ${plan.counts.byBump.unknown} 个 unknown 项的快照(npm view <name> version 写进本地快照),不要凭印象升`)
|
|
169
|
+
if (plan.counts.byBump.major > 0) list.push(`major 升级共 ${plan.counts.byBump.major} 个:单独 PR、读 CHANGELOG、准备回滚`)
|
|
170
|
+
if (plan.order.length > 0) list.push(`按 ${plan.order.length} 步顺序执行;每步升完跑测试再进下一步`)
|
|
171
|
+
if (problems.length > 0) list.push(`先修数据问题:${problems.slice(0, 3).join(';')}`)
|
|
172
|
+
return list
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* why:解释一个依赖为什么是这个判定。
|
|
177
|
+
* @param {string} name 依赖名
|
|
178
|
+
* @param {object[]} entries 条目
|
|
179
|
+
* @param {object} context 上下文
|
|
180
|
+
* @param {object} context.snapshot 快照 Map
|
|
181
|
+
* @param {object} context.installed installed Map
|
|
182
|
+
* @param {string} context.nodeVersion Node 版本
|
|
183
|
+
* @param {object} context.manifest 根 manifest
|
|
184
|
+
* @returns {object} 解释
|
|
185
|
+
*/
|
|
186
|
+
export function explainEntry(name, entries, context) {
|
|
187
|
+
const entry = entries.find((item) => item.name === name)
|
|
188
|
+
if (!entry) return { found: false, name, reason: `依赖清单里没有 ${name}`, checks: [], conclusion: `依赖清单里没有 ${name}:它不是这份 package.json 的依赖,无从判定。` }
|
|
189
|
+
const snapshotEntry = context.snapshot.get(name)
|
|
190
|
+
const checks = [
|
|
191
|
+
{ id: 'declared-range', value: entry.current, detail: `package.json 声明 ${entry.section}: ${entry.name} = ${entry.current}` },
|
|
192
|
+
{ id: 'current-version', value: entry.currentVersion, detail: entry.versionSource === 'installed' ? '来自 installed 快照' : entry.versionSource === 'range-floor' ? '取自声明范围下界(不是 lock 里的实际版本)' : '无法确定' },
|
|
193
|
+
{ id: 'snapshot', value: snapshotEntry ? snapshotEntry.latest : null, detail: snapshotEntry ? `本地快照 latest=${snapshotEntry.latest ?? '(缺失)'}` : '本地快照里没有这个包' },
|
|
194
|
+
{ id: 'bump', value: entry.bump, detail: entry.reason },
|
|
195
|
+
{ id: 'risk', value: entry.risk, detail: RISK_LABEL[entry.risk] ?? entry.risk },
|
|
196
|
+
{ id: 'signals', value: entry.signals.map((signal) => signal.signal), detail: entry.signals.map((signal) => signal.detail).join(';') || '没有触发风险信号' },
|
|
197
|
+
]
|
|
198
|
+
if (entry.peer) checks.push({ id: 'peer', value: entry.current, detail: `这是 peerDependency:升级要同时确认宿主支持,并同步 peer 范围` })
|
|
199
|
+
const conclusion = entry.bump === 'unknown'
|
|
200
|
+
? `未判定:${entry.reason}`
|
|
201
|
+
: entry.bump === 'pinned'
|
|
202
|
+
? `无需升级:${entry.reason}`
|
|
203
|
+
: `判定为 ${entry.bump}(风险 ${entry.risk}):${entry.reason}`
|
|
204
|
+
return { found: true, name, entry, checks, conclusion }
|
|
205
|
+
}
|
package/lib/version.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semver 子集:只做「升级规划」需要的解析、比较、范围判定与范围下界。
|
|
3
|
+
*
|
|
4
|
+
* 明确支持:`1.2.3` / `^1.2.3` / `~1.2.3` / `>=1.2.3` / `>1.2.3` / `<=` / `<` /
|
|
5
|
+
* 空格连接的 AND 范围 / `||` OR / `1.x` / `1.2.x` / `*` / 连字符 `1.2.3 - 2.3.4`。
|
|
6
|
+
*
|
|
7
|
+
* **明确不支持**(调用方会看到 unknown,而不是猜):
|
|
8
|
+
* `npm:` 别名、`git:`/`github:`、`file:`、`link:`、`workspace:`、`latest` 标签、
|
|
9
|
+
* 以及 npm 的 prerelease 特例(`^1.2.3` 不自动含 `1.3.0-beta`)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 版本解析失败。 */
|
|
13
|
+
export class SemverError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} message 说明
|
|
16
|
+
*/
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'SemverError'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 非 semver 的范围前缀:这些一律 unknown,不去猜。 */
|
|
24
|
+
export const UNSUPPORTED_RANGE_PREFIXES = ['npm:', 'git:', 'git+', 'github:', 'file:', 'link:', 'workspace:', 'http:', 'https:', 'portal:', 'patch:']
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 解析版本。
|
|
28
|
+
* @param {unknown} input 版本字符串
|
|
29
|
+
* @returns {{major:number, minor:number, patch:number, prerelease:string[], raw:string}|null} 解析结果
|
|
30
|
+
*/
|
|
31
|
+
export function parseVersion(input) {
|
|
32
|
+
if (typeof input !== 'string') return null
|
|
33
|
+
const text = input.trim().replace(/^v/i, '')
|
|
34
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text)
|
|
35
|
+
if (!match) return null
|
|
36
|
+
return {
|
|
37
|
+
major: Number(match[1]),
|
|
38
|
+
minor: Number(match[2]),
|
|
39
|
+
patch: Number(match[3]),
|
|
40
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
41
|
+
raw: text,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 比较两个版本。
|
|
47
|
+
* @param {object|string} a 版本 a
|
|
48
|
+
* @param {object|string} b 版本 b
|
|
49
|
+
* @returns {number} -1 / 0 / 1
|
|
50
|
+
*/
|
|
51
|
+
export function compareVersions(a, b) {
|
|
52
|
+
const left = typeof a === 'string' ? parseVersion(a) : a
|
|
53
|
+
const right = typeof b === 'string' ? parseVersion(b) : b
|
|
54
|
+
if (!left || !right) throw new SemverError(`无法比较版本:${String(a)} vs ${String(b)}`)
|
|
55
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
56
|
+
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1
|
|
57
|
+
}
|
|
58
|
+
const leftPre = left.prerelease ?? []
|
|
59
|
+
const rightPre = right.prerelease ?? []
|
|
60
|
+
if (leftPre.length === 0 && rightPre.length === 0) return 0
|
|
61
|
+
if (leftPre.length === 0) return 1
|
|
62
|
+
if (rightPre.length === 0) return -1
|
|
63
|
+
for (let i = 0; i < Math.max(leftPre.length, rightPre.length); i++) {
|
|
64
|
+
const x = leftPre[i]
|
|
65
|
+
const y = rightPre[i]
|
|
66
|
+
if (x === undefined) return -1
|
|
67
|
+
if (y === undefined) return 1
|
|
68
|
+
const nx = /^\d+$/.test(x)
|
|
69
|
+
const ny = /^\d+$/.test(y)
|
|
70
|
+
if (nx && ny) {
|
|
71
|
+
if (Number(x) !== Number(y)) return Number(x) > Number(y) ? 1 : -1
|
|
72
|
+
} else if (nx !== ny) {
|
|
73
|
+
return nx ? -1 : 1
|
|
74
|
+
} else if (x !== y) {
|
|
75
|
+
return x > y ? 1 : -1
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 版本是否严格相等。
|
|
83
|
+
* @param {string} a 版本 a
|
|
84
|
+
* @param {string} b 版本 b
|
|
85
|
+
* @returns {boolean} 是否相等
|
|
86
|
+
*/
|
|
87
|
+
export function versionEquals(a, b) {
|
|
88
|
+
const left = parseVersion(a)
|
|
89
|
+
const right = parseVersion(b)
|
|
90
|
+
return Boolean(left && right && compareVersions(left, right) === 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 解析一个比较器(`>=1.2.3` / `^1.2.3` / `1.2.x` …)。
|
|
95
|
+
* @param {string} token 片段
|
|
96
|
+
* @returns {object|null} 比较器
|
|
97
|
+
*/
|
|
98
|
+
function parseComparator(token) {
|
|
99
|
+
const text = token.trim()
|
|
100
|
+
if (text === '' || text === '*' || text === 'x' || text === 'X') return null
|
|
101
|
+
const match = /^(>=|<=|>|<|=|\^|~)?\s*v?(\d+|[x*])(?:\.(\d+|[x*]))?(?:\.(\d+|[x*]))?(?:-([0-9A-Za-z.-]+))?$/.exec(text)
|
|
102
|
+
if (!match) return undefined
|
|
103
|
+
const op = match[1] ?? ''
|
|
104
|
+
const wild = (part) => part === undefined || part === 'x' || part === 'X' || part === '*'
|
|
105
|
+
const nums = [match[2], match[3], match[4]]
|
|
106
|
+
if (wild(nums[0])) return null
|
|
107
|
+
const major = Number(nums[0])
|
|
108
|
+
const minor = wild(nums[1]) ? null : Number(nums[1])
|
|
109
|
+
const patch = wild(nums[2]) ? null : Number(nums[2])
|
|
110
|
+
const prerelease = match[5] ? match[5].split('.') : []
|
|
111
|
+
|
|
112
|
+
if (op === '^') {
|
|
113
|
+
if (minor === null) return { op: 'range', from: { major, minor: 0, patch: 0 }, to: { major: major + 1, minor: 0, patch: 0 } }
|
|
114
|
+
if (patch === null) return { op: 'range', from: { major, minor, patch: 0 }, to: { major: major + 1, minor: 0, patch: 0 } }
|
|
115
|
+
if (major > 0) return { op: 'range', from: { major, minor, patch }, to: { major: major + 1, minor: 0, patch: 0 } }
|
|
116
|
+
if (minor > 0) return { op: 'range', from: { major, minor, patch }, to: { major: 0, minor: minor + 1, patch: 0 } }
|
|
117
|
+
return { op: 'range', from: { major, minor, patch }, to: { major: 0, minor: 0, patch: patch + 1 } }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (op === '~' || (op === '' && (minor === null || patch === null))) {
|
|
121
|
+
if (minor === null) return { op: 'range', from: { major, minor: 0, patch: 0 }, to: { major: major + 1, minor: 0, patch: 0 } }
|
|
122
|
+
return { op: 'range', from: { major, minor, patch: patch ?? 0 }, to: { major, minor: minor + 1, patch: 0 } }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (op === '' || op === '=') {
|
|
126
|
+
if (patch === null) {
|
|
127
|
+
return minor === null
|
|
128
|
+
? { op: 'range', from: { major, minor: 0, patch: 0 }, to: { major: major + 1, minor: 0, patch: 0 } }
|
|
129
|
+
: { op: 'range', from: { major, minor, patch: 0 }, to: { major, minor: minor + 1, patch: 0 } }
|
|
130
|
+
}
|
|
131
|
+
return { op: 'eq', version: { major, minor, patch, prerelease } }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const version = { major, minor: minor ?? 0, patch: patch ?? 0, prerelease }
|
|
135
|
+
return { op, version }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 判断版本是否落在单个比较器集合(AND)里。
|
|
140
|
+
* @param {object} version 版本
|
|
141
|
+
* @param {Array<object|null>} comparators 比较器
|
|
142
|
+
* @returns {boolean} 是否满足
|
|
143
|
+
*/
|
|
144
|
+
function satisfiesSet(version, comparators) {
|
|
145
|
+
for (const comparator of comparators) {
|
|
146
|
+
if (comparator === null) continue
|
|
147
|
+
if (comparator.op === 'eq') {
|
|
148
|
+
if (compareVersions(version, comparator.version) !== 0) return false
|
|
149
|
+
} else if (comparator.op === 'range') {
|
|
150
|
+
if (compareVersions(version, comparator.from) < 0) return false
|
|
151
|
+
if (compareVersions(version, comparator.to) >= 0) return false
|
|
152
|
+
} else if (comparator.op === '>=') {
|
|
153
|
+
if (compareVersions(version, comparator.version) < 0) return false
|
|
154
|
+
} else if (comparator.op === '>') {
|
|
155
|
+
if (compareVersions(version, comparator.version) <= 0) return false
|
|
156
|
+
} else if (comparator.op === '<=') {
|
|
157
|
+
if (compareVersions(version, comparator.version) > 0) return false
|
|
158
|
+
} else if (comparator.op === '<') {
|
|
159
|
+
if (compareVersions(version, comparator.version) >= 0) return false
|
|
160
|
+
} else {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 解析范围;不支持或不认识就返回 null(调用方标 unknown)。
|
|
169
|
+
* @param {unknown} range 范围字符串
|
|
170
|
+
* @returns {{sets:Array<Array<object>>}|null} 解析结果
|
|
171
|
+
*/
|
|
172
|
+
export function parseRange(range) {
|
|
173
|
+
if (typeof range !== 'string') return null
|
|
174
|
+
const text = range.trim()
|
|
175
|
+
if (text.length === 0) return null
|
|
176
|
+
const lower = text.toLowerCase()
|
|
177
|
+
if (UNSUPPORTED_RANGE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null
|
|
178
|
+
if (lower === 'latest' || lower === 'next' || lower === 'beta') return null
|
|
179
|
+
|
|
180
|
+
const sets = []
|
|
181
|
+
for (const part of text.split('||')) {
|
|
182
|
+
const trimmed = part.trim()
|
|
183
|
+
if (trimmed === '') return null
|
|
184
|
+
// 连字符范围
|
|
185
|
+
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(trimmed)
|
|
186
|
+
if (hyphen) {
|
|
187
|
+
const from = parseComparator(`>=${hyphen[1]}`)
|
|
188
|
+
const to = parseComparator(`<=${hyphen[2]}`)
|
|
189
|
+
if (!from || !to) return null
|
|
190
|
+
sets.push([from, to])
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const comparators = []
|
|
194
|
+
for (const token of trimmed.split(/\s+/)) {
|
|
195
|
+
const comparator = parseComparator(token)
|
|
196
|
+
if (comparator === undefined) return null
|
|
197
|
+
comparators.push(comparator)
|
|
198
|
+
}
|
|
199
|
+
sets.push(comparators)
|
|
200
|
+
}
|
|
201
|
+
return { sets }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 版本是否满足范围。
|
|
206
|
+
* @param {string} version 版本
|
|
207
|
+
* @param {string} range 范围
|
|
208
|
+
* @returns {{supported:boolean, satisfies:boolean, reason?:string}} 判定
|
|
209
|
+
*/
|
|
210
|
+
export function satisfiesRange(version, range) {
|
|
211
|
+
const parsedVersion = parseVersion(version)
|
|
212
|
+
if (!parsedVersion) return { supported: false, satisfies: false, reason: `版本不可解析:${version}` }
|
|
213
|
+
const parsedRange = parseRange(range)
|
|
214
|
+
if (!parsedRange) return { supported: false, satisfies: false, reason: `范围不可解析:${range}` }
|
|
215
|
+
const satisfies = parsedRange.sets.some((set) => satisfiesSet(parsedVersion, set))
|
|
216
|
+
return { supported: true, satisfies }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 范围下界(能代表「当前声明」的最早版本);无从判断返回 null。
|
|
221
|
+
* @param {unknown} range 范围
|
|
222
|
+
* @returns {string|null} 下界版本
|
|
223
|
+
*/
|
|
224
|
+
export function rangeFloor(range) {
|
|
225
|
+
const parsed = parseRange(range)
|
|
226
|
+
if (!parsed) return null
|
|
227
|
+
let best = null
|
|
228
|
+
for (const set of parsed.sets) {
|
|
229
|
+
const froms = set.filter((comparator) => comparator && (comparator.op === 'range' || comparator.op === '>=' || comparator.op === '>' || comparator.op === 'eq'))
|
|
230
|
+
for (const comparator of froms) {
|
|
231
|
+
const version = comparator.op === 'eq' ? comparator.version : comparator.version ?? comparator.from
|
|
232
|
+
if (!version) continue
|
|
233
|
+
if (best === null || compareVersions(version, best) < 0) best = version
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (best === null) return null
|
|
237
|
+
return `${best.major}.${best.minor}.${best.patch}${best.prerelease?.length ? `-${best.prerelease.join('.')}` : ''}`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 升级类型:比较两个版本给 patch / minor / major。
|
|
242
|
+
* @param {string} from 当前
|
|
243
|
+
* @param {string} to 目标
|
|
244
|
+
* @returns {'patch'|'minor'|'major'|'same'} 升级类型
|
|
245
|
+
*/
|
|
246
|
+
export function bumpType(from, to) {
|
|
247
|
+
const left = parseVersion(from)
|
|
248
|
+
const right = parseVersion(to)
|
|
249
|
+
if (!left || !right) return 'same'
|
|
250
|
+
if (compareVersions(left, right) === 0) return 'same'
|
|
251
|
+
if (left.major !== right.major) return 'major'
|
|
252
|
+
if (left.minor !== right.minor) return 'minor'
|
|
253
|
+
return 'patch'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 判断引擎范围是否与当前 Node 兼容。
|
|
258
|
+
* @param {unknown} engines package.json 的 engines 字段
|
|
259
|
+
* @param {string} nodeVersion 当前 Node 版本
|
|
260
|
+
* @returns {{declared:boolean, compatible:boolean|null, range?:string, reason?:string}} 判定
|
|
261
|
+
*/
|
|
262
|
+
export function checkEngines(engines, nodeVersion) {
|
|
263
|
+
if (engines === null || engines === undefined) return { declared: false, compatible: null }
|
|
264
|
+
if (typeof engines !== 'object' || Array.isArray(engines)) return { declared: true, compatible: null, reason: 'engines 不是对象' }
|
|
265
|
+
const range = engines.node
|
|
266
|
+
if (typeof range !== 'string' || range.trim().length === 0) return { declared: false, compatible: null }
|
|
267
|
+
const result = satisfiesRange(nodeVersion, range)
|
|
268
|
+
if (!result.supported) return { declared: true, compatible: null, range, reason: `engines.node 范围不可判定:${range}` }
|
|
269
|
+
return { declared: true, compatible: result.satisfies, range }
|
|
270
|
+
}
|
package/package.json
CHANGED
|
@@ -1,24 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-deps",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "依赖升级规划 DSH 插件:给定 package.json 与本地 registry 快照,算哪些依赖能升、升了有什么风险、按什么顺序升。五档分级写死(patch / minor / major / pinned / unknown);latest 只来自调用方给的本地快照,缺了就 unknown、绝不联网猜;风险信号具体到 major / engines 不兼容 / deprecated / yanked / peer 破坏,每条给建议动作;建议顺序先 patch 后 minor 再 major、同组按被依赖次数降序并说明理由;只读、不自动改文件(pin 只返回 diff 文本)。零运行时依赖,含 CLI。 | Dependency upgrade planner for DeepSeek Harness: five fixed bump classes, latest only from a caller-provided local snapshot (never network, never guessed), concrete risk signals with actions, and an upgrade order (patch → minor → major, dependents first) with rationale. Read-only: never edits package.json or lockfiles; pin returns a diff only.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"dsh-plugin",
|
|
8
8
|
"deepseek-harness",
|
|
9
9
|
"dependencies",
|
|
10
|
-
"license",
|
|
11
|
-
"audit",
|
|
12
10
|
"upgrade",
|
|
11
|
+
"audit",
|
|
12
|
+
"semver",
|
|
13
13
|
"supply-chain",
|
|
14
|
-
"依赖"
|
|
14
|
+
"依赖",
|
|
15
|
+
"升级"
|
|
15
16
|
],
|
|
16
17
|
"license": "MIT",
|
|
18
|
+
"author": "he fangsheng (https://github.com/dongsheng123132)",
|
|
17
19
|
"type": "module",
|
|
20
|
+
"main": "./plugin.mjs",
|
|
21
|
+
"bin": {
|
|
22
|
+
"dsh-deps": "./lib/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./plugin.mjs",
|
|
26
|
+
"./cli": "./lib/cli.js",
|
|
27
|
+
"./lib/*": "./lib/*",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
18
30
|
"files": [
|
|
19
|
-
"
|
|
31
|
+
"plugin.mjs",
|
|
32
|
+
"lib/",
|
|
33
|
+
"cordis.patch.yml",
|
|
34
|
+
"dsh-plugin.naming.json",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
20
37
|
],
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
21
43
|
"engines": {
|
|
22
|
-
"node": ">=22"
|
|
44
|
+
"node": ">=22",
|
|
45
|
+
"dsh": ">=0.2.2-rc.1 <0.3.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test test/*.test.mjs",
|
|
49
|
+
"test:dir": "node --test test/",
|
|
50
|
+
"check": "node --check plugin.mjs && node scripts/check.mjs"
|
|
23
51
|
}
|
|
24
52
|
}
|