dsh-vibe-math 2.7.1 → 2.7.3

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 (114) hide show
  1. package/README.en.md +682 -690
  2. package/README.md +19 -27
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +744 -518
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/final-paper.md +172 -172
  8. package/docs/formal-verification.md +588 -588
  9. package/docs/math-computation.md +194 -163
  10. package/docs/parameter-schema.md +37 -0
  11. package/docs/release-notes/README.en.md +49 -0
  12. package/docs/release-notes/README.md +48 -0
  13. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  16. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  17. package/docs/release-notes/TEMPLATE.md +126 -0
  18. package/docs/status-report-fields.md +167 -0
  19. package/docs/test-timing.md +157 -154
  20. package/installer.js +27 -17
  21. package/package.json +55 -3
  22. package/prompt-corpus-persona/persona-corpus.json +9 -9
  23. package/prompt-corpus-persona/persona-corpus.md +225 -33
  24. package/prompt-corpus-v2/formal-verify-v2.json +305 -113
  25. package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
  26. package/prompt-corpus-v3/formal-verify-v3.json +134 -110
  27. package/prompt-corpus-v3/formal-verify-v3.md +409 -141
  28. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  29. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  30. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  31. package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
  32. package/scripts/update-doc-counts.mjs +71 -0
  33. package/tests/audit-artifact-docs.mjs +72 -0
  34. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  35. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  36. package/tests/audit-installer-compat.test.mjs +52 -4
  37. package/tests/audit-installer-policy.test.mjs +41 -8
  38. package/tests/audit-math-computation-contract.mjs +107 -9
  39. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  40. package/tests/audit-math-computation-parity.mjs +164 -3
  41. package/tests/audit-math-computation-sensitivity.mjs +6 -4
  42. package/tests/audit-package-membership.mjs +57 -0
  43. package/tests/audit-package-membership.mutants.mjs +28 -0
  44. package/tests/audit-participant-set-parity.mjs +160 -0
  45. package/tests/audit-path-discipline.mjs +83 -0
  46. package/tests/audit-path-discipline.mutants.mjs +51 -0
  47. package/tests/audit-persona-sensitivity.mjs +14 -0
  48. package/tests/audit-persona-surface.mutants.mjs +69 -0
  49. package/tests/audit-persona-surface.test.mjs +63 -22
  50. package/tests/audit-prompt-duplication.mjs +143 -0
  51. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  52. package/tests/audit-prompt-invariants.mjs +43 -5
  53. package/tests/audit-readme-bilingual.test.mjs +152 -0
  54. package/tests/audit-readme-counts.mjs +95 -0
  55. package/tests/audit-readme-counts.mutants.mjs +52 -0
  56. package/tests/audit-registration.mjs +78 -0
  57. package/tests/audit-status-report-fields.mjs +172 -0
  58. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  59. package/tests/audit-v3-registration-parity.mjs +156 -0
  60. package/tests/audit-v5-integrity.mjs +300 -13
  61. package/tests/audit-v5-lean-abstention.mjs +392 -0
  62. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  63. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  64. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  65. package/tests/audit-v5-sensitivity.mjs +15 -8
  66. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  67. package/tests/e2e-v5-round2.test.mjs +566 -12
  68. package/tests/formal-verify-v2.test.mjs +91 -5
  69. package/tests/formal-verify-v3.mutants.mjs +117 -0
  70. package/tests/formal-verify-v3.test.mjs +85 -6
  71. package/tests/formal-verify-v4.mutants.mjs +152 -0
  72. package/tests/formal-verify-v4.test.mjs +628 -9
  73. package/tests/formal-verify-v5.test.mjs +68 -9
  74. package/tests/helpers/doc-counts-format.mjs +41 -0
  75. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  76. package/tests/math-computation-a1.mutants.mjs +72 -0
  77. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  78. package/tests/math-computation-discovery.mutants.mjs +76 -0
  79. package/tests/math-computation-shared.test.mjs +396 -2
  80. package/tests/math-computation-v2.test.mjs +767 -0
  81. package/tests/math-computation-v3.test.mjs +759 -0
  82. package/tests/math-computation-v4.mutants.mjs +133 -0
  83. package/tests/math-computation-v4.test.mjs +739 -0
  84. package/tests/prompt-v5-integrity.test.mjs +13 -2
  85. package/tests/run-tests.mjs +139 -9
  86. package/tests/run-tests.mutants.mjs +70 -0
  87. package/tests/selfdrive-v5.mjs +53 -1
  88. package/tests/selfdrive-v5.mutants.mjs +132 -0
  89. package/tests/v2-fix-probes.mutants.mjs +100 -0
  90. package/tests/v2-fix-probes.test.mjs +1039 -0
  91. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  92. package/tests/v3-fix-probes.mutants.mjs +95 -0
  93. package/tests/v3-fix-probes.test.mjs +891 -0
  94. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  95. package/vibe-math-v2/agent.cordis.yml +62 -6
  96. package/vibe-math-v2/math-computation.js +229 -29
  97. package/vibe-math-v2/math-engines.js +12 -12
  98. package/vibe-math-v2/vibe-math-v2.js +458 -119
  99. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  100. package/vibe-math-v3/agent.cordis.yml +72 -6
  101. package/vibe-math-v3/math-computation.js +229 -29
  102. package/vibe-math-v3/math-engines.js +12 -12
  103. package/vibe-math-v3/vibe-math-v3.js +482 -139
  104. package/vibe-math-v4/agent.cordis.yml +44 -8
  105. package/vibe-math-v4/math-computation.js +229 -29
  106. package/vibe-math-v4/math-engines.js +12 -12
  107. package/vibe-math-v4/vibe-math-v4.js +245 -52
  108. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  109. package/vibe-math-v5/agent.cordis.yml +46 -12
  110. package/vibe-math-v5/math-computation.js +229 -29
  111. package/vibe-math-v5/math-engines.js +12 -12
  112. package/vibe-math-v5/vibe-math-v5.js +1307 -243
  113. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
  114. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1166,16 +1166,27 @@ section('13 full-corpus sweep over every prompt ever sent')
1166
1166
  // A single prompt must not deliver the same message twice. The inbox used to be
1167
1167
  // prepended AND re-emitted from the [状态] block, so a member read every new message
1168
1168
  // twice in one prompt.
1169
+ // A3 (assertion-quality review): this used to end with a vacuous `assert(true, …)` and to
1170
+ // `break` after the FIRST duplicate frame, so (a) a property nobody checked was attributed to an
1171
+ // assertion that asserted nothing, and (b) only one duplicate per prompt was ever reported.
1172
+ // The loop now COLLECTS every duplicate frame and reports the count, and the summary assertion
1173
+ // states exactly what was checked.
1174
+ const duplicateFrames = []
1175
+ let inboxOffenders = 0
1169
1176
  for (const c of corpus) {
1170
1177
  const bodies = c.prompt.match(/【[^】]*】[^\n]{20,}/g) || []
1171
1178
  for (const frame of new Set(bodies)) {
1172
1179
  const n = bodies.filter(b => b === frame).length
1173
- if (n > 1) { assert(false, 'message delivered ' + n + '× in one prompt (' + c.kind + '/' + c.owner + '): ' + frame.slice(0, 60)); break }
1180
+ if (n > 1) duplicateFrames.push({ kind: c.kind, owner: c.owner, n, frame: frame.slice(0, 60) })
1174
1181
  }
1175
1182
  const inboxHeads = (c.prompt.match(/\[新到的消息/g) || []).length
1183
+ if (inboxHeads > 1) inboxOffenders += 1
1176
1184
  assert(inboxHeads <= 1, c.kind + '/' + c.owner + ': at most one inbox section per prompt (found ' + inboxHeads + ')')
1177
1185
  }
1178
- assert(true, 'no prompt delivers the same framed message twice, and no prompt has two inbox sections')
1186
+ assert(duplicateFrames.length === 0,
1187
+ 'no prompt delivers the same framed message twice (duplicate frames found: ' + duplicateFrames.length
1188
+ + (duplicateFrames.length ? ' — ' + duplicateFrames.slice(0, 5).map(d => d.kind + '/' + d.owner + ' ' + d.n + '× ' + d.frame).join(' | ') : '')
1189
+ + '); prompts with two inbox sections: ' + inboxOffenders)
1179
1190
  // The identity claim inside a prompt must agree with the persona shipped alongside it.
1180
1191
  for (const c of corpus) {
1181
1192
  if (!c.persona) continue
@@ -62,6 +62,115 @@ const asJson = has('json')
62
62
  const concurrency = Math.max(1, Number(flag('concurrency')[0] || Math.min(4, cpus().length)))
63
63
 
64
64
  const SELF = 'run-tests.mjs'
65
+ // Per-suite HARD timeout (GATE_SUITE_TIMEOUT_MS overrides, ms). It bounds an intermittent hang so the
66
+ // gate always terminates and a hang is reported - never mistaken for "still running". 180 s comes from
67
+ // measurements: whole-gate wall ~200 s, slowest honest suite 135-144 s (audit-math-computation-sensitivity),
68
+ // so 180 s bounds a hang without killing an honest slow suite. Do NOT raise it silently to "fix" reds.
69
+ const SUITE_TIMEOUT_MS = Math.max(1000, Number(process.env.GATE_SUITE_TIMEOUT_MS || 180000))
70
+
71
+ // NAMED per-suite overrides for suites that are HONEST but slow (measured, not guessed). Raising a
72
+ // limit is a documented act: update the numbers here AND say so in the commit message - never silently.
73
+ // v2-fix-probes.mutants.mjs: ~43.1 s per family x 10 families ~= 430 s measured (inner child cap 120 s)
74
+ // v3-fix-probes.mutants.mjs: ~23.5 s per family x 11 families ~= 260 s measured (inner child cap 120 s)
75
+ // Both report hangs=[] with their own 120 s child cap, i.e. they are slow, not hung - so the 180 s
76
+ // default would misreport them. Everything else keeps the 180 s default.
77
+ const TIMEOUT_OVERRIDES = {
78
+ 'v2-fix-probes.mutants.mjs': 900000,
79
+ 'v3-fix-probes.mutants.mjs': 900000,
80
+ // v5-institute-fixes.mutants.mjs: MEASURED 201.9 s wall (hangs=[], skipped=[], ALL MUTANTS RED
81
+ // AS REQUIRED) - i.e. OVER the 180 s default, so it gets the same 900 s as the v2/v3 families.
82
+ // History worth keeping: a writer CLAIMED this family fitted its override when it did not, and the
83
+ // gate caught it as "FAILED: v5-institute-fixes.mutants.mjs [probe] (TIMEOUT after 180s)" - the
84
+ // named-timeout mechanism doing its job. For contrast, formal-verify-v4.mutants.mjs MEASURED
85
+ // 70.9 s wall, i.e. ample headroom, so it needs no override.
86
+ 'v5-institute-fixes.mutants.mjs': 900000,
87
+ }
88
+ /** One place decides a job limit: explicit job value, then the named override, then the default. */
89
+ function jobLimit(job) { return job.timeoutMs || TIMEOUT_OVERRIDES[job.file] || SUITE_TIMEOUT_MS }
90
+ /**
91
+ * DIAGNOSABILITY (protocol: every red must name an assertion): a failing suite's assertion NAMES are
92
+ * what a reader needs, and they must appear under the FAILED line - not only in the suite's own last
93
+ * stdout line. Extract the failing-assertion lines (this repo prints ` - <name>` or ` FAIL - <name>`,
94
+ * and suites that abort print `FAILURES:`/`Error:` markers), and fall back to the raw tail when a suite
95
+ * names nothing.
96
+ */
97
+ /**
98
+ * A reporting line's OWN prefix decides whether it names a failure. Anchored at the start (after the
99
+ * reporter's indent) so a token appearing INSIDE a passing line (e.g. ` ok - FAIL - ★★★ message`) can
100
+ * never be extracted as a failure — that false positive made a green run look red.
101
+ * own prefix: `FAIL`, `FAIL -`/`FAIL:`, `FAILURES:`; assertion bullets `- <name>` / `* <name>`; `✗`/`✘`.
102
+ * line-anchored abort markers: `SyntaxError:`, `TypeError:`, `ReferenceError:`, `Error:`.
103
+ * `ok`-prefixed lines are excluded explicitly, so a passing bullet is never counted.
104
+ */
105
+ function namedFailureLines(text) {
106
+ const out = []
107
+ for (const raw of String(text || '').split(/\r?\n/)) {
108
+ const l = raw.replace(/\s+$/, '')
109
+ if (!l.trim()) continue
110
+ if (/^\s*ok\b/.test(l)) continue // a PASSING line, whatever it contains
111
+ if (/^\s*(?:FAIL\b|FAILURES:|✗|✘)/.test(l)) { out.push(l); continue }
112
+ if (/^\s*(?:-|\*)\s+\S/.test(l)) { out.push(l); continue }
113
+ if (/^\s*(?:SyntaxError|TypeError|ReferenceError|Error):/.test(l)) { out.push(l); continue }
114
+ }
115
+ return out
116
+ }
117
+
118
+ function failureDetail(r) {
119
+ const lines = (String(r.out || '') + '\n' + String(r.err || '')).split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean)
120
+ const named = namedFailureLines(String(r.out || '') + '\n' + String(r.err || ''))
121
+ const chosen = (named.length ? named : lines).slice(-40)
122
+ return chosen.length ? chosen : ['(no captured output)']
123
+ }
124
+ // `--self-check`: prove the diagnostics above actually surface a NAME. Runs one synthetic failing child
125
+ // through the same extractor and asserts the extracted detail contains its assertion name; the mutant
126
+ // that strips `failureDetail` makes this red.
127
+ /** ONE place builds the failure line, so the reporter and the self-check cannot diverge. */
128
+ function failedLine(b) {
129
+ const kind = '[' + b.job.kind + ']'
130
+ const why = b.timedOut ? 'TIMEOUT after ' + Math.round(jobLimit(b.job) / 1000) + 's'
131
+ : 'exit ' + b.code + (b.job.expectExit ? ', required exit ' + b.job.expectExit : '')
132
+ return ' FAILED: ' + label(b.job) + ' ' + kind + ' (' + why + ')'
133
+ }
134
+
135
+ if (process.argv.includes('--self-check')) {
136
+ const { spawnSync } = await import('node:child_process')
137
+ const synth = spawnSync(process.execPath, ['-e', "console.error(' - synthetic assertion name XYZ'); process.exit(1)"], { encoding: 'utf8' })
138
+ const detail = failureDetail({ out: synth.stdout, err: synth.stderr }).join('\n')
139
+ const okSelf = detail.indexOf('synthetic assertion name XYZ') !== -1
140
+ console.log((okSelf ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a failing child yields a NAMED assertion line (' + detail.trim().slice(0, 80) + ')')
141
+ // FALSE-POSITIVE direction: a PASSING line whose message literally contains `FAIL - ` must extract
142
+ // NOTHING (the v2/v3 owner saw a green run look red this way).
143
+ const leaked = namedFailureLines(' ok - FAIL - ★★★ [F2cap] the assertion message mentions FAIL - inside it\n')
144
+ const okPassing = leaked.length === 0
145
+ console.log((okPassing ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a passing line containing "FAIL - " is NOT extracted as a failure (' + JSON.stringify(leaked) + ')')
146
+ // FALSE-NEGATIVE direction stays closed: a genuine FAIL line and a line-anchored abort marker extract.
147
+ const okReal = namedFailureLines(' FAIL - ★★★ real failure\n').length === 1
148
+ const okAbort = namedFailureLines('TypeError: boom\n').length === 1
149
+ console.log((okReal && okAbort ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a genuine FAIL line and an abort marker are still extracted')
150
+ // TIMEOUT case on the REAL path: a synthetic job that sleeps past a 1 s per-job limit, run through
151
+ // the SAME runSuite/reporting path the gate uses. A string-only check could pass with a half-armed
152
+ // timer; this one cannot, because it reads the run own timedOut/exit and the line the reporter builds.
153
+ const slow = await runSuite({ file: '(synthetic-sleeper)', args: [], expectExit: 0, kind: 'probe', eval: 'setTimeout(() => {}, 5000)', timeoutMs: 1000 })
154
+ const killedByRunner = slow.timedOut === true && slow.code !== 0
155
+ const line = failedLine(slow)
156
+ const namedTimeout = /FAILED: .*\[probe\] \(TIMEOUT after 1s\)/.test(line)
157
+ console.log((killedByRunner ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': the RUNNER kills a sleeping job at its per-job limit (timedOut=true, exit=' + slow.code + ')')
158
+ console.log((namedTimeout ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': the failure line names the timeout: ' + line.trim())
159
+ // OVERRIDE case (real path): the named override must actually EXTEND the limit. A job that sleeps
160
+ // 1.5 s would be killed by 1 s, so it must survive under the override and report its own seconds.
161
+ const NAMED_OVERRIDES = ['v2-fix-probes.mutants.mjs', 'v3-fix-probes.mutants.mjs', 'v5-institute-fixes.mutants.mjs']
162
+ const overrideOk = NAMED_OVERRIDES.every((f) => jobLimit({ file: f }) === 900000)
163
+ && jobLimit({ file: 'anything-else.mjs' }) === SUITE_TIMEOUT_MS
164
+ && jobLimit({ file: 'formal-verify-v4.mutants.mjs' }) === SUITE_TIMEOUT_MS // measured 70.9 s: no override needed
165
+ const survived = await runSuite({ file: '(synthetic-ok)', args: [], expectExit: 0, kind: 'probe', eval: 'setTimeout(() => {}, 1500)', timeoutMs: 900000 })
166
+ const extended = survived.timedOut === false && survived.code === 0
167
+ const overrideLine = failedLine({ job: { file: 'v2-fix-probes.mutants.mjs', args: [], kind: 'probe', expectExit: 0 }, code: null, timedOut: true })
168
+ const namesOverride = /TIMEOUT after 900s/.test(overrideLine)
169
+ console.log((overrideOk ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a named override is resolved for its suite and nothing else')
170
+ console.log((extended ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a job that would die at 1 s SURVIVES under the override (real runSuite)')
171
+ console.log((namesOverride ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a timed-out override reports its own limit (' + overrideLine.trim() + ')')
172
+ process.exit(okSelf && okPassing && okReal && okAbort && killedByRunner && namedTimeout && overrideOk && extended && namesOverride ? 0 : 1)
173
+ }
65
174
  // The scripts that genuinely cannot run without arguments. They are named here (with the exact
66
175
  // command a human must run) instead of being omitted quietly: an entry that no longer exists
67
176
  // aborts the run, so a rename cannot turn into a silent gap.
@@ -81,6 +190,12 @@ const VARIANTS = [
81
190
  // The invariant scanner's own falsifiability check: it exits 0 only when every injected
82
191
  // mutation really made a run go RED (and the unmutated control stayed green).
83
192
  { file: 'audit-prompt-invariants.mjs', args: ['--self-probe'] },
193
+ // The participant-set proof, shipped: the guard's own predicates against broken strings.
194
+ { file: 'audit-participant-set-parity.mjs', args: ['--self-probe'] },
195
+ // §7/§8 predicates, shipped: same functions fed broken strings.
196
+ { file: 'audit-math-computation-parity.mjs', args: ['--self-probe'] },
197
+ // §30 verify-provenance predicates (case 3), shipped.
198
+ { file: 'math-computation-shared.test.mjs', args: ['--self-probe'] },
84
199
  // The two-registration-paths probe, inverted: it applies a real description mutation and
85
200
  // REQUIRES the parity check to exit 1 (exit 2 means the mutation no longer applies = drift).
86
201
  {
@@ -114,22 +229,38 @@ for (const v of VARIANTS) {
114
229
  if (DEV_CHECKOUT) { console.error('a VARIANTS entry names a missing script: ' + v.file); process.exit(2) }
115
230
  continue
116
231
  }
117
- suites.push({ file: v.file, args: v.args || [], expectExit: v.expectExit || 0, kind: v.file.endsWith('.test.mjs') ? 'suite' : 'probe' })
232
+ // A VARIANT is an EXTRA instrumented run of the SAME script (argument variants), never an additional
233
+ // suite: the counting unit for `suites` is "the suites proper", one per script. Classifying by file name
234
+ // here would inflate `suites` whenever a suite gains a `--self-probe` job (R18: it did, 44 -> 45).
235
+ suites.push({ file: v.file, args: v.args || [], expectExit: v.expectExit || 0, kind: 'probe' })
118
236
  }
119
237
  suites.sort((a, b) => (label(a) < label(b) ? -1 : 1))
120
238
  if (only.length) suites = suites.filter((j) => only.some((o) => label(j).includes(o)))
121
239
  if (exclude.length) suites = suites.filter((j) => !exclude.some((o) => label(j).includes(o)))
122
240
  if (!suites.length) { console.error('no suites matched'); process.exit(2) }
123
241
 
242
+ // --counts: print the DERIVED suite/probe totals (the same job list the gate runs) as JSON, then
243
+ // exit. Docs quote these numbers, so they must be derived and checked rather than typed by hand.
244
+ if (process.argv.includes('--counts')) {
245
+ const suiteN = suites.filter((j) => j.kind === 'suite').length
246
+ console.log(JSON.stringify({ total: suites.length, suites: suiteN, probes: suites.length - suiteN }))
247
+ process.exit(0)
248
+ }
249
+
124
250
  function runSuite(job) {
125
251
  return new Promise((resolve) => {
126
252
  const t0 = Date.now()
127
- const child = spawn(process.execPath, [join(HERE, job.file), ...job.args], { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
253
+ const argv = job.eval ? ['-e', job.eval] : [join(HERE, job.file), ...job.args]
254
+ const limitMs = jobLimit(job)
255
+ const child = spawn(process.execPath, argv, { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
256
+ let timedOut = false
257
+ const killer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL') } catch (e) { /* already gone */ } }, limitMs)
258
+ const clearKiller = () => clearTimeout(killer)
128
259
  let out = '', err = ''
129
260
  child.stdout.on('data', (d) => { out += d.toString() })
130
261
  child.stderr.on('data', (d) => { err += d.toString() })
131
- child.on('error', (e) => resolve({ job, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e) }))
132
- child.on('close', (code) => resolve({ job, code, ms: Date.now() - t0, out, err }))
262
+ child.on('error', (e) => { clearKiller(); resolve({ job, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e), timedOut }) })
263
+ child.on('close', (code) => { clearKiller(); resolve({ job, code, ms: Date.now() - t0, out, err, timedOut }) })
133
264
  })
134
265
  }
135
266
 
@@ -154,13 +285,11 @@ async function worker(id) {
154
285
  (tail ? ' ' + tail.slice(0, 60) : '')
155
286
  )
156
287
  if (!r.ok) {
157
- const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
158
- for (const l of lines.slice(-15)) console.log(' ' + l)
288
+ for (const l of failureDetail(r)) console.log(' ' + l)
159
289
  }
160
290
  } else if (!r.ok) {
161
291
  // In --json mode keep stdout machine-readable: the failure detail travels in the JSON.
162
- const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
163
- r.tailDetail = lines.slice(-15).join('\n')
292
+ r.tailDetail = failureDetail(r).join('\n')
164
293
  }
165
294
  }
166
295
  }
@@ -193,7 +322,8 @@ if (asJson) {
193
322
  console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length
194
323
  + ' (suites ' + suiteCount + ' · probes ' + (results.length - suiteCount) + ')')
195
324
  for (const b of bad) {
196
- console.log(' FAILED: ' + label(b.job) + ' (exit ' + b.code + (b.job.expectExit ? ', required exit ' + b.job.expectExit : '') + ')')
325
+ console.log(failedLine(b))
326
+ for (const l of failureDetail(b)) console.log(' ' + l)
197
327
  }
198
328
  for (const [f, why] of Object.entries(NEEDS_ARGS)) {
199
329
  console.log(' SKIPPED (needs CLI args): ' + f + ' — ' + why + (presentSet.has(f) ? '' : ' [not present in this checkout]'))
@@ -0,0 +1,70 @@
1
+ // In-repo mutant harness for the runner-diagnostics guard (ships: `tests/*.mutants.mjs`).
2
+ // It proves the `--self-check` guard can REDDEN: strip the named-failure detail from a COPY of
3
+ // run-tests.mjs, then run that copy's own `--self-check` and require it to fail.
4
+ // Usage: node tests/run-tests.mutants.mjs
5
+ import { mkdtempSync, copyFileSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const HERE = fileURLToPath(new URL('./', import.meta.url))
12
+ const REPO = join(HERE, '..')
13
+ const RUNNER = join(HERE, 'run-tests.mjs')
14
+
15
+ let passed = 0, failed = 0
16
+ const ok = (cond, label, detail) => {
17
+ if (cond) { passed++; console.log(' ok ' + label); return true }
18
+ failed++; console.log(' FAIL ' + label + (detail ? ' — ' + detail : '')); return false
19
+ }
20
+
21
+ // baseline: the real runner's self-check must pass
22
+ const base = spawnSync(process.execPath, [RUNNER, '--self-check'], { cwd: REPO, encoding: 'utf8' })
23
+ ok(base.status === 0 && /SELF-CHECK PASS/.test(base.stdout || ''),
24
+ 'baseline: the runner self-check passes (a failing child yields a named assertion)',
25
+ JSON.stringify(String(base.stdout || '').trim().slice(0, 80)))
26
+
27
+ // mutant: strip the named-failure extraction (fall back to a constant) -> the self-check must REDDEN
28
+ const dir = mkdtempSync(join(tmpdir(), 'runner-mut-'))
29
+ // Normalise CRLF so the anchor is line-ending agnostic (the runner is checked out with CRLF on Windows).
30
+ const src = readFileSync(RUNNER, 'utf8').replace(/\r\n/g, '\n')
31
+ const from = " const chosen = (named.length ? named : lines).slice(-40)\n return chosen.length ? chosen : ['(no captured output)']"
32
+ if (src.indexOf(from) === -1) {
33
+ ok(false, 'mutant anchor applies (the named-failure extraction is present)', 'ANCHOR MISS')
34
+ } else {
35
+ writeFileSync(join(dir, 'run-tests.mjs'), src.replace(from, " return ['(no captured output)']"))
36
+ const mut = spawnSync(process.execPath, [join(dir, 'run-tests.mjs'), '--self-check'], { cwd: REPO, encoding: 'utf8' })
37
+ ok(mut.status !== 0 && /SELF-CHECK FAIL/.test(String(mut.stdout || '')),
38
+ '★ mutant (named-failure detail stripped) makes the self-check RED',
39
+ 'exit=' + mut.status + ' out=' + JSON.stringify(String(mut.stdout || '').trim().slice(0, 80)))
40
+ }
41
+ rmSync(dir, { recursive: true, force: true })
42
+ // TIMEOUT path, BOTH directions (a criterion must be validated against a known-positive AND a
43
+ // known-negative before it gates anything): the same real suite under a 1 s limit must be killed
44
+ // and NAMED in the failure list; under the default limit it must not be reported as a timeout.
45
+ const slowRun = spawnSync(process.execPath, [RUNNER, '--only', 'audit-prompt-invariants'], { cwd: REPO, encoding: 'utf8', env: Object.assign({}, process.env, { GATE_SUITE_TIMEOUT_MS: '1000' }) })
46
+ const slowOut = String(slowRun.stdout || '') + String(slowRun.stderr || '')
47
+ const failList = slowOut.split('\n').filter((l) => /^\s*FAILED:/.test(l))
48
+ const timedOutLine = failList.find((l) => /\[probe\] \(TIMEOUT after 1s\)/.test(l)) || ''
49
+ ok(slowRun.status !== 0 && !!timedOutLine,
50
+ '★ POSITIVE: a 1 s limit makes the runner kill the suite and NAME the timeout in the failure list',
51
+ 'exit=' + slowRun.status + ' line=' + timedOutLine.trim())
52
+ // NEGATIVE (two readings, so the criterion cannot be always-red):
53
+ // (a) ANY suite under the DEFAULT limit produces no TIMEOUT marker, whatever its own verdict;
54
+ // (b) a known-green fast suite exits 0 (no false timeout).
55
+ // (a) is deliberately verdict-agnostic: at the time of writing audit-prompt-invariants is red for an
56
+ // unrelated in-flight reason (v2/v3 I13/I14: the parameter schema parses to 0 keys), and a proof tool
57
+ // must not inherit someone else's red.
58
+ const fastRun = spawnSync(process.execPath, [RUNNER, '--only', 'audit-prompt-invariants'], { cwd: REPO, encoding: 'utf8' })
59
+ const fastOut = String(fastRun.stdout || '') + String(fastRun.stderr || '')
60
+ ok(!/TIMEOUT after/.test(fastOut),
61
+ 'NEGATIVE (a): with the default limit no TIMEOUT marker is produced (the criterion is not always-red)',
62
+ 'exit=' + fastRun.status + ' (its own verdict is another owner\'s concern)')
63
+ const greenRun = spawnSync(process.execPath, [RUNNER, '--only', 'audit-package-membership'], { cwd: REPO, encoding: 'utf8' })
64
+ const greenOut = String(greenRun.stdout || '') + String(greenRun.stderr || '')
65
+ ok(greenRun.status === 0 && !/TIMEOUT after/.test(greenOut),
66
+ 'NEGATIVE (b): a known-green suite exits 0 under the default limit (no false timeout)',
67
+ 'exit=' + greenRun.status)
68
+ console.log('')
69
+ console.log('=== RUNNER DIAGNOSTICS MUTANTS: ' + passed + ' passed, ' + failed + ' failed ===')
70
+ process.exit(failed === 0 ? 0 : 1)
@@ -257,7 +257,21 @@ async function drainVerifyRound(n) {
257
257
  console.log('-- V5 self-drive --')
258
258
 
259
259
  // ---------- founding ----------
260
- const started = await callTool('vibe_v5_start', { problem: '证明:不存在整数解 x^n + y^n = z^n(n>2)的初等情形', researcherCount: 3 })
260
+ // F6 tightening (staff): the persona parameter must actually REACH the member prompts. MEASURED: a
261
+ // single-site mutant that drops `params.staffPersona` (`const extra = ''`) is caught by NO current
262
+ // suite (audit-participant-set-parity ok, audit-persona-sensitivity ok, audit-persona-surface 276/0).
263
+ await callTool('vibe_v5_set', { staffPersona: 'STAFF-MARKER-X' })
264
+ // V5-A5 (path contract, docs-vs-code class): `memberPathContractOk()` (vibe-math-v5.js:993) compares
265
+ // the DOCUMENTED member root with the root the framework actually writes; a divergence logs a NAMED
266
+ // error and folds into noteLoadProblem. MEASURED: no suite referenced it before this assertion.
267
+ const pathErrs = []
268
+ const realErrA5 = console.error
269
+ console.error = (...a) => { const line = a.map(String).join(' '); if (/member library path contract BROKEN/.test(line)) pathErrs.push(line); realErrA5(...a) }
270
+ let started
271
+ try {
272
+ started = await callTool('vibe_v5_start', { problem: '证明:不存在整数解 x^n + y^n = z^n(n>2)的初等情形', researcherCount: 3 })
273
+ } finally { console.error = realErrA5 }
274
+ assert(pathErrs.length === 0, '* V5-A5 the documented member root equals the root the framework writes (no path-contract divergence; got ' + JSON.stringify(pathErrs.slice(0, 1)) + ')')
261
275
  assert(started.ok === true, 'vibe_v5_start ok (' + JSON.stringify(started).slice(0, 150) + ')')
262
276
  assert(spawns.length === 4, 'founded 1 academician + 3 researchers (got ' + spawns.length + ')')
263
277
  assert(!!spawnOf('acad'), 'academician acad exists')
@@ -332,6 +346,27 @@ assert(!!sv.verify, 'verification of ' + target + ' is in flight right after pro
332
346
  JSON.stringify({ verify: sv.verify, queue: sv.verifyQueue, undecided: sv.undecided, running: sv.running, autoDone: sv.autoDone, phase: sv.phase, meeting: sv.meeting, parked: sv.parkedMeeting, tasks: sv.tasks.length }))
333
347
  if (sv.verify) {
334
348
  assert(sv.verify.m === 3 && sv.verify.P === 4, 'verification reports m=3 over P=4 voters')
349
+ // V5-A1: P=4 is a COUNT - assert the semantic unit too. The voter set is the roster identity that
350
+ // quorum is computed over, so a duplicate id (P unchanged) must not pass. Measured shape:
351
+ // status.verify.voters is the id array (product: `voters: E` in the verdict payload).
352
+ // V5-A1: the SHAPE was measured first - status.verify carries {target,kind,stage,round,voted,m,P} but
353
+ // NO voter list (vibe-math-v5.js:7316); the voter IDS live in `status.quorum` (quorumView(), :7277/:1262).
354
+ const voterIds = (sv.quorum && Array.isArray(sv.quorum.voters)) ? sv.quorum.voters.map(String).sort() : null
355
+ assert(!!voterIds && voterIds.join(',') === ['acad', 'r-1', 'r-2', 'r-3'].sort().join(','), '* V5-A1 the voter id SET is exactly {acad, r-1, r-2, r-3} (P=4 keeps the count; got ' + JSON.stringify(voterIds) + ')')
356
+ // V5-A2 (roster): the published participant SET and its own COUNT must agree, and the ids must be
357
+ // distinct. MEASURED: a mutant that publishes `vs.slice(1)` as `voters` while keeping `voterCount`
358
+ // is caught by NO current suite (the parity audit checks field PRESENCE, not agreement).
359
+ const qv = sv.quorum || {}
360
+ const qvoters = Array.isArray(qv.voters) ? qv.voters.map(String) : null
361
+ assert(!!qvoters && qvoters.length === Number(qv.voterCount), '* V5-A2 the published voter SET agrees with its own count (voters=' + (qvoters ? qvoters.length : 'none') + ' voterCount=' + qv.voterCount + ')')
362
+ assert(!!qvoters && new Set(qvoters).size === qvoters.length, '* V5-A2 and the published voter ids are DISTINCT (got ' + JSON.stringify(qvoters) + ')')
363
+ // V5-A3 (staff): the staffPersona set before founding must appear in the member PERSONA/prompts.
364
+ // NOTE (measured): the existing `every member got the full charter` length check stays GREEN under
365
+ // the mutant, so the marker search is the only discriminator.
366
+ const marker = 'STAFF-MARKER-X'
367
+ const sawStaff = spawns.some((sp) => String(sp.persona || '').indexOf(marker) !== -1)
368
+ || wakes.some((w) => (((w.blocks && w.blocks[0] && w.blocks[0].text) || '')).indexOf(marker) !== -1)
369
+ assert(sawStaff, '* V5-A3 the staff persona reaches the member persona/prompts (marker=' + marker + '; spawns=' + spawns.length + ', wakes=' + wakes.length + ')')
335
370
 
336
371
  // Round: only ONE boolean vote (< m) must NOT verify.
337
372
  plannedVotes = new Map([['acad', 0.9], ['r-1', 1], ['r-2', 0.5], ['r-3', 0.7]])
@@ -396,6 +431,23 @@ const t1 = await callTool('vibe_v5_task_create', { subject: '整理已知特例'
396
431
  assert(t1.ok === true && t1.task.revision === 1, 'a member can open a task (revision 1)')
397
432
  const stale = await callTool('vibe_v5_task_update', { task_id: t1.task.id, expected_revision: 99, action: 'claim' }, childAgent(childOf('r-3')))
398
433
  assert(stale.ok === false && stale.code === 'V5_TASK_STALE_REVISION', 'a stale revision is refused (compare-and-set)')
434
+ // V5-A4 (CAS semantic unit) - R17 scope refinement: the assertion detects any refused-but-written
435
+ // change VISIBLE IN THE OBSERVABLE TRIPLE (revision|ownerId|status). Measured by R17: a
436
+ // `task.status='done'` write inside the refusal branch => V5-A4 RED (`before=1||pending after=1||done`);
437
+ // a write OUTSIDE the triple (e.g. `task.notes`) => V5-A4 green and remains a MEASURED BOUNDARY (the
438
+ // harness cannot see it). The earlier, broader phrasing ("cannot be the sole discriminator") is
439
+ // superseded by this scope statement.
440
+ // The assertions above (here,
441
+ // in e2e-v5-round2 and in prompt-v5-integrity) only check the RETURN VALUE, so a mutant that refuses
442
+ // but still writes - or writes first and checks afterwards - would pass all of them. Read the task
443
+ // back and compare the observable triple.
444
+ const t1AfterStale = await callTool('vibe_v5_task_get', { task_id: t1.task.id }, childAgent(childOf('r-3')))
445
+ const taskOf = (r) => (r && r.task) ? r.task : (r && r.id ? r : null)
446
+ const t1Before = taskOf(t1) || {}
447
+ const t1Now = taskOf(t1AfterStale) || {}
448
+ const tupleOf = (t) => [t.revision, t.ownerId || '', t.status || ''].join('|')
449
+ assert(!!t1Now.revision, '* V5-A4 the task is readable back after the refusal (got ' + JSON.stringify(t1AfterStale).slice(0, 120) + ')')
450
+ assert(tupleOf(t1Now) === tupleOf(t1Before), '* V5-A4 a REFUSED stale CAS leaves the task UNCHANGED (before=' + tupleOf(t1Before) + ' after=' + tupleOf(t1Now) + ')')
399
451
  const claimed = await callTool('vibe_v5_task_update', { task_id: t1.task.id, expected_revision: 1, action: 'claim' }, childAgent(childOf('r-3')))
400
452
  assert(claimed.ok === true && claimed.task.ownerId === 'r-3', 'claiming sets the owner')
401
453
  const blocked = await callTool('vibe_v5_task_create', { subject: '依赖前一个', description: 'd', blocked_by: [t1.task.id] }, childAgent(childOf('r-3')))
@@ -0,0 +1,132 @@
1
+ // Registered mutant harness for the v5 self-drive suite — the suite carries its own proof.
2
+ // Mirrors tests/formal-verify-v4.mutants.mjs / formal-verify-v3.mutants.mjs: a SINGLE content-anchored
3
+ // mutation of the v5 preset (no line numbers), run through the V5_PLUGIN seam, bounded child runs (a
4
+ // timeout is a HANG, never a pass), skipped families printed and counted, and the family's TOTAL WALL
5
+ // TIME reported (per-family numbers alone can hide a timeout).
6
+ // Run: node tests/selfdrive-v5.mutants.mjs
7
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, readdirSync } from 'node:fs'
8
+ import { execFileSync } from 'node:child_process'
9
+ import { dirname, join } from 'node:path'
10
+ import { tmpdir } from 'node:os'
11
+ import { fileURLToPath } from 'node:url'
12
+ const HERE = dirname(fileURLToPath(import.meta.url))
13
+ const REPO = join(HERE, '..')
14
+ const PRESET = 'vibe-math-v5'
15
+ const MAIN = PRESET + '.js'
16
+ const SUITE = 'tests/selfdrive-v5.mjs'
17
+ const ENV = 'V5_PLUGIN'
18
+ // Measured: a selfdrive-v5 child run is ~2 s; MUTANT_CHILD_TIMEOUT_MS overrides it.
19
+ const CHILD_TIMEOUT_MS = Number(process.env.MUTANT_CHILD_TIMEOUT_MS || 300000)
20
+ const TIMES = []
21
+ const hangs = []
22
+ const skipped = []
23
+ function copyGraph(file, dest) {
24
+ const src = join(REPO, PRESET, file)
25
+ copyFileSync(src, join(dest, file))
26
+ for (const m of readFileSync(src, 'utf8').matchAll(/from\s+'(\.\/[A-Za-z0-9_.-]+\.js)'/g)) {
27
+ const dep = m[1].slice(2)
28
+ if (!readdirSync(dest).includes(dep)) copyGraph(dep, dest)
29
+ }
30
+ }
31
+ function runFamily(f) {
32
+ const dest = join(tmpdir(), 'v5sd-mut-' + Math.random().toString(36).slice(2, 10))
33
+ mkdirSync(dest, { recursive: true })
34
+ copyGraph(MAIN, dest)
35
+ const target = join(dest, f.editFile || MAIN)
36
+ const before = readFileSync(target, 'utf8')
37
+ const n = before.split(f.from).length - 1
38
+ const okCount = f.lastOnly || f.all ? n >= 1 : n === 1
39
+ if (!okCount) {
40
+ skipped.push(f.name + ' (anchor x' + n + ')')
41
+ console.error(' SKIP - ' + f.name + ' (anchor x' + n + ')')
42
+ rmSync(dest, { recursive: true, force: true })
43
+ return false
44
+ }
45
+ writeFileSync(target, before.split(f.from).join(f.to), 'utf8')
46
+ try { execFileSync(process.execPath, ['--check', target], { stdio: 'pipe' }) }
47
+ catch (e) {
48
+ writeFileSync(target, before, 'utf8')
49
+ skipped.push(f.name + ' (mutation did not compile)')
50
+ console.error(' SKIP - ' + f.name + ' (mutation did not compile; restored)')
51
+ rmSync(dest, { recursive: true, force: true })
52
+ return false
53
+ }
54
+ const t0 = Date.now()
55
+ let out = ''
56
+ let code = 0
57
+ let hang = false
58
+ try {
59
+ out = execFileSync(process.execPath, [SUITE], {
60
+ cwd: REPO, encoding: 'utf8', timeout: CHILD_TIMEOUT_MS, killSignal: 'SIGKILL',
61
+ env: Object.assign({}, process.env, { [ENV]: join(dest, MAIN) }),
62
+ })
63
+ } catch (e) {
64
+ if (e && (e.killed || e.signal === 'SIGKILL')) hang = true
65
+ code = (e && e.status) || 1
66
+ out = String((e && e.stdout) || '') + String((e && e.stderr) || '')
67
+ }
68
+ const ms = Date.now() - t0
69
+ TIMES.push([f.name, ms])
70
+ if (hang) hangs.push(f.name + '(' + Math.round(ms / 1000) + 's)')
71
+ const named = out.split(/\r?\n/).filter((l) => /^\s*(FAIL - | {2}- )/.test(l) && f.expect.test(l)).map((l) => l.trim())
72
+ const ok = !hang && code !== 0 && named.length > 0
73
+ console.log((hang ? ' HANG - ' : (ok ? ' ok - ' : ' FAIL - ')) + f.name + ' [' + ms + 'ms]' + (named.length ? ' :: ' + named[0].slice(0, 150) : ' :: no named red (exit=' + code + ')'))
74
+ rmSync(dest, { recursive: true, force: true })
75
+ return ok
76
+ }
77
+ const FAMILIES = [
78
+ {
79
+ // V5-A1: the LAST voter is replaced by a DUPLICATE of the first, so P stays 4 (the count assertion
80
+ // passes) while the voter id SET loses a member - the roster identity, not the head-count.
81
+ name: 'V5-A1: the last voter is a duplicate of the first (P still 4)',
82
+ from: "const voters = () => activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher')",
83
+ to: "const voters = () => { const v = activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher'); return v.length > 1 ? v.slice(0, -1).concat([v[0]]) : v }",
84
+ expect: /V5-A1 the voter id SET is exactly/,
85
+ },
86
+ {
87
+ // F6 tightening (roster): the published voter set drops one member while its COUNT stays put.
88
+ name: 'V5-A2: the published voter set disagrees with its own count',
89
+ from: 'voters: vs.map((m) => m.id),',
90
+ to: 'voters: vs.slice(1).map((m) => m.id),',
91
+ expect: /V5-A2 the published voter SET agrees with its own count/,
92
+ },
93
+ {
94
+ // F6 tightening (staff): the member persona ignores `params.staffPersona` again.
95
+ name: 'V5-A3: the staff persona never reaches the prompts',
96
+ from: "const extra = String(params.staffPersona || '').trim()",
97
+ to: "const extra = ''",
98
+ expect: /V5-A3 the staff persona reaches the member persona\/prompts/,
99
+ },
100
+ {
101
+ // ④ CAS: the stale-revision guard is disabled, so a stale update lands (the return-value
102
+ // assertions AND the new "unchanged after refusal" assertion redden).
103
+ // R17 scope refinement for V5-A4: it detects any refused-but-written change VISIBLE IN THE
104
+ // OBSERVABLE TRIPLE (revision|ownerId|status) - measured: an in-branch `task.status='done'`
105
+ // makes it RED (`before=1||pending after=1||done`), while a write OUTSIDE the triple (e.g.
106
+ // `task.notes`) keeps it green and remains a MEASURED BOUNDARY (invisible to the harness).
107
+ // assertions AND the new "unchanged after refusal" assertion redden).
108
+ name: 'V5-A4: the stale-revision guard is disabled (a stale CAS lands)',
109
+ from: 'if (expected !== task.revision) {',
110
+ to: 'if (false) {',
111
+ expect: /V5-A4 a REFUSED stale CAS leaves the task UNCHANGED|a stale revision is refused/,
112
+ },
113
+ {
114
+ // ⑤ path contract: ONLY the documented composition moves (instRootRel), while the framework
115
+ // keeps writing through instRoot() - exactly the v4 58fff7f defect class this guard exists for.
116
+ name: 'V5-A5: the documented member root diverges from the written root',
117
+ from: "const instRootRel = () => 'VibeMath/Projects/' + project + '/Institutes/' + instituteName",
118
+ to: "const instRootRel = () => 'VibeMath/Projects/' + project + '/Institutes/' + instituteName + 'X'",
119
+ expect: /V5-A5 the documented member root equals the root the framework writes/
120
+ },
121
+ ]
122
+ let red = 0
123
+ for (const f of FAMILIES) { const ok = runFamily(f); if (ok) red++ }
124
+ const totalMs = TIMES.reduce((a, t) => a + t[1], 0)
125
+ console.log('')
126
+ console.log('mutant families reddening the v5 self-drive suite by name: ' + red + '/' + FAMILIES.length)
127
+ console.log('timings (per family): ' + TIMES.map((t) => String(t[0]).split(':')[0] + '=' + t[1] + 'ms').join(' '))
128
+ console.log('TOTAL WALL TIME (all families + setup): ' + totalMs + 'ms (' + Math.round(totalMs / 1000) + 's)')
129
+ console.log('hangs=[' + hangs.join(' | ') + ']')
130
+ console.log('skipped=[' + skipped.join(' | ') + ']')
131
+ if (red !== FAMILIES.length || skipped.length || hangs.length) process.exit(1)
132
+ console.log('ALL MUTANTS RED AS REQUIRED')
@@ -0,0 +1,100 @@
1
+ // Registered mutant harness for tests/v2-fix-probes.test.mjs — the probe carries its own proof.
2
+ // Defect classes: surface-shape contract (F1), declared asymmetry (F-A), path-base mixing (F6),
3
+ // silent-failure surfacing (F6a/F6b/F6c). Single content-anchored mutations; V2_PLUGIN seam.
4
+ // Exit criterion: every family reddens BY NAME; any skipped family is printed and counted (never a
5
+ // silent NO-OP impersonating a pass); ALL MUTANTS RED AS REQUIRED + exit 0 only then.
6
+ // Run: node tests/v2-fix-probes.mutants.mjs
7
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, readdirSync } from 'node:fs'
8
+ import { execFileSync } from 'node:child_process'
9
+ import { dirname, join } from 'node:path'
10
+ import { tmpdir } from 'node:os'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ // Bound every child run: run-tests has no per-suite timeout, so an unbounded child hangs the gate.
14
+ // A timeout is reported as HANG, counts as a failure for that family, and is listed in hangs=[].
15
+ const CHILD_TIMEOUT_MS = Number(process.env.MUTANT_CHILD_TIMEOUT_MS || 120000)
16
+ const TIMES = []
17
+ // R15: whole-process wall time, printed at the end (the timeout row quotes this line).
18
+ const START_MS = Date.now()
19
+ const hangs = []
20
+ let lastHang = false
21
+ const HERE = dirname(fileURLToPath(import.meta.url))
22
+ const REPO = join(HERE, '..')
23
+ const PRESET = 'vibe-math-v2'
24
+ const MAIN = PRESET + '.js'
25
+ const SUITE = 'tests/v2-fix-probes.test.mjs'
26
+ const ENV = 'V2_PLUGIN'
27
+ function copyGraph(file, dest) {
28
+ const src = join(REPO, PRESET, file)
29
+ copyFileSync(src, join(dest, file))
30
+ for (const m of readFileSync(src, 'utf8').matchAll(/from\s+'(\.\/[A-Za-z0-9_.-]+\.js)'/g)) {
31
+ const dep = m[1].slice(2)
32
+ if (!readdirSync(dest).includes(dep)) copyGraph(dep, dest)
33
+ }
34
+ }
35
+ const skipped = []
36
+ function runFamily(f) {
37
+ const dest = join(tmpdir(), 'v2probes-mut-' + Math.random().toString(36).slice(2, 10))
38
+ mkdirSync(dest, { recursive: true })
39
+ copyGraph(MAIN, dest)
40
+ const target = join(dest, f.editFile || MAIN)
41
+ const before = readFileSync(target, 'utf8')
42
+ const n = before.split(f.from).length - 1
43
+ const okCount = f.lastOnly || f.firstOnly || f.all ? n >= 1 : n === 1
44
+ if (!okCount) { skipped.push(f.name + ' (anchor x' + n + ')'); console.error(' SKIP - ' + f.name + ' (anchor x' + n + ')'); rmSync(dest, { recursive: true, force: true }); return false }
45
+ let mutated
46
+ if (f.lastOnly) { const i = before.lastIndexOf(f.from); mutated = before.slice(0, i) + f.to + before.slice(i + f.from.length) }
47
+ else if (f.firstOnly) { const i = before.indexOf(f.from); mutated = before.slice(0, i) + f.to + before.slice(i + f.from.length) }
48
+ else if (f.all) mutated = before.split(f.from).join(f.to)
49
+ else mutated = before.split(f.from).join(f.to)
50
+ writeFileSync(target, mutated, 'utf8')
51
+ try { execFileSync(process.execPath, ['--check', target], { stdio: 'pipe' }) }
52
+ catch (e) { writeFileSync(target, before, 'utf8'); skipped.push(f.name + ' (mutation did not compile)'); console.error(' SKIP - ' + f.name + ' (mutation did not compile; restored)'); rmSync(dest, { recursive: true, force: true }); return false }
53
+ let out = ''
54
+ let code = 0
55
+ try { out = execFileSync(process.execPath, [SUITE], { cwd: REPO, encoding: 'utf8', timeout: CHILD_TIMEOUT_MS, killSignal: 'SIGKILL', env: Object.assign({}, process.env, { [ENV]: join(dest, MAIN) }) }) }
56
+ catch (e) { if (e && (e.killed || e.signal === 'SIGKILL')) lastHang = true; code = (e && e.status) || 1; out = String((e && e.stdout) || '') + String((e && e.stderr) || '') }
57
+ const named = out.split(/\r?\n/).filter((l) => /^\s*(FAIL - | {2}- )/.test(l) && f.expect.test(l)).map((l) => l.trim())
58
+ const ok = code !== 0 && named.length > 0
59
+ console.log((ok ? ' ok - ' : ' FAIL - ') + f.name + (named.length ? ' :: ' + named[0].slice(0, 130) : ' :: no named red (exit=' + code + ')'))
60
+ rmSync(dest, { recursive: true, force: true })
61
+ return ok
62
+ }
63
+ const ANCHOR_LEN = "pendingDecisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).length,"
64
+ const FAMILIES = [
65
+ { name: 'F1: report pendingDecisions back to an array', expect: /\[F1\] pendingDecisions 两面同形/, from: ANCHOR_LEN, firstOnly: true,
66
+ to: "pendingDecisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } })," },
67
+ { name: 'F-A: status gains pendingDecisionItems', expect: /\[F-A\] status \*\*不得\*\*携带明细/, from: ANCHOR_LEN,
68
+ to: ANCHOR_LEN + "\n pendingDecisionItems: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } }),", lastOnly: true },
69
+ { name: 'F6: paths base dropped from both surfaces', expect: /\[F6\] status\.formal\.paths 带 base \+ note/,
70
+ from: "paths: { base: frameworkRoot() + '/', project: 'Formal/', proofs: 'Verified/Lean/', lib:",
71
+ to: "paths: { project: 'Formal/', proofs: 'Verified/Lean/', lib:", all: true },
72
+ { name: 'F6a: provider list-failure warning removed', expect: /\[F6a\]/,
73
+ from: "warnProviderFallback('subagents.list() failed: ' + ((e && e.message) || e))", to: 'void e' },
74
+ { name: 'F6b: interrupt emit statement deleted (anchored by content)', expect: /\[F6b\/v2\]/,
75
+ from: "if (r && r.ok === false) logActivity('interrupt', '中断失败(' + why + '):' + String(cid) + ' — ' + String(r.message || r.code || ''))", to: 'void r' },
76
+ { name: 'F6c: project-pointer write failure re-silenced', expect: /\[F6c\]/,
77
+ from: "noteStateWriteFailure('current.' + safeId(sessionId) + '.json', (e && e.message) || e)", to: 'void e' },
78
+ ]
79
+ let red = 0
80
+ FAMILIES.push(
81
+ { name: 'F5/v2: fence-root comparison removed', expect: /\[F5\/v2\] 围栏根漂移/,
82
+ from: 'if (actual && expected && actual !== expected) warnFenceDriftOnce(actual, expected)', to: 'void actual' },
83
+ )
84
+ FAMILIES.push(
85
+ { name: 'F-B: derivation drops the source descriptions', expect: /\[F-B\/v2\] 每个机读描述都来自真源且非空/,
86
+ from: 'if (e.description) p.description = e.description', to: 'void e /* MUTANT F-B: no source description */' },
87
+ { name: 'F-B: one exception entry deleted', expect: /\[F-B\/v2\] 例外集合 == docs\/parameter-schema\.md/,
88
+ from: "solverToolAllow: { type: 'array', items: { type: 'string' } },", to: '' },
89
+ { name: 'F-B: one registration site reverted to a literal', expect: /\[F-B\/v2\] 两处注册点都由 paramProps\(\)/,
90
+ from: "objParams(paramProps()), 'vibe_math_set_params')", to: "objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }), 'vibe_math_set_params')" },
91
+ )
92
+ for (const f of FAMILIES) { const t0 = Date.now(); lastHang = false; const ok = runFamily(f); const ms = Date.now() - t0; TIMES.push([f.name, ms]); if (lastHang) hangs.push(f.name + "(" + Math.round(ms / 1000) + "s)"); if (ok) red++ }
93
+ console.log('')
94
+ console.log('mutant families reddening the v2 probe by name: ' + red + '/' + FAMILIES.length)
95
+ console.log('skipped=[' + skipped.join(' | ') + ']')
96
+ console.log('timings: ' + TIMES.map((t) => String(t[0]).split(':')[0] + '=' + t[1] + 'ms').join(' '))
97
+ console.log('TOTAL WALL TIME (all families + setup): ' + (Date.now() - START_MS) + 'ms (' + Math.round((Date.now() - START_MS) / 1000) + 's)')
98
+ console.log('hangs=[' + hangs.join(' | ') + ']')
99
+ if (red !== FAMILIES.length || skipped.length || hangs.length) process.exit(1)
100
+ console.log('ALL MUTANTS RED AS REQUIRED')