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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 he fangsheng (https://github.com/dongsheng123132)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,19 +1,148 @@
|
|
|
1
|
-
# dsh-deps ·
|
|
1
|
+
# dsh-deps · 依赖升级规划(五档分级 + 风险信号 + 建议顺序)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
>
|
|
5
|
-
> **English**: Audit dependency trees for license risk, dupes, abandoned packages and version drift, with an actionable upgrade plan per package.
|
|
3
|
+
dsh-deps 是一个 DSH(DeepSeek Harness)插件,用于把「这堆依赖该怎么升」变成一份可执行计划:它注册工具 `dsh_deps`,读 `package.json` 与调用方给的**本地** registry 快照,算出每个依赖的五档分级(`patch` / `minor` / `major` / `pinned` / `unknown`)、具体风险信号与建议动作,再给出先 patch 后 minor 再 major、同组按被依赖次数降序的升级顺序。**latest 只来自本地快照,快照里没有就 `unknown`,绝不联网猜**;全程只读,`pin` 只生成 diff 文本,不改任何文件。安装:`dsh plugin --profile web add dsh-deps`。
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
包 `dsh-deps@0.1.0` · 已实测宿主 DSH 0.2.2-rc.1、Node 23.11.0 · 许可 MIT · 作者主页 https://github.com/dongsheng123132 · 仓库 https://github.com/dongsheng123132/dsh-deps(发布时创建)
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
**分工**:`dsh-pkg` 查依赖冲突、`dsh-lockfile` 查 lock 漂移,本包只做**升级规划**(能不能升、升了有什么风险、按什么顺序升),不重复它们的 dupes / peer 求解逻辑。
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
## dsh-deps 是什么?
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
一个挂在 DSH 里的工具 `dsh_deps` + 一个零依赖 CLI。四个动作:
|
|
12
|
+
|
|
13
|
+
| 动作 | 做什么 | 会不会写盘 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `plan` | 升级清单 + 五档分级 + 风险信号 + 建议顺序 | 只读 |
|
|
16
|
+
| `why` | 解释某个依赖为什么被这么判(逐条依据) | 只读 |
|
|
17
|
+
| `pin` | 把声明范围收紧到已锁版本,**只输出 diff 文本** | 只读 |
|
|
18
|
+
| `audit` | 一次体检:结论、信号计数、建议动作、顺序 | 只读 |
|
|
19
|
+
|
|
20
|
+
## 五档分级(写死)
|
|
21
|
+
|
|
22
|
+
| 档位 | 判据 | 例子 | risk |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `patch` | 同 major 同 minor,patch 变大 | 1.2.3 → 1.2.9 | low |
|
|
25
|
+
| `minor` | 同 major,minor 变大 | 1.2.3 → 1.9.0 | medium |
|
|
26
|
+
| `major` | major 变大(breaking 风险) | 1.2.3 → 2.0.0 | high |
|
|
27
|
+
| `pinned` | 当前版本已不低于快照 latest | 1.2.9 且 latest 1.2.9 | none |
|
|
28
|
+
| `unknown` | 无从判断:快照缺包 / 范围不可解析 / 没有当前版本 | `git+https://…` | unknown |
|
|
29
|
+
|
|
30
|
+
每条都给 `current`(声明范围)、`currentVersion`(当前版本)、`latest`(仅来自快照)、`bump`、`risk`、`reason`、`suggested`、`signals`。**只有快照里真的有 latest 且能确定当前版本,才会给 patch/minor/major**;其余一律 unknown 并写清为什么。
|
|
31
|
+
|
|
32
|
+
## 快照:latest 的唯一来源
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"react": { "latest": "19.2.0", "engines": { "node": ">=20" } },
|
|
37
|
+
"old-lib": { "latest": "2.0.0", "deprecated": "use new-lib instead" },
|
|
38
|
+
"risky": { "latest": "3.1.0", "yanked": ["3.0.0"] }
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- 支持 `latest`(必填才有分级)、`deprecated`(字符串或 true)、`engines`(只读,不猜)、`yanked`(`true` / 版本字符串 / 版本数组);
|
|
43
|
+
- **快照里没有的包 → `unknown`**,并建议先用 `npm view <name> version` 取事实再写进快照;
|
|
44
|
+
- 本包**不联网、不跑 `npm outdated`、不读 node_modules**,也不替你维护快照。
|
|
45
|
+
|
|
46
|
+
当前版本默认取「声明范围下界」(`^1.2.3` → `1.2.3`),这不是 lock 里的实际版本;要准确请传 `installed` 快照(例如从 lockfile 解析)。`versionSource` 会写明用的是哪一种。
|
|
47
|
+
|
|
48
|
+
## 风险信号(每条给 signal + 建议动作)
|
|
49
|
+
|
|
50
|
+
| signal | 触发条件 | 建议动作 |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `major-bump` | 升级跨大版本 | 读 CHANGELOG/迁移指南,单独 PR,升级前跑完整测试 |
|
|
53
|
+
| `engines-incompatible` | 目标版本声明 `engines.node` 与当前 Node 不兼容 | 先升 Node 或选兼容版本,别在现环境硬升(risk=blocked,不进队列) |
|
|
54
|
+
| `deprecated` | 快照标记该包已废弃 | 优先迁移到替代包,而不是继续升级 |
|
|
55
|
+
| `yanked-current` | 当前版本被快照标为 yanked | 立即升级/回退到安全版本(risk=blocked) |
|
|
56
|
+
| `yanked-target` | 快照把 latest 本身标为 yanked | 不要升到被撤版本,等快照更新或选上一稳定版 |
|
|
57
|
+
| `peer-breaking` | 是 peerDependency,且 latest 落在声明的 peer 范围外 | 先确认宿主支持,再同步放宽 peer 范围 |
|
|
58
|
+
| `prerelease-target` | latest 是预发布版本 | 不默认进生产,确认需要再升并固定版本 |
|
|
59
|
+
| `unknown-latest` | 快照缺这个包 / 缺 latest | 先取事实写进快照,不猜 |
|
|
60
|
+
| `unsupported-range` | 范围不是可判定的 semver(git/file/workspace/npm 别名) | 改成 semver 范围或人工确认 |
|
|
61
|
+
| `no-current-version` | 范围无法得到当前版本(如 `*`) | 传 installed 快照或用具体范围 |
|
|
62
|
+
|
|
63
|
+
## 建议顺序:为什么先 patch?
|
|
64
|
+
|
|
65
|
+
**先 patch,再 minor,最后 major**:小步先行,每一步都能单独测试与回滚,出问题容易二分定位。**同组内按被依赖次数降序**:被依赖越多,升级收益越大,也越早暴露兼容问题,先升它能让后续升级更安全。`blocked`(engines 不兼容 / 当前版本被 yanked)不进入队列;`unknown` 不猜、不排队,单独列出来等你补数据。
|
|
66
|
+
|
|
67
|
+
被依赖次数默认按给定 manifest 统计(只给一份时每项为 1);传 `manifests`(工作区里的多份 package.json)或显式 `dependents` 才会出现真实差异。
|
|
68
|
+
|
|
69
|
+
## 它和 npm outdated / npm-check-updates / Renovate 有什么区别?
|
|
70
|
+
|
|
71
|
+
| 方案 | 它解决什么 | 代价与盲区 |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `npm outdated` | 联网查最新版与 wanted | 必须联网、输出是表格;没有风险分级、没有顺序、没有 peer/engines 语义 |
|
|
74
|
+
| `npm-check-updates` | 批量把范围改到最新 | 会改 package.json;默认不区分「该不该升」,也没有基于快照的审计口径 |
|
|
75
|
+
| Renovate / Dependabot | 持续开 PR 升级 | 需要托管平台与仓库配置;本机离线场景用不上,且不解释「为什么先升这个」 |
|
|
76
|
+
| dsh-pkg / dsh-lockfile | 依赖冲突、lock 漂移 | 不回答「能不能升、升了有什么风险、按什么顺序升」 |
|
|
77
|
+
| dsh-deps(本包) | 本地快照驱动的五档分级 + 风险信号 + 顺序 + pin diff,供模型/CI 消费 | latest 依赖你提供快照;不解析真实 node_modules,也不替你执行升级 |
|
|
78
|
+
|
|
79
|
+
## 怎么装、怎么用?
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
dsh plugin --profile web add dsh-deps
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
装好后在会话里说需求,模型会调 `dsh_deps`:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
按这份快照给我一份依赖升级计划
|
|
89
|
+
为什么 host 被判成高风险
|
|
90
|
+
把范围收紧到已锁版本的 patch,给我 diff
|
|
91
|
+
这个项目依赖健康吗,先升哪些
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
不装插件也能用 CLI(零依赖,只要 Node ≥22):
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
node lib/cli.js plan --package-json package.json --snapshot snapshot.json --installed installed.json
|
|
98
|
+
node lib/cli.js why host --package-json package.json --snapshot snapshot.json
|
|
99
|
+
node lib/cli.js pin --package-json package.json --installed installed.json
|
|
100
|
+
node lib/cli.js audit --package-json package.json --snapshot snapshot.json --json
|
|
101
|
+
node lib/cli.js snapshot-shape
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
退出码:`0` 无高危、`1` 参数/输入错误、`3` 有 high/blocked 升级项。只读:不写 `package.json`/lockfile,`pin` 的 diff 需要你自己复制应用。
|
|
105
|
+
|
|
106
|
+
## 三个常见问题
|
|
107
|
+
|
|
108
|
+
### 为什么不直接跑 `npm outdated`?
|
|
109
|
+
|
|
110
|
+
因为那要联网,而且它只给「最新版是什么」,不给「该不该升、升了会不会破坏 peer/engines、先升哪个」。本包把「事实」(latest)和「判断」(分级/风险/顺序)拆开:事实由你提供本地快照,判断写死在代码里、可测试、可复核。快照里没有的包,本包说 `unknown`,不编一个 latest 出来。
|
|
111
|
+
|
|
112
|
+
### 为什么 current 不是 lock 里的实际版本?
|
|
113
|
+
|
|
114
|
+
`package.json` 只有声明范围,没有实际安装版本。本包默认取范围下界(`^1.2.3` → `1.2.3`)并在 `reason` 里注明;把 lockfile 解析出的版本作为 `installed` 传进来,才会用真实版本。lock 一致性/漂移是 `dsh-lockfile` 的职责,本包不重复。
|
|
115
|
+
|
|
116
|
+
### pin 会改我的 package.json 吗?
|
|
117
|
+
|
|
118
|
+
不会。`pin` 只返回 `changes` 与一段 unified diff 文本(`applied:false`、`wroteFiles:[]`),复制后你自己决定是否应用。没有 `installed` 快照时它不会猜锁版本,只写说明。
|
|
119
|
+
|
|
120
|
+
## 事实
|
|
121
|
+
|
|
122
|
+
- 源码 **1738 行**(`plugin.mjs` + `lib/` 共 10 个文件),**零运行时依赖**,没有编译步骤
|
|
123
|
+
- 测试 **61 条**、**8 个测试文件**:五档分级 / 快照缺失 → unknown / engines 不兼容 / deprecated / peer 破坏 / yanked / 排序与理由 / pin diff / audit 汇总 / 空依赖 / 命名同源
|
|
124
|
+
- 所有测试数据在内存或 /tmp 造,**不联网、不跑 npm outdated、不读真实 node_modules**
|
|
125
|
+
- 已实测:宿主 DSH 0.2.2-rc.1、Node 23.11.0、macOS
|
|
126
|
+
- `npm pack` 实测:发布 **16 个文件**、tarball **26 KB**、解包 **82 KB**(`test/` 与 `scripts/` 不进包)
|
|
127
|
+
- 工具名 `dsh_deps`;命名清单见 `dsh-plugin.naming.json`(`npm run check` 校验五处同源)
|
|
128
|
+
|
|
129
|
+
## 已知限制
|
|
130
|
+
|
|
131
|
+
- latest 只来自你给的快照:快照过期,结论就过期;本包不联网校验快照新鲜度。
|
|
132
|
+
- semver 是子集:不支持 npm 别名、git/file/workspace 范围与 prerelease 特例,遇到就 unknown。
|
|
133
|
+
- 当前版本默认取范围下界,不等于实际安装版本;需要准确请传 installed 快照。
|
|
134
|
+
- 只做升级规划,不做依赖冲突 / 重复包 / peer 求解(dsh-pkg 的职责),也不改文件。
|
|
135
|
+
- 风险信号是静态判据,不代表真实兼容性;升级前仍要跑测试。
|
|
136
|
+
- 被依赖次数依赖你传的 manifests / dependents;只给一份 manifest 时每项都是 1,排序退化为按名字。
|
|
137
|
+
|
|
138
|
+
## 开发与自检
|
|
14
139
|
|
|
15
140
|
```sh
|
|
16
|
-
|
|
141
|
+
node --test test/ # 或 node --test test/*.test.mjs(61 条)
|
|
142
|
+
npm test # 同上(npm script 版本)
|
|
143
|
+
npm run check # 包结构 + 语法 + 命名五处同源 + 假 ctx 真加载 + 宿主 schema 校验 + 只读/不联网红线
|
|
144
|
+
node scripts/host-smoke.mjs # 宿主注册表 + 执行管道冒烟
|
|
145
|
+
node lib/cli.js snapshot-shape
|
|
17
146
|
```
|
|
18
147
|
|
|
19
148
|
## 许可
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# dsh-deps:往宿主挂一个 host 侧插件,注册模型可调用工具 dsh_deps。
|
|
2
|
+
# 行 id 与包名同名(三个名字同源),避免「行 id ≠ 包名」两个命名空间带来的重复注册。
|
|
3
|
+
- insert:
|
|
4
|
+
- id: dsh-deps
|
|
5
|
+
name: dsh-deps
|
|
6
|
+
config:
|
|
7
|
+
# 默认本地 registry 元数据快照({name:{latest,deprecated?,engines?,yanked?}});留空 = 调用时传
|
|
8
|
+
snapshot: null
|
|
9
|
+
# 默认 installed 快照({name:"x.y.z"});留空 = 用声明范围下界当当前版本
|
|
10
|
+
installed: null
|
|
11
|
+
# 默认 Node 版本;留空 = 取运行时版本
|
|
12
|
+
nodeVersion: ''
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"policy": "dsh-plugin-naming/v1",
|
|
4
|
+
"plugin": {
|
|
5
|
+
"namespace": "hfshfg",
|
|
6
|
+
"name": "deps",
|
|
7
|
+
"coordinate": "hfshfg/deps",
|
|
8
|
+
"packageName": "dsh-deps"
|
|
9
|
+
},
|
|
10
|
+
"names": {
|
|
11
|
+
"pluginNames": [
|
|
12
|
+
"dsh-deps"
|
|
13
|
+
],
|
|
14
|
+
"loaderIds": [
|
|
15
|
+
"dsh-deps"
|
|
16
|
+
],
|
|
17
|
+
"services": [],
|
|
18
|
+
"tools": [
|
|
19
|
+
"dsh_deps"
|
|
20
|
+
],
|
|
21
|
+
"commands": [],
|
|
22
|
+
"skills": [],
|
|
23
|
+
"skillProviders": [],
|
|
24
|
+
"events": [],
|
|
25
|
+
"settingsNamespaces": [],
|
|
26
|
+
"routes": []
|
|
27
|
+
}
|
|
28
|
+
}
|
package/lib/classify.mjs
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 五档分级 + 风险信号。判据写死,不联网、不猜 latest:
|
|
3
|
+
*
|
|
4
|
+
* bump(五档,写死):patch / minor / major / pinned / unknown
|
|
5
|
+
* risk:none / low / medium / high / blocked / unknown
|
|
6
|
+
*
|
|
7
|
+
* 只有「快照里真的有 latest」且「能确定当前版本」时才会给 patch/minor/major;
|
|
8
|
+
* 其余一律 unknown 并写清原因(快照缺失 / 范围不可解析 / 没有当前版本)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { compareVersions, bumpType, rangeFloor, satisfiesRange, checkEngines, parseVersion, parseRange } from './version.mjs'
|
|
12
|
+
|
|
13
|
+
/** 五档(顺序即风险由低到高,unknown 单列)。 */
|
|
14
|
+
export const BUMPS = ['patch', 'minor', 'major', 'pinned', 'unknown']
|
|
15
|
+
|
|
16
|
+
/** 风险档(由轻到重;unknown 单列)。 */
|
|
17
|
+
export const RISKS = ['none', 'low', 'medium', 'high', 'blocked', 'unknown']
|
|
18
|
+
|
|
19
|
+
/** 风险排序权重。 */
|
|
20
|
+
export const RISK_RANK = { none: 0, low: 1, medium: 2, high: 3, blocked: 4, unknown: 5 }
|
|
21
|
+
|
|
22
|
+
/** 风险中文说明(README / 报告共用)。 */
|
|
23
|
+
export const RISK_LABEL = {
|
|
24
|
+
none: '无需动作',
|
|
25
|
+
low: '低风险',
|
|
26
|
+
medium: '中风险',
|
|
27
|
+
high: '高风险',
|
|
28
|
+
blocked: '被阻塞',
|
|
29
|
+
unknown: '未判定',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 保留原范围的运算符,把版本替换成 latest。
|
|
34
|
+
* @param {string} range 原范围
|
|
35
|
+
* @param {string} latest 目标版本
|
|
36
|
+
* @returns {string} 建议范围
|
|
37
|
+
*/
|
|
38
|
+
export function suggestRange(range, latest) {
|
|
39
|
+
const text = String(range ?? '').trim()
|
|
40
|
+
if (text.startsWith('^')) return `^${latest}`
|
|
41
|
+
if (text.startsWith('~')) return `~${latest}`
|
|
42
|
+
if (text.startsWith('>=')) return `>=${latest}`
|
|
43
|
+
if (text.startsWith('>')) return `>${latest}`
|
|
44
|
+
if (parseVersion(text)) return latest
|
|
45
|
+
return `^${latest}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 分类单个依赖。
|
|
50
|
+
* @param {object} dep collectDependencies 的一项
|
|
51
|
+
* @param {object} context 上下文
|
|
52
|
+
* @param {Map<string,object>} context.snapshot 快照
|
|
53
|
+
* @param {Map<string,string>} context.installed installed 快照
|
|
54
|
+
* @param {number} [context.dependents] 被依赖次数
|
|
55
|
+
* @param {string} context.nodeVersion 当前 Node 版本
|
|
56
|
+
* @param {object} context.manifest 根 manifest
|
|
57
|
+
* @returns {object} 条目
|
|
58
|
+
*/
|
|
59
|
+
export function classifyDependency(dep, context) {
|
|
60
|
+
const snapshotEntry = context.snapshot.get(dep.name)
|
|
61
|
+
const installedVersion = context.installed.get(dep.name) ?? null
|
|
62
|
+
const floor = rangeFloor(dep.range)
|
|
63
|
+
const currentVersion = installedVersion ?? floor
|
|
64
|
+
const versionSource = installedVersion ? 'installed' : floor ? 'range-floor' : 'unknown'
|
|
65
|
+
const dependents = context.dependents ?? 1
|
|
66
|
+
const signals = []
|
|
67
|
+
|
|
68
|
+
const latest = snapshotEntry?.latest ?? null
|
|
69
|
+
let bump
|
|
70
|
+
let reason
|
|
71
|
+
|
|
72
|
+
if (parseRange(dep.range) === null) {
|
|
73
|
+
bump = 'unknown'
|
|
74
|
+
reason = `声明范围 ${dep.range || '(空)'} 不是可判定的 semver 范围(git/file/workspace/npm 别名等),不猜`
|
|
75
|
+
signals.push({
|
|
76
|
+
signal: 'unsupported-range',
|
|
77
|
+
severity: 'unknown',
|
|
78
|
+
detail: `范围 ${dep.range} 不在本包支持的 semver 子集里`,
|
|
79
|
+
action: '改成语义化版本范围(如 ^1.2.3),或人工确认该依赖的升级路径;本包不猜。',
|
|
80
|
+
})
|
|
81
|
+
} else if (!snapshotEntry || latest === null) {
|
|
82
|
+
bump = 'unknown'
|
|
83
|
+
reason = snapshotEntry
|
|
84
|
+
? `快照里 ${dep.name} 没有可解析的 latest:不能判断(不猜)`
|
|
85
|
+
: `快照里没有 ${dep.name}:不能判断 latest(不猜)`
|
|
86
|
+
signals.push({
|
|
87
|
+
signal: 'unknown-latest',
|
|
88
|
+
severity: 'unknown',
|
|
89
|
+
detail: snapshotEntry ? '快照条目缺少可解析的 latest' : '没有该包的快照条目',
|
|
90
|
+
action: `先取事实再规划:npm view ${dep.name} version,把结果写进本地快照 JSON 后重跑;本包不联网、不跑 npm outdated。`,
|
|
91
|
+
})
|
|
92
|
+
} else if (!currentVersion) {
|
|
93
|
+
bump = 'unknown'
|
|
94
|
+
reason = `声明范围 ${dep.range || '(空)'} 无法得到当前版本(如 * / 非 semver 范围),不猜`
|
|
95
|
+
signals.push({
|
|
96
|
+
signal: 'no-current-version',
|
|
97
|
+
severity: 'unknown',
|
|
98
|
+
detail: `范围 ${dep.range} 没有可判定的下界`,
|
|
99
|
+
action: '传 installed 快照(例如从 lockfile 解析出的实际版本),或用具体版本范围重跑。',
|
|
100
|
+
})
|
|
101
|
+
} else if (compareVersions(latest, currentVersion) <= 0) {
|
|
102
|
+
bump = 'pinned'
|
|
103
|
+
reason = `当前 ${currentVersion} 已不低于快照 latest(${latest}),无需升级`
|
|
104
|
+
signals.push({ signal: 'up-to-date', severity: 'info', detail: `latest ${latest} 不高于当前 ${currentVersion}`, action: '无需动作;如果 latest 更低,检查是否被 yanked 或快照过期。' })
|
|
105
|
+
} else {
|
|
106
|
+
bump = bumpType(currentVersion, latest)
|
|
107
|
+
const sourceNote = versionSource === 'installed' ? '(当前版本来自 installed 快照)' : '(当前版本取自声明范围下界;实际安装版本请传 installed 快照)'
|
|
108
|
+
reason = `当前 ${currentVersion} → 快照 latest ${latest}(${bump})${sourceNote}`
|
|
109
|
+
if (bump === 'major') {
|
|
110
|
+
signals.push({
|
|
111
|
+
signal: 'major-bump',
|
|
112
|
+
severity: 'high',
|
|
113
|
+
detail: `${currentVersion} → ${latest} 跨大版本(breaking 风险)`,
|
|
114
|
+
action: '先读 CHANGELOG / 迁移指南,单独开 PR,升级前跑完整测试;必要时找 codemod。',
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
if (latest.includes('-')) {
|
|
118
|
+
signals.push({
|
|
119
|
+
signal: 'prerelease-target',
|
|
120
|
+
severity: 'medium',
|
|
121
|
+
detail: `快照 latest ${latest} 是预发布版本`,
|
|
122
|
+
action: '预发布不默认进生产;确认确实需要再升,并固定到具体版本。',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// engines 兼容性:快照声明了 engines.node 才判;没声明就明确「未声明」,不猜
|
|
128
|
+
if (snapshotEntry?.engines) {
|
|
129
|
+
const engines = checkEngines(snapshotEntry.engines, context.nodeVersion)
|
|
130
|
+
if (engines.declared && engines.compatible === false) {
|
|
131
|
+
signals.push({
|
|
132
|
+
signal: 'engines-incompatible',
|
|
133
|
+
severity: 'blocked',
|
|
134
|
+
detail: `目标版本声明 engines.node=${engines.range},当前 Node ${context.nodeVersion} 不满足`,
|
|
135
|
+
action: '先升级 Node(或选择兼容 Node 的版本),不要在现环境硬升。',
|
|
136
|
+
})
|
|
137
|
+
} else if (engines.declared && engines.compatible === null) {
|
|
138
|
+
signals.push({
|
|
139
|
+
signal: 'engines-unsupported',
|
|
140
|
+
severity: 'info',
|
|
141
|
+
detail: `engines.node=${engines.range} 无法判定(非本包支持的 semver 子集)`,
|
|
142
|
+
action: '人工确认 Node 版本是否满足;本包不猜。',
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// deprecated
|
|
148
|
+
if (snapshotEntry?.deprecated) {
|
|
149
|
+
signals.push({
|
|
150
|
+
signal: 'deprecated',
|
|
151
|
+
severity: 'high',
|
|
152
|
+
detail: `快照标记 deprecated:${snapshotEntry.deprecated}`,
|
|
153
|
+
action: '优先迁移到替代包/替代 API,而不是升级到已废弃版本。',
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// yanked:当前版本被撤,或目标 latest 被撤
|
|
158
|
+
if (snapshotEntry?.yankedVersions?.length > 0) {
|
|
159
|
+
const yankedAll = snapshotEntry.yankedVersions.includes('*')
|
|
160
|
+
const currentYanked = currentVersion && (yankedAll || snapshotEntry.yankedVersions.includes(currentVersion))
|
|
161
|
+
const latestYanked = latest && (yankedAll || snapshotEntry.yankedVersions.includes(latest))
|
|
162
|
+
if (currentYanked) {
|
|
163
|
+
signals.push({
|
|
164
|
+
signal: 'yanked-current',
|
|
165
|
+
severity: 'blocked',
|
|
166
|
+
detail: `当前版本 ${currentVersion} 在快照里被标记 yanked`,
|
|
167
|
+
action: '立即升级到未 yanked 的版本(或回退到安全版本),并查明撤回原因。',
|
|
168
|
+
})
|
|
169
|
+
} else if (latestYanked) {
|
|
170
|
+
signals.push({
|
|
171
|
+
signal: 'yanked-target',
|
|
172
|
+
severity: 'medium',
|
|
173
|
+
detail: `快照把 latest ${latest} 标为 yanked`,
|
|
174
|
+
action: '不要升到被撤版本;等快照更新到安全版本,或选择上一稳定版。',
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// peer:升级后 latest 落在声明的 peer 范围外 → 会破坏宿主/消费方约束
|
|
180
|
+
if (dep.peer && latest) {
|
|
181
|
+
const peerRange = context.manifest?.peerDependencies?.[dep.name] ?? dep.range
|
|
182
|
+
const satisfied = satisfiesRange(latest, peerRange)
|
|
183
|
+
if (satisfied.supported && !satisfied.satisfies) {
|
|
184
|
+
signals.push({
|
|
185
|
+
signal: 'peer-breaking',
|
|
186
|
+
severity: 'high',
|
|
187
|
+
detail: `latest ${latest} 不满足声明 peerDependencies["${dep.name}"]=${peerRange}`,
|
|
188
|
+
action: '先确认宿主支持该版本,再同步放宽 peer 范围;不要只升依赖不改 peer 约束。',
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const risk = deriveRisk(bump, signals)
|
|
194
|
+
return {
|
|
195
|
+
name: dep.name,
|
|
196
|
+
section: dep.section,
|
|
197
|
+
sections: dep.sections,
|
|
198
|
+
current: dep.range,
|
|
199
|
+
currentVersion,
|
|
200
|
+
versionSource,
|
|
201
|
+
latest,
|
|
202
|
+
latestSource: latest ? 'snapshot' : null,
|
|
203
|
+
bump,
|
|
204
|
+
risk,
|
|
205
|
+
dependents,
|
|
206
|
+
dev: dep.dev,
|
|
207
|
+
optional: dep.optional,
|
|
208
|
+
peer: dep.peer,
|
|
209
|
+
suggested: latest && bump !== 'unknown' && bump !== 'pinned' ? suggestRange(dep.range, latest) : null,
|
|
210
|
+
reason,
|
|
211
|
+
signals,
|
|
212
|
+
known: bump !== 'unknown',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 由 bump + 信号推风险(取最重的那条,信号可以升级风险)。
|
|
218
|
+
* @param {string} bump 五档
|
|
219
|
+
* @param {Array<object>} signals 信号
|
|
220
|
+
* @returns {string} 风险档
|
|
221
|
+
*/
|
|
222
|
+
export function deriveRisk(bump, signals) {
|
|
223
|
+
let risk = bump === 'unknown' ? 'unknown' : bump === 'pinned' ? 'none' : bump === 'major' ? 'high' : bump === 'minor' ? 'medium' : 'low'
|
|
224
|
+
for (const signal of signals) {
|
|
225
|
+
const mapped = signal.severity === 'blocked' ? 'blocked'
|
|
226
|
+
: signal.severity === 'high' ? 'high'
|
|
227
|
+
: signal.severity === 'medium' ? 'medium'
|
|
228
|
+
: null
|
|
229
|
+
if (mapped && RISK_RANK[mapped] > RISK_RANK[risk]) risk = mapped
|
|
230
|
+
}
|
|
231
|
+
return risk
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 批量分类。
|
|
236
|
+
* @param {object} manifest 根 manifest
|
|
237
|
+
* @param {object} context 上下文(snapshot / installed / manifests)
|
|
238
|
+
* @returns {{entries:Array<object>, problems:string[]}} 结果
|
|
239
|
+
*/
|
|
240
|
+
export function classifyAll(manifest, context) {
|
|
241
|
+
const deps = context.deps ?? []
|
|
242
|
+
const entries = deps.map((dep) => classifyDependency(dep, {
|
|
243
|
+
snapshot: context.snapshot,
|
|
244
|
+
installed: context.installed,
|
|
245
|
+
dependents: context.dependents?.get(dep.name) ?? 1,
|
|
246
|
+
nodeVersion: context.nodeVersion,
|
|
247
|
+
manifest,
|
|
248
|
+
}))
|
|
249
|
+
return { entries, problems: context.problems ?? [] }
|
|
250
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-deps` 命令行:依赖升级规划(只读、不联网)。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node lib/cli.js plan --package-json <path> [--snapshot <快照.json>] [--installed <json>] [--json]
|
|
7
|
+
* node lib/cli.js why <name> --package-json <path> [--snapshot <快照.json>] [--json]
|
|
8
|
+
* node lib/cli.js pin --package-json <path> --installed <json> [--json]
|
|
9
|
+
* node lib/cli.js audit --package-json <path> [--snapshot <快照.json>] [--json]
|
|
10
|
+
* node lib/cli.js snapshot-shape
|
|
11
|
+
*
|
|
12
|
+
* 退出码:0 = 无高危(或查询成功)、1 = 参数/输入错误、3 = 有 high/blocked 升级项。
|
|
13
|
+
* 只读:不写 package.json / lockfile,不联网,不跑 npm outdated。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { realpathSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { resolve } from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { runAction, VERSION, describeSnapshotShape } from './engine.mjs'
|
|
21
|
+
import { renderReport, renderJson } from './format.mjs'
|
|
22
|
+
import { describeError } from './errors.mjs'
|
|
23
|
+
|
|
24
|
+
const USAGE = `dsh-deps ${VERSION} —— 依赖升级规划(五档分级 + 风险信号 + 建议顺序;只读、不联网)
|
|
25
|
+
|
|
26
|
+
用法:
|
|
27
|
+
node lib/cli.js plan --package-json <path> [--snapshot <json>] [--installed <json>] [--manifests a,b]
|
|
28
|
+
node lib/cli.js why <name> --package-json <path> [--snapshot <json>] [--installed <json>]
|
|
29
|
+
node lib/cli.js pin --package-json <path> --installed <json>
|
|
30
|
+
node lib/cli.js audit --package-json <path> [--snapshot <json>] [--installed <json>]
|
|
31
|
+
node lib/cli.js snapshot-shape
|
|
32
|
+
|
|
33
|
+
选项:
|
|
34
|
+
--package-json <路径> 要规划的 package.json(只读,不改)
|
|
35
|
+
--snapshot <路径> 本地 registry 元数据快照 {name: {latest, deprecated?, engines?, yanked?}}
|
|
36
|
+
--installed <路径> installed 快照 {name: "x.y.z"}(例如从 lockfile 解析;不传就用声明范围下界)
|
|
37
|
+
--manifests <a,b> 额外的 manifest 路径(用于统计「被依赖次数」;第一个仍是根)
|
|
38
|
+
--node-version <x.y.z> 当前 Node 版本(默认取运行时版本)
|
|
39
|
+
--json / --brief 输出 JSON / 只给结论
|
|
40
|
+
--help, -h / --version, -v
|
|
41
|
+
|
|
42
|
+
latest 只来自 --snapshot;快照里没有的包一律 unknown,本包不会联网去猜。
|
|
43
|
+
退出码:0 无高危、1 参数/文件错误、3 有 high/blocked 升级项`
|
|
44
|
+
|
|
45
|
+
/** 参数错误。 */
|
|
46
|
+
class UsageError extends Error {}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析 argv。
|
|
50
|
+
* @param {string[]} argv 参数
|
|
51
|
+
* @returns {object} flags
|
|
52
|
+
*/
|
|
53
|
+
export function parseArgs(argv) {
|
|
54
|
+
const flags = { json: false, brief: false, positionals: [] }
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const arg = argv[i]
|
|
57
|
+
const takeValue = (name) => {
|
|
58
|
+
const next = argv[++i]
|
|
59
|
+
if (next === undefined) throw new UsageError(`${name} 需要一个值`)
|
|
60
|
+
return next
|
|
61
|
+
}
|
|
62
|
+
switch (arg) {
|
|
63
|
+
case '--help': case '-h': flags.help = true; break
|
|
64
|
+
case '--version': case '-v': flags.version = true; break
|
|
65
|
+
case '--json': flags.json = true; break
|
|
66
|
+
case '--brief': flags.brief = true; break
|
|
67
|
+
case '--package-json': flags.packageJsonPath = takeValue('--package-json'); break
|
|
68
|
+
case '--snapshot': flags.snapshotPath = takeValue('--snapshot'); break
|
|
69
|
+
case '--installed': flags.installedPath = takeValue('--installed'); break
|
|
70
|
+
case '--manifests': flags.manifestPaths = takeValue('--manifests').split(',').map((item) => item.trim()).filter(Boolean); break
|
|
71
|
+
case '--node-version': flags.nodeVersion = takeValue('--node-version'); break
|
|
72
|
+
default:
|
|
73
|
+
if (arg.startsWith('--')) throw new UsageError(`未知参数 ${arg}`)
|
|
74
|
+
flags.positionals.push(arg)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return flags
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 读 JSON 文件(失败给稳定错误码)。
|
|
82
|
+
* @param {string} path 路径
|
|
83
|
+
* @param {string} code 错误码
|
|
84
|
+
* @returns {any} 解析结果
|
|
85
|
+
*/
|
|
86
|
+
function readJson(path, code) {
|
|
87
|
+
let text
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(resolve(path), 'utf8')
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new UsageError(`读 ${path} 失败:${error instanceof Error ? error.message : String(error)}`)
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(text)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new UsageError(`${path} 不是合法 JSON:${error instanceof Error ? error.message : String(error)}(${code})`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* CLI 主流程。
|
|
102
|
+
* @param {string[]} argv 参数
|
|
103
|
+
* @returns {Promise<number>} 退出码
|
|
104
|
+
*/
|
|
105
|
+
export async function main(argv) {
|
|
106
|
+
let flags
|
|
107
|
+
try { flags = parseArgs(argv) } catch (error) {
|
|
108
|
+
process.stderr.write(`${error.message}\n\n${USAGE}\n`)
|
|
109
|
+
return 1
|
|
110
|
+
}
|
|
111
|
+
if (flags.help) { process.stdout.write(`${USAGE}\n`); return 0 }
|
|
112
|
+
if (flags.version) { process.stdout.write(`${VERSION}\n`); return 0 }
|
|
113
|
+
|
|
114
|
+
const [action, ...rest] = flags.positionals
|
|
115
|
+
if (!action) { process.stderr.write(`必须给动作(plan / why / pin / audit / snapshot-shape)\n\n${USAGE}\n`); return 1 }
|
|
116
|
+
|
|
117
|
+
if (action === 'snapshot-shape') {
|
|
118
|
+
const shape = describeSnapshotShape()
|
|
119
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(shape, null, 2)}\n`)
|
|
120
|
+
else {
|
|
121
|
+
process.stdout.write('本地 registry 快照形状:\n')
|
|
122
|
+
for (const [key, value] of Object.entries(shape)) process.stdout.write(`- ${key}: ${value}\n`)
|
|
123
|
+
}
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!['plan', 'why', 'pin', 'audit'].includes(action)) {
|
|
128
|
+
process.stderr.write(`不认识的动作 ${JSON.stringify(action)}(可选:plan / why / pin / audit / snapshot-shape)\n\n${USAGE}\n`)
|
|
129
|
+
return 1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const name = action === 'why' ? rest[0] : undefined
|
|
134
|
+
if (action === 'why' && !name) throw new UsageError('why 需要 <name> 参数')
|
|
135
|
+
if (action !== 'why' && rest.length > 0) throw new UsageError(`多余的参数 ${rest.join(' ')}`)
|
|
136
|
+
if (!flags.packageJsonPath) throw new UsageError('必须给 --package-json <路径>')
|
|
137
|
+
|
|
138
|
+
let packageJsonText
|
|
139
|
+
let manifest
|
|
140
|
+
try {
|
|
141
|
+
packageJsonText = readFileSync(resolve(flags.packageJsonPath), 'utf8')
|
|
142
|
+
manifest = JSON.parse(packageJsonText)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new UsageError(`读/解析 ${flags.packageJsonPath} 失败:${error instanceof Error ? error.message : String(error)}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const snapshot = flags.snapshotPath ? readJson(flags.snapshotPath, 'snapshot_invalid') : undefined
|
|
148
|
+
const installed = flags.installedPath ? readJson(flags.installedPath, 'installed_invalid') : undefined
|
|
149
|
+
const manifests = flags.manifestPaths?.length ? flags.manifestPaths.map((path) => readJson(path, 'manifest_invalid')) : undefined
|
|
150
|
+
|
|
151
|
+
const report = runAction(action, {
|
|
152
|
+
manifest,
|
|
153
|
+
packageJsonText,
|
|
154
|
+
snapshot,
|
|
155
|
+
installed,
|
|
156
|
+
manifests,
|
|
157
|
+
nodeVersion: flags.nodeVersion,
|
|
158
|
+
name,
|
|
159
|
+
})
|
|
160
|
+
process.stdout.write(flags.json ? `${renderJson(report)}\n` : `${renderReport(report, { detail: flags.brief ? 'brief' : 'full' })}\n`)
|
|
161
|
+
|
|
162
|
+
if (action === 'plan' || action === 'audit') {
|
|
163
|
+
const serious = report.entries.some((entry) => entry.risk === 'high' || entry.risk === 'blocked')
|
|
164
|
+
return serious ? 3 : 0
|
|
165
|
+
}
|
|
166
|
+
return 0
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const info = describeError(error)
|
|
169
|
+
if (error instanceof UsageError) info.code = 'invalid_args'
|
|
170
|
+
if (flags.json) process.stderr.write(`${JSON.stringify({ ok: false, error: info }, null, 2)}\n`)
|
|
171
|
+
else {
|
|
172
|
+
process.stderr.write(`✗ ${info.message}\n`)
|
|
173
|
+
if (info.hint) process.stderr.write(` → ${info.hint}\n`)
|
|
174
|
+
}
|
|
175
|
+
return 1
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 是否直接执行。
|
|
181
|
+
* @returns {boolean} 是否直接执行
|
|
182
|
+
*/
|
|
183
|
+
function isInvokedDirectly() {
|
|
184
|
+
const entry = process.argv[1]
|
|
185
|
+
if (!entry) return false
|
|
186
|
+
const self = fileURLToPath(import.meta.url)
|
|
187
|
+
try { return realpathSync(entry) === realpathSync(self) } catch { return resolve(entry) === self }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (isInvokedDirectly()) {
|
|
191
|
+
main(process.argv.slice(2))
|
|
192
|
+
.then((code) => { process.exitCode = code })
|
|
193
|
+
.catch((error) => {
|
|
194
|
+
process.stderr.write(`✗ 未捕获异常:${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
|
|
195
|
+
process.exitCode = 1
|
|
196
|
+
})
|
|
197
|
+
}
|