dsh-vibe-math 2.3.1 → 2.3.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.
Files changed (38) hide show
  1. package/AUDIT-CHECKLIST.md +45 -0
  2. package/README.md +16 -6
  3. package/RELEASE-NOTES-2.3.2.md +145 -0
  4. package/audit-formal-sensitivity.mjs +11 -2
  5. package/audit-prompt-invariants.mjs +414 -0
  6. package/audit-spec-traceability.mjs +173 -0
  7. package/docs/formal-verification.md +33 -10
  8. package/docs/generate_framework_diagram_v5.mjs +2 -1
  9. package/docs/test-timing.md +32 -10
  10. package/formal-verify-v2.test.mjs +242 -2
  11. package/formal-verify-v3.test.mjs +176 -4
  12. package/formal-verify-v4.test.mjs +184 -5
  13. package/formal-verify-v5.test.mjs +91 -4
  14. package/installer.js +3 -1
  15. package/package.json +5 -2
  16. package/prompt-corpus-persona/persona-corpus.json +2 -2
  17. package/prompt-corpus-persona/persona-corpus.md +6 -2
  18. package/prompt-corpus-v2/formal-verify-v2.json +134 -44
  19. package/prompt-corpus-v2/formal-verify-v2.md +1033 -44
  20. package/prompt-corpus-v3/formal-verify-v3.json +200 -128
  21. package/prompt-corpus-v3/formal-verify-v3.md +948 -243
  22. package/prompt-corpus-v4/formal-verify-v4.json +8 -3
  23. package/prompt-corpus-v4/formal-verify-v4.md +38 -10
  24. package/prompt-corpus-v5/prompt-corpus-v5.json +175 -246
  25. package/prompt-corpus-v5/prompt-corpus-v5.md +341 -781
  26. package/prompt-v5-integrity.test.mjs +136 -22
  27. package/run-tests.mjs +30 -11
  28. package/vibe-math-v2/vibe-math-v2.js +149 -35
  29. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +53 -5
  30. package/vibe-math-v3/vibe-math-v3.js +88 -23
  31. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +7 -6
  32. package/vibe-math-v4/vibe-math-v4.js +103 -24
  33. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +34 -11
  34. package/vibe-math-v5/agent.cordis.yml +6 -2
  35. package/vibe-math-v5/vibe-math-v5.js +56 -10
  36. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +40 -13
  37. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +16 -2
  38. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +6 -5
@@ -46,9 +46,40 @@ const failures = []
46
46
  const assert = (c, m) => {
47
47
  if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) }
48
48
  }
49
- const sleep = ms => new Promise(r => setTimeout(r, ms))
50
49
  const section = (t) => console.log('\n[' + t + ']')
51
50
 
51
+ // ---------------------------------------------------------------
52
+ // VIRTUAL CLOCK
53
+ // The institute is driven by `ctx.timeout` + `Date.now()`. On the real clock, WHICH member a timer
54
+ // picks (heartbeat / meeting / digest) depends on load and on millisecond deltas, so the shipped
55
+ // corpus recorded different members on different runs and changed on every run — which makes it
56
+ // undiffable and hides real drift (AUDIT-CHECKLIST §2.4). A virtual clock fires every timer in a
57
+ // fixed order regardless of machine speed, and makes the suite faster because `sleep(n)` advances
58
+ // virtual time instead of waiting n real milliseconds.
59
+ let vnow = 1700000000000
60
+ let vtimerSeq = 0
61
+ const vtimers = new Map()
62
+ Date.now = () => vnow
63
+ function advanceClock(ms) {
64
+ const target = vnow + Math.max(0, Math.round(Number(ms) || 0))
65
+ for (;;) {
66
+ let next = null
67
+ for (const t of vtimers.values()) {
68
+ if (t.at > target) continue
69
+ if (!next || t.at < next.at || (t.at === next.at && t.seq < next.seq)) next = t
70
+ }
71
+ if (!next) break
72
+ vtimers.delete(next.id)
73
+ vnow = Math.max(vnow, next.at)
74
+ try { next.cb() } catch (e) { /* a throwing timer must not stop the clock */ }
75
+ }
76
+ vnow = target
77
+ }
78
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms))
79
+ // Advance the virtual clock, then give the real event loop a few turns so awaited work (fs writes,
80
+ // handler chains) can finish before the next assertion reads the state.
81
+ const sleep = async (ms) => { advanceClock(ms); for (let i = 0; i < 3; i++) await realSleep(1) }
82
+
52
83
  // ---------------------------------------------------------------
53
84
  // mock host
54
85
  // ---------------------------------------------------------------
@@ -161,7 +192,13 @@ const ctx = {
161
192
  on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
162
193
  effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
163
194
  logger: { info() {}, warn() {}, error() {} },
164
- timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
195
+ // Virtualised: the plugin's timers fire when the suite advances the clock (see the virtual-clock
196
+ // block above), which is what makes timer-driven prompt selection reproducible.
197
+ timeout(cb, ms) {
198
+ const id = ++vtimerSeq
199
+ vtimers.set(id, { id, seq: id, at: vnow + Math.max(0, Math.round(Number(ms) || 0)), cb })
200
+ return () => { vtimers.delete(id) }
201
+ },
165
202
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
166
203
  commands: { register() { return () => {} } },
167
204
  sessions: { async flush() { return true } },
@@ -403,10 +440,19 @@ const scrub = (s) => {
403
440
  // Timestamps are part of the prompt a member reads, but not part of what a reviewer needs:
404
441
  // normalise them too. Otherwise the shipped corpus changes on EVERY run — its headings carry
405
442
  // `### YYYY-MM-DD hh:mm:ss|<member>` — and its diffs stop being meaningful.
406
- return t.replace(re, '<WS>').replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/g, '<TIME>')
443
+ // The VibeMath ROOT gets its own token (not `<WS>/VibeMath`), so v5's corpus can be diffed
444
+ // side by side with v2/v3/v4's, which render the same root as `<VIBEMATH>`.
445
+ return t.replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/g, '<TIME>')
446
+ .replace(/[A-Za-z]:\/[^\s"'`)),。;:]*?[\\/]VibeMath/g, '<VIBEMATH>')
447
+ .replace(/\/VibeMath/g, '<VIBEMATH>')
448
+ .replace(re, '<WS>')
407
449
  }
408
450
  // Every Lean tool mention in agent-facing text must be the REGISTERED name.
409
- const noBareLeanTool = (t) => !/(^|[^a-z_])lean_(run|archive|lib)/.test(String(t || ''))
451
+ // `Verified/Lean/` is a contract PATH, not a tool name, and on a case-insensitive reading it
452
+ // even contains the substring "lean_"; strip that exact path before the check (the same path
453
+ // is written by the plugin, so the prompt builders genuinely can emit it).
454
+ const noBareLeanTool = (t) => !/(^|[^a-z_])lean_(run|archive|lib)/.test(
455
+ String(t || '').replace(/Verified\/Lean\//g, ''))
410
456
  function record(kind, owner, prompt, persona, extra) {
411
457
  corpus.push({
412
458
  kind, owner,
@@ -496,20 +542,38 @@ assert(normalWakes.some(w => w.owner === 'r-2' && w.prompt.indexOf('【研究所
496
542
  'r-2 is woken with its inbox containing the DM framed from r-1')
497
543
  assert(normalWakes.filter(w => w.owner === 'r-2').every(w => w.prompt.indexOf('【研究所·私信 from r-2】') === -1),
498
544
  'r-2 never receives the DM framed as coming from itself')
499
- // (b) the heartbeat must produce a CHECKPOINT prompt
500
- delivered.length = 0
501
- await sleep(260); await settle(); await drainWakes(10, RB)
502
- let checkpointWakes = delivered.filter(w => /【心跳检查/.test(w.prompt))
503
- if (!checkpointWakes.length) { await sleep(260); await settle(); await drainWakes(10, RB); checkpointWakes = delivered.filter(w => /【心跳检查/.test(w.prompt)) }
504
- assert(checkpointWakes.length > 0, 'the heartbeat produced a checkpoint prompt (' + checkpointWakes.length + ')')
505
- for (const w of checkpointWakes) {
506
- recordAndCheck('checkpoint', w.owner, w.prompt)
545
+ // (b) the heartbeat must produce a CHECKPOINT prompt. Captured in its OWN single-member institute:
546
+ // the heartbeat wakes the most idle non-busy member, so with exactly one member the pick cannot
547
+ // depend on real `Date.now()` deltas under load — the earlier shared-root version recorded
548
+ // heartbeat prompts for different members on different runs, which made the SHIPPED corpus change
549
+ // between runs (AUDIT-CHECKLIST §2.4: corpora must be byte-diffable).
550
+ const RHB = makeRoot()
551
+ await callTool('vibe_v5_start', { problem: '心跳语料(单成员)', researcherCount: 1, academician: false }, RHB)
552
+ for (const sp of spawnsFor(RHB)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
553
+ await settleInstitute(RHB)
554
+ await callTool('vibe_v5_set', { activityTimeoutMs: 80, maxParallel: 6, chatDigestMax: 1 }, RHB)
555
+ let hbQueueIdx = -1
556
+ for (let i = 0; i < 15 && hbQueueIdx === -1; i++) {
557
+ await sleep(120); await settle()
558
+ // Inspect the QUEUE (never drains it): draining answers turns and changes who is idle next.
559
+ hbQueueIdx = wakes.findIndex(w => w.rootId === RHB.id && /【心跳检查/.test(w.prompt))
560
+ }
561
+ assert(hbQueueIdx !== -1, 'the heartbeat produced a checkpoint prompt')
562
+ if (hbQueueIdx !== -1) {
563
+ const hbWake = wakes.splice(hbQueueIdx, 1)[0]
564
+ const hbMember = memberOfChild(hbWake.childId)
565
+ assert(hbMember === 'r-1',
566
+ '★ the heartbeat picked the only member (r-1) — deterministic, not timing-dependent (got ' + hbMember + ')')
567
+ recordAndCheck('checkpoint', hbMember, hbWake.prompt)
507
568
  // The heartbeat body may be preceded by a delivered inbox or the core-rules recap
508
569
  // after a real compaction, so match anywhere rather than at offset 0.
509
- assert(w.prompt.indexOf('【心跳检查 —— ') !== -1, w.owner + "'s heartbeat prompt names its own office and id")
510
- assert(new RegExp('【心跳检查 —— (院士|常驻研究员|临时工) ' + w.owner + '】').test(w.prompt),
511
- w.owner + "'s heartbeat header carries its own kind and id")
570
+ assert(hbWake.prompt.indexOf('【心跳检查 —— ') !== -1, hbMember + "'s heartbeat prompt names its own office and id")
571
+ assert(new RegExp('【心跳检查 —— (院士|常驻研究员|临时工) ' + hbMember + '】').test(hbWake.prompt),
572
+ hbMember + "'s heartbeat header carries its own kind and id")
573
+ fireEnd(hbWake.childId, { progress: hbMember + ':继续推进。', solved: false, contextPct: 20 })
574
+ await settle()
512
575
  }
576
+ await endCase(RHB)
513
577
  await endCase(RB)
514
578
 
515
579
  // =============== CASE 3: interaction framing ====================================
@@ -970,6 +1034,7 @@ for (const w of vwB) recordAndCheck('lean-fidelity', memberOfChild(w.childId), w
970
1034
  assert(/不要投 0/.test(txt), '★ it forbids expressing a faithfulness defect as 0 (= 命题为假)')
971
1035
  assert(/decision:'defect'/.test(txt), 'it names the defect reply channel that withdraws the proof')
972
1036
  assert(!/偏离 → 0/.test(txt), '★ the old "any deviation → 0" instruction is gone')
1037
+ assert(/本档没有门禁:请务必给一个严格介于 0 与 1 之间的弃权值/.test(txt), '★ the encourage branch says there is NO gate and that the abstention is what blocks a conclusion')
973
1038
  assert(noBareLeanTool(txt), 'no abbreviated tool name appears in the fidelity prompt')
974
1039
  }
975
1040
  await drainWakes(10, RL2)
@@ -994,6 +1059,7 @@ for (const w of vwR) recordAndCheck('lean-require', memberOfChild(w.childId), w.
994
1059
  assert(/本模式要求/.test(txt) && /formal-required/.test(txt), 'it states the conclusion gate and its reason code')
995
1060
  assert(/vibe_v5_lean_archive/.test(txt) && /kind='blocked'/.test(txt), 'it names the full archive tool for the blocker route')
996
1061
  assert(/宿主无 Lean 工具链/.test(txt), 'it also says what to do when the host has no Lean toolchain')
1062
+ assert(/LEAN_NOT_FOUND/.test(txt) && /NO_SUBPROCESS/.test(txt), '★ it names BOTH no-toolchain codes (a service-less host must not look like an unknown failure)')
997
1063
  assert(noBareLeanTool(txt), 'no abbreviated tool name appears in the require-mode prompt')
998
1064
  }
999
1065
  await drainWakes(30, R_LEANREQ)
@@ -1027,6 +1093,45 @@ for (const w of delivered.filter(d => d.rootId === R_LEANDEF.id)) recordAndCheck
1027
1093
  }
1028
1094
  await endCase(R_LEANDEF)
1029
1095
 
1096
+ // (f) the three Lean tools' own agent-facing strings (hint / LEAN_NOT_FOUND message). A tool
1097
+ // `hint` is injected text by the contract's own wording ("工具自己返回的 hint 字段同样算注入文本"),
1098
+ // but it reached the corpus only through the prompts that happened to embed it. Capture it
1099
+ // directly so a reviewer can check the failure routes without reading the plugin source.
1100
+ const R_LEANHINT = makeRoot()
1101
+ await callTool('vibe_v5_start', { problem: 'Lean 工具提示语料测试', researcherCount: 2 }, R_LEANHINT)
1102
+ for (const sp of spawnsFor(R_LEANHINT)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1103
+ await settleInstitute(R_LEANHINT)
1104
+ await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage' }, R_LEANHINT)
1105
+ {
1106
+ const r1 = childAgent(childOf(R_LEANHINT, 'r-1'))
1107
+ // Labels are OURS (they name the call that produced the string); only the VALUES are
1108
+ // plugin text, so the bare-tool-name check runs on the values, never on the labels.
1109
+ const toolTexts = []
1110
+ await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-hint', content: 'theorem p_lean_hint : 1 + 1 = 2 := by decide\n' }, r1)
1111
+ const green = await callTool('vibe_v5_lean_run', { file: 'Formal/p-lean-hint.lean' }, r1)
1112
+ toolTexts.push(['vibe_v5_lean_run hint (green)', String(green.hint || '')])
1113
+ await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-hint-red', content: 'theorem p_lean_hint_red : 1 = 2 := by sorry\n' }, r1)
1114
+ const red = await callTool('vibe_v5_lean_run', { file: 'Formal/p-lean-hint-red.lean' }, r1)
1115
+ toolTexts.push(['vibe_v5_lean_run hint (red)', String(red.hint || '')])
1116
+ const missing = await callTool('vibe_v5_lean_run', { file: 'Formal/no-such-file.lean' }, r1)
1117
+ toolTexts.push(['vibe_v5_lean_run on a missing file', String(missing.code || '') + '|' + String(missing.message || '')])
1118
+ const noName = await callTool('vibe_v5_lean_archive', { kind: 'def', content: 'def x := 1\n' }, r1)
1119
+ toolTexts.push(['vibe_v5_lean_archive without a name', String(noName.code || '') + '|' + String(noName.message || '')])
1120
+ const noNote = await callTool('vibe_v5_lean_archive', { kind: 'blocked', target: 'p-lean-hint' }, r1)
1121
+ toolTexts.push(['vibe_v5_lean_archive blocked without a note', String(noNote.code || '') + '|' + String(noNote.message || '')])
1122
+ const lib = await callTool('vibe_v5_lean_lib', {}, r1)
1123
+ toolTexts.push(['vibe_v5_lean_lib hint', String(lib.hint || '')])
1124
+ const values = toolTexts.map(([, v]) => v).join('\n')
1125
+ assert(/vibe_v5_lean_archive/.test(values), 'the tool hints name the REGISTERED archive tool (an abbreviated lean_archive is not callable)')
1126
+ assert(noBareLeanTool(values), 'no tool hint or failure message uses an abbreviated lean_* name')
1127
+ assert(/V5_NOT_FOUND/.test(values) && /必须写明原因/.test(values), 'the failure routes are explicit (missing file / missing note)')
1128
+ // NOT via recordAndCheck: these strings are tool output, not a round prompt, so they carry
1129
+ // no [状态] block and the identity sweep does not apply to them.
1130
+ record('lean-tool-hint', 'r-1', toolTexts.map(([k, v]) => k + ': ' + v).join('\n'))
1131
+ }
1132
+ await drainWakes(10, R_LEANHINT)
1133
+ await endCase(R_LEANHINT)
1134
+
1030
1135
  // =============== PART: full-corpus sweep ========================================
1031
1136
  section('13 full-corpus sweep over every prompt ever sent')
1032
1137
  {
@@ -1044,7 +1149,8 @@ section('13 full-corpus sweep over every prompt ever sent')
1044
1149
  for (const need of ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
1045
1150
  'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
1046
1151
  'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure',
1047
- 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect']) {
1152
+ 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect',
1153
+ 'lean-tool-hint']) {
1048
1154
  assert(kinds.has(need), 'the corpus contains a ' + need + ' prompt')
1049
1155
  }
1050
1156
  assert(corpus.every(c => c.prompt && c.prompt.length > 200), 'no captured prompt is suspiciously short')
@@ -1081,7 +1187,7 @@ md.push('')
1081
1187
  md.push('由 `prompt-v5-integrity.test.mjs` 在每次运行时重写。这里保存的是**框架真正发给每个')
1082
1188
  md.push('成员的提示词原文**,用于人工复核提示词分配、成员代号与交互内容的正确性。')
1083
1189
  md.push('')
1084
- md.push('- 生成时刻的工作区路径被替换为 `<WS>`,因此内容是确定性的、可 diff 的。')
1190
+ md.push('- 生成时刻的工作区路径被替换为 `<WS>`,VibeMath 根被替换为 `<VIBEMATH>`(与 v2/v3/v4 的语料一致,可并排 diff),因此内容是确定性的、可 diff 的。')
1085
1191
  md.push('- `owner` 是这条提示词**实际发给的成员**;`kind` 是提示词类型。')
1086
1192
  md.push('- 人设(charter/persona)按成员只完整打印一次,其余条目只记录字符数。')
1087
1193
  md.push('- 这是提示词正确性的人工复核入口:任何“成员代号/职位/在册名单/交互署名”问题')
@@ -1091,8 +1197,16 @@ const seenPersona = new Set()
1091
1197
  const order = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
1092
1198
  'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
1093
1199
  'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure',
1094
- 'lean-work', 'lean-verify', 'lean-fidelity']
1095
- const sorted = corpus.slice().sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
1200
+ 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect', 'lean-tool-hint']
1201
+ // The sort must be TOTAL, not just by kind: entries of the same kind were emitted in whatever order
1202
+ // the asynchronous drain produced them (two meeting prompts, two members' normal rounds), so the
1203
+ // shipped corpus still changed between runs even after the clock was virtualised. Sorting by
1204
+ // (kind, owner, prompt) makes the file a pure function of the recorded SET.
1205
+ const sorted = corpus.slice().sort((a, b) =>
1206
+ (order.indexOf(a.kind) - order.indexOf(b.kind)) ||
1207
+ (a.owner < b.owner ? -1 : a.owner > b.owner ? 1 : 0) ||
1208
+ (a.prompt < b.prompt ? -1 : a.prompt > b.prompt ? 1 : 0) ||
1209
+ (String(a.toolFilter || '') < String(b.toolFilter || '') ? -1 : 1))
1096
1210
  for (let i = 0; i < sorted.length; i++) {
1097
1211
  const c = sorted[i]
1098
1212
  md.push('---')
@@ -1119,7 +1233,7 @@ for (let i = 0; i < sorted.length; i++) {
1119
1233
  md.push('')
1120
1234
  }
1121
1235
  const byKind = {}
1122
- for (const c of corpus) byKind[c.kind] = (byKind[c.kind] || 0) + 1
1236
+ for (const c of sorted) byKind[c.kind] = (byKind[c.kind] || 0) + 1
1123
1237
  md.push('---')
1124
1238
  md.push('')
1125
1239
  md.push('## 统计')
@@ -1132,8 +1246,8 @@ const mdPath = join(CORPUS_DIR, 'prompt-corpus-v5.md')
1132
1246
  writeFileSync(mdPath, md.join('\n'), 'utf8')
1133
1247
  writeFileSync(join(CORPUS_DIR, 'prompt-corpus-v5.json'), JSON.stringify({
1134
1248
  note: 'Vibe Math V5 prompt/interaction corpus — generated by prompt-v5-integrity.test.mjs. <WS> = the run workspace.',
1135
- counts: byKind, total: corpus.length,
1136
- prompts: corpus.map(c => ({
1249
+ counts: byKind, total: sorted.length,
1250
+ prompts: sorted.map(c => ({
1137
1251
  kind: c.kind, owner: c.owner, sentToLabel: c.sentToLabel,
1138
1252
  charterChars: c.persona == null ? null : c.persona.length,
1139
1253
  charter: c.persona, toolFilter: c.toolFilter, prompt: c.prompt,
package/run-tests.mjs CHANGED
@@ -28,7 +28,17 @@ import { fileURLToPath } from 'node:url'
28
28
 
29
29
  const HERE = fileURLToPath(new URL('./', import.meta.url))
30
30
  const argv = process.argv.slice(2)
31
- const flag = (name) => argv.filter((a) => a.startsWith('--' + name + '=')).map((a) => a.split('=').slice(1).join('='))
31
+ // Accept BOTH `--only=x` and `--only x` (the help text used the space form, which a value-taking
32
+ // flag() did not understand — the filter silently did nothing and every suite still ran).
33
+ const flag = (name) => {
34
+ const out = []
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i]
37
+ if (a === '--' + name && i + 1 < argv.length && !argv[i + 1].startsWith('--')) out.push(argv[++i])
38
+ else if (a.startsWith('--' + name + '=')) out.push(a.split('=').slice(1).join('='))
39
+ }
40
+ return out
41
+ }
32
42
  const has = (name) => argv.includes('--' + name)
33
43
  const only = flag('only')
34
44
  const exclude = flag('exclude')
@@ -62,16 +72,22 @@ async function worker(id) {
62
72
  const r = await runSuite(suites[i])
63
73
  const tail = String(r.out).trim().split('\n').filter(Boolean).slice(-1)[0] || ''
64
74
  results[i] = r
65
- const mark = r.code === 0 ? 'PASS' : 'FAIL'
66
- console.log(
67
- mark + ' ' + r.file.padEnd(38) +
68
- ' exit=' + String(r.code).padStart(3) +
69
- ' ' + (r.ms / 1000).toFixed(1).padStart(6) + 's' +
70
- (tail ? ' ' + tail.slice(0, 78) : '')
71
- )
72
- if (r.code !== 0) {
75
+ if (!asJson) {
76
+ const mark = r.code === 0 ? 'PASS' : 'FAIL'
77
+ console.log(
78
+ mark + ' ' + r.file.padEnd(38) +
79
+ ' exit=' + String(r.code).padStart(3) +
80
+ ' ' + (r.ms / 1000).toFixed(1).padStart(6) + 's' +
81
+ (tail ? ' ' + tail.slice(0, 78) : '')
82
+ )
83
+ if (r.code !== 0) {
84
+ const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
85
+ for (const l of lines.slice(-15)) console.log(' ' + l)
86
+ }
87
+ } else if (r.code !== 0) {
88
+ // In --json mode keep stdout machine-readable: the failure detail travels in the JSON.
73
89
  const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
74
- for (const l of lines.slice(-15)) console.log(' ' + l)
90
+ r.tailDetail = lines.slice(-15).join('\n')
75
91
  }
76
92
  }
77
93
  }
@@ -86,7 +102,10 @@ if (asJson) {
86
102
  console.log(JSON.stringify({
87
103
  concurrency, wallSeconds: Number(wall.toFixed(1)), sumSeconds: Number(sum.toFixed(1)),
88
104
  pass: results.length - bad.length, fail: bad.length,
89
- suites: results.map((r) => ({ file: r.file, exit: r.code, seconds: Number((r.ms / 1000).toFixed(1)) })),
105
+ suites: results.map((r) => ({
106
+ file: r.file, exit: r.code, seconds: Number((r.ms / 1000).toFixed(1)),
107
+ ...(r.tailDetail ? { detail: r.tailDetail } : {}),
108
+ })),
90
109
  }, null, 2))
91
110
  } else {
92
111
  console.log('')