@gotcos/glasses-server 6.91.1 → 6.92.0

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,229 @@
1
+ #!/usr/bin/env node
2
+ // 6.92.0 dictation parity Wave A mutation gate (plan r3): the keyed and slow polish breakers, the
3
+ // prewarm route on the keyed breaker, prompt edit's switch, cap, own counter, breaker, refusals and
4
+ // text runner, the filler pass's language gate and token exemptions, the options answer's
5
+ // meaningGuard, and the eval harness limits. The runner is the 6.91.0 voice-style gate's:
6
+ //
7
+ // 0. The target suites are GREEN unmutated (a gate over a red suite proves nothing).
8
+ // 1. Each target string appears EXACTLY ONCE in its file, and the mutated file differs.
9
+ // 2. The mutated file still PARSES (esbuild). One that does not is INVALID, never a kill.
10
+ // 3. A KILL counts only when the test NAMED for that mutant fails (its 5th field, a substring of
11
+ // exactly one test's "file > describe > test"). SURVIVED, MISATTRIBUTED, INVALID and CRASHED
12
+ // each fail the gate.
13
+ // 4. The file is restored byte for byte before the next mutant.
14
+ // Each mutant runs the named test's file only; the baseline runs all.
15
+ // Usage: node server/scripts/dictation-parity-6920-mutation-gate.mjs [--list | --check | --discover] [name...]
16
+ import { mkdtempSync, cpSync, readFileSync, writeFileSync, symlinkSync, rmSync, existsSync, realpathSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { resolve, join, dirname, basename } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { spawnSync } from 'node:child_process'
21
+ import { transformSync } from 'esbuild'
22
+
23
+
24
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
25
+ const tests = ['server/lib/dictation-polish-breakers.test.ts', 'server/lib/claude-circuit.test.ts', 'server/routes/prompt-drafts.test.ts',
26
+ 'server/routes/prompt-edit.test.ts', 'server/routes/prompt-edit-g2-contract.test.ts', 'server/lib/claude-text-runner.test.ts',
27
+ 'server/lib/key-lanes-spawn-census.test.ts', 'server/lib/dictation-fillers.test.ts', 'server/lib/dictation-fillers-fixture.test.ts',
28
+ 'server/lib/dictation-meaning-guard.test.ts', 'server/routes/dictation-polish-settings.test.ts', 'server/scripts/dictation-eval-harness.test.ts',
29
+ 'server/lib/dictation-polish-budget.test.ts']
30
+ const PL = 'server/lib/dictation-polish-pipeline.ts', PD = 'server/routes/prompt-drafts.ts'
31
+ const PE = 'server/lib/prompt-edit.ts', PR = 'server/routes/prompt-edit.ts', CT = 'server/lib/claude-text-runner.ts'
32
+ const F = 'server/lib/dictation-fillers.ts', G = 'server/lib/dictation-meaning-guard.ts', R = 'server/routes/dictation-polish-settings.ts'
33
+ const FE = 'server/scripts/dictation-format-eval.ts', CE = 'server/scripts/dictation-custom-polish-eval.ts'
34
+ const BEFORE = 'const TOUCH_BEFORE = /[/.@_:#$]$/'
35
+ const AFTER = 'const TOUCH_AFTER = /^(?:[/@_#$]|[.:][\\p{L}\\p{N}])/u'
36
+ const BK = 'a filler right after a path or handle character is kept', AK = 'a filler right before a path or code character is kept'
37
+ const mutations = [
38
+ // A-W0a: an abort is neutral to the failure breaker; the slow breaker; keyed by (model, bucket group).
39
+ ['abort-neutral', PL, " if (opts.signal?.aborted) {\n // 6.92.0: the client went away", " if (false) {\n // 6.92.0: the client went away", '2 client aborts leave the failure breaker closed'],
40
+ ['abort-then-error-counts-one', PL, ' } else {\n breakers.failure.recordFailure()', ' } else {\n breakers.failure.recordFailure(); breakers.failure.recordFailure()', 'an abort followed by an error counts 1'],
41
+ ['slow-threshold-4', PL, 'envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_ABORTS, 4)', 'envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_ABORTS, 5)', '4 aborts with at least 4 s of model time open the slow breaker'],
42
+ ['slow-open-5-minutes', PL, 'envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_MS, 300_000)', 'envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_MS, 600_000)', '4 aborts with at least 4 s of model time open the slow breaker'],
43
+ ['slow-env-read', PL, 'cooldownMs: () => slowBreakerMs()', 'cooldownMs: 300_000', 'COS_DICTATION_SLOW_BREAKER_ABORTS and _MS set the threshold'],
44
+ ['slow-counted', PL, ' if (durationMs >= SLOW_ABORT_MIN_MODEL_MS) breakers.slow.recordFailure()\n', '', '4 aborts with at least 4 s of model time open the slow breaker'],
45
+ ['slow-consulted', PL, ' return breakers.failure.isOpen() || breakers.slow.isOpen()', ' return breakers.failure.isOpen()', '4 aborts with at least 4 s of model time open the slow breaker'],
46
+ ['slow-reset-on-success', PL, ' breakers.slow.recordSuccess()\n', '', 'a success between aborts resets the slow count'],
47
+ ['model-time-4s', PL, 'export const SLOW_ABORT_MIN_MODEL_MS = 4_000', 'export const SLOW_ABORT_MIN_MODEL_MS = 0', 'an abort under 4 s of model time does not count'],
48
+ ['model-time-inclusive', PL, ' if (durationMs >= SLOW_ABORT_MIN_MODEL_MS)', ' if (durationMs > SLOW_ABORT_MIN_MODEL_MS)', 'an abort under 4 s of model time does not count'],
49
+ ['aborted-audit-row', PL, " polishOutcome: 'aborted',\n", '', 'an abort writes an audit row with outcome aborted'],
50
+ ['bucket-key-meeting', PL, " return bucket === 'meeting' ? 'meeting' : 'dictation'", " return 'dictation'", 'a meeting failure leaves the message breaker closed'],
51
+ ['bucket-key-drafts-with-messages', PL, " return bucket === 'meeting' ? 'meeting' : 'dictation'", " return bucket === 'message' ? 'dictation' : 'meeting'", 'message and prompt_draft are one group'],
52
+ ['prewarm-keyed', PD, ' if (polishBreakerOpen(model, surface)) return res.status(409)', " if (polishBreakerOpen(model, 'message')) return res.status(409)", 'prewarm refuses when it could not be used'],
53
+ // A-W0b: prompt edit's switch, cap, own counter, breaker, refusals and runner.
54
+ ['edit-switch', PE, " if (!promptEditEnabled(env)) return 'off'", " if (false) return 'off'", 'COS_PROMPT_EDIT=0 answers 503'],
55
+ ['edit-switch-default-on', PE, ' return readSwitch(env.COS_PROMPT_EDIT, true)', ' return readSwitch(env.COS_PROMPT_EDIT, false)', 'COS_PROMPT_EDIT=0 answers 503'],
56
+ ['edit-cap', PE, " if (promptEditCountToday() >= promptEditDailyCap(env)) return 'cap'", " if (false) return 'cap'", 'the daily cap (COS_PROMPT_EDIT_DAILY_CAP'],
57
+ ['edit-cap-at-limit', PE, " if (promptEditCountToday() >= promptEditDailyCap(env)) return 'cap'", " if (promptEditCountToday() > promptEditDailyCap(env)) return 'cap'", 'the daily cap (COS_PROMPT_EDIT_DAILY_CAP'],
58
+ ['edit-cap-default-200', PE, 'export const PROMPT_EDIT_DEFAULT_DAILY_CAP = 200', 'export const PROMPT_EDIT_DEFAULT_DAILY_CAP = 300', 'the daily cap (COS_PROMPT_EDIT_DAILY_CAP'],
59
+ ['edit-call-counted', PR, ' recordPromptEditCall()\n', '', 'the daily cap (COS_PROMPT_EDIT_DAILY_CAP'],
60
+ ['edit-own-counter', PE, "export const PROMPT_EDIT_COUNT_FILE = '.prompt_edit_count.json'", "export const PROMPT_EDIT_COUNT_FILE = '.dictation_autoclean_count.json'", 'the daily cap (COS_PROMPT_EDIT_DAILY_CAP'],
61
+ ['edit-breaker', PE, " if (promptEditBreaker.isOpen()) return 'paused'", " if (false) return 'paused'", '3 CLI failures open the breaker'],
62
+ ['edit-breaker-3', PE, 'export const PROMPT_EDIT_BREAKER_FAILURES = 3', 'export const PROMPT_EDIT_BREAKER_FAILURES = 4', '3 CLI failures open the breaker'],
63
+ ['edit-breaker-counts', PR, ' promptEditBreaker.recordFailure()\n', '', '3 CLI failures open the breaker'],
64
+ ['edit-breaker-success', PR, ' promptEditBreaker.recordSuccess()\n', '', '3 CLI failures open the breaker'],
65
+ ['edit-abort-neutral', PR, ' if (abort.signal.aborted) {', ' if (false) {', 'a client abort is neutral to the breaker'],
66
+ ['edit-refusal-text-plain', PR, " res.status(status).type('text/plain').send(body)", ' res.status(status).json({ error: body })', 'COS_PROMPT_EDIT=0 answers 503'],
67
+ ['edit-500-no-tails', PR, " refuse(res, 'failed')\n }\n})", ' res.status(500).json({ error: String(err) })\n }\n})', 'a failure answers 500'],
68
+ ['edit-refusal-short', PE, " cap: { status: 429, body: 'Edit limit reached' },", " cap: { status: 429, body: 'Prompt edit paused until tomorrow (daily limit).' },", 'cap: the lens footer is 40 characters or fewer'],
69
+ ['edit-runs-isolated', PE, ' return runClaudeText(buildPromptEditPrompt(input), {', ' return spawnClaudeText(buildPromptEditPrompt(input), {', 'prompt edit runs through the tool-less text runner'],
70
+ ['runner-no-tools', CT, " '--tools', '', '--settings'", " '--settings'", 'isolation pin'],
71
+ ['runner-scratch-cwd', CT, 'env: claudeTextEnv(binary.path), cwd: workspace,', 'env: claudeTextEnv(binary.path), cwd: process.cwd(),', 'runs the binary with that argv in a scratch directory'],
72
+ ['runner-keeps-env', CT, ' const out = { ...env }\n delete out.CLAUDECODE', " const out = { ...env }\n delete out.ANTHROPIC_API_KEY\n delete out.CLAUDECODE", 'keeps the environment as spawnClaudeText did'],
73
+ // A-W10: the language gate.
74
+ ['gate-wired', F, ' return fillerTextLooksEnglish(text) ? stripDisfluencies(text) : text', ' return stripDisfluencies(text)', 'the server filler step is gated'],
75
+ // QA fix: the filler gate has its own function; the meaning guard keeps 6.91.1's looksEnglish.
76
+ ['gate-short-replies', G, ' const english = tokens.filter(t => ENGLISH.has(t.low) || SHORT_REPLY_ENGLISH.has(t.low)).length', ' const english = tokens.filter(t => ENGLISH.has(t.low)).length', 'a short English reply or command counts as English evidence'],
77
+ ['gate-long-text-ratio', G, ' if (!looksEnglish(text)) return false\n', '', 'a short text with foreign function words is not English'],
78
+ ['guard-names-keep-6911-language', G, ' const withNames = looksEnglish(cleaned)', ' const withNames = fillerTextLooksEnglish(cleaned)', '"um thanks Dana" polished to "Thanks." is refused for the name'],
79
+ // QA S1: the abort's model time is stamped in the listener.
80
+ ['abort-time-stamped', PL, ' const durationMs = (abortedAt ?? Date.now()) - startedAt', ' const durationMs = Date.now() - startedAt', 'the model time is stamped at the abort'],
81
+ // Forces the gate open (an English word ahead of the text, sliced off after): the pipeline must call the gated step.
82
+ ['gate-in-pipeline', PL, 'cleaned = stripFillersIfEnglish(cleaned)', "cleaned = stripFillersIfEnglish('the ' + cleaned).slice(4)", 'a German or Portuguese message reaches the model as said'],
83
+ ['gate-foreign-words', G, ' if (foreign > 0) return english > foreign', ' if (false) return english > foreign', 'a short text with foreign function words is not English'],
84
+ ['gate-all-caps-not-foreign', G, ' && !(t.raw.length > 1 && t.raw === t.raw.toUpperCase())).length', ').length', 'a short text with foreign function words is not English'],
85
+ ['gate-inline-um-er', G, ' if (inlineAmbiguous) return english > 0', ' if (false) return english > 0', 'an inline um or er needs an English function word'],
86
+ ['gate-paused-is-hesitation', G, '&& i + 1 < tokens.length && !tokens[i + 1].pause)', '&& i + 1 < tokens.length)', 'an inline um or er needs an English function word'],
87
+ // A-W10: the token exemptions, one character at a time.
88
+ ['touch-before-slash', F, BEFORE, 'const TOUCH_BEFORE = /[.@_:#$]$/', `${BK} (before /)`],
89
+ ['touch-before-dot', F, BEFORE, 'const TOUCH_BEFORE = /[/@_:#$]$/', `${BK} (before .)`],
90
+ ['touch-before-at', F, BEFORE, 'const TOUCH_BEFORE = /[/._:#$]$/', `${BK} (before @)`],
91
+ ['touch-before-underscore', F, BEFORE, 'const TOUCH_BEFORE = /[/.@:#$]$/', `${BK} (before _)`],
92
+ ['touch-before-colon', F, BEFORE, 'const TOUCH_BEFORE = /[/.@_#$]$/', `${BK} (before :)`],
93
+ ['touch-before-hash', F, BEFORE, 'const TOUCH_BEFORE = /[/.@_:$]$/', `${BK} (before #)`],
94
+ ['touch-before-dollar', F, BEFORE, 'const TOUCH_BEFORE = /[/.@_:#]$/', `${BK} (before $)`],
95
+ ['touch-before-wired', F, ' || TOUCH_BEFORE.test(out + before)\n', '', `${BK} (before /)`],
96
+ ['touch-after-slash', F, AFTER, 'const TOUCH_AFTER = /^(?:[@_#$]|[.:][\\p{L}\\p{N}])/u', `${AK} (after /)`],
97
+ ['touch-after-at', F, AFTER, 'const TOUCH_AFTER = /^(?:[/_#$]|[.:][\\p{L}\\p{N}])/u', `${AK} (after @)`],
98
+ ['touch-after-underscore', F, AFTER, 'const TOUCH_AFTER = /^(?:[/@#$]|[.:][\\p{L}\\p{N}])/u', `${AK} (after _)`],
99
+ ['touch-after-hash', F, AFTER, 'const TOUCH_AFTER = /^(?:[/@_$]|[.:][\\p{L}\\p{N}])/u', `${AK} (after #)`],
100
+ ['touch-after-dollar', F, AFTER, 'const TOUCH_AFTER = /^(?:[/@_#]|[.:][\\p{L}\\p{N}])/u', `${AK} (after $)`],
101
+ ['touch-after-dot', F, AFTER, 'const TOUCH_AFTER = /^(?:[/@_#$]|[:][\\p{L}\\p{N}])/u', `${AK} (after . and a letter)`],
102
+ ['touch-after-colon', F, AFTER, 'const TOUCH_AFTER = /^(?:[/@_#$]|[.][\\p{L}\\p{N}])/u', `${AK} (after : and a digit)`],
103
+ ['touch-after-needs-alnum', F, AFTER, 'const TOUCH_AFTER = /^(?:[/@_#$]|[.:])/u', 'a sentence-final period, ellipsis or colon is not a token character'],
104
+ ['touch-after-wired', F, ' || TOUCH_AFTER.test(after)\n', '', `${AK} (after /)`],
105
+ // A-W2: the options answer.
106
+ ['options-meaning-guard-flag', R, ' meaningGuard: meaningGuardEnabled(),', ' meaningGuard: true,', 'the options answer says meaningGuard'],
107
+ ['options-style-pairs', R, 'filter(model => styleAndRegisterApply(model, effectiveByModel[model]))', "filter(model => model === 'sonnet')", 'the options answer says meaningGuard'],
108
+ // A-W0c: the harness defaults.
109
+ ['format-default-fits', FE, 'export const FORMAT_DEFAULT_MAX_CALLS = 50', 'export const FORMAT_DEFAULT_MAX_CALLS = 40', 'the format default --max-calls is at least'],
110
+ ['custom-default-fits', CE, 'export const CUSTOM_POLISH_DEFAULT_MAX_CALLS = 205', 'export const CUSTOM_POLISH_DEFAULT_MAX_CALLS = 200', 'the custom-polish default fits its cap'],
111
+ ]
112
+
113
+ const DISCOVER_FILES = {}
114
+
115
+ const args = process.argv.slice(2)
116
+ if (args.includes('--list')) { for (const [name, , , , check] of mutations) console.log(`${name} <- ${check ?? '(no check)'}`); process.exit(0) }
117
+ const names = args.filter(a => !a.startsWith('--'))
118
+ const selected = names.length ? mutations.filter(([name]) => names.includes(name)) : mutations
119
+ if (names.length && selected.length !== names.length) throw new Error('Unknown mutation name in: ' + args.join(' '))
120
+ const discover = args.includes('--discover')
121
+
122
+ let badTargets = 0
123
+ for (const [name, file, find, replacement] of mutations) {
124
+ const text = readFileSync(join(root, file), 'utf8'), count = text.split(find).length - 1
125
+ if (count !== 1 || find === replacement) { badTargets++; console.log(`${name}: ${count} matches in ${file}`) }
126
+ }
127
+ if (badTargets) { console.log(`${mutations.length - badTargets} of ${mutations.length} targets unique`); process.exit(1) }
128
+
129
+ /** Parse only (esbuild, no type check): a mutant that does not parse is INVALID. */
130
+ function parses(file, text) {
131
+ try { transformSync(text, { loader: file.endsWith('.ts') ? 'ts' : 'js', format: 'esm', logLevel: 'silent' }); return null }
132
+ catch (err) { return String(err?.errors?.[0]?.text ?? err?.message ?? err).split('\n')[0] }
133
+ }
134
+
135
+ // The parse check itself must be able to fail: the 31c59dd gate counted exactly this shape (an object
136
+ // spread with a bare object in it) as a kill.
137
+ if (parses('x.ts', 'const used = {\n ...a,\n { polishRegister: register },\n}') === null || parses('x.ts', 'const n: number = 1') !== null) {
138
+ throw new Error('parse self-test failed')
139
+ }
140
+ console.log('parse self-test: rejects a bare object in a spread list, accepts plain TypeScript')
141
+
142
+ const scratch = mkdtempSync(join(tmpdir(), 'dictation-parity-6920-mutations-'))
143
+ // --check and --discover end with process.exit, which skips `finally`: remove the copy on exit too.
144
+ process.on('exit', () => rmSync(scratch, { recursive: true, force: true }))
145
+ try {
146
+ const skip = new Set(['data', 'models', 'certs', 'node_modules'])
147
+ cpSync(join(root, 'server'), join(scratch, 'server'), { recursive: true, filter: src => !(skip.has(basename(src)) && dirname(src) === join(root, 'server')) })
148
+ cpSync(join(root, 'shared'), join(scratch, 'shared'), { recursive: true })
149
+ cpSync(join(root, 'server/models/silero_vad.onnx'), join(scratch, 'server/models/silero_vad.onnx'))
150
+ cpSync(join(root, 'bin'), join(scratch, 'bin'), { recursive: true })
151
+ cpSync(join(root, 'vitest.config.ts'), join(scratch, 'vitest.config.ts'))
152
+ cpSync(join(root, 'package.json'), join(scratch, 'package.json'))
153
+ cpSync(join(root, 'managed-runtime-contract.json'), join(scratch, 'managed-runtime-contract.json'))
154
+ // 6.92.0: dictation-polish-budget.test.ts checks every key is documented there.
155
+ cpSync(join(root, '.env.example'), join(scratch, '.env.example'))
156
+ symlinkSync(join(root, 'node_modules'), join(scratch, 'node_modules'), 'dir')
157
+ const report = join(scratch, 'vitest-report.json')
158
+ const scratchReal = realpathSync(scratch)
159
+ /** Run vitest on these files; the per-test results from its JSON report, or null when there is none. */
160
+ const run = files => {
161
+ rmSync(report, { force: true })
162
+ const result = spawnSync(process.execPath, [join(root, 'node_modules/vitest/vitest.mjs'), 'run', '--maxWorkers=1', '--reporter=json', `--outputFile=${report}`, ...files], {
163
+ cwd: scratch,
164
+ env: {
165
+ PATH: process.env.PATH, HOME: scratch, COS_DATA_DIR: join(scratch, 'data'), COS_PROFILE_PATH: join(scratch, 'data', 'profile.json'),
166
+ COS_MAINTENANCE_GATE_PATH: join(scratch, 'gate', 'maintenance-gate.json'),
167
+ },
168
+ encoding: 'utf8', timeout: 300000,
169
+ })
170
+ if (result.error || !existsSync(report)) return { status: result.status, error: result.error?.message ?? 'no report', tests: null }
171
+ const json = JSON.parse(readFileSync(report, 'utf8'))
172
+ const tests = json.testResults.flatMap(file => {
173
+ // The report names files by real path (/private/var/... for a /var/... temp dir).
174
+ const rel = file.name.startsWith(scratchReal + '/') ? file.name.slice(scratchReal.length + 1)
175
+ : file.name.startsWith(scratch + '/') ? file.name.slice(scratch.length + 1) : file.name
176
+ // A file that failed to load has no tests: report it as one failed entry, never as a kill.
177
+ if (!file.assertionResults.length && file.status === 'failed') return [{ name: `${rel} (file failed to load)`, status: 'failed' }]
178
+ return file.assertionResults.map(a => ({ name: [rel, ...a.ancestorTitles, a.title].join(' > '), status: a.status }))
179
+ })
180
+ return { status: result.status, error: null, tests }
181
+ }
182
+
183
+ const baseline = run(tests)
184
+ if (baseline.status !== 0 || !baseline.tests || baseline.tests.some(t => t.status === 'failed')) {
185
+ const failing = (baseline.tests ?? []).filter(t => t.status === 'failed').map(t => t.name)
186
+ throw new Error('Baseline failed; a gate over a red suite proves nothing: ' + (baseline.error ?? failing.join(' | ')))
187
+ }
188
+ const passed = baseline.tests.filter(t => t.status === 'passed').length
189
+ console.log(`baseline green (${passed} tests passed in ${tests.length} files)`)
190
+
191
+ // Every named check names exactly one test that ran green in the baseline.
192
+ if (!discover) {
193
+ let unnamed = 0
194
+ for (const [name, , , , check] of selected) {
195
+ const hits = typeof check === 'string' && check ? baseline.tests.filter(t => t.name.includes(check)) : []
196
+ if (hits.length !== 1 || hits[0].status !== 'passed') { unnamed++; console.log(`${name}: check ${JSON.stringify(check)} names ${hits.length} tests`) }
197
+ }
198
+ if (unnamed) throw new Error(`${unnamed} mutants have no single named check`)
199
+ if (args.includes('--check')) { console.log(`${mutations.length} of ${mutations.length} targets unique; ${selected.length} named checks each name one green test`); process.exit(0) }
200
+ }
201
+
202
+ const tally = { KILLED: [], SURVIVED: [], MISATTRIBUTED: [], INVALID: [], CRASHED: [] }
203
+ for (const [name, file, find, replacement, check] of selected) {
204
+ const target = join(scratch, file), original = readFileSync(target, 'utf8')
205
+ if (original.split(find).length !== 2) throw new Error(`Mutation ${name} does not match exactly once in ${file}`)
206
+ const mutated = original.replace(find, () => replacement)
207
+ if (mutated === original) throw new Error(`Mutation ${name} did not change ${file}`)
208
+ const parseError = parses(file, mutated)
209
+ if (parseError) { tally.INVALID.push(name); console.log(`INVALID ${name} (does not parse: ${parseError})`); continue }
210
+ writeFileSync(target, mutated)
211
+ const checkFile = discover ? null : baseline.tests.find(t => t.name.includes(check)).name.split(' > ')[0]
212
+ let result = run(discover ? DISCOVER_FILES[file] ?? tests : [checkFile])
213
+ if (discover && result.tests && !result.tests.some(t => t.status === 'failed') && DISCOVER_FILES[file]) result = run(tests)
214
+ writeFileSync(target, original)
215
+ if (readFileSync(target, 'utf8') !== original) throw new Error(`Restore failed for ${file} after ${name}`)
216
+ if (!result.tests) { tally.CRASHED.push(name); console.log(`CRASHED ${name} (${result.error})`); continue }
217
+ const failed = result.tests.filter(t => t.status === 'failed').map(t => t.name)
218
+ if (discover) { console.log(`${failed.length ? 'FAILS' : 'SURVIVED'} ${name}${failed.map(f => '\n ' + f).join('')}`); (failed.length ? tally.KILLED : tally.SURVIVED).push(name); continue }
219
+ if (failed.some(f => f.includes(check))) { tally.KILLED.push(name); console.log(`KILLED ${name} <- ${check}`); continue }
220
+ if (!failed.length) { tally.SURVIVED.push(name); console.log(`SURVIVED ${name} (named: ${check})`); continue }
221
+ tally.MISATTRIBUTED.push(name); console.log(`MISATTRIBUTED ${name} (named: ${check}; failed: ${failed.slice(0, 2).join(' | ')})`)
222
+ }
223
+ const bad = selected.length - tally.KILLED.length
224
+ console.log(`${tally.KILLED.length} killed by their named check, ${tally.SURVIVED.length} survived, ${tally.MISATTRIBUTED.length} misattributed, `
225
+ + `${tally.INVALID.length} invalid, ${tally.CRASHED.length} crashed`)
226
+ if (discover) process.exit(0)
227
+ if (bad) { for (const k of ['SURVIVED', 'MISATTRIBUTED', 'INVALID', 'CRASHED']) if (tally[k].length) console.log(`${k}: ${tally[k].join(', ')}`); process.exitCode = 1 }
228
+ else console.log(`PASS dictation parity 6.92.0 mutation gate: ${selected.length} of ${selected.length}, each killed by its named check, green baseline`)
229
+ } finally { rmSync(scratch, { recursive: true, force: true }) }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ // 6.92.0 (plan r3, A-W10): writes server/lib/__fixtures__/dictation-fillers.json from the server's own
3
+ // filler functions (lib/dictation-fillers-fixture-cases.ts). No model calls, no network.
4
+ // Usage: npx tsx server/scripts/generate-dictation-fillers-fixture.ts
5
+ import { createHash } from 'node:crypto'
6
+ import { writeFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { FILLER_FIXTURE_CASES, fillerFixtureOutput, renderFillerFixture } from '../lib/dictation-fillers-fixture-cases.js'
9
+
10
+ const wrong = FILLER_FIXTURE_CASES.filter(c => fillerFixtureOutput(c.scope, c.input) !== c.want)
11
+ if (wrong.length) {
12
+ for (const c of wrong) console.error(`${c.id}: want ${JSON.stringify(c.want)}, got ${JSON.stringify(fillerFixtureOutput(c.scope, c.input))}`)
13
+ process.exit(1)
14
+ }
15
+ const text = renderFillerFixture()
16
+ const target = fileURLToPath(new URL('../lib/__fixtures__/dictation-fillers.json', import.meta.url))
17
+ writeFileSync(target, text)
18
+ console.log(`${FILLER_FIXTURE_CASES.length} rows, sha256 ${createHash('sha256').update(text).digest('hex')}`)