dsh-vibe-math 2.3.11 → 2.3.13

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.
Files changed (50) hide show
  1. package/README.md +17 -40
  2. package/{AUDIT-CHECKLIST.md → docs/AUDIT-CHECKLIST.md} +323 -304
  3. package/docs/COMPAT-AUDIT-ROUND2.md +325 -0
  4. package/docs/generate_framework_diagram_v2.py +114 -0
  5. package/docs/generate_framework_diagram_v3.py +127 -0
  6. package/{RELEASE-NOTES-2.1.0.md → docs/release-notes/RELEASE-NOTES-2.1.0.md} +143 -143
  7. package/{RELEASE-NOTES-2.2.0.md → docs/release-notes/RELEASE-NOTES-2.2.0.md} +266 -266
  8. package/{RELEASE-NOTES-2.2.1.md → docs/release-notes/RELEASE-NOTES-2.2.1.md} +43 -43
  9. package/{RELEASE-NOTES-2.2.2.md → docs/release-notes/RELEASE-NOTES-2.2.2.md} +88 -88
  10. package/{RELEASE-NOTES-2.3.0.md → docs/release-notes/RELEASE-NOTES-2.3.0.md} +207 -207
  11. package/{RELEASE-NOTES-2.3.1.md → docs/release-notes/RELEASE-NOTES-2.3.1.md} +134 -134
  12. package/{RELEASE-NOTES-2.3.10.md → docs/release-notes/RELEASE-NOTES-2.3.10.md} +105 -105
  13. package/{RELEASE-NOTES-2.3.11.md → docs/release-notes/RELEASE-NOTES-2.3.11.md} +57 -57
  14. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.3.13.md +137 -0
  16. package/{RELEASE-NOTES-2.3.2.md → docs/release-notes/RELEASE-NOTES-2.3.2.md} +145 -145
  17. package/{RELEASE-NOTES-2.3.3.md → docs/release-notes/RELEASE-NOTES-2.3.3.md} +115 -115
  18. package/{RELEASE-NOTES-2.3.4.md → docs/release-notes/RELEASE-NOTES-2.3.4.md} +69 -69
  19. package/{RELEASE-NOTES-2.3.5.md → docs/release-notes/RELEASE-NOTES-2.3.5.md} +63 -63
  20. package/{RELEASE-NOTES-2.3.6.md → docs/release-notes/RELEASE-NOTES-2.3.6.md} +66 -66
  21. package/{RELEASE-NOTES-2.3.7.md → docs/release-notes/RELEASE-NOTES-2.3.7.md} +59 -59
  22. package/{RELEASE-NOTES-2.3.8.md → docs/release-notes/RELEASE-NOTES-2.3.8.md} +45 -45
  23. package/{RELEASE-NOTES-2.3.9.md → docs/release-notes/RELEASE-NOTES-2.3.9.md} +70 -70
  24. package/docs/test-timing.md +18 -17
  25. package/installer.js +258 -53
  26. package/package.json +50 -40
  27. package/{audit-formal-sensitivity.mjs → tests/audit-formal-sensitivity.mjs} +342 -342
  28. package/tests/audit-installer-compat.test.mjs +136 -0
  29. package/tests/audit-installer-policy.test.mjs +245 -0
  30. package/{audit-persona-sensitivity.mjs → tests/audit-persona-sensitivity.mjs} +249 -249
  31. package/{audit-persona-surface.test.mjs → tests/audit-persona-surface.test.mjs} +349 -349
  32. package/{audit-prompt-invariants.mjs → tests/audit-prompt-invariants.mjs} +508 -508
  33. package/{audit-spec-traceability.mjs → tests/audit-spec-traceability.mjs} +193 -193
  34. package/{audit-v5-integrity.mjs → tests/audit-v5-integrity.mjs} +448 -448
  35. package/{audit-v5-sensitivity.mjs → tests/audit-v5-sensitivity.mjs} +384 -384
  36. package/{e2e-v5-round2.test.mjs → tests/e2e-v5-round2.test.mjs} +521 -521
  37. package/{formal-verify-v2.test.mjs → tests/formal-verify-v2.test.mjs} +1315 -1315
  38. package/{formal-verify-v3.test.mjs → tests/formal-verify-v3.test.mjs} +1257 -1257
  39. package/{formal-verify-v4.test.mjs → tests/formal-verify-v4.test.mjs} +1082 -1082
  40. package/{formal-verify-v5.test.mjs → tests/formal-verify-v5.test.mjs} +708 -708
  41. package/{prompt-v5-integrity.test.mjs → tests/prompt-v5-integrity.test.mjs} +3 -3
  42. package/{run-tests.mjs → tests/run-tests.mjs} +121 -118
  43. package/{selfdrive-v5.mjs → tests/selfdrive-v5.mjs} +470 -470
  44. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  45. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  46. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +1 -1
  47. package//347/244/272/344/276/213/345/233/276//345/256/236/351/231/205/344/275/277/347/224/250/347/244/272/344/276/213-/351/225/277/346/210/252/345/233/276.png +0 -0
  48. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2.png +0 -0
  49. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3.png +0 -0
  50. /package/{RELEASE-NOTES-2.0.22.md → docs/release-notes/RELEASE-NOTES-2.0.22.md} +0 -0
@@ -0,0 +1,136 @@
1
+ // ============================================================================================
2
+ // INSTALLER HOST-COMPATIBILITY AUDIT
3
+ //
4
+ // The installer's host self-check warns when the running DSH is outside the range this package
5
+ // declares. That verdict must come from the SAME declaration dsh-market renders on the plugin card
6
+ // (`engines.dsh` / `dsh.engines.dsh`) and not from a second source, or the two disagree: the card
7
+ // says "compatible" while the log warns, or the reverse. This suite pins that:
8
+ //
9
+ // · the range matcher reproduces semver 7.8.5 exactly — the expectations below were generated with
10
+ // npm's own bundled semver (`require('…/npm/node_modules/semver')`) and re-checked against the
11
+ // shipped matcher over 320 (range, version, includePrerelease) pairs, 0 mismatches;
12
+ // · `dshVersionVerdict` prefers the declared range and falls back to `dsh.compatibility`;
13
+ // · every release our own compatibility matrix calls `compatible` is NOT judged incompatible by
14
+ // the declared range — the two sources cannot drift apart.
15
+ //
16
+ // Run: node tests/audit-installer-compat.test.mjs (part of `node tests/run-tests.mjs`)
17
+ // ============================================================================================
18
+ import { readFileSync } from 'node:fs'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { join } from 'node:path'
21
+ import { satisfiesDshRange, dshVersionVerdict } from '../installer.js'
22
+
23
+ const HERE = fileURLToPath(new URL('../', import.meta.url))
24
+ const pkg = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'))
25
+
26
+ let passed = 0, failed = 0
27
+ const failures = []
28
+ const ok = (cond, label, detail) => {
29
+ if (cond) { passed++; console.log(' ok ' + label); return true }
30
+ failed++; failures.push(label + (detail ? ' — ' + detail : ''))
31
+ console.error(' FAIL ' + label + (detail ? ' — ' + detail : ''))
32
+ return false
33
+ }
34
+ const eq = (actual, expected, label) => ok(actual === expected, label, 'got ' + JSON.stringify(actual) + ', expected ' + JSON.stringify(expected))
35
+
36
+ // ---------------------------------------------------------------------------------------------
37
+ // 1. the matcher reproduces semver 7.8.5 (expectations generated with npm's bundled semver)
38
+ // ---------------------------------------------------------------------------------------------
39
+ const CASES = [
40
+ // [range, version, includePrerelease, expected]
41
+ ['*', '1.2.3', false, true],
42
+ ['*', '0.1.5-rc.2', false, false], // npm: `*` excludes prereleases unless opted in
43
+ ['*', '0.1.5-rc.2', true, true],
44
+ ['1.2.3', '1.2.3', false, true],
45
+ ['1.2.3', '1.2.4', false, false],
46
+ ['^0.1.0', '0.1.4', false, true],
47
+ ['^0.1.0', '0.2.0-rc.1', true, false], // 0.x caret never reaches the next minor
48
+ ['^0.1.0', '0.1.0-rc.5', true, false], // ...nor a prerelease below its own floor
49
+ ['^0.1.0-rc.7', '0.1.0-rc.7', false, true],
50
+ ['^0.1.0-rc.7', '0.1.5-rc.2', true, true],
51
+ ['^0.0.1', '0.0.1', false, true],
52
+ ['^0.0.1', '0.0.2', false, false],
53
+ ['~0.1.2', '0.1.4', false, true],
54
+ ['~0.1.2', '0.2.0', false, false],
55
+ ['>0.1.0 <=0.1.5', '0.1.5', false, true],
56
+ ['>=0.1.5-rc.1', '0.1.5-rc.2', false, true],
57
+ ['>=0.1.5-rc.1', '0.2.0', false, true], // a floor-only range keeps admitting newer hosts
58
+ // the trap contributing.md warns about: no comparator on the 0.1.5 tuple carrying a prerelease
59
+ ['>=0.1.2-rc.1 <0.2.0', '0.1.5-rc.2', false, false],
60
+ ['>=0.1.2-rc.1 <0.2.0', '0.1.5-rc.2', true, true],
61
+ // the market-style chain
62
+ ['^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2', '0.1.2-rc.1', false, true],
63
+ ['^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2', '0.1.3-alpha.2', false, false],
64
+ ['^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2', '0.1.3-alpha.2', true, true],
65
+ ]
66
+ for (const [range, version, pre, expected] of CASES) {
67
+ eq(satisfiesDshRange(version, range, { includePrerelease: pre }), expected,
68
+ 'matcher: ' + JSON.stringify(range) + ' vs ' + version + (pre ? ' (includePrerelease)' : ''))
69
+ }
70
+ // our own declaration, both semantics (the exact chain the market and the installer share)
71
+ for (const v of ['0.1.2-alpha.4', '0.1.2-rc.1', '0.1.3-alpha.2', '0.1.5-rc.1', '0.1.5-rc.2']) {
72
+ eq(satisfiesDshRange(v, pkg.engines.dsh), true, 'own range admits ' + v + ' (default semantics)')
73
+ eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), true, 'own range admits ' + v + ' (includePrerelease)')
74
+ }
75
+ for (const v of ['0.1.1-rc.1', '0.2.0-rc.1', '0.2.0', '1.0.0']) {
76
+ eq(satisfiesDshRange(v, pkg.engines.dsh, { includePrerelease: true }), false, 'own range rejects ' + v)
77
+ }
78
+ // unsupported forms are UNKNOWN (null) — reported, never asserted incompatible
79
+ for (const r of ['not-a-range', '>=1.2', '1.2.x', '']) {
80
+ eq(satisfiesDshRange('1.2.5', r, { includePrerelease: true }), null, 'unsupported range is unknown: ' + JSON.stringify(r))
81
+ }
82
+ eq(satisfiesDshRange('not-a-version', pkg.engines.dsh), null, 'an unparseable host version is unknown, not incompatible')
83
+
84
+ // ---------------------------------------------------------------------------------------------
85
+ // 2. the verdict prefers the declared range, falls back to the matrix
86
+ // ---------------------------------------------------------------------------------------------
87
+ {
88
+ const a = dshVersionVerdict('0.1.5-rc.2', pkg)
89
+ ok(a.status === 'compatible' && a.basis === 'engines', 'our own host line is compatible via the declared range', JSON.stringify(a))
90
+ eq(a.requirement, pkg.engines.dsh, 'the verdict surfaces the declared range (so the log and the market card say the same thing)')
91
+ const b = dshVersionVerdict('0.2.0', pkg)
92
+ ok(b.status === 'incompatible' && b.basis === 'engines', 'a host outside the declared range is incompatible via the range', JSON.stringify(b))
93
+ const c = dshVersionVerdict('0.1.0-rc.6', pkg)
94
+ ok(c.status === 'incompatible', 'a host below the declared floor is incompatible', JSON.stringify(c))
95
+ // dsh.engines.dsh is the second position the ecosystem (and the market) reads
96
+ const onlyNested = { dsh: { engines: { dsh: '>=0.1.0-rc.6' } } }
97
+ const d = dshVersionVerdict('0.1.0-rc.6', onlyNested)
98
+ ok(d.status === 'compatible' && d.basis === 'engines', 'dsh.engines.dsh is honoured when engines.dsh is absent', JSON.stringify(d))
99
+ // top-level wins when both are present (mirrors dsh-market's manifestFacts)
100
+ const both = { engines: { dsh: '>=0.2.0' }, dsh: { engines: { dsh: '>=0.1.0' } } }
101
+ eq(dshVersionVerdict('0.1.5-rc.2', both).status, 'incompatible', 'engines.dsh wins when both positions are declared')
102
+ // fallback: a manifest that declares no range keeps the dshReleases behaviour
103
+ const mapOnly = {
104
+ dsh: { compatibility: { dshReleases: { '0.1.5-rc.2': 'compatible', '0.1.0-rc.1': 'incompatible', '0.1.9': 'unknown' } } },
105
+ }
106
+ const e = dshVersionVerdict('0.1.5-rc.2', mapOnly)
107
+ ok(e.status === 'compatible' && e.basis === 'dshReleases', 'fallback: the matrix still decides when no range is declared', JSON.stringify(e))
108
+ eq(dshVersionVerdict('0.1.0-rc.1', mapOnly).status, 'incompatible', 'fallback: an explicitly incompatible release')
109
+ eq(dshVersionVerdict('0.1.7', mapOnly).status, 'undeclared', 'fallback: an unlisted release is undeclared (soft warning)')
110
+ eq(dshVersionVerdict('0.1.9', mapOnly).status, 'unknown', 'fallback: an explicitly unknown release')
111
+ // a range the matcher cannot parse must not become "incompatible"
112
+ const unparsable = { engines: { dsh: 'not a range' } }
113
+ const f = dshVersionVerdict('0.1.5-rc.2', unparsable)
114
+ ok(f.status === 'unknown' && f.basis === 'engines', 'an unparseable declared range is unknown, never incompatible', JSON.stringify(f))
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------------------------
118
+ // 3. the two sources cannot drift: every release our matrix calls compatible must not be judged
119
+ // incompatible by the declared range (the installer and the market must agree).
120
+ // ---------------------------------------------------------------------------------------------
121
+ {
122
+ const matrix = Object.entries((pkg.dsh.compatibility || {}).dshReleases || {})
123
+ const contradicted = matrix.filter(([ver, status]) => {
124
+ const v = dshVersionVerdict(ver, pkg)
125
+ return status === 'compatible' && v.status === 'incompatible'
126
+ }).map(([ver]) => ver)
127
+ ok(contradicted.length === 0, 'no release marked compatible in dsh.compatibility is rejected by engines.dsh',
128
+ contradicted.join(', '))
129
+ const missedByRange = matrix.filter(([ver]) => dshVersionVerdict(ver, pkg).status === 'unknown').map(([ver]) => ver)
130
+ ok(missedByRange.length === 0, 'the declared range can be evaluated for every release in the matrix', missedByRange.join(', '))
131
+ }
132
+
133
+ console.log('')
134
+ console.log('=== INSTALLER COMPAT: ' + passed + ' passed, ' + failed + ' failed ===')
135
+ if (failed) { for (const f of failures) console.error(' - ' + f); process.exit(1) }
136
+ console.log('ALL GREEN')
@@ -0,0 +1,245 @@
1
+ // ============================================================================================
2
+ // INSTALLER UPDATE POLICY — what a user's preset directory looks like after an upgrade.
3
+ //
4
+ // installer.js (the single row cordis.patch.yml mounts) is the only thing that puts these four
5
+ // presets in front of someone who installed the bundle, so its copy policy IS the product for
6
+ // npm users. The contract under test:
7
+ //
8
+ // · a VERSION CHANGE replaces every managed file, edited or not — a preset assembled from two
9
+ // different versions is the failure shape this prevents — and the replaced bytes are kept
10
+ // under <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>;
11
+ // · the SAME VERSION is a no-op: restarting DSH must not rewrite a file (the composition file's
12
+ // mtime keys the preset's generation) and must not create backups;
13
+ // · a MISSING file is always restored;
14
+ // · a preset this bundle no longer ships is removed, except files recorded as user-owned;
15
+ // · the managed list covers every file each preset needs at runtime.
16
+ //
17
+ // It drives the REAL apply() against a throwaway DSH home and a throwaway COPY of the package
18
+ // (whose version can be changed at will), so the developer's own ~/.dsh/.agent-presets is never
19
+ // touched.
20
+ //
21
+ // Usage: node tests/audit-installer-policy.test.mjs
22
+ // INSTALLER_JS=<path> run against another installer implementation. The sensitivity probe
23
+ // (_oneoff/probe-installer-policy.mjs) uses this to prove these assertions
24
+ // really do detect the previous, edit-preserving policy.
25
+ // ============================================================================================
26
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs'
27
+ import { createHash } from 'node:crypto'
28
+ import { tmpdir } from 'node:os'
29
+ import { join, dirname, resolve } from 'node:path'
30
+ import { fileURLToPath, pathToFileURL } from 'node:url'
31
+ import { PRESETS } from '../installer.js'
32
+
33
+ const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
34
+ const INSTALLER_SRC = process.env.INSTALLER_JS ? resolve(process.env.INSTALLER_JS) : join(REPO, 'installer.js')
35
+ const pkg = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8'))
36
+
37
+ let passed = 0, failed = 0
38
+ const failures = []
39
+ function ok(cond, label, detail) {
40
+ if (cond) { passed++; console.log(' ok ' + label); return true }
41
+ failed++; failures.push(label + (detail ? ' — ' + detail : ''))
42
+ console.error(' FAIL ' + label + (detail ? ' — ' + detail : ''))
43
+ return false
44
+ }
45
+
46
+ /** A throwaway copy of the package at a chosen version; every managed file is version-marked. */
47
+ function buildPackage(version, dir) {
48
+ mkdirSync(dir, { recursive: true })
49
+ cpSync(INSTALLER_SRC, join(dir, 'installer.js'))
50
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({
51
+ name: 'dsh-vibe-math', version, type: 'module', dsh: { bundle: { patch: './cordis.patch.yml' } },
52
+ }, null, 2))
53
+ for (const p of PRESETS) {
54
+ mkdirSync(join(dir, p.src), { recursive: true })
55
+ for (const f of p.files) {
56
+ const text = readFileSync(join(REPO, p.src, f), 'utf8')
57
+ const marker = f.endsWith('.js') ? '\n// version ' + version + '\n'
58
+ : f.endsWith('.yml') ? '\n# version ' + version + '\n'
59
+ : '\n<!-- version ' + version + ' -->\n'
60
+ writeFileSync(join(dir, p.src, f), text + marker)
61
+ }
62
+ }
63
+ return dir
64
+ }
65
+
66
+ async function applyFrom(pkgDir, home, logs) {
67
+ process.env.DSH_HOME = home
68
+ const mod = await import(pathToFileURL(join(pkgDir, 'installer.js')).href + '?run=' + Date.now() + Math.random())
69
+ const ctx = {
70
+ get: () => undefined, // no host services: apply() must still copy files and only warn
71
+ logger: { info: (m) => logs.push('info: ' + m), warn: (m) => logs.push('warn: ' + m), error: (m) => logs.push('error: ' + m) },
72
+ }
73
+ await mod.apply(ctx)
74
+ return logs
75
+ }
76
+ const sha = (buf) => createHash('sha256').update(buf).digest('hex')
77
+ const failedLog = (logs) => logs.filter((l) => l.includes('preset install/update failed'))
78
+
79
+ const tmp = mkdtempSync(join(tmpdir(), 'vibe-installer-policy-'))
80
+ const home = join(tmp, 'dshhome')
81
+ mkdirSync(home, { recursive: true })
82
+ const presetRoot = join(home, '.agent-presets')
83
+ const stateFile = join(presetRoot, '.vibe-math-installed.json')
84
+ const backupRoot = join(presetRoot, '.vibe-math-backup')
85
+ const pkgA = buildPackage('1.0.0', join(tmp, 'pkg-1.0.0'))
86
+ const pkgB = buildPackage('2.0.0', join(tmp, 'pkg-2.0.0'))
87
+ const at = (p, f) => join(presetRoot, p.dst, f)
88
+ const shipped = (p, f, from = pkgB) => join(from, p.src, f)
89
+ const isCopyOf = (p, f, from = pkgB) => {
90
+ try { return readFileSync(at(p, f)).equals(readFileSync(shipped(p, f, from))) } catch (e) { return false }
91
+ }
92
+ const managedDigest = () => {
93
+ const h = createHash('sha256')
94
+ for (const p of PRESETS) for (const f of p.files) if (existsSync(at(p, f))) h.update(p.src + '/' + f + ':' + sha(readFileSync(at(p, f))))
95
+ return h.digest('hex')
96
+ }
97
+
98
+ console.log('=== 1. baseline install into an empty DSH home ===')
99
+ {
100
+ const logs = await applyFrom(pkgA, home, [])
101
+ ok(existsSync(stateFile), 'the state file is written next to the presets')
102
+ const state = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : {}
103
+ ok(state.version === '1.0.0', 'the state records the version it installed', String(state.version))
104
+ const missing = []
105
+ let identical = true
106
+ for (const p of PRESETS) for (const f of p.files) {
107
+ if (!existsSync(at(p, f))) missing.push(p.dst + '/' + f)
108
+ else if (!isCopyOf(p, f, pkgA)) identical = false
109
+ }
110
+ ok(missing.length === 0, 'every managed file of all four presets is copied', missing.join(', '))
111
+ ok(identical, 'every copied file is byte-identical to the shipped file')
112
+ ok(failedLog(logs).length === 0, 'apply() swallowed no failure', failedLog(logs)[0])
113
+ }
114
+
115
+ console.log('=== 2. a user edit + the SAME version: untouched, and no backup ===')
116
+ const editedJs = { p: PRESETS[3], f: 'vibe-math-v5.js' }
117
+ const editedDoc = { p: PRESETS[0], f: '实现方案.md' }
118
+ {
119
+ appendFileSync(at(editedJs.p, editedJs.f), '\n// USER EDIT\n')
120
+ appendFileSync(at(editedDoc.p, editedDoc.f), '\nUSER EDIT\n')
121
+ const userJs = readFileSync(at(editedJs.p, editedJs.f))
122
+ const logs = await applyFrom(pkgA, home, [])
123
+ ok(readFileSync(at(editedJs.p, editedJs.f)).equals(userJs), 'same version: the edited plugin file is NOT rewritten')
124
+ ok(readFileSync(at(editedDoc.p, editedDoc.f), 'utf8').includes('USER EDIT'), 'same version: the edited document is NOT rewritten')
125
+ ok(!existsSync(backupRoot), 'same version: nothing is backed up (nothing was replaced)')
126
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'))
127
+ ok(state.version === '1.0.0', 'same version: the state still records 1.0.0', String(state.version))
128
+ ok(failedLog(logs).length === 0, 'apply() swallowed no failure', failedLog(logs)[0])
129
+ // the recorded hash must still describe what the installer WROTE, so the drift is recognised later
130
+ const rec = state.files[editedJs.p.src + '/' + editedJs.f]
131
+ ok(rec && rec.hash === sha(readFileSync(shipped(editedJs.p, editedJs.f, pkgA))),
132
+ 'the state keeps the hash of the installed bytes (not of the edit), so the drift survives a same-version boot')
133
+ ok(isCopyOf(PRESETS[2], 'preset.yml', pkgA), 'same version: an unedited file is still the installed bytes')
134
+ }
135
+
136
+ console.log('=== 3. a VERSION CHANGE: every managed file is replaced, the edit is preserved ===')
137
+ let userJsBytes = null
138
+ {
139
+ userJsBytes = readFileSync(at(editedJs.p, editedJs.f))
140
+ const logs = await applyFrom(pkgB, home, [])
141
+ ok(isCopyOf(editedJs.p, editedJs.f), 'version change: the edited plugin file IS replaced by the shipped bytes')
142
+ ok(isCopyOf(editedDoc.p, editedDoc.f), 'version change: the edited document IS replaced too')
143
+ ok(isCopyOf(PRESETS[1], 'preset.yml'), 'version change: an unedited file is replaced as well')
144
+ const backup = join(backupRoot, '1.0.0', editedJs.p.dst, editedJs.f)
145
+ ok(existsSync(backup), 'the replaced edit is kept at .vibe-math-backup/<fromVersion>/<preset>/<file>')
146
+ ok(existsSync(backup) && readFileSync(backup).equals(userJsBytes), 'the backup holds exactly the bytes the user had')
147
+ ok(!existsSync(join(backupRoot, '1.0.0', PRESETS[1].dst, 'preset.yml')), 'an unedited file is replaced WITHOUT a backup (nothing to preserve)')
148
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'))
149
+ ok(state.version === '2.0.0', 'the state records the new version', String(state.version))
150
+ ok(logs.some((l) => l.includes('.vibe-math-backup')), 'the log names the backup location')
151
+ ok(logs.some((l) => l.includes('复制一份')), 'the log tells the user how to customize a preset properly')
152
+ ok(failedLog(logs).length === 0, 'apply() swallowed no failure', failedLog(logs)[0])
153
+ }
154
+
155
+ console.log('=== 4. a deleted file at the SAME version: restored, nothing backed up ===')
156
+ {
157
+ rmSync(at(PRESETS[2], 'agent.cordis.yml'))
158
+ const logs = await applyFrom(pkgB, home, [])
159
+ ok(existsSync(at(PRESETS[2], 'agent.cordis.yml')), 'a deleted managed file is restored')
160
+ ok(isCopyOf(PRESETS[2], 'agent.cordis.yml'), 'the restored file is byte-identical to the shipped one')
161
+ ok(!existsSync(join(backupRoot, '2.0.0')), 'a run that replaced nothing creates no backup directory')
162
+ ok(logs.some((l) => l.includes('restored')), 'the restore is reported')
163
+ ok(failedLog(logs).length === 0, 'apply() swallowed no failure', failedLog(logs)[0])
164
+ }
165
+
166
+ console.log('=== 5. a preset this bundle no longer ships ===')
167
+ {
168
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'))
169
+ mkdirSync(join(presetRoot, 'vibe-math-v1'), { recursive: true })
170
+ mkdirSync(join(presetRoot, 'vibe-math-v0'), { recursive: true })
171
+ writeFileSync(join(presetRoot, 'vibe-math-v1', 'legacy.js'), 'package-owned\n')
172
+ writeFileSync(join(presetRoot, 'vibe-math-v0', 'mine.js'), 'user-owned\n')
173
+ state.files['vibe-math-v1/legacy.js'] = { hash: sha('package-owned\n'), provenance: 'package' }
174
+ state.files['vibe-math-v0/mine.js'] = { hash: sha('user-owned\n'), provenance: 'user' }
175
+ writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n')
176
+ const logs = await applyFrom(pkgB, home, [])
177
+ ok(!existsSync(join(presetRoot, 'vibe-math-v1', 'legacy.js')), 'a package-owned file of a dropped preset is removed')
178
+ ok(!existsSync(join(presetRoot, 'vibe-math-v1')), 'the dropped preset directory is removed once empty')
179
+ ok(existsSync(join(presetRoot, 'vibe-math-v0', 'mine.js')), 'a file recorded as user-owned in a dropped preset is kept')
180
+ ok(logs.some((l) => l.includes('cleanup')), 'the cleanup is reported')
181
+ ok(failedLog(logs).length === 0, 'apply() swallowed no failure', failedLog(logs)[0])
182
+ }
183
+
184
+ console.log('=== 6. a same-version re-run is a no-op ===')
185
+ {
186
+ const before = managedDigest()
187
+ const logs = await applyFrom(pkgB, home, [])
188
+ ok(managedDigest() === before, 'no managed file changed on a same-version re-run')
189
+ ok(failedLog(logs).length === 0, 'apply() swallowed no failure', failedLog(logs)[0])
190
+ }
191
+
192
+ console.log('=== 7. an unreadable package manifest replaces nothing (and self-heals) ===')
193
+ {
194
+ // a package whose manifest cannot be read: the installer cannot tell whether this is an upgrade,
195
+ // so the only safe behaviour is to copy nothing and still restore what is missing. (The manifest
196
+ // must stay VALID JSON — Node parses the nearest package.json to decide the module type — so the
197
+ // unreadable-version case is a manifest with no `version` field, which is exactly what a
198
+ // hand-assembled package looks like.)
199
+ const broken = buildPackage('9.9.9', join(tmp, 'pkg-broken'))
200
+ writeFileSync(join(broken, 'package.json'), JSON.stringify({ name: 'dsh-vibe-math', type: 'module' }, null, 2))
201
+ const pkgC = buildPackage('3.0.0', join(tmp, 'pkg-3.0.0'))
202
+
203
+ appendFileSync(at(PRESETS[1], 'preset.yml'), '\n# USER EDIT 2\n')
204
+ const driftedBytes = readFileSync(at(PRESETS[1], 'preset.yml'))
205
+ rmSync(at(PRESETS[2], 'preset.yml')) // ...but a missing file is still restored
206
+ // an earlier upgrade already preserved an edit of this same file: that copy must win
207
+ const earlier = join(backupRoot, '2.0.0', PRESETS[1].dst, 'preset.yml')
208
+ mkdirSync(dirname(earlier), { recursive: true })
209
+ writeFileSync(earlier, 'EARLIER COPY\n')
210
+
211
+ const logs = await applyFrom(broken, home, [])
212
+ ok(readFileSync(at(PRESETS[1], 'preset.yml')).equals(driftedBytes), 'an unreadable manifest leaves drifted files alone')
213
+ ok(isCopyOf(PRESETS[2], 'preset.yml', broken), '...while a missing file is still restored')
214
+ ok(logs.some((l) => l.includes('读不到本包版本')), 'the reason is reported, not swallowed')
215
+ ok(JSON.parse(readFileSync(stateFile, 'utf8')).version === '2.0.0', 'the previously recorded version is kept (so the next run reports the right "from" version)')
216
+ ok(!existsSync(join(backupRoot, '(unversioned)')), 'no "(unversioned)" backup directory was created')
217
+
218
+ const logs2 = await applyFrom(pkgC, home, [])
219
+ ok(readFileSync(at(PRESETS[1], 'preset.yml')).equals(readFileSync(shipped(PRESETS[1], 'preset.yml', pkgC))),
220
+ 'the next readable run replaces the drift as a normal version change')
221
+ ok(readFileSync(earlier, 'utf8') === 'EARLIER COPY\n', 'a backup that already exists for that version is kept byte-for-byte (the earliest copy wins)')
222
+ ok(logs2.every((l) => !l.includes('备份失败')), 'a pre-existing backup for the same version is NOT reported as a failure')
223
+ ok(JSON.parse(readFileSync(stateFile, 'utf8')).version === '3.0.0', 'the state records the new version')
224
+
225
+ // the backup directory must never be readable as a preset: DSH only accepts [a-z0-9][a-z0-9-]* ids
226
+ ok(!/^[a-z0-9]/.test('.vibe-math-backup'), 'the backup directory name cannot be mistaken for a preset id (discovery skips it)')
227
+ }
228
+
229
+ console.log('=== 8. the managed list covers what the presets need ===')
230
+ for (const p of PRESETS) {
231
+ const shippedFiles = (pkg.files || []).filter((f) => f.startsWith(p.src + '/')).map((f) => f.slice(p.src.length + 1))
232
+ const runtime = ['agent.cordis.yml', 'preset.yml', p.src + '.js']
233
+ const unmanagedRuntime = runtime.filter((f) => !p.files.includes(f))
234
+ ok(unmanagedRuntime.length === 0, `${p.dst}: every runtime file is in the installer's managed list`, unmanagedRuntime.join(', '))
235
+ const EXEMPT = ['v2的额外要求.txt'] // an author note, deliberately not installed next to the preset
236
+ const unmanaged = shippedFiles.filter((f) => !p.files.includes(f) && !EXEMPT.includes(f))
237
+ ok(unmanaged.length === 0, `${p.dst}: every shipped file is managed or explicitly exempt`, unmanaged.join(', '))
238
+ }
239
+
240
+ rmSync(tmp, { recursive: true, force: true })
241
+
242
+ console.log('')
243
+ console.log('=== INSTALLER POLICY: ' + passed + ' passed, ' + failed + ' failed ===')
244
+ if (failed) { for (const f of failures) console.error(' - ' + f); process.exit(1) }
245
+ console.log('ALL GREEN')