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.
- package/README.md +17 -40
- package/{AUDIT-CHECKLIST.md → docs/AUDIT-CHECKLIST.md} +323 -304
- package/docs/COMPAT-AUDIT-ROUND2.md +325 -0
- package/docs/generate_framework_diagram_v2.py +114 -0
- package/docs/generate_framework_diagram_v3.py +127 -0
- package/{RELEASE-NOTES-2.1.0.md → docs/release-notes/RELEASE-NOTES-2.1.0.md} +143 -143
- package/{RELEASE-NOTES-2.2.0.md → docs/release-notes/RELEASE-NOTES-2.2.0.md} +266 -266
- package/{RELEASE-NOTES-2.2.1.md → docs/release-notes/RELEASE-NOTES-2.2.1.md} +43 -43
- package/{RELEASE-NOTES-2.2.2.md → docs/release-notes/RELEASE-NOTES-2.2.2.md} +88 -88
- package/{RELEASE-NOTES-2.3.0.md → docs/release-notes/RELEASE-NOTES-2.3.0.md} +207 -207
- package/{RELEASE-NOTES-2.3.1.md → docs/release-notes/RELEASE-NOTES-2.3.1.md} +134 -134
- package/{RELEASE-NOTES-2.3.10.md → docs/release-notes/RELEASE-NOTES-2.3.10.md} +105 -105
- package/{RELEASE-NOTES-2.3.11.md → docs/release-notes/RELEASE-NOTES-2.3.11.md} +57 -57
- package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -0
- package/docs/release-notes/RELEASE-NOTES-2.3.13.md +137 -0
- package/{RELEASE-NOTES-2.3.2.md → docs/release-notes/RELEASE-NOTES-2.3.2.md} +145 -145
- package/{RELEASE-NOTES-2.3.3.md → docs/release-notes/RELEASE-NOTES-2.3.3.md} +115 -115
- package/{RELEASE-NOTES-2.3.4.md → docs/release-notes/RELEASE-NOTES-2.3.4.md} +69 -69
- package/{RELEASE-NOTES-2.3.5.md → docs/release-notes/RELEASE-NOTES-2.3.5.md} +63 -63
- package/{RELEASE-NOTES-2.3.6.md → docs/release-notes/RELEASE-NOTES-2.3.6.md} +66 -66
- package/{RELEASE-NOTES-2.3.7.md → docs/release-notes/RELEASE-NOTES-2.3.7.md} +59 -59
- package/{RELEASE-NOTES-2.3.8.md → docs/release-notes/RELEASE-NOTES-2.3.8.md} +45 -45
- package/{RELEASE-NOTES-2.3.9.md → docs/release-notes/RELEASE-NOTES-2.3.9.md} +70 -70
- package/docs/test-timing.md +18 -17
- package/installer.js +258 -53
- package/package.json +50 -40
- package/{audit-formal-sensitivity.mjs → tests/audit-formal-sensitivity.mjs} +342 -342
- package/tests/audit-installer-compat.test.mjs +136 -0
- package/tests/audit-installer-policy.test.mjs +245 -0
- package/{audit-persona-sensitivity.mjs → tests/audit-persona-sensitivity.mjs} +249 -249
- package/{audit-persona-surface.test.mjs → tests/audit-persona-surface.test.mjs} +349 -349
- package/{audit-prompt-invariants.mjs → tests/audit-prompt-invariants.mjs} +508 -508
- package/{audit-spec-traceability.mjs → tests/audit-spec-traceability.mjs} +193 -193
- package/{audit-v5-integrity.mjs → tests/audit-v5-integrity.mjs} +448 -448
- package/{audit-v5-sensitivity.mjs → tests/audit-v5-sensitivity.mjs} +384 -384
- package/{e2e-v5-round2.test.mjs → tests/e2e-v5-round2.test.mjs} +521 -521
- package/{formal-verify-v2.test.mjs → tests/formal-verify-v2.test.mjs} +1315 -1315
- package/{formal-verify-v3.test.mjs → tests/formal-verify-v3.test.mjs} +1257 -1257
- package/{formal-verify-v4.test.mjs → tests/formal-verify-v4.test.mjs} +1082 -1082
- package/{formal-verify-v5.test.mjs → tests/formal-verify-v5.test.mjs} +708 -708
- package/{prompt-v5-integrity.test.mjs → tests/prompt-v5-integrity.test.mjs} +3 -3
- package/{run-tests.mjs → tests/run-tests.mjs} +121 -118
- package/{selfdrive-v5.mjs → tests/selfdrive-v5.mjs} +470 -470
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +1 -1
- 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
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2.png +0 -0
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3.png +0 -0
- /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')
|