dsh-vibe-math 2.8.0 → 2.8.2

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.
@@ -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)
@@ -1299,6 +1299,13 @@ console.log('\n[24] manual paper, office editor: parts → cross-review → cons
1299
1299
  console.log(' [task-9] v5 detection evidence: compile=' + String(meta && meta.compile && meta.compile.status) + ' triedPaths=' + JSON.stringify((meta && meta.compile && meta.compile.triedPaths) || null))
1300
1300
  assert(Array.isArray(meta && meta.compile && meta.compile.triedPaths) && meta.compile.triedPaths.length > 0,
1301
1301
  '★ [task-9] the durable meta records the probed TeX paths (triedPaths)')
1302
+ // task-18 (v4 got this in 2.8.1): the DELIVERED artifacts must state why no PDF was produced — the body is
1303
+ // composed before the compile runs, so the note is written afterwards, exactly once each (idempotent).
1304
+ // The note follows paperLanguage, so the check is language-agnostic and still requires EXACTLY one.
1305
+ const noteHits = (t) => t.split('本机未检测到 LaTeX 引擎:只产出 tex+md').length - 1
1306
+ + t.split('No LaTeX engine detected: tex+md only').length - 1
1307
+ assert(noteHits(md) === 1 && noteHits(tex) === 1,
1308
+ '★ [task-18/v5] the DELIVERED paper.md and paper.tex state that no engine was detected (once each; md=' + noteHits(md) + ' tex=' + noteHits(tex) + ')')
1302
1309
  const warnText = (meta && Array.isArray(meta.warnings) ? meta.warnings : []).join(' | ')
1303
1310
  assert(warnText.indexOf('只交付 paper.tex 与 paper.md。') !== -1 && warnText.indexOf('已探测 PATH 与文档化的常见 TeX 根;可用 paperLatexCommand 指定绝对路径') !== -1,
1304
1311
  '★ [task-9] the not-detected warning keeps its tail AND says where it looked and how to pin an engine')
@@ -1437,6 +1444,13 @@ console.log('\n[27] fake LaTeX compiler: repair path and persistent-failure degr
1437
1444
  const metap = JSON.parse(readFileSync(join(dp, 'paper.meta.json'), 'utf8'))
1438
1445
  assert(metap.compile.status === 'not-detected',
1439
1446
  '★ [task-10/v5] an unresolvable explicit paperLatexCommand degrades (never falls through to another engine) — got ' + JSON.stringify(metap.compile.status))
1447
+ // task-17 (found on a real host with 2.8.1): the explicit value must be probed ALONE. Probing it as a bare
1448
+ // command name let the known-root stage build impossible paths like `<MiKTeX root>/Z:/…/xelatex.exe`, which
1449
+ // made `triedPaths` — the very field that says WHERE it looked — report paths that cannot exist.
1450
+ const tp = metap.compile.triedPaths || []
1451
+ const joined = tp.filter((p) => /[\\/][A-Za-z]:[\\/]/.test(String(p).replace(/^[A-Za-z]:[\\/]/, '')))
1452
+ assert(tp.length >= 1 && joined.length === 0,
1453
+ '★ [task-17/v5] an ABSOLUTE explicit paperLatexCommand is probed ALONE — triedPaths never joins it onto a known TeX root (triedPaths=' + JSON.stringify(tp) + ')')
1440
1454
  }
1441
1455
 
1442
1456
  // ---------- 28. office-only surface + id normalisation ----------
@@ -0,0 +1,78 @@
1
+ // Falsifiability driver for scripts/release-check.mjs: a SINGLE-SITE edit must turn its own guard
2
+ // (`--self-test`) NAMED-red, while the unmutated control stays green. The mutated copy is written as a
3
+ // dot-sibling inside scripts/ so relative paths still resolve, and it is always removed in `finally`.
4
+ import { spawnSync } from 'node:child_process'
5
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url))
10
+ const REPO = join(HERE, '..')
11
+ const SRC = join(REPO, 'scripts', 'release-check.mjs')
12
+
13
+ const FAMILIES = [
14
+ {
15
+ // Without this, a lockfile that drifted away from package.json would ship and the market guard would
16
+ // be the only thing that notices.
17
+ name: 'the version-triple check is dropped (a drifted lockfile would ship)',
18
+ from: 'if (lockTop !== pkgVersion) out.push(',
19
+ to: 'if (false) out.push(',
20
+ expect: /drifted lock top-level version is caught/,
21
+ },
22
+ {
23
+ // Without this, a release note missing half its sections would ship.
24
+ name: 'the section-count check is dropped (a truncated release note would ship)',
25
+ from: 'if (got.length !== want.length) out.push(',
26
+ to: 'if (false) out.push(',
27
+ expect: /truncated note is caught by its section count/,
28
+ },
29
+ {
30
+ // Without this, a note whose sections are in the wrong order would ship.
31
+ name: 'the section-order check is dropped (a reordered release note would ship)',
32
+ from: 'if (got[i] !== want[i]) out.push(',
33
+ to: 'if (false) out.push(',
34
+ expect: /reordered note is caught/,
35
+ },
36
+ {
37
+ // Without this, a package containing CRLF would ship - the exact defect this whole line of work fixed.
38
+ name: 'the CRLF scan is dropped (a CRLF package would ship)',
39
+ from: 'if (Number(crlf) > 0) out.push(',
40
+ to: 'if (false) out.push(',
41
+ expect: /CRLF inside the package is caught/,
42
+ },
43
+ ]
44
+
45
+ function runSelftest(scriptPath) {
46
+ const r = spawnSync(process.execPath, [scriptPath, '--self-test'], { cwd: REPO, encoding: 'utf8', timeout: 60000 })
47
+ return { code: r.status, out: String(r.stdout || '') + String(r.stderr || '') }
48
+ }
49
+
50
+ const control = runSelftest(SRC)
51
+ console.log('control (unmutated release-check): exit=' + control.code + (control.code === 0 ? ' green, as required' : ' NOT GREEN'))
52
+ if (control.code !== 0) console.log(control.out.split(/\r?\n/).filter((l) => /FAIL/.test(l)).slice(0, 4).join('\n'))
53
+
54
+ let red = 0
55
+ for (const f of FAMILIES) {
56
+ const dest = join(REPO, 'scripts', '.tmp-release-check-' + Math.random().toString(36).slice(2, 10) + '.mjs')
57
+ let ok = false
58
+ try {
59
+ let text = readFileSync(SRC, 'utf8').replace(/\r\n?/g, '\n')
60
+ if (/\r/.test(text)) { console.error('SETUP-FAIL - the anchor source was not EOL-normalised (a lone CR leaked in)'); process.exit(1) }
61
+ const hits = text.split(f.from).length - 1
62
+ if (hits !== 1) { console.error('SETUP-FAIL - anchor ' + JSON.stringify(f.from) + ' matched ' + hits + ' time(s), expected exactly 1'); process.exit(1) }
63
+ writeFileSync(dest, text.replace(f.from, f.to), 'utf8')
64
+ const r = runSelftest(dest)
65
+ const named = r.out.split(/\r?\n/).filter((l) => /^\s*FAIL - /.test(l) && f.expect.test(l)).map((l) => l.trim())
66
+ ok = r.code !== 0 && named.length > 0
67
+ console.log((ok ? ' ok - ' : ' FAIL - ') + f.name + ' [exit=' + r.code + ']' + (named.length ? ' :: ' + named[0].slice(0, 140) : ' :: no named red (exit=' + r.code + ')'))
68
+ if (ok) red++
69
+ } finally {
70
+ try { rmSync(dest, { force: true }) } catch (e) { /* best effort */ }
71
+ }
72
+ }
73
+
74
+ console.log('')
75
+ console.log('mutant families reddening the release-check guard by name: ' + red + '/' + FAMILIES.length)
76
+ console.log('control green: ' + (control.code === 0))
77
+ if (red !== FAMILIES.length || control.code !== 0) process.exit(1)
78
+ console.log('ALL MUTANTS RED AS REQUIRED')
@@ -95,6 +95,14 @@ const FAMILIES = [
95
95
  to: 'const order=(prefer?[prefer]:[]).concat(base.filter(x=>x!==prefer))',
96
96
  expect: /\[task-10\/v4\]/,
97
97
  },
98
+ {
99
+ // The delivered artifacts must state WHY no PDF was produced (the body is composed BEFORE compilation,
100
+ // so this note is written after it). Blunting the zh note must redden that guard BY NAME.
101
+ name: 'deliverable/v4: the delivered paper stops stating that no engine was detected',
102
+ from: "zh?'本机未检测到 LaTeX 引擎:只产出 tex+md'",
103
+ to: "zh?''",
104
+ expect: /\[deliverable\/v4\]/,
105
+ },
98
106
  ]
99
107
  let red = 0
100
108
  for (const f of FAMILIES) { const t0 = Date.now(); const ok = runFamily(f); const ms = Date.now() - t0; if (ok) red++ }