dsh-vibe-math 2.4.1 → 2.5.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/README.en.md +14 -14
- package/README.md +15 -14
- package/cordis.patch.yml +33 -10
- package/docs/AUDIT-CHECKLIST.md +25 -6
- package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
- package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
- package/docs/test-timing.md +73 -40
- package/installer.js +225 -45
- package/package.json +3 -2
- package/preset-declaration.js +38 -7
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +67 -21
- package/prompt-corpus-v2/formal-verify-v2.json +116 -92
- package/prompt-corpus-v2/formal-verify-v2.md +240 -93
- package/prompt-corpus-v3/formal-verify-v3.json +81 -81
- package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
- package/prompt-corpus-v4/formal-verify-v4.json +38 -8
- package/prompt-corpus-v4/formal-verify-v4.md +157 -25
- package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
- package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
- package/tests/audit-installer-compat.test.mjs +70 -4
- package/tests/audit-installer-policy.test.mjs +229 -4
- package/tests/audit-persona-sensitivity.mjs +11 -4
- package/tests/audit-persona-surface.test.mjs +5 -1
- package/tests/audit-v5-sensitivity.mjs +47 -6
- package/tests/e2e-v5-round2.test.mjs +354 -0
- package/tests/formal-verify-v2.test.mjs +54 -2
- package/tests/formal-verify-v3.test.mjs +63 -2
- package/tests/formal-verify-v4.test.mjs +120 -4
- package/tests/run-tests.mjs +104 -23
- package/vibe-math-v2/agent.cordis.yml +6 -0
- package/vibe-math-v2/vibe-math-v2.js +503 -99
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
- package/vibe-math-v3/agent.cordis.yml +26 -10
- package/vibe-math-v3/vibe-math-v3.js +555 -175
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
- package/vibe-math-v4/agent.cordis.yml +26 -6
- package/vibe-math-v4/vibe-math-v4.js +404 -51
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
- package/vibe-math-v5/agent.cordis.yml +8 -4
- package/vibe-math-v5/vibe-math-v5.js +513 -105
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
package/docs/test-timing.md
CHANGED
|
@@ -8,52 +8,77 @@
|
|
|
8
8
|
## 1. 怎么跑(并行是默认)
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
node tests/run-tests.mjs #
|
|
12
|
-
node tests/run-tests.mjs --only formal # 只跑名字含 formal
|
|
11
|
+
node tests/run-tests.mjs # 全部套件 + 全部探针(tests/ 下每个 .mjs),并行(并发 = min(4, CPU 核数);命令都从**仓库根**执行)
|
|
12
|
+
node tests/run-tests.mjs --only formal # 只跑名字含 formal 的项(套件与探针一起筛)
|
|
13
13
|
node tests/run-tests.mjs --concurrency=6 # 手动指定并发
|
|
14
|
-
node tests/
|
|
15
|
-
node tests/audit-persona-sensitivity.mjs # 11 条提示词面探针(串行,本身只要几秒)
|
|
16
|
-
node tests/audit-prompt-invariants.mjs # 静态:四套的提示词/工具面不变式 + 扫描器自检(< 0.1s)
|
|
17
|
-
node tests/audit-prompt-invariants.mjs --self-probe # 证明上面那 157 条不变式真的会变红(5 个自探针)
|
|
18
|
-
node tests/audit-spec-traceability.mjs # 静态:规格/README ↔ 代码可追溯(< 0.1s)
|
|
19
|
-
node tests/audit-v5-integrity.mjs # 静态:v5 完整性/理念门禁 + 扫描器解析级自检(≈0.5 s)
|
|
14
|
+
node tests/run-tests.mjs --json # 机器可读汇总(含 skippedNeedsArgs / devCheckout)
|
|
20
15
|
```
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
`run-tests.mjs` 的收集规则(Round B 起):`tests/` 下**每个** `.mjs` 都会被跑——
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
- `*.test.mjs` 是**套件**;其余 `.mjs` 是**探针/审计**(灵敏度变异、注册面、自驱动守卫);
|
|
20
|
+
- 只有登记在 runner 的 `NEEDS_ARGS` 里的脚本才会被跳过(当前只有 `audit-tool-exec.mjs`:它必须带
|
|
21
|
+
`<preset-js> <tool-name>` 才能跑)。跳过项**每次运行都会打印出来**;在开发检出(存在 `.git`)里
|
|
22
|
+
该登记失效会**直接报错退出**,所以改名不可能悄悄把一条守卫踢出发布门禁;
|
|
23
|
+
- 需要参数的变体登记在 `VARIANTS`:`audit-registration.mjs` 四个预设各跑一次(裸跑只查 v3)、
|
|
24
|
+
`audit-prompt-invariants.mjs --self-probe`、以及 `audit-v3-registration-parity.mjs --self-probe`
|
|
25
|
+
(这条**成功 = 退出码 1**:它注入真实变异并要求守卫变红,runner 用 `expectExit` 表达)。
|
|
26
|
+
|
|
27
|
+
### 1.1 随包发布 vs 仅仓库(`npm pack --dry-run --json`)
|
|
28
|
+
|
|
29
|
+
`package.json` 的 `files` 只发 `tests/` 的一个**子集**(发布物共 102 个文件,其中 `tests/` 18 项),
|
|
30
|
+
所以安装用户跑 `node tests/run-tests.mjs` 得到的是子集的结果——runner 会把缺失的跳过项打印出来,
|
|
31
|
+
不会静默少跑:
|
|
32
|
+
|
|
33
|
+
- **随包(18 项)**:`run-tests.mjs`、`audit-formal-sensitivity.mjs`、`audit-persona-sensitivity.mjs`、
|
|
34
|
+
`audit-prompt-invariants.mjs`、`audit-spec-traceability.mjs`、`audit-v5-integrity.mjs`、
|
|
35
|
+
`audit-v5-sensitivity.mjs`、`selfdrive-v5.mjs`;套件 **10** 个:`audit-installer-compat`、
|
|
36
|
+
`audit-installer-policy`、`audit-persona-surface`、`audit-preset-rows`、`e2e-v5-round2`、
|
|
37
|
+
`formal-verify-v2`、`formal-verify-v3`、`formal-verify-v4`、`formal-verify-v5`、`prompt-v5-integrity`。
|
|
38
|
+
- **仅仓库(不发)**:其余 28 个套件(v2/v3/v4 的 e2e、`selfdrive-v3`/`v4`、v2/v3 修复探针、
|
|
39
|
+
`audit-fuzz-helpers` 之外的若干 `audit-*`……)与 7 个脚本(`audit-fuzz-helpers`、
|
|
40
|
+
`audit-registration`、`audit-roundtrip-idempotence`、`audit-tool-exec`、
|
|
41
|
+
`audit-v3-registration-parity`、`selfdrive-v3`、`selfdrive-v4`)。
|
|
42
|
+
- 因此**完整门禁只能在开发检出里跑**;发布物里的子集是"用户可自查"的一部分,不是全部证据。
|
|
43
|
+
(`audit-fuzz-helpers.mjs` 在 2.3.13 时曾随包发布;现在不在 `files` 里。)
|
|
44
|
+
|
|
45
|
+
## 2. 基线(本机:4 核 / Windows,实测)
|
|
25
46
|
|
|
26
47
|
| 脚本 | 串行(sum) | 并行(wall) | 实测输出 |
|
|
27
48
|
|---|---|---|---|
|
|
28
|
-
| `tests/run-tests.mjs
|
|
29
|
-
| `tests/audit-
|
|
30
|
-
| `tests/audit-
|
|
31
|
-
| `tests/audit-
|
|
32
|
-
| `tests/audit-
|
|
33
|
-
| `audit-
|
|
34
|
-
| `audit-
|
|
35
|
-
| `audit-
|
|
36
|
-
| `audit-
|
|
37
|
-
| `audit-
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
| `tests/run-tests.mjs`(**56 项** = 38 套件 + 18 探针/变体) | ≈ 629 s | **≈ 208 s**(并发 4,speed-up x3.02) | `TOTAL 56 PASS 56 FAIL 0 (suites 38 · probes 18)`;关键路径 = `audit-formal-sensitivity` 208 s |
|
|
50
|
+
| `tests/audit-formal-sensitivity.mjs`(49 探针,自带并发 4) | 803 s | **≈ 208 s** | 关键路径 = 12 个 v2 探针(每个 ≈42–60 s) |
|
|
51
|
+
| `tests/audit-v5-sensitivity.mjs`(39 探针,串行) | ≈ 101 s | — | 每条 = 一次被测套件重跑 |
|
|
52
|
+
| `tests/audit-persona-sensitivity.mjs`(11 探针) | ≈ 4.4 s | — | 本身很快,不需要并行 |
|
|
53
|
+
| `tests/audit-prompt-invariants.mjs`(157 条) | ≈ 1.9 s | — | 静态 |
|
|
54
|
+
| `tests/audit-prompt-invariants.mjs --self-probe`(5 探针) | ≈ 7.4 s | — | 每个探针 = 一次自我重跑 |
|
|
55
|
+
| `tests/audit-spec-traceability.mjs`(94 条) | ≈ 0.2 s | — | 静态 |
|
|
56
|
+
| `tests/audit-v5-integrity.mjs` | ≈ 0.6 s | — | 静态审计(含扫描器自检) |
|
|
57
|
+
| `tests/audit-registration.mjs`(4 个预设各一次) | ≈ 1.0 s | — | 裸跑只查 v3,所以 runner 跑四个变体 |
|
|
58
|
+
| `tests/audit-v3-registration-parity.mjs`(+ `--self-probe`) | ≈ 0.3 s | — | 自探针**成功 = exit 1**(变异后守卫必须变红) |
|
|
59
|
+
| `tests/prompt-v5-integrity.test.mjs` | ≈ 2.2 s | — | 虚拟时钟下生成 v5 语料(语料字节稳定;**stdout 里的会议成员顺序仍是运行间随机的**,只有落盘语料是逐字节确定的) |
|
|
60
|
+
| `tests/e2e-v5-round2.test.mjs` | ≈ 3.2 s | — | v5 e2e(含本轮新增的"预置快照不被覆盖""命名研究所被读取""失败轮次"三节) |
|
|
61
|
+
| `tests/host-failure-paths.test.mjs` | ≈ 6.3 s | — | 真实宿主的失败路径(`kind:'error'`、`/v4 set` 键校验、失败轮次不算进展) |
|
|
62
|
+
| `tests/v4-mailbox-stall.test.mjs` | ≈ 7.1 s | — | `sendMessage` 拒绝后必须重投并唤醒(control + treatment 各一个 host) |
|
|
63
|
+
| `tests/v2-fix-probes.test.mjs` | ≈ 8.6 s | — | v2 的 flat 等权均值 / ≥2 名验证者 / runShell 失败分支(26 条) |
|
|
64
|
+
| `tests/v2-path-escape.test.mjs` | ≈ 2.2 s | — | 验证日志路径逃逸(7 条) |
|
|
65
|
+
| `tests/v2-tool-cap.test.mjs` | ≈ 0.4 s | — | 能力表来自真实组合、*MaxToolCalls 只是提示(10 条) |
|
|
44
66
|
|
|
45
67
|
单套件耗时(并行时的关键路径按此排序):
|
|
46
68
|
|
|
47
69
|
| 套件 | 耗时 | 备注 |
|
|
48
70
|
|---|---|---|
|
|
49
|
-
| `e2e-v4-fixes.test.mjs` | **≈
|
|
50
|
-
| `formal-verify-v2.test.mjs` | **≈
|
|
71
|
+
| `e2e-v4-fixes.test.mjs` | **≈ 104 s** | 9 个用例是**轮次采样**型(如 T13 采样 400 轮、T19/T25 多轮);时间 ≈ 轮数 × 框架自身的 40 ms 计时粒度 |
|
|
72
|
+
| `formal-verify-v2.test.mjs` | **≈ 43 s** | 曾为 186 s:见 §3 |
|
|
73
|
+
| `e2e-v3.test.mjs` | ≈ 21 s | |
|
|
51
74
|
| `e2e-regression.test.mjs` | ≈ 14 s | |
|
|
52
|
-
| `
|
|
53
|
-
| `e2e-d9-d13.test.mjs` | ≈ 13 s | |
|
|
54
|
-
|
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
| `formal-verify-v3.test.mjs` | ≈ 13 s | |
|
|
76
|
+
| `e2e-business.test.mjs` / `e2e-d9-d13.test.mjs` | ≈ 13 s | |
|
|
77
|
+
| 其余套件 | ≤ 10 s | 其中绝大多数 < 2 s |
|
|
78
|
+
|
|
79
|
+
> 优化前:全量回归 ≈ 5.5 min(串行,`formal-verify-v2` 单独 186 s);
|
|
80
|
+
> 探针脚本 ≈ **38 min**(49 条串行,其中 12 条 × `formal-verify-v2` 162 s)。
|
|
81
|
+
> 现在:**一套 `node tests/run-tests.mjs` ≈ 3.5 min 覆盖套件 + 探针**(探针单独跑仍是 ≈2.6 min)。
|
|
57
82
|
|
|
58
83
|
## 3. 已经做过的优化(别再重复踩)
|
|
59
84
|
|
|
@@ -68,9 +93,9 @@ node tests/audit-v5-integrity.mjs # 静态:v5 完整性/理念门
|
|
|
68
93
|
现在连续 100 次无待答 followup 就停(`V4_IDLE_POLLS` 可调)。
|
|
69
94
|
**再往下压就要砍采样深度了**——那 9 个慢用例(T13/T19/T22/T23/T25/T27/T2/T9/T20)是在
|
|
70
95
|
观察"多轮之后某个指令**没有**泄漏/重复",轮数是它们的不变式本体,不要再动。
|
|
71
|
-
3. **两个 runner
|
|
96
|
+
3. **两个 runner 并行**(Round A):探针 38 min → 2.6 min(sum 612 s,wall 154.6 s,x3.96);
|
|
72
97
|
全量回归 5.5 min → 1.9 min(sum 221.5 s,wall 111.5 s,x1.99)。
|
|
73
|
-
4. **虚拟时钟**(`prompt-v5-integrity.test.mjs
|
|
98
|
+
4. **虚拟时钟**(`prompt-v5-integrity.test.mjs`):v5 研究所由 `ctx.timeout` + `Date.now()`
|
|
74
99
|
驱动,真实时钟下"哪个成员被心跳/会议唤醒"取决于负载与毫秒差 → **随包语料每跑一次都变**(无法 diff,
|
|
75
100
|
真实缺陷会被淹没)。套件现在把 `ctx.timeout` 接到**虚拟时钟**、`sleep(n)` 推进虚拟时间:
|
|
76
101
|
套件 **7–9 s → 1.7 s**,且语料连续 6 次运行**字节一致**。语料写入端另加**全序排序**
|
|
@@ -79,13 +104,21 @@ node tests/audit-v5-integrity.mjs # 静态:v5 完整性/理念门
|
|
|
79
104
|
> 想给别的套件套用同一手法前请注意:若套件的等待助手用 `Date.now()` 做**超时判据**、又用
|
|
80
105
|
> `setInterval` 轮询(例如 `e2e-v4-fixes.test.mjs` 的 `waitFor`),冻结时钟会让判据永不超时;
|
|
81
106
|
> 那种情况必须连**轮询定时器**一起虚拟化,不能只改 `sleep`。
|
|
107
|
+
5. **语料里的随机 id 也要归一化**(Round B,`formal-verify-v4.test.mjs`):v4 的
|
|
108
|
+
`propose_task` 用 `Math.random` 造 `t-<8 位 hex>`,语料把 `[YOU CLAIMED TASK t-…]` 逐字写盘,
|
|
109
|
+
于是**每次跑测试都会改写随包语料**(`git status` 永远有 `prompt-corpus-v4/*`,且违反
|
|
110
|
+
`AUDIT-CHECKLIST` §2.4"连续两次哈希相同")。`scrub()` 现在把它归一化成 `<TASKID>`,并断言
|
|
111
|
+
"语料里既没有裸 id、也真的有 `<TASKID>`"。连续两次运行哈希相同(`d3381886…` / `e6c4e445…`)。
|
|
82
112
|
|
|
83
113
|
## 4. 并行安全(为什么可以并发)
|
|
84
114
|
|
|
85
115
|
- 每个套件/探针都自建 `mkdtempSync` 工作区,互不共享状态;
|
|
86
116
|
- **会写语料的套件必须给不同的语料目录**:`V2_CORPUS_DIR` / `V3_CORPUS_DIR` / `V4_CORPUS_DIR` /
|
|
87
117
|
`V5_CORPUS_DIR`。探针 runner 为**每个探针**分配独立目录,否则同一套件的并发实例会互相覆盖语料;
|
|
118
|
+
- `audit-v5-sensitivity.mjs` 现在**自己**把 `V5_CORPUS_DIR` 指向临时目录:被变异的插件绝不能把
|
|
119
|
+
自己的提示词倒进随包语料;
|
|
88
120
|
- `audit-persona-sensitivity.mjs` 用 `PERSONA_ROOT` 指向变异副本,且在覆盖模式下**不写**语料;
|
|
121
|
+
- `audit-formal-sensitivity.mjs` 同样给每个探针独立的 `V{2,3,4,5}_CORPUS_DIR`;
|
|
89
122
|
- `audit-prompt-invariants.mjs --self-probe` 用 `PROMPT_INVARIANTS_MUTATE`(JSON `[rel, from, to]`)
|
|
90
123
|
在**内存里**变异一个文件并自我重跑,**不碰磁盘**,因此可与任何东西并发;
|
|
91
124
|
变异串里不要用 NUL 分隔(环境变量不允许 NUL 字节)。
|
|
@@ -94,11 +127,11 @@ node tests/audit-v5-integrity.mjs # 静态:v5 完整性/理念门
|
|
|
94
127
|
|
|
95
128
|
| 目的 | 跑什么 | 预期 |
|
|
96
129
|
|---|---|---|
|
|
97
|
-
| 改了某个架构的插件 | `node tests/run-tests.mjs --only <vN>` + `node tests/audit-formal-sensitivity.mjs --only=vN` | 30 s –
|
|
98
|
-
| 改了提示词/人设 | `node tests/run-tests.mjs --only persona --only prompt` + `node tests/audit-persona-sensitivity.mjs` | ≈
|
|
99
|
-
| **改了任何工具的参数 schema / 参数处理** | `node tests/
|
|
100
|
-
|
|
|
101
|
-
| 只想快速看提示词/文档有没有漂移 | `node tests/audit-prompt-invariants.mjs && node tests/audit-spec-traceability.mjs` | **<
|
|
130
|
+
| 改了某个架构的插件 | `node tests/run-tests.mjs --only <vN>` + `node tests/audit-formal-sensitivity.mjs --only=vN` | 30 s – 3.5 min |
|
|
131
|
+
| 改了提示词/人设 | `node tests/run-tests.mjs --only persona --only prompt` + `node tests/audit-persona-sensitivity.mjs` | ≈ 20 s |
|
|
132
|
+
| **改了任何工具的参数 schema / 参数处理** | `node tests/run-tests.mjs --only audit-prompt-invariants --only formal` | ≈ 60 s(v2 套件占大头) |
|
|
133
|
+
| 改了 `tests/` 下的 runner/守卫 | `node tests/run-tests.mjs`(**必须**:它现在同时跑套件与探针,任何一条守卫变红都会让门禁失败) | **≈ 3.5 min** |
|
|
134
|
+
| 只想快速看提示词/文档有没有漂移 | `node tests/audit-prompt-invariants.mjs && node tests/audit-spec-traceability.mjs` | **< 3 s** |
|
|
102
135
|
| 只想知道"快不快" | `node tests/run-tests.mjs --json` | 读 `wallSeconds` / `slowest` |
|
|
103
136
|
|
|
104
137
|
**每次跑完都要看那几行 timing**:如果某个套件突然比基线慢很多,先怀疑新增的固定等待,
|
package/installer.js
CHANGED
|
@@ -27,10 +27,16 @@
|
|
|
27
27
|
// provides a real one (copy the preset: the picker's copy action, or a new directory
|
|
28
28
|
// under <presetRoot>), which leaves the managed set updatable.
|
|
29
29
|
// - Nothing is destroyed silently: before a file whose bytes are not what the installer last
|
|
30
|
-
// wrote is
|
|
31
|
-
// <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// wrote is REPLACED — or DELETED along with a preset this bundle no longer ships — the user's
|
|
31
|
+
// copy is kept under <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in
|
|
32
|
+
// the log. A file that still matches the hash recorded for it is a copy of the package, so
|
|
33
|
+
// removing it destroys nothing and needs no backup.
|
|
34
|
+
// - Files are written ATOMICALLY: a uniquely named temp file is written and then renamed over the
|
|
35
|
+
// destination, so an interrupted boot (a kill, a full disk, two sessions writing at once) can
|
|
36
|
+
// never leave a half-written preset for the next same-version run to mistake for a user edit.
|
|
37
|
+
// - same version: no-op (idempotent) — restarting DSH never rewrites a preset file, never churns
|
|
38
|
+
// the preset's generation stamp and never rewrites this state file (it is written only when its
|
|
39
|
+
// content actually changes). Missing files are ALWAYS restored, at any version.
|
|
34
40
|
// - force a full refresh at any time: delete the preset dirs and restart DSH.
|
|
35
41
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs'
|
|
36
42
|
import { createRequire } from 'node:module'
|
|
@@ -76,6 +82,27 @@ const BACKUP_DIR = '.vibe-math-backup'
|
|
|
76
82
|
|
|
77
83
|
function sha256(buf) { return createHash('sha256').update(buf).digest('hex') }
|
|
78
84
|
|
|
85
|
+
let presetTmpSeq = 0
|
|
86
|
+
/**
|
|
87
|
+
* Write one preset file ATOMICALLY: a uniquely named temp sibling, then a rename over the
|
|
88
|
+
* destination. An in-place `writeFileSync` can be interrupted (a killed or crashed DSH, a full disk,
|
|
89
|
+
* two sessions writing the same path) and leave a TRUNCATED preset behind — and the next
|
|
90
|
+
* same-version boot would take those bytes for the user's own edit and never repair them (a
|
|
91
|
+
* same-version run only restores MISSING files). The pid plus a per-process sequence make the temp
|
|
92
|
+
* name unique, so concurrent writers cannot rename each other's half-written file. A leftover temp
|
|
93
|
+
* (possible only after SIGKILL between the two calls) is inert: DSH reads the named preset files.
|
|
94
|
+
*/
|
|
95
|
+
function writePresetFile(dest, buf) {
|
|
96
|
+
const tmp = dest + '.' + process.pid + '.' + (presetTmpSeq++) + '.vibe-math-tmp'
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(tmp, buf)
|
|
99
|
+
renameSync(tmp, dest)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
|
|
102
|
+
throw e
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
/**
|
|
80
107
|
* Preserve one file that is about to be replaced by the shipped version, under
|
|
81
108
|
* `<presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>`.
|
|
@@ -107,13 +134,57 @@ function readState(path) {
|
|
|
107
134
|
return null
|
|
108
135
|
}
|
|
109
136
|
|
|
110
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Does the new payload equal the recorded one? Compared field by field (never as JSON text) so key
|
|
139
|
+
* ORDER cannot fake a change, and the timestamp is ignored on purpose: `updatedAt` records when the
|
|
140
|
+
* file was written, not that anything happened.
|
|
141
|
+
*/
|
|
142
|
+
function sameStatePayload(previous, next) {
|
|
143
|
+
if (!previous || !next) return false
|
|
144
|
+
if (String(previous.version || '') !== String(next.version || '')) return false
|
|
145
|
+
const a = previous.files || {}
|
|
146
|
+
const b = next.files || {}
|
|
147
|
+
const ka = Object.keys(a).sort()
|
|
148
|
+
const kb = Object.keys(b).sort()
|
|
149
|
+
if (ka.length !== kb.length) return false
|
|
150
|
+
for (let i = 0; i < ka.length; i++) {
|
|
151
|
+
if (ka[i] !== kb[i]) return false
|
|
152
|
+
const ra = a[ka[i]] || {}
|
|
153
|
+
const rb = b[kb[i]] || {}
|
|
154
|
+
if (String(ra.hash || '') !== String(rb.hash || '')) return false
|
|
155
|
+
if (String(ra.provenance || '') !== String(rb.provenance || '')) return false
|
|
156
|
+
}
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let stateTmpSeq = 0
|
|
161
|
+
let stateWriteWarned = false
|
|
162
|
+
/**
|
|
163
|
+
* Persist the state file, atomically. Three things here are deliberate:
|
|
164
|
+
* · an UNCHANGED payload is not written at all — a same-version boot must not churn this file (the
|
|
165
|
+
* "restarting DSH writes nothing" promise covers the state file too, not only the presets);
|
|
166
|
+
* · the temp name carries the pid and a sequence, so two concurrent boots cannot rename each
|
|
167
|
+
* other's half-written file (with a fixed `path + '.tmp'` the loser's rename failed and the
|
|
168
|
+
* whole write vanished without a word);
|
|
169
|
+
* · a failure is REPORTED once instead of being swallowed. The copy step already did its work; a
|
|
170
|
+
* missing state only means the next boot re-compares everything (at worst it backs up one extra
|
|
171
|
+
* original, it never overwrites a user edit).
|
|
172
|
+
*/
|
|
173
|
+
function writeState(path, state, previous, logger) {
|
|
174
|
+
if (sameStatePayload(previous, state)) return 'unchanged'
|
|
175
|
+
const tmp = path + '.' + process.pid + '.' + (stateTmpSeq++) + '.tmp'
|
|
111
176
|
try {
|
|
112
|
-
const tmp = path + '.tmp'
|
|
113
177
|
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8')
|
|
114
178
|
renameSync(tmp, path)
|
|
179
|
+
return 'written'
|
|
115
180
|
} catch (e) {
|
|
116
|
-
|
|
181
|
+
try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
|
|
182
|
+
if (!stateWriteWarned) {
|
|
183
|
+
stateWriteWarned = true
|
|
184
|
+
logger?.warn?.('[dsh-vibe-math] 状态文件写入失败(' + path + '):' + String((e && e.message) || e) +
|
|
185
|
+
'。preset 文件本身已按上面的日志处理完毕;下一次启动会按「没有记录」重新比对(最坏情况是多备份一份原文,不会覆盖你的改动)。')
|
|
186
|
+
}
|
|
187
|
+
return 'failed'
|
|
117
188
|
}
|
|
118
189
|
}
|
|
119
190
|
|
|
@@ -129,12 +200,30 @@ function writeState(path, state) {
|
|
|
129
200
|
// that works on a global <= 0.1.6 install.
|
|
130
201
|
// When none resolves, the capability self-check below is still the authoritative gate.
|
|
131
202
|
const __require = createRequire(import.meta.url)
|
|
203
|
+
|
|
204
|
+
// The detected version is only ever used for diagnostics, and this probe sits on the installer ROW's
|
|
205
|
+
// activation path: a service that never answers must not hold the boot there. On timeout (or on a
|
|
206
|
+
// rejection) the next source is tried instead — no verdict depends on this value.
|
|
207
|
+
const LIST_BUNDLES_TIMEOUT_MS = 1500
|
|
208
|
+
function withTimeout(promise, ms) {
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
let settled = false
|
|
211
|
+
let timer
|
|
212
|
+
const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value) } }
|
|
213
|
+
timer = setTimeout(() => { if (!settled) { settled = true; resolve(undefined) } }, ms)
|
|
214
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
215
|
+
Promise.resolve(promise).then(done, () => done(undefined))
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
132
219
|
async function detectDshVersion(ctx) {
|
|
133
220
|
try { const v = process.env.DSH_VERSION; if (v && String(v).trim()) return { version: String(v).trim(), source: 'DSH_VERSION' } } catch (e) {}
|
|
134
221
|
try {
|
|
135
222
|
const pm = (ctx && ctx.get) ? ctx.get('pluginManager') : undefined
|
|
136
223
|
if (pm && typeof pm.listBundles === 'function') {
|
|
137
|
-
|
|
224
|
+
// withTimeout: a slow or stuck pluginManager falls through to the next source instead of
|
|
225
|
+
// stalling this row's activation (non-fatal either way — see the constant's comment).
|
|
226
|
+
const bundles = await withTimeout(pm.listBundles(), LIST_BUNDLES_TIMEOUT_MS)
|
|
138
227
|
const host = (bundles || []).find((b) => b && (b.name === '@deepseek-ai/dsh-base' || b.name === '@deepseek-ai/dsh'))
|
|
139
228
|
if (host && host.version) return { version: String(host.version), source: 'pluginManager:' + host.name }
|
|
140
229
|
}
|
|
@@ -161,12 +250,14 @@ async function detectDshVersion(ctx) {
|
|
|
161
250
|
* 'directory' — DSH <= 0.1.6: <DSH_HOME>/.agent-presets/<id>/agent.cordis.yml, which is what the
|
|
162
251
|
* copy in apply() installs.
|
|
163
252
|
*
|
|
164
|
-
* Judged from the LOADER TREE,
|
|
165
|
-
* service exists on BOTH lines with different meaning (a
|
|
166
|
-
* from 0.1.7 on)
|
|
167
|
-
*
|
|
253
|
+
* Judged from the LOADER TREE first, then from the one capability that actually differs between the
|
|
254
|
+
* two lines: the `agentPresets` service. That service exists on BOTH lines with different meaning (a
|
|
255
|
+
* directory scanner below 0.1.7, a row registry from 0.1.7 on) and may not be up yet when a bundle is
|
|
256
|
+
* activated on its own, which is why the tree is asked first — but when only the service can be seen,
|
|
257
|
+
* `register` (never `list`, which both have) is what tells them apart. Missing the row line would skip
|
|
258
|
+
* the only working install path, so this decision has to be the reliable one.
|
|
168
259
|
*/
|
|
169
|
-
function detectPresetMechanism(ctx) {
|
|
260
|
+
export function detectPresetMechanism(ctx) {
|
|
170
261
|
// 1) the LOADER TREE: only the row-based line mounts the agent-preset package (or its registry).
|
|
171
262
|
// This is visible before any plugin activates, which matters because a bundle activation can run
|
|
172
263
|
// this installer before the services are up (then ctx.get('agentPresets') is still undefined).
|
|
@@ -180,11 +271,23 @@ function detectPresetMechanism(ctx) {
|
|
|
180
271
|
}
|
|
181
272
|
}
|
|
182
273
|
} catch (e) { /* no loader service: fall through to the service probe */ }
|
|
183
|
-
// 2) the service itself
|
|
274
|
+
// 2) the service itself, when it is already available. The PRECISE capability difference between
|
|
275
|
+
// the two lines is `register`: the >= 0.1.7 row registry has it, the <= 0.1.6 directory scanner
|
|
276
|
+
// is INFERRED to lack it. The record is partial — a real 0.1.6-alpha.2 boot exposed the service
|
|
277
|
+
// and its `list()` returned the directory roster (_oneoff/roster-016a2.json), but that probe
|
|
278
|
+
// never ASKED for `register`; the inference follows from the package difference (npm:
|
|
279
|
+
// 0.1.6-alpha.2 ships no `@deepseek-ai/dsh-agent-preset`, while 0.1.7-rc.2 adds it). The
|
|
280
|
+
// misclassification this guards against is therefore a LATENT RISK, not an observed failure:
|
|
281
|
+
// testing `list` (which BOTH lines have) would classify a service-ready old host as the row
|
|
282
|
+
// line, and apply() would then return before writing a single preset directory — "installed but
|
|
283
|
+
// invisible", the 2.4.0 defect mirrored onto the older line. That outcome was never observed:
|
|
284
|
+
// the recorded 0.1.6 boot DID write all four preset directories. Never test a capability both
|
|
285
|
+
// lines share.
|
|
184
286
|
try {
|
|
185
287
|
const ap = (ctx && ctx.get) ? ctx.get('agentPresets') : undefined
|
|
186
|
-
|
|
187
|
-
} catch (e) {
|
|
288
|
+
if (ap !== undefined) return typeof ap.register === 'function' ? 'rows' : 'directory'
|
|
289
|
+
} catch (e) { /* no service to ask: the directory form */ }
|
|
290
|
+
return 'directory'
|
|
188
291
|
}
|
|
189
292
|
|
|
190
293
|
/**
|
|
@@ -222,7 +325,8 @@ export function satisfiesDshRange(version, range, options = {}) {
|
|
|
222
325
|
const admitted = parsed.some((p) => p.target !== null && p.target.pre.length > 0 &&
|
|
223
326
|
p.target.major === v.major && p.target.minor === v.minor && p.target.patch === v.patch)
|
|
224
327
|
if (!admitted) continue
|
|
225
|
-
}
|
|
328
|
+
}
|
|
329
|
+
if (parsed.every((p) => matchesComparator(v, p))) return true
|
|
226
330
|
}
|
|
227
331
|
return sawUnknown ? null : false
|
|
228
332
|
}
|
|
@@ -478,7 +582,9 @@ export async function apply(ctx) {
|
|
|
478
582
|
|
|
479
583
|
const nextFiles = {}
|
|
480
584
|
let installed = 0, updated = 0, kept = 0
|
|
585
|
+
let userEditedKept = 0
|
|
481
586
|
const replacedEdits = []
|
|
587
|
+
const unknownPrev = []
|
|
482
588
|
const backupFailures = []
|
|
483
589
|
|
|
484
590
|
for (const p of PRESETS) {
|
|
@@ -495,7 +601,7 @@ export async function apply(ctx) {
|
|
|
495
601
|
const curHash = sha256(cur)
|
|
496
602
|
if (!existsSync(d)) {
|
|
497
603
|
// missing file: always restore, whatever the version
|
|
498
|
-
|
|
604
|
+
writePresetFile(d, cur)
|
|
499
605
|
installed += 1
|
|
500
606
|
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
501
607
|
continue
|
|
@@ -514,17 +620,32 @@ export async function apply(ctx) {
|
|
|
514
620
|
// same version: nothing is being updated, so a file that differs from the package is left
|
|
515
621
|
// exactly as it is. The recorded hash stays "what this installer last wrote" (or unknown),
|
|
516
622
|
// so the drift is still recognised — and backed up — at the next version change.
|
|
623
|
+
//
|
|
624
|
+
// PROVENANCE, recorded honestly: when the on-disk bytes are NOT what this installer last
|
|
625
|
+
// wrote, something else edited this managed file, so it is marked user-owned — which is
|
|
626
|
+
// what makes the stale-preset cleanup below KEEP it instead of unlinking it. A legacy
|
|
627
|
+
// state with no hash cannot tell, so it stays 'package' (it is still backed up before any
|
|
628
|
+
// version-change replacement; the cleanup backs it up before deleting too).
|
|
517
629
|
kept += 1
|
|
518
|
-
|
|
630
|
+
const userEdited = typeof prevRec.hash === 'string' && destHash !== prevRec.hash
|
|
631
|
+
if (userEdited) userEditedKept += 1
|
|
632
|
+
nextFiles[key] = userEdited
|
|
633
|
+
? { hash: prevRec.hash, provenance: 'user' }
|
|
634
|
+
: typeof prevRec.hash === 'string' ? { hash: prevRec.hash, provenance: 'package' } : { provenance: 'package' }
|
|
519
635
|
continue
|
|
520
636
|
}
|
|
521
|
-
// replacing: preserve the user's bytes when they are not what this installer last wrote
|
|
522
|
-
//
|
|
523
|
-
|
|
637
|
+
// replacing: preserve the user's bytes when they are not what this installer last wrote.
|
|
638
|
+
// The two cases are NOT the same claim and are counted apart: bytes that differ from the
|
|
639
|
+
// recorded hash are a provable edit, while a legacy state WITHOUT a hash cannot tell — that
|
|
640
|
+
// file is backed up all the same, but the log must not call it "被改过" (it cannot know).
|
|
641
|
+
if (typeof prevRec.hash !== 'string') {
|
|
642
|
+
if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
|
|
643
|
+
unknownPrev.push(key)
|
|
644
|
+
} else if (destHash !== prevRec.hash) {
|
|
524
645
|
if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
|
|
525
646
|
replacedEdits.push(key)
|
|
526
647
|
}
|
|
527
|
-
|
|
648
|
+
writePresetFile(d, cur)
|
|
528
649
|
updated += 1
|
|
529
650
|
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
530
651
|
}
|
|
@@ -534,10 +655,21 @@ export async function apply(ctx) {
|
|
|
534
655
|
// removed at v2.0.0). The copy loop only adds/updates PRESETS; it never deletes a preset that
|
|
535
656
|
// was dropped, so an old removed preset would linger in the picker forever. Here we remove the
|
|
536
657
|
// files this installer previously recorded as package-owned under a prefix that is no longer in
|
|
537
|
-
// PRESETS, then drop the dir if it became empty.
|
|
658
|
+
// PRESETS, then drop the dir if it became empty. Files recorded as user-owned are KEPT.
|
|
659
|
+
//
|
|
660
|
+
// DELETION IS THE ONE PATH THAT DOES NOT REPLACE, so it obeys the same rule as the replacement
|
|
661
|
+
// path: a file whose bytes are not what this installer last wrote is copied to
|
|
662
|
+
// <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> FIRST and named in the log. A file
|
|
663
|
+
// that still matches its recorded hash IS the package's copy, so removing it destroys nothing.
|
|
538
664
|
const currentPrefixes = new Set(PRESETS.map(p => p.src + '/'))
|
|
539
665
|
let removedFiles = 0
|
|
540
|
-
let
|
|
666
|
+
let matchedPackageFiles = 0
|
|
667
|
+
let keptUserFiles = 0
|
|
668
|
+
const removedDirs = []
|
|
669
|
+
const backedUpStale = []
|
|
670
|
+
const backupFailedStale = []
|
|
671
|
+
const unreadableStale = []
|
|
672
|
+
const keptUserDirs = new Set()
|
|
541
673
|
const stale = new Map() // prefix -> [keys]
|
|
542
674
|
for (const key of Object.keys(prevFiles)) {
|
|
543
675
|
const slash = key.indexOf('/')
|
|
@@ -551,47 +683,95 @@ export async function apply(ctx) {
|
|
|
551
683
|
let dirEmpty = true
|
|
552
684
|
for (const key of keys) {
|
|
553
685
|
const rec = (prevFiles[key] && typeof prevFiles[key] === 'object') ? prevFiles[key] : { provenance: 'package' }
|
|
554
|
-
if (rec.provenance === 'user') { dirEmpty = false; continue } // 用户文件 → 保留
|
|
686
|
+
if (rec.provenance === 'user') { dirEmpty = false; keptUserFiles += 1; keptUserDirs.add(prefix.slice(0, -1)); continue } // 用户文件 → 保留
|
|
555
687
|
const f = join(presetRoot, key)
|
|
556
|
-
if (existsSync(f)) {
|
|
688
|
+
if (existsSync(f)) {
|
|
689
|
+
let buf = null
|
|
690
|
+
try { buf = readFileSync(f) } catch (e) { /* unreadable: treated as "cannot prove it is ours" */ }
|
|
691
|
+
// A file that still matches its recorded hash IS the copy this installer wrote, so deleting
|
|
692
|
+
// it destroys nothing. Anything else — edited bytes, or a legacy record with no hash that
|
|
693
|
+
// cannot tell — is copied to the backup root first. Deletion is allowed ONLY when nothing of
|
|
694
|
+
// the user's can be lost: a package copy, or bytes that really were preserved. An unreadable
|
|
695
|
+
// file cannot be backed up, and a FAILED backup means the bytes were not preserved either —
|
|
696
|
+
// both are KEPT and named in the log. A stale directory left behind is cosmetic; destroyed
|
|
697
|
+
// user bytes are not. (The REPLACEMENT path above keeps its own rule — a failed backup must
|
|
698
|
+
// not block a version upgrade — because there the file is overwritten, not removed.)
|
|
699
|
+
const isPackageCopy = buf !== null && typeof rec.hash === 'string' && sha256(buf) === rec.hash
|
|
700
|
+
let mayDelete = false
|
|
701
|
+
if (isPackageCopy) {
|
|
702
|
+
matchedPackageFiles += 1
|
|
703
|
+
mayDelete = true
|
|
704
|
+
} else if (buf !== null) {
|
|
705
|
+
const cut = key.lastIndexOf('/')
|
|
706
|
+
const backup = backupReplacedFile(presetRoot, fromVersion, key.slice(0, cut), key.slice(cut + 1), buf)
|
|
707
|
+
if (backup === 'failed') backupFailedStale.push(key)
|
|
708
|
+
else { backedUpStale.push(key); mayDelete = true }
|
|
709
|
+
} else {
|
|
710
|
+
unreadableStale.push(key)
|
|
711
|
+
}
|
|
712
|
+
if (mayDelete) { try { unlinkSync(f); removedFiles += 1 } catch (e) {} }
|
|
713
|
+
}
|
|
557
714
|
if (existsSync(f)) dirEmpty = false
|
|
558
715
|
}
|
|
559
716
|
const dir = join(presetRoot, prefix.slice(0, -1))
|
|
560
717
|
if (dirEmpty && existsSync(dir)) { try { rmdirSync(dir); removedDirs.push(dir) } catch (e) {} }
|
|
561
718
|
}
|
|
562
719
|
|
|
563
|
-
writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() })
|
|
720
|
+
writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() }, state, logger)
|
|
564
721
|
|
|
565
|
-
|
|
566
|
-
|
|
722
|
+
const cleanupNotes = []
|
|
723
|
+
if (matchedPackageFiles > 0) cleanupNotes.push(matchedPackageFiles + ' 个仍是本安装器写入的字节(删掉的不是你的改动)')
|
|
724
|
+
if (backedUpStale.length > 0) cleanupNotes.push(backedUpStale.length + ' 个的字节与上一次安装不同(被改过),原文已备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + backedUpStale.join(', '))
|
|
725
|
+
if (backupFailedStale.length > 0) cleanupNotes.push(backupFailedStale.length + ' 个被改过的文件**备份失败**(原文未保留,已跳过删除并保留原文件):' + backupFailedStale.join(', '))
|
|
726
|
+
if (unreadableStale.length > 0) cleanupNotes.push(unreadableStale.length + ' 个文件**无法读取**(既不能证明是本安装器写入的,也无法备份),已跳过删除并保留原文件:' + unreadableStale.join(', '))
|
|
727
|
+
if (keptUserFiles > 0) cleanupNotes.push(keptUserFiles + ' 个记为用户所有的文件被保留(' + [...keptUserDirs].join(', ') + '),要清理请手动删除')
|
|
728
|
+
if (removedFiles > 0 || removedDirs.length > 0 || cleanupNotes.length > 0) {
|
|
729
|
+
logger?.info?.('[dsh-vibe-math] preset cleanup: removed ' + removedFiles + ' file(s) from ' + removedDirs.length + ' stale preset dir(s) (' + removedDirs.map(d => d.split(/[\\/]/).pop()).join(', ') + ') that are no longer shipped' +
|
|
730
|
+
(cleanupNotes.length > 0 ? ';' + cleanupNotes.join(';') : '') + '。')
|
|
567
731
|
}
|
|
568
732
|
|
|
733
|
+
const backupsDir = join(presetRoot, BACKUP_DIR, String(fromVersion))
|
|
734
|
+
// Two kinds of preserved bytes, reported apart because they are not the same claim:
|
|
735
|
+
// · replacedEdits — the bytes on disk are NOT what this installer last wrote: a provable edit.
|
|
736
|
+
// · unknownPrev — a legacy state with no hash cannot tell; backed up all the same, and never
|
|
737
|
+
// called "被改过" (the old wording claimed an edit it could not see).
|
|
738
|
+
const editClause = replacedEdits.length > 0
|
|
739
|
+
? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
|
|
740
|
+
(backupFailures.length === 0
|
|
741
|
+
? ',原文备份在 ' + backupsDir + ':' + replacedEdits.join(', ')
|
|
742
|
+
: ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
|
|
743
|
+
'。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
|
|
744
|
+
: ''
|
|
745
|
+
const unknownClause = unknownPrev.length > 0
|
|
746
|
+
? '另有 ' + unknownPrev.length + ' 个文件在旧版状态里没有哈希(无法判断你是否改过),已先备份原文到 ' + backupsDir + ' 再替换' +
|
|
747
|
+
(backupFailures.length > 0 ? '(其中备份失败:' + backupFailures.join(', ') + ')' : '') + '。'
|
|
748
|
+
: ''
|
|
749
|
+
|
|
569
750
|
if (isUpgrade) {
|
|
570
751
|
logger?.info?.('[dsh-vibe-math] preset auto-update: version ' + fromVersion + ' → ' + pkgVersion +
|
|
571
752
|
' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件。' +
|
|
572
|
-
|
|
573
|
-
? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
|
|
574
|
-
(backupFailures.length === 0
|
|
575
|
-
? ',原文备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + replacedEdits.join(', ')
|
|
576
|
-
: ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
|
|
577
|
-
'。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
|
|
578
|
-
: '') +
|
|
753
|
+
editClause + unknownClause +
|
|
579
754
|
'新版本 preset 将在新会话生效。')
|
|
580
755
|
} else if (isBaseline) {
|
|
581
756
|
logger?.info?.('[dsh-vibe-math] preset baseline: refreshed ' + (installed + updated) + ' file(s) to v' + pkgVersion +
|
|
582
757
|
(replacedEdits.length > 0
|
|
583
758
|
? ',其中 ' + replacedEdits.length + ' 个原有文件与随包版本不同' +
|
|
584
|
-
(backupFailures.length === 0 ? ',原文已备份在 ' +
|
|
759
|
+
(backupFailures.length === 0 ? ',原文已备份在 ' + backupsDir
|
|
585
760
|
: ',但有 ' + backupFailures.length + ' 个备份失败(原文未保留):' + backupFailures.join(', '))
|
|
586
761
|
: '') +
|
|
762
|
+
unknownClause +
|
|
587
763
|
' — 已启用版本化自动更新(后续版本变更会直接替换被管理的 preset 文件)。')
|
|
588
|
-
} else if (installed > 0) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
' file(s) differ from the shipped copy
|
|
764
|
+
} else if (installed > 0 || kept > 0) {
|
|
765
|
+
// SAME VERSION, and BOTH facts are reported: a missing file being restored must not hide the
|
|
766
|
+
// drift notice (the audit's repro: the log said "restored 1" and never mentioned the drift).
|
|
767
|
+
const parts = []
|
|
768
|
+
if (installed > 0) parts.push('restored ' + installed + ' missing preset file(s)')
|
|
769
|
+
if (kept > 0) {
|
|
770
|
+
parts.push(kept + ' file(s) differ from the shipped copy and were left untouched (v' + pkgVersion + ' 未变)' +
|
|
771
|
+
(userEditedKept > 0 ? ',其中 ' + userEditedKept + ' 个是安装之后的改动(记为用户所有:弃用 preset 的清理不会删除它们)' : '') +
|
|
772
|
+
';下一次版本变更会先备份原文再替换')
|
|
773
|
+
}
|
|
774
|
+
logger?.info?.('[dsh-vibe-math] preset files: ' + parts.join(';') + '。')
|
|
595
775
|
}
|
|
596
776
|
/* the host self-check ran at the top of apply() — it also decides the preset mechanism */
|
|
597
777
|
} catch (err) {
|