dsh-vibe-math 2.7.3 → 2.8.1

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 (106) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -682
  3. package/README.md +683 -683
  4. package/docs/AUDIT-CHECKLIST.md +758 -744
  5. package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/audit-methodology.md +93 -0
  8. package/docs/final-paper.md +172 -172
  9. package/docs/formal-verification.md +613 -588
  10. package/docs/generate_framework_diagram_v2.py +114 -114
  11. package/docs/generate_framework_diagram_v3.py +127 -127
  12. package/docs/math-computation.md +195 -194
  13. package/docs/release-notes/README.en.md +2 -0
  14. package/docs/release-notes/README.md +2 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  16. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  17. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  18. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  19. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  20. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  21. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  22. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  23. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  24. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  25. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  26. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  27. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  28. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  29. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  30. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  31. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  32. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  33. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  34. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  35. package/docs/release-notes/RELEASE-NOTES-2.8.1.en.md +49 -0
  36. package/docs/release-notes/RELEASE-NOTES-2.8.1.md +49 -0
  37. package/docs/release-playbook.md +111 -0
  38. package/docs/slv-playbook.md +117 -0
  39. package/docs/test-timing.md +158 -157
  40. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  41. package/installer.js +792 -792
  42. package/package.json +18 -3
  43. package/prompt-corpus-v2/formal-verify-v2.json +2 -2
  44. package/prompt-corpus-v2/formal-verify-v2.md +4 -0
  45. package/prompt-corpus-v3/formal-verify-v3.json +7 -7
  46. package/prompt-corpus-v3/formal-verify-v3.md +14 -0
  47. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  48. package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
  49. package/scripts/clean-temp.mjs +234 -0
  50. package/scripts/release-check.mjs +181 -0
  51. package/tests/audit-engine-faces.mjs +298 -0
  52. package/tests/audit-formal-sensitivity.mjs +345 -342
  53. package/tests/audit-installer-compat.test.mjs +256 -256
  54. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  55. package/tests/audit-persona-sensitivity.mjs +323 -323
  56. package/tests/audit-persona-surface.test.mjs +431 -431
  57. package/tests/audit-prompt-duplication.mutants.mjs +3 -2
  58. package/tests/audit-prompt-invariants.mjs +589 -546
  59. package/tests/audit-registration.mjs +78 -78
  60. package/tests/audit-spec-traceability.mjs +193 -193
  61. package/tests/audit-status-report-fields.mjs +172 -172
  62. package/tests/audit-v5-integrity.mjs +795 -795
  63. package/tests/audit-v5-sensitivity.mjs +433 -432
  64. package/tests/e2e-v5-round2.test.mjs +2453 -2426
  65. package/tests/formal-verify-v2.test.mjs +1806 -1800
  66. package/tests/formal-verify-v3.test.mjs +1757 -1750
  67. package/tests/formal-verify-v4.test.mjs +2114 -2106
  68. package/tests/formal-verify-v5.test.mjs +774 -767
  69. package/tests/math-computation-discovery.mutants.mjs +3 -3
  70. package/tests/math-computation-shared.test.mjs +122 -5
  71. package/tests/math-computation-v2.test.mjs +14 -2
  72. package/tests/math-computation-v3.test.mjs +14 -2
  73. package/tests/math-computation-v4.test.mjs +739 -739
  74. package/tests/prompt-v5-integrity.test.mjs +1291 -1291
  75. package/tests/release-check.mutants.mjs +78 -0
  76. package/tests/run-tests.mjs +393 -332
  77. package/tests/selfdrive-v5.mjs +560 -560
  78. package/tests/temp-hygiene.mutants.mjs +80 -0
  79. package/tests/v2-fix-probes.mutants.mjs +4 -0
  80. package/tests/v2-fix-probes.test.mjs +1034 -1033
  81. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  82. package/tests/v3-fix-probes.mutants.mjs +99 -95
  83. package/tests/v3-fix-probes.test.mjs +891 -890
  84. package/tests/v4-final-paper.mutants.mjs +116 -0
  85. package/tests/v4-final-paper.test.mjs +628 -0
  86. package/tests/v5-institute-fixes.mutants.mjs +218 -190
  87. package/vibe-math-v2/agent.cordis.yml +376 -376
  88. package/vibe-math-v2/math-computation.js +17 -15
  89. package/vibe-math-v2/preset.yml +2 -2
  90. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  91. package/vibe-math-v2/vibe-math-v2.js +5078 -5011
  92. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
  93. package/vibe-math-v3/agent.cordis.yml +420 -420
  94. package/vibe-math-v3/math-computation.js +17 -15
  95. package/vibe-math-v3/preset.yml +2 -2
  96. package/vibe-math-v3/vibe-math-v3.js +6215 -6149
  97. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  98. package/vibe-math-v4/agent.cordis.yml +438 -438
  99. package/vibe-math-v4/math-computation.js +17 -15
  100. package/vibe-math-v4/preset.yml +2 -2
  101. package/vibe-math-v4/vibe-math-v4.js +4520 -4422
  102. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
  103. package/vibe-math-v5/math-computation.js +17 -15
  104. package/vibe-math-v5/vibe-math-v5.js +93 -11
  105. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
  106. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TEMP HYGIENE (task-12) — sweep STALE test scratch directories and prefer a roomier temp root.
4
+ *
5
+ * WHY THIS EXISTS (measured, 2026-10 on this machine): the suites create their workspace with
6
+ * `mkdtempSync(join(tmpdir(), '<prefix>-…'))` and remove it in `finally` — but a suite KILLED by the
7
+ * runner's per-suite timeout cannot clean up, and some suites create many directories per run. After
8
+ * hundreds of sweeps `%TEMP%` had reached **248,737 top-level entries, 245,288 of them ours**
9
+ * (`vibe-*` 221,939, `v2-fix-*` 23,160, …), with 111,223 older than 24 h (oldest ~2 weeks).
10
+ *
11
+ * TWO RULES, both bounded:
12
+ * 1. SWEEP: delete only entries at the TOP LEVEL of the temp root, only whose name starts with a
13
+ * prefix ACTUALLY USED BY THE SUITES (extracted from `tests/*.mjs`, never hand-written), and only
14
+ * when older than `--age-hours` (default 6 h — a concurrently running gate's dirs stay safe).
15
+ * 2. PREFER D:: when `D:\_tmp` can be created, point TEMP/TMP/TMPDIR at it (the suites follow
16
+ * `os.tmpdir()` automatically, so this needs NO product change). Never hard-fail: if the drive is
17
+ * absent the ambient temp root is kept.
18
+ *
19
+ * Usage:
20
+ * node scripts/clean-temp.mjs # sweep (age 6 h) and print counts
21
+ * node scripts/clean-temp.mjs --dry-run # print the plan, delete nothing
22
+ * node scripts/clean-temp.mjs --age-hours=12 # change the threshold
23
+ * node scripts/clean-temp.mjs --root=<dir> # sweep another temp root (mainly for tests)
24
+ * node scripts/clean-temp.mjs --self-test # prove the guards below (fresh dirs are NEVER swept)
25
+ */
26
+ import { execFileSync } from 'node:child_process'
27
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
28
+ import { tmpdir } from 'node:os'
29
+ import { dirname, join, resolve } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url))
33
+ const REPO = resolve(HERE, '..')
34
+ const TESTS = join(REPO, 'tests')
35
+
36
+ /** Prefixes the suites really use — extracted from their own `mkdtempSync(join(tmpdir(), '<prefix>'))` calls. */
37
+ export function suitePrefixes(testsDir = TESTS) {
38
+ const out = new Set()
39
+ let files = []
40
+ try { files = readdirSync(testsDir) } catch (e) { return [] }
41
+ for (const f of files) {
42
+ if (!f.endsWith('.mjs')) continue
43
+ let text = ''
44
+ try { text = readFileSync(join(testsDir, f), 'utf8') } catch (e) { continue }
45
+ for (const m of text.matchAll(/mkdtempSync\(\s*join\(\s*tmpdir\(\)\s*,\s*'([^']+)'/g)) out.add(m[1])
46
+ for (const m of text.matchAll(/mkdtempSync\(\s*join\(\s*tmpdir\(\)\s*,\s*"([^"]+)"/g)) out.add(m[1])
47
+ }
48
+ return [...out].sort()
49
+ }
50
+
51
+ /**
52
+ * PURE planner: which entries may be deleted. `entries` are `{name, mtimeMs}` already read from the
53
+ * temp root. An entry qualifies only if BOTH hold: its name starts with a suite prefix, and it is older
54
+ * than the threshold. (This is the function the mutant family attacks: replacing the age test with
55
+ * `true` must make the "a FRESH dir is never swept" check go red BY NAME.)
56
+ */
57
+ export function planSweep(entries, { prefixes, ageHours = 6, now = Date.now() } = {}) {
58
+ const cut = now - ageHours * 3600 * 1000
59
+ return entries
60
+ .filter((e) => Array.isArray(prefixes) ? prefixes.some((p) => String(e.name).startsWith(p)) : false)
61
+ .filter((e) => Number(e.mtimeMs) < cut)
62
+ .map((e) => e.name)
63
+ }
64
+
65
+ /** Read the top level of a temp root into planner entries. Only DIRECTORIES are considered. */
66
+ export function listTop(root) {
67
+ const out = []
68
+ let names = []
69
+ try { names = readdirSync(root, { withFileTypes: true }) } catch (e) { return out }
70
+ for (const d of names) {
71
+ if (!d.isDirectory()) continue
72
+ let mtimeMs = 0
73
+ try { mtimeMs = statSync(join(root, d.name)).mtimeMs } catch (e) { continue }
74
+ out.push({ name: d.name, mtimeMs })
75
+ }
76
+ return out
77
+ }
78
+
79
+ /** Delete the planned entries. Returns counts so callers can print a checkable summary. */
80
+ export function sweep({ root = tmpdir(), prefixes, ageHours = 6, dryRun = false, log = () => {} } = {}) {
81
+ const all = listTop(root)
82
+ const doomed = planSweep(all, { prefixes, ageHours })
83
+ if (dryRun) return { root, scanned: all.length, planned: doomed.length, deleted: 0, failed: 0, dryRun: true }
84
+ let deleted = 0
85
+ let failed = 0
86
+ for (const name of doomed) {
87
+ try { rmSync(join(root, name), { recursive: true, force: true }); deleted++ } catch (e) { failed++ }
88
+ }
89
+ log('temp-hygiene: root=' + root + ' scanned=' + all.length + ' stale=' + doomed.length + ' deleted=' + deleted + ' failed=' + failed + ' ageHours=' + ageHours)
90
+ return { root, scanned: all.length, planned: doomed.length, deleted, failed }
91
+ }
92
+
93
+ /** `D:\_tmp` when the D: drive is usable; otherwise null (keep the ambient temp root). Never throws. */
94
+ export function preferredTempRoot(candidate = 'D:\\_tmp') {
95
+ try {
96
+ if (!existsSync(candidate)) mkdirSync(candidate, { recursive: true })
97
+ return existsSync(candidate) ? candidate : null
98
+ } catch (e) { return null }
99
+ }
100
+
101
+ /** Point TEMP/TMP/TMPDIR at `root` (children inherit; `os.tmpdir()` follows). No-op when root is falsy. */
102
+ export function useTempRoot(root) {
103
+ if (!root) return false
104
+ process.env.TEMP = root
105
+ process.env.TMP = root
106
+ process.env.TMPDIR = root
107
+ return true
108
+ }
109
+
110
+ /**
111
+ * Leftover-PROCESS detector (task-15). Measured motivation: a leftover DSH host (`--profile vmfix7`,
112
+ * started two days earlier, its parent gone) had burned **197,410 s of CPU** on this 4-core machine - a
113
+ * full core, permanently. With it, whole-gate `sum of suite times` went 2822 s -> 7875 s (x2.8) and
114
+ * individual suites ran 1.3x-4.5x slower, so honest suites reported failures that never reproduced
115
+ * standalone. The rule is deliberately NARROW: flag a process only when its command line matches THIS
116
+ * project/team AND its parent is gone (a process the running gate spawned has a live parent, so a healthy
117
+ * gate is never flagged - that property is asserted in `--self-test` and mutated by the family).
118
+ */
119
+ export const DEFAULT_PROCESS_PATTERNS = [
120
+ /--profile\s+\S+/, // a DSH host started against any profile (including our clones)
121
+ /[\\/]review[\\/][^\\/]*\.mjs/, // the dev-only harness scripts kept under _oneoff/review
122
+ /[\\/]tests[\\/][^\\/]*\.mjs/, // a test/probe script (gates, mutant families)
123
+ /--temp-dry-run/, // another gate run
124
+ ]
125
+
126
+ export function suspectProcesses(entries, { selfPids = [], patterns = DEFAULT_PROCESS_PATTERNS } = {}) {
127
+ const self = new Set((selfPids || []).map((p) => String(p)))
128
+ return (entries || []).filter((e) => {
129
+ if (!e || self.has(String(e.pid))) return false
130
+ if (!patterns.some((re) => re.test(String(e.cmd || '')))) return false
131
+ return e.parentGone === true // the safety property: a live parent means something owns it
132
+ })
133
+ }
134
+
135
+ /**
136
+ * List node processes with a `parentGone` flag. Windows-only in practice (PowerShell JSON); other
137
+ * platforms return [] rather than pretending. `exec` is injectable so the self-test can feed fixtures.
138
+ */
139
+ export function listProcesses({ exec = null } = {}) {
140
+ if (process.platform !== 'win32' && !exec) return []
141
+ const ps = "$live=@{}; Get-Process | ForEach-Object { $live[[int]$_.Id]=1 }; " +
142
+ "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' } | ForEach-Object { " +
143
+ "[pscustomobject]@{ pid=[int]$_.ProcessId; parent=[int]$_.ParentProcessId; cmd=[string]$_.CommandLine; " +
144
+ "parentGone=(-not $live.ContainsKey([int]$_.ParentProcessId)); created=[string]$_.CreationDate } } | ConvertTo-Json -Compress"
145
+ try {
146
+ const run = exec || ((args) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', args], { encoding: 'utf8', timeout: 30000 }))
147
+ const raw = String(run(ps) || '').trim()
148
+ if (!raw) return []
149
+ const parsed = JSON.parse(raw)
150
+ return Array.isArray(parsed) ? parsed : [parsed]
151
+ } catch (e) { return [] }
152
+ }
153
+
154
+ export function formatSuspect(s) {
155
+ const cmd = String(s.cmd || '')
156
+ return ' pid=' + s.pid + ' parent=' + s.parent + (s.parentGone ? ' (GONE)' : '') + ' ' + (s.created || '') + ' ' + cmd.slice(0, 120)
157
+ }
158
+
159
+ const argv = process.argv.slice(2)
160
+ const has = (n) => argv.includes('--' + n)
161
+ const val = (n, d) => { const a = argv.find((x) => x.startsWith('--' + n + '=')); return a ? a.split('=').slice(1).join('=') : d }
162
+
163
+ if (has('self-test')) {
164
+ // The GUARD: these three checks fail BY NAME if the planner's prefix test or age test is broken.
165
+ const now = Date.now()
166
+ const prefixes = ['vibe-', 'v2-fix-']
167
+ const entries = [
168
+ { name: 'vibe-fresh-abc', mtimeMs: now - 60 * 1000 }, // fresh ⇒ must NOT be planned
169
+ { name: 'vibe-stale-abc', mtimeMs: now - 48 * 3600 * 1000 }, // stale ⇒ MUST be planned
170
+ { name: 'v2-fix-stale', mtimeMs: now - 48 * 3600 * 1000 }, // stale ⇒ MUST be planned
171
+ { name: 'unrelated-stale', mtimeMs: now - 48 * 3600 * 1000 }, // foreign⇒ must NOT be planned
172
+ { name: 'vibestale-nodash', mtimeMs: now - 48 * 3600 * 1000 }, // prefix must match exactly
173
+ ]
174
+ const planned = planSweep(entries, { prefixes, ageHours: 6, now })
175
+ const failures = []
176
+ const check = (cond, label) => { console.log((cond ? 'PASS ' : 'FAIL - ') + label); if (!cond) failures.push(label) }
177
+ check(!planned.includes('vibe-fresh-abc'), 'TEMP-HYGIENE: a FRESH suite dir is never swept (age threshold respected)')
178
+ check(planned.includes('vibe-stale-abc') && planned.includes('v2-fix-stale'), 'TEMP-HYGIENE: STALE dirs with a suite prefix ARE swept')
179
+ check(!planned.includes('unrelated-stale') && !planned.includes('vibestale-nodash'), 'TEMP-HYGIENE: only exact suite prefixes are swept (no glob-like overreach)')
180
+ const live = suitePrefixes()
181
+ check(live.length >= 3, 'TEMP-HYGIENE: the prefix table is EXTRACTED from the suites (found ' + live.length + ': ' + live.slice(0, 4).join(', ') + ' …)')
182
+ // task-15: the leftover-PROCESS predicate, tested on fixtures so the property is checkable and mutable.
183
+ const procs = [
184
+ { pid: 1, parent: 999, parentGone: true, cmd: 'node tests/run-tests.mjs --temp-dry-run' }, // excluded via selfPids ONLY
185
+ { pid: 5, parent: 995, parentGone: false, cmd: 'node tests/run-tests.mjs --temp-dry-run' }, // LIVE parent -> never suspect
186
+ { pid: 2, parent: 998, parentGone: true, cmd: 'node tests/run-tests.mjs --temp-dry-run' }, // an ORPHANED gate
187
+ { pid: 3, parent: 997, parentGone: true, cmd: 'node bin.js --profile vmfix7 --port 7791' }, // the measured leftover
188
+ { pid: 4, parent: 996, parentGone: true, cmd: 'C:/apps/editor/editor.exe --open notes.md' },// unrelated program
189
+ ]
190
+ const susp = suspectProcesses(procs, { selfPids: [1] })
191
+ check(!susp.some((s) => s.pid === 1), 'TEMP-HYGIENE: an explicitly excluded pid (self / own parent) is never reported')
192
+ // This one is what the "parent-gone" mutation attacks: pid 5 is NOT excluded by selfPids, so only the
193
+ // live-parent property keeps it out of the report.
194
+ check(!susp.some((s) => s.pid === 5), 'TEMP-HYGIENE: a process with a LIVE parent (our running gate) is never called a leftover')
195
+ check(susp.some((s) => s.pid === 2) && susp.some((s) => s.pid === 3), 'TEMP-HYGIENE: ORPHANED project/team processes are reported (incl. a DSH host started against a clone profile)')
196
+ check(!susp.some((s) => s.pid === 4), 'TEMP-HYGIENE: an unrelated program is never reported (the command-line gate holds)')
197
+ console.log('temp-hygiene self-test: ' + (failures.length ? failures.length + ' failure(s)' : 'all checks passed'))
198
+ process.exit(failures.length ? 1 : 0)
199
+ }
200
+
201
+ // CLI entry detection must survive being IMPORTED (then `process.argv[1]` is undefined — the naive
202
+ // `process.argv[1].replace(...)` form threw a TypeError for every importer).
203
+ const isCli = (() => {
204
+ try { return !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) } catch (e) { return false }
205
+ })()
206
+
207
+ if (isCli) {
208
+ // task-15: read-only leftover-process report. `--kill` must be asked for explicitly, and the current
209
+ // process + its parent are always excluded (a running gate must never kill itself).
210
+ if (has('ps')) {
211
+ const procs = listProcesses()
212
+ const susp = suspectProcesses(procs, { selfPids: [process.pid, process.ppid] })
213
+ console.log('temp-hygiene --ps: scanned ' + procs.length + ' node process(es); suspects=' + susp.length)
214
+ for (const s of susp) console.log(formatSuspect(s))
215
+ if (has('kill')) {
216
+ let killed = 0
217
+ for (const s of susp) { try { process.kill(Number(s.pid), 'SIGKILL'); killed++ } catch (e) { /* already gone */ } }
218
+ console.log('temp-hygiene --ps --kill: killed=' + killed)
219
+ } else if (susp.length) {
220
+ console.log('temp-hygiene --ps: read-only. Re-run with --kill to stop them (each one steals CPU from the gate).')
221
+ }
222
+ process.exit(0)
223
+ }
224
+ const root = val('root', null)
225
+ if (!root) { const pref = preferredTempRoot(); if (useTempRoot(pref)) console.log('temp-hygiene: using temp root ' + pref) }
226
+ const res = sweep({
227
+ root: val('root', tmpdir()),
228
+ prefixes: suitePrefixes(),
229
+ ageHours: Number(val('age-hours', '6')),
230
+ dryRun: has('dry-run'),
231
+ log: (m) => console.log(m),
232
+ })
233
+ console.log('temp-hygiene: ' + JSON.stringify(res))
234
+ }
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RELEASE CHECK (task-8 follow-up) — the checks a maintainer had to run BY HAND for 2.8.0, in one command.
4
+ *
5
+ * `docs/release-playbook.md` lists them as prose across §1/§2/§6; this script makes them mechanical, so the
6
+ * next release cannot ship a drifted version, a missing release note, an unregistered note, a manifest that
7
+ * lies about the tarball, or CRLF line endings inside the package.
8
+ *
9
+ * Usage:
10
+ * node scripts/release-check.mjs # static checks + a real `npm pack` + tarball EOL scan
11
+ * node scripts/release-check.mjs --skip-pack # static checks only (no packing, no temp extraction)
12
+ * node scripts/release-check.mjs --registry # ALSO compare the published shasum (post-publish only)
13
+ * node scripts/release-check.mjs --self-test # prove the predicates below are not vacuous
14
+ *
15
+ * Exit 0 = every check passed. Exit 1 = at least one FAIL (printed with a `FAIL ` prefix).
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
18
+ import { execFileSync } from 'node:child_process'
19
+ import { createHash } from 'node:crypto'
20
+ import { dirname, join } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+ const REPO = join(HERE, '..')
25
+ const TMP = process.env.RELEASE_CHECK_TMP || join(process.env.TEMP || process.env.TMPDIR || '/tmp', 'release-check')
26
+
27
+ // ---------------------------------------------------------------- pure predicates (fixture-testable)
28
+ /** The version must agree in all three places the market guard reads (playbook §1). */
29
+ export function versionProblems({ pkgVersion, lockTop, lockEmpty }) {
30
+ const out = []
31
+ if (!pkgVersion) out.push('package.json has no version')
32
+ if (lockTop !== pkgVersion) out.push('package-lock.json top-level version ' + lockTop + ' != package.json ' + pkgVersion)
33
+ if (lockEmpty !== pkgVersion) out.push('package-lock.json packages[""].version ' + lockEmpty + ' != package.json ' + pkgVersion)
34
+ return out
35
+ }
36
+
37
+ /** Release notes must carry the template's sections, in the template's order (playbook §1 / TEMPLATE.md). */
38
+ export const SECTIONS = {
39
+ zh: ['## 概述', '## 新增', '## 变更', '## 修复', '## 兼容性与迁移', '## 已知限制', '## 验证方式', '## 依赖'],
40
+ en: ['## Overview', '## Added', '## Changed', '## Fixed', '## Compatibility & Migration', '## Known Limitations', '## Verification', '## Dependencies'],
41
+ }
42
+ export function sectionProblems(headings, lang) {
43
+ const want = SECTIONS[lang]
44
+ if (!want) return ['unknown language ' + lang]
45
+ const got = (headings || []).map((h) => String(h).trim())
46
+ const out = []
47
+ if (got.length !== want.length) out.push('expected ' + want.length + ' sections, found ' + got.length)
48
+ for (let i = 0; i < want.length; i++) if (got[i] !== want[i]) out.push('section ' + (i + 1) + ' is ' + JSON.stringify(got[i]) + ', expected ' + JSON.stringify(want[i]))
49
+ return out
50
+ }
51
+
52
+ /** Every declared file must exist in the packed tarball (playbook §2/§6.2). */
53
+ export function manifestProblems(declared, packed) {
54
+ const have = new Set(packed || [])
55
+ return (declared || []).filter((f) => !have.has(f)).map((f) => 'declared but missing from the tarball: ' + f)
56
+ }
57
+
58
+ /** Nothing may ship that is repository-only tooling (playbook §6.4). */
59
+ export function devOnlyProblems(packed, devOnly = ['.gitattributes']) {
60
+ const have = new Set(packed || [])
61
+ return devOnly.filter((f) => have.has(f)).map((f) => 'repository-only file shipped in the tarball: ' + f)
62
+ }
63
+
64
+ /** Packaged text must be LF-only so the tarball is byte-reproducible (playbook §7). */
65
+ export function crlfProblems(files) {
66
+ const out = []
67
+ for (const { path, crlf } of files || []) if (Number(crlf) > 0) out.push('CRLF in the packaged file: ' + path + ' (' + crlf + ' lines)')
68
+ return out
69
+ }
70
+
71
+ /** `compatNote` is user-visible and must name the version being released (playbook §2). */
72
+ export function compatNoteProblems(compatNote, version) {
73
+ if (!compatNote) return ['dsh.compatNote is missing']
74
+ return String(compatNote).includes(version) ? [] : ['dsh.compatNote does not mention the current version ' + version]
75
+ }
76
+
77
+ // ---------------------------------------------------------------- self-test (the family mutates these)
78
+ if (process.argv.includes('--self-test')) {
79
+ const failures = []
80
+ const check = (cond, label) => { console.log((cond ? 'PASS ' : 'FAIL - ') + label); if (!cond) failures.push(label) }
81
+
82
+ check(versionProblems({ pkgVersion: '9.9.9', lockTop: '9.9.9', lockEmpty: '9.9.9' }).length === 0,
83
+ 'RELEASE-CHECK: a consistent version triple passes')
84
+ check(versionProblems({ pkgVersion: '9.9.9', lockTop: '9.9.8', lockEmpty: '9.9.9' }).some((p) => /top-level/.test(p)),
85
+ 'RELEASE-CHECK: a drifted lock top-level version is caught')
86
+ check(versionProblems({ pkgVersion: '9.9.9', lockTop: '9.9.9', lockEmpty: '9.9.8' }).some((p) => /packages/.test(p)),
87
+ 'RELEASE-CHECK: a drifted packages[""] version is caught')
88
+
89
+ check(sectionProblems(SECTIONS.zh, 'zh').length === 0 && sectionProblems(SECTIONS.en, 'en').length === 0,
90
+ 'RELEASE-CHECK: the canonical section lists pass their own check')
91
+ check(sectionProblems(['## 概述', '## 新增'], 'zh').some((p) => /expected 8 sections/.test(p)),
92
+ 'RELEASE-CHECK: a truncated note is caught by its section count')
93
+ check(sectionProblems([...SECTIONS.en].reverse(), 'en').length > 0, 'RELEASE-CHECK: a reordered note is caught')
94
+
95
+ check(manifestProblems(['a.js', 'b.md'], ['a.js', 'b.md', 'package.json']).length === 0, 'RELEASE-CHECK: a fully packed manifest passes')
96
+ check(manifestProblems(['a.js', 'gone.md'], ['a.js']).length === 1, 'RELEASE-CHECK: a declared-but-missing file is caught')
97
+
98
+ check(devOnlyProblems(['README.md'], ['.gitattributes']).length === 0, 'RELEASE-CHECK: repository-only tooling stays out')
99
+ check(devOnlyProblems(['.gitattributes'], ['.gitattributes']).length === 1, 'RELEASE-CHECK: a shipped repository-only file is caught')
100
+
101
+ check(crlfProblems([{ path: 'a.js', crlf: 0 }]).length === 0, 'RELEASE-CHECK: LF-only content passes')
102
+ check(crlfProblems([{ path: 'a.js', crlf: 3 }]).length === 1, 'RELEASE-CHECK: CRLF inside the package is caught')
103
+
104
+ check(compatNoteProblems('v9.9.9 功能版:…', '9.9.9').length === 0, 'RELEASE-CHECK: a compatNote naming the version passes')
105
+ check(compatNoteProblems('v9.9.8 补丁版:…', '9.9.9').length === 1, 'RELEASE-CHECK: a stale compatNote is caught')
106
+
107
+ console.log('release-check self-test: ' + (failures.length ? failures.length + ' failure(s)' : 'all checks passed'))
108
+ process.exit(failures.length ? 1 : 0)
109
+ }
110
+
111
+ // ---------------------------------------------------------------- the real run
112
+ const argv = process.argv.slice(2)
113
+ const has = (n) => argv.includes('--' + n)
114
+ const problems = []
115
+ const fail = (p) => { for (const x of p) problems.push(x) }
116
+ const readJson = (rel) => JSON.parse(readFileSync(join(REPO, rel), 'utf8'))
117
+
118
+ const pkg = readJson('package.json')
119
+ const lock = readJson('package-lock.json')
120
+ const version = pkg.version
121
+ console.log('release-check: ' + pkg.name + '@' + version)
122
+
123
+ fail(versionProblems({ pkgVersion: version, lockTop: lock.version, lockEmpty: ((lock.packages || {})[''] || {}).version }))
124
+ fail(compatNoteProblems((pkg.dsh || {}).compatNote, version))
125
+
126
+ const notes = { zh: 'docs/release-notes/RELEASE-NOTES-' + version + '.md', en: 'docs/release-notes/RELEASE-NOTES-' + version + '.en.md' }
127
+ for (const [lang, rel] of Object.entries(notes)) {
128
+ if (!existsSync(join(REPO, rel))) { problems.push('missing release note (' + lang + '): ' + rel); continue }
129
+ const text = readFileSync(join(REPO, rel), 'utf8')
130
+ fail(sectionProblems((text.match(/^## .*$/gm) || []), lang).map((p) => rel + ': ' + p))
131
+ if (!(pkg.files || []).includes(rel)) problems.push('release note not listed in package.json#files: ' + rel)
132
+ }
133
+
134
+ if (has('skip-pack')) {
135
+ console.log('release-check: --skip-pack, skipping the tarball checks')
136
+ } else {
137
+ rmSync(TMP, { recursive: true, force: true })
138
+ mkdirSync(TMP, { recursive: true })
139
+ const out = execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: REPO, encoding: 'utf8', shell: true })
140
+ const packed = JSON.parse(out)[0].files.map((f) => f.path)
141
+ console.log('release-check: tarball would contain ' + packed.length + ' files; declared ' + (pkg.files || []).length)
142
+ fail(manifestProblems(pkg.files, packed))
143
+ fail(devOnlyProblems(packed, ['.gitattributes']))
144
+
145
+ // Real pack + extract, so the EOL check reads the bytes a user would download.
146
+ const tgzName = String(execFileSync('npm', ['pack', '--pack-destination', TMP], { cwd: REPO, encoding: 'utf8', shell: true })).trim().split(/\r?\n/).pop()
147
+ const tgz = join(TMP, tgzName)
148
+ if (!existsSync(tgz)) {
149
+ problems.push('npm pack produced no tarball at ' + tgz)
150
+ } else {
151
+ execFileSync('tar', ['-xzf', tgz, '-C', TMP], { stdio: 'pipe' })
152
+ const root = join(TMP, 'package')
153
+ const eol = []
154
+ const walk = (d, prefix) => {
155
+ for (const e of readdirSync(d, { withFileTypes: true })) {
156
+ const p = join(d, e.name)
157
+ if (e.isDirectory()) { walk(p, prefix + e.name + '/'); continue }
158
+ if (!/\.(js|mjs|cjs|json|md|yml|yaml|txt|svg)$/.test(e.name)) continue // binaries are not text
159
+ const text = readFileSync(p, 'utf8')
160
+ eol.push({ path: prefix + e.name, crlf: (text.match(/\r\n/g) || []).length })
161
+ }
162
+ }
163
+ walk(root, '')
164
+ fail(crlfProblems(eol))
165
+ const sha1 = createHash('sha1').update(readFileSync(tgz)).digest('hex')
166
+ console.log('release-check: local tarball sha1 = ' + sha1 + ' (' + eol.length + ' text files scanned for CRLF)')
167
+ if (has('registry')) {
168
+ try {
169
+ const published = String(execFileSync('npm', ['view', pkg.name + '@' + version, 'dist.shasum'], { cwd: REPO, encoding: 'utf8', shell: true })).trim()
170
+ if (published !== sha1) problems.push('registry dist.shasum ' + published + ' != local tarball sha1 ' + sha1)
171
+ else console.log('release-check: registry shasum matches the local tarball (byte-reproducible)')
172
+ } catch (e) { problems.push('registry lookup failed: ' + String((e && e.message) || e).slice(0, 120)) }
173
+ }
174
+ rmSync(TMP, { recursive: true, force: true })
175
+ }
176
+ }
177
+
178
+ console.log('')
179
+ for (const p of problems) console.log('FAIL ' + p)
180
+ console.log(problems.length ? 'release-check: ' + problems.length + ' problem(s)' : 'release-check: ALL CHECKS PASSED')
181
+ process.exit(problems.length ? 1 : 0)