a73x

959a43f3

test: exclude literals from web source checks

a73x   2026-08-18 12:27

Commit message
test: exclude literals from web source checks

web/verify.js
Old New
@@ -13,6 +13,7 @@
13 'use strict'; 13 'use strict';
14 const fs = require('fs'); 14 const fs = require('fs');
15 const path = require('path'); 15 const path = require('path');
16 const vm = require('vm');
16 17
17 const wasmPath = process.argv[2] || 18 const wasmPath = process.argv[2] ||
18 path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); 19 path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm');
@@ -31,13 +32,14 @@ function check(name, got, want) {
31 else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } 32 else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); }
32 } 33 }
33 34
34 // Replace comments with spaces, preserving newlines and every live token. 35 // Reduce JavaScript to executable tokens: comments and literal contents are
35 // This is a deliberately small lexer rather than a comment-shaped regex: 36 // spaces, while punctuation, identifiers, numbers, and newlines stay put.
36 // mux.js uses strings and nested template expressions, and regex literals 37 // Template expressions recurse back into code. This is intentionally a
37 // can themselves contain quotes and escaped comment delimiters. Regex-vs- 38 // lexer, not a comment-shaped regex: the verifier must not mistake a route
38 // division is decided from the preceding token, covering every expression 39 // written in a string/regex for behavior, nor `//` inside a regex for a
39 // form used by mux.js and the mutation fixture below. 40 // comment. The context tracking covers every form used by mux.js and the
40 function maskJsComments(source) { 41 // control-flow/division mutation fixtures below.
42 function executableJsTokens(source) {
41 const out = source.split(''); 43 const out = source.split('');
42 let i = 0; 44 let i = 0;
43 45
@@ -48,58 +50,92 @@ function maskJsComments(source) {
48 }; 50 };
49 51
50 const skipQuoted = (quote) => { 52 const skipQuoted = (quote) => {
51 i++; 53 const contentStart = ++i; // preserve the opening quote
52 while (i < source.length) { 54 while (i < source.length) {
53 if (source[i] === '\\') { i += 2; continue; } 55 if (source[i] === '\\') { i += 2; continue; }
54 const c = source[i++]; 56 const c = source[i];
55 if (c === quote || c === '\n' || c === '\r') return; 57 if (c === quote) {
58 mask(contentStart, i);
59 i++; // preserve the closing quote
60 return;
61 }
62 if (c === '\n' || c === '\r') {
63 mask(contentStart, i);
64 return;
65 }
66 i++;
56 } 67 }
68 mask(contentStart, i);
57 }; 69 };
58 70
59 const skipRegex = () => { 71 const skipRegex = () => {
60 i++; // opening slash 72 const contentStart = ++i; // preserve the opening slash
61 let inClass = false; 73 let inClass = false;
62 while (i < source.length) { 74 while (i < source.length) {
63 if (source[i] === '\\') { i += 2; continue; } 75 if (source[i] === '\\') { i += 2; continue; }
64 const c = source[i++]; 76 const c = source[i];
65 if (c === '[') inClass = true; 77 if (c === '[') inClass = true;
66 else if (c === ']') inClass = false; 78 else if (c === ']') inClass = false;
67 else if (c === '/' && !inClass) { 79 else if (c === '/' && !inClass) {
80 mask(contentStart, i);
81 i++; // preserve the closing slash
82 const flagsStart = i;
68 while (i < source.length && /[a-z]/i.test(source[i])) i++; 83 while (i < source.length && /[a-z]/i.test(source[i])) i++;
84 mask(flagsStart, i);
85 return;
86 } else if (c === '\n' || c === '\r') {
87 mask(contentStart, i);
69 return; 88 return;
70 } else if (c === '\n' || c === '\r') return; 89 }
90 i++;
71 } 91 }
92 mask(contentStart, i);
72 }; 93 };
73 94
74 const expressionKeywords = new Set([ 95 const expressionKeywords = new Set([
75 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 96 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new',
76 'of', 'return', 'throw', 'typeof', 'void', 'yield', 97 'of', 'return', 'throw', 'typeof', 'void', 'yield',
77 ]); 98 ]);
99 const controlKeywords = new Set(['catch', 'for', 'if', 'switch', 'while', 'with']);
100 const statementKeywords = new Set(['do', 'else', 'finally', 'try']);
78 101
79 let scanCode; 102 let scanCode;
80 const skipTemplate = () => { 103 const skipTemplate = () => {
81 i++; // opening backtick 104 i++; // preserve the opening backtick
105 let rawStart = i;
82 while (i < source.length) { 106 while (i < source.length) {
83 if (source[i] === '\\') { i += 2; continue; } 107 if (source[i] === '\\') { i += 2; continue; }
84 if (source[i] === '`') { i++; return; } 108 if (source[i] === '`') {
109 mask(rawStart, i);
110 i++; // preserve the closing backtick
111 return;
112 }
85 if (source[i] === '$' && source[i + 1] === '{') { 113 if (source[i] === '$' && source[i + 1] === '{') {
114 mask(rawStart, i);
86 i += 2; 115 i += 2;
87 scanCode(true); 116 scanCode(true);
117 rawStart = i;
88 continue; 118 continue;
89 } 119 }
90 i++; 120 i++;
91 } 121 }
122 mask(rawStart, i);
92 }; 123 };
93 124
94 scanCode = (templateExpression) => { 125 scanCode = (templateExpression) => {
95 let expressionAllowed = true; 126 let expressionAllowed = true;
96 let braceDepth = 0; 127 let statementExpected = !templateExpression;
128 let pendingControl = false;
129 let previousToken = null;
130 const parenKinds = [];
131 const braceKinds = [];
132
97 while (i < source.length) { 133 while (i < source.length) {
98 const c = source[i]; 134 const c = source[i];
99 const next = source[i + 1]; 135 const next = source[i + 1];
100 136
101 if (/\s/.test(c)) { i++; continue; } 137 if (/\s/.test(c)) { i++; continue; }
102 if (templateExpression && c === '}' && braceDepth === 0) { i++; return; } 138 if (templateExpression && c === '}' && braceKinds.length === 0) { i++; return; }
103 139
104 if (c === '/' && next === '/') { 140 if (c === '/' && next === '/') {
105 const start = i; 141 const start = i;
@@ -119,52 +155,117 @@ function maskJsComments(source) {
119 if (c === '"' || c === "'") { 155 if (c === '"' || c === "'") {
120 skipQuoted(c); 156 skipQuoted(c);
121 expressionAllowed = false; 157 expressionAllowed = false;
158 statementExpected = false;
159 previousToken = 'literal';
122 continue; 160 continue;
123 } 161 }
124 if (c === '`') { 162 if (c === '`') {
125 skipTemplate(); 163 skipTemplate();
126 expressionAllowed = false; 164 expressionAllowed = false;
165 statementExpected = false;
166 previousToken = 'literal';
127 continue; 167 continue;
128 } 168 }
129 if (c === '/' && expressionAllowed) { 169 if (c === '/' && expressionAllowed) {
130 skipRegex(); 170 skipRegex();
131 expressionAllowed = false; 171 expressionAllowed = false;
172 statementExpected = false;
173 previousToken = 'literal';
132 continue; 174 continue;
133 } 175 }
134 176
135 if (/[A-Za-z_$]/.test(c)) { 177 if (/[A-Za-z_$]/.test(c)) {
136 const start = i++; 178 const start = i++;
137 while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++; 179 while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++;
138 expressionAllowed = expressionKeywords.has(source.slice(start, i)); 180 const word = source.slice(start, i);
181 pendingControl = controlKeywords.has(word);
182 expressionAllowed = expressionKeywords.has(word);
183 statementExpected = statementKeywords.has(word);
184 previousToken = word;
139 continue; 185 continue;
140 } 186 }
141 if (/[0-9]/.test(c)) { 187 if (/[0-9]/.test(c)) {
142 i++; 188 i++;
143 while (i < source.length && /[A-Za-z0-9_.]/.test(source[i])) i++; 189 while (i < source.length && /[A-Za-z0-9_.]/.test(source[i])) i++;
144 expressionAllowed = false; 190 expressionAllowed = false;
191 statementExpected = false;
192 previousToken = 'number';
145 continue; 193 continue;
146 } 194 }
147 195
148 if (c === '{') { 196 if (c === '(') {
149 if (templateExpression) braceDepth++; 197 parenKinds.push(pendingControl ? 'control' : 'expression');
198 pendingControl = false;
199 i++;
200 expressionAllowed = true;
201 statementExpected = false;
202 previousToken = '(';
203 } else if (c === ')') {
204 const kind = parenKinds.pop() ?? 'expression';
205 i++;
206 expressionAllowed = kind === 'control';
207 statementExpected = kind === 'control';
208 previousToken = kind === 'control' ? 'control-close' : ')';
209 } else if (c === '{') {
210 const kind = statementExpected || !expressionAllowed || previousToken === '=>'
211 ? 'block'
212 : 'object';
213 braceKinds.push(kind);
150 i++; 214 i++;
151 expressionAllowed = true; 215 expressionAllowed = true;
216 statementExpected = kind === 'block';
217 previousToken = '{';
152 } else if (c === '}') { 218 } else if (c === '}') {
153 if (templateExpression) braceDepth--; 219 const kind = braceKinds.pop() ?? 'block';
154 i++; 220 i++;
155 expressionAllowed = false; 221 expressionAllowed = kind === 'block';
156 } else if (c === ')' || c === ']') { 222 statementExpected = kind === 'block';
223 previousToken = kind === 'block' ? 'block-close' : 'object-close';
224 } else if (c === '[') {
225 i++;
226 expressionAllowed = true;
227 statementExpected = false;
228 previousToken = '[';
229 } else if (c === ']') {
157 i++; 230 i++;
158 expressionAllowed = false; 231 expressionAllowed = false;
232 statementExpected = false;
233 previousToken = ']';
159 } else if (c === '.' || ((c === '+' || c === '-') && next === c)) { 234 } else if (c === '.' || ((c === '+' || c === '-') && next === c)) {
160 i += next === c ? 2 : 1; 235 i += next === c ? 2 : 1;
161 expressionAllowed = false; 236 expressionAllowed = false;
237 statementExpected = false;
238 previousToken = c === '.' ? '.' : c + next;
162 } else if (c === '/') { 239 } else if (c === '/') {
163 i += next === '=' ? 2 : 1; 240 i += next === '=' ? 2 : 1;
164 expressionAllowed = true; 241 expressionAllowed = true;
242 statementExpected = false;
243 previousToken = '/';
244 } else if (c === ';') {
245 i++;
246 expressionAllowed = true;
247 statementExpected = true;
248 previousToken = ';';
249 } else if (c === ',') {
250 i++;
251 expressionAllowed = true;
252 statementExpected = false;
253 previousToken = ',';
254 } else if (c === ':' || c === '?') {
255 i++;
256 expressionAllowed = true;
257 statementExpected = c === ':' && braceKinds.at(-1) === 'block';
258 previousToken = c;
259 } else if (c === '=' && next === '>') {
260 i += 2;
261 expressionAllowed = true;
262 statementExpected = true;
263 previousToken = '=>';
165 } else { 264 } else {
166 i++; 265 i++;
167 expressionAllowed = true; 266 expressionAllowed = true;
267 statementExpected = false;
268 previousToken = c;
168 } 269 }
169 } 270 }
170 }; 271 };
@@ -173,8 +274,22 @@ function maskJsComments(source) {
173 return out.join(''); 274 return out.join('');
174 } 275 }
175 276
277 function balancedBodiesAfter(source, headerPattern) {
278 return [...source.matchAll(headerPattern)].map((match) => {
279 const open = match.index + match[0].lastIndexOf('{');
280 let depth = 1;
281 for (let at = open + 1; at < source.length; at++) {
282 if (source[at] === '{') depth++;
283 else if (source[at] === '}' && --depth === 0) {
284 return { body: source.slice(open + 1, at), start: open, end: at + 1 };
285 }
286 }
287 return { body: null, start: open, end: source.length };
288 });
289 }
290
176 function wasmCalls(source) { 291 function wasmCalls(source) {
177 const executable = maskJsComments(source); 292 const executable = executableJsTokens(source);
178 return [...new Set( 293 return [...new Set(
179 [...executable.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), 294 [...executable.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]),
180 )].sort(); 295 )].sort();
@@ -444,8 +559,8 @@ async function main() {
444 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); 559 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
445 560
446 // Mutation fixture for the source checks below. A correct-looking route 561 // Mutation fixture for the source checks below. A correct-looking route
447 // in either kind of comment must be invisible, while comment delimiters 562 // in either kind of comment must be invisible, while live literal
448 // inside live literals must survive unchanged. 563 // boundaries remain valid after their non-executable contents are masked.
449 const lexicalFixture = [ 564 const lexicalFixture = [
450 '// const MSG = { term_modes: 0x8d, term_event: 0x8f };', 565 '// const MSG = { term_modes: 0x8d, term_event: 0x8f };',
451 '/* const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 }; */', 566 '/* const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 }; */',
@@ -459,7 +574,7 @@ async function main() {
459 'const templateLiteral = `// literal /* template */`;', 574 'const templateLiteral = `// literal /* template */`;',
460 "const regexLiteral = /[\"']https?:\\/\\/host/; // remove this tail", 575 "const regexLiteral = /[\"']https?:\\/\\/host/; // remove this tail",
461 ].join('\n'); 576 ].join('\n');
462 const executableFixture = maskJsComments(lexicalFixture); 577 const executableFixture = executableJsTokens(lexicalFixture);
463 check('source masker rejects line-comment declaration decoy', /\bconst MSG\b/.test(executableFixture), false); 578 check('source masker rejects line-comment declaration decoy', /\bconst MSG\b/.test(executableFixture), false);
464 check('source masker rejects block-comment declaration decoy', /\bconst CLIENT_ACTION\b/.test(executableFixture), false); 579 check('source masker rejects block-comment declaration decoy', /\bconst CLIENT_ACTION\b/.test(executableFixture), false);
465 check('source masker rejects commented semantic-route decoy', /\bcase MSG\.term_modes\b/.test(executableFixture), false); 580 check('source masker rejects commented semantic-route decoy', /\bcase MSG\.term_modes\b/.test(executableFixture), false);
@@ -467,30 +582,126 @@ async function main() {
467 check('source masker rejects commented placeholder decoy', /\bonClipboardEffect\s*\(/.test(executableFixture), false); 582 check('source masker rejects commented placeholder decoy', /\bonClipboardEffect\s*\(/.test(executableFixture), false);
468 check('WASM-call scan rejects commented decoy', wasmCalls(lexicalFixture).includes('mux_comment_decoy'), false); 583 check('WASM-call scan rejects commented decoy', wasmCalls(lexicalFixture).includes('mux_comment_decoy'), false);
469 check('source masker removes a regex trailing comment', /remove this tail/.test(executableFixture), false); 584 check('source masker removes a regex trailing comment', /remove this tail/.test(executableFixture), false);
470 check('source masker preserves string comment text', executableFixture.includes('"https://host/*literal*/"'), true); 585 check('source masker excludes string literal contents', executableFixture.includes('https://host'), false);
471 check('source masker preserves template comment text', executableFixture.includes('`// literal /* template */`'), true); 586 check('source masker excludes template raw contents', executableFixture.includes('literal /* template */'), false);
472 check('source masker preserves regex comment text', executableFixture.includes('/[\"\']https?:\\/\\/host/'), true); 587 check('source masker excludes regex body contents', executableFixture.includes('https?:\\/\\/host'), false);
588 let lexicalFixtureCompiles = true;
589 try { new vm.Script(executableFixture); } catch (_) { lexicalFixtureCompiles = false; }
590 check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true);
591
592 const completeDecoy = [
593 'const MSG = { term_modes: 0x8d, term_event: 0x8f };',
594 'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 };',
595 'case MSG.term_modes: case MSG.term_event: { if (!this.stage(payload)) return;',
596 'const action = this.core.mux_client_frame(type, payload.length);',
597 'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); return; }',
598 'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);',
599 'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }',
600 'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);',
601 ].join(' ');
602 const literalDecoys = [
603 `const stringDecoy = '${completeDecoy}';`,
604 `const templateDecoy = \`${completeDecoy}\`;`,
605 `const regexDecoy = /${completeDecoy}/;`,
606 ].join('\n');
607 let literalDecoysCompile = true;
608 try { new vm.Script(literalDecoys); } catch (_) { literalDecoysCompile = false; }
609 check('complete literal decoy attack is valid JavaScript', literalDecoysCompile, true);
610 const executableLiteralDecoys = executableJsTokens(literalDecoys);
611 check('literal decoys add no MSG declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []).length, 0);
612 check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0);
613 check(
614 'literal decoys add no semantic route body',
615 balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length,
616 0,
617 );
618 check(
619 'literal decoys add no paste method body',
620 balancedBodiesAfter(executableLiteralDecoys, /\bsendPaste\s*\([^)]*\)\s*\{/g).length,
621 0,
622 );
623 check(
624 'literal decoys add no placeholder body',
625 balancedBodiesAfter(executableLiteralDecoys, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g).length,
626 0,
627 );
628 check('WASM-call scan rejects all literal decoys', wasmCalls(literalDecoys).includes('mux_client_frame'), false);
629
630 const templateExpressionFixture = [
631 'const nested = `raw mux_raw_decoy ${',
632 'this.core.mux_live_call("mux_string_decoy" /* mux_comment_decoy */)',
633 '}`;',
634 'const flagged = /mux_regex_decoy/gim;',
635 ].join(' ');
636 const executableTemplateExpression = executableJsTokens(templateExpressionFixture);
637 check('template raw text is not searchable', /mux_raw_decoy/.test(executableTemplateExpression), false);
638 check('template expression code remains live', /\.mux_live_call\s*\(/.test(executableTemplateExpression), true);
639 check('nested string content is not searchable', /mux_string_decoy/.test(executableTemplateExpression), false);
640 check('nested comment content is not searchable', /mux_comment_decoy/.test(executableTemplateExpression), false);
641 check('regex body is not searchable', /mux_regex_decoy/.test(executableTemplateExpression), false);
642 check('regex flags are not searchable', /\bgim\b/.test(executableTemplateExpression), false);
643
644 const slashFixture = [
645 'let ok = true, x = "/", left = 6, right = 3;',
646 'if (ok) /[//]/.test(x);',
647 'if (ok) {} /[/*]/.test(x);',
648 'const quotient = left / right;',
649 'const objectQuotient = {} / 2;',
650 ].join('\n');
651 let rawSlashFixtureCompiles = true;
652 try { new vm.Script(slashFixture); } catch (_) { rawSlashFixtureCompiles = false; }
653 check('regex/control and division attack is valid JavaScript', rawSlashFixtureCompiles, true);
654 const executableSlashes = executableJsTokens(slashFixture);
655 let slashFixtureCompiles = true;
656 try { new vm.Script(executableSlashes); } catch (_) { slashFixtureCompiles = false; }
657 check('masked regex/control and division fixture remains valid JS', slashFixtureCompiles, true);
658 check('regex after control and block remains live', (executableSlashes.match(/\.test\s*\(/g) ?? []).length, 2);
659 check('division after a value remains live', /left\s*\/\s*right/.test(executableSlashes), true);
660 check('division after an object expression remains live', /\{\}\s*\/\s*2/.test(executableSlashes), true);
661
662 const neighborFixture = executableJsTokens([
663 'case MSG.term_modes:',
664 'case MSG.term_event: { if (ready) { route(); } }',
665 'case MSG.unrelated: { return; }',
666 'case MSG.pty_mode: return;',
667 'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }',
668 'unrelatedMethod() { return 1; }',
669 'sendResizeIfDiffers() {}',
670 ].join('\n'));
671 check(
672 'semantic extraction ignores a later unrelated case',
673 balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length,
674 1,
675 );
676 check(
677 'paste extraction ignores a later unrelated method',
678 balancedBodiesAfter(neighborFixture, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g).length,
679 1,
680 );
473 681
474 const executableShell = maskJsComments(shell); 682 const executableShell = executableJsTokens(shell);
683 let executableShellCompiles = true;
684 try { new vm.Script(executableShell); } catch (_) { executableShellCompiles = false; }
685 check('real shell executable-token representation remains valid JS', executableShellCompiles, true);
475 686
476 // The browser must route sampled terminal state and host effects through 687 // The browser must route sampled terminal state and host effects through
477 // the same semantic core as the CLI. Pin the real page source here: this 688 // the same semantic core as the CLI. Pin the real page source here: this
478 // verifier otherwise exercises only the WASM half of that handshake. 689 // verifier otherwise exercises only the WASM half of that handshake.
479 const msgBindings = executableShell.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []; 690 const msgBindings = executableShell.match(/\b(?:const|let|var)\s+MSG\b/g) ?? [];
480 const msgObjects = [...executableShell.matchAll(/\bconst MSG\s*=\s*\{([\s\S]*?)\};/g)]; 691 const msgObjects = balancedBodiesAfter(executableShell, /\bconst MSG\s*=\s*\{/g);
481 check('shell has exactly one live MSG declaration', msgBindings.length, 1); 692 check('shell has exactly one live MSG declaration', msgBindings.length, 1);
482 check('shell MSG declaration has the expected object shape', msgObjects.length, 1); 693 check('shell MSG declaration has the expected object shape', msgObjects.length, 1);
483 const msgDecl = msgObjects.length === 1 ? msgObjects[0][1] : ''; 694 const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : '';
484 check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1); 695 check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1);
485 check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1); 696 check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1);
486 check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); 697 check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true);
487 check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); 698 check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true);
488 699
489 const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []; 700 const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? [];
490 const actionObjects = [...executableShell.matchAll(/\bconst CLIENT_ACTION\s*=\s*\{([\s\S]*?)\};/g)]; 701 const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g);
491 check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1); 702 check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1);
492 check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1); 703 check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1);
493 const actionDecl = actionObjects.length === 1 ? actionObjects[0][1] : ''; 704 const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : '';
494 for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell']) { 705 for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell']) {
495 check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1); 706 check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1);
496 } 707 }
@@ -503,13 +714,14 @@ async function main() {
503 true, 714 true,
504 ); 715 );
505 716
506 const semanticRoutes = [...executableShell.matchAll( 717 const semanticRoutes = balancedBodiesAfter(
507 /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{([\s\S]*?)\}\s*case\s+MSG\.pty_mode\s*:/g, 718 executableShell,
508 )]; 719 /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g,
720 );
509 check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1); 721 check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1);
510 check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1); 722 check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1);
511 check('shell shares exactly one semantic frame route', semanticRoutes.length, 1); 723 check('shell shares exactly one semantic frame route', semanticRoutes.length, 1);
512 const semanticCases = semanticRoutes.length === 1 ? semanticRoutes[0][1] : ''; 724 const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : '';
513 check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); 725 check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1);
514 check( 726 check(
515 'shell stages semantic payload before WASM', 727 'shell stages semantic payload before WASM',
@@ -534,18 +746,16 @@ async function main() {
534 /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(executableShell), 746 /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(executableShell),
535 false, 747 false,
536 ); 748 );
537 const clipboardMethods = executableShell.match(/\bonClipboardEffect\s*\([^)]*\)\s*\{/g) ?? []; 749 const clipboardMethods = balancedBodiesAfter(executableShell, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g);
538 const emptyClipboardMethods = executableShell.match(/\bonClipboardEffect\s*\(\s*\)\s*\{\s*\}/g) ?? []; 750 const emptyClipboardMethods = clipboardMethods.filter((method) => method.body?.trim() === '');
539 check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1); 751 check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1);
540 check('shell clipboard effect is the one empty placeholder', emptyClipboardMethods.length, 1); 752 check('shell clipboard effect is the one empty placeholder', emptyClipboardMethods.length, 1);
541 753
542 const sendPasteMethods = executableShell.match(/\bsendPaste\s*\([^)]*\)\s*\{/g) ?? []; 754 const sendPasteMethods = balancedBodiesAfter(executableShell, /\bsendPaste\s*\([^)]*\)\s*\{/g);
543 const sendPasteBodies = [...executableShell.matchAll( 755 const sendPasteBodies = balancedBodiesAfter(executableShell, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g);
544 /\bsendPaste\s*\(\s*text\s*\)\s*\{([\s\S]*?)\}\s*sendResizeIfDiffers/g,
545 )];
546 check('shell has exactly one live sendPaste method', sendPasteMethods.length, 1); 756 check('shell has exactly one live sendPaste method', sendPasteMethods.length, 1);
547 check('shell sendPaste has the expected text contract', sendPasteBodies.length, 1); 757 check('shell sendPaste has the expected text contract', sendPasteBodies.length, 1);
548 const sendPaste = sendPasteBodies.length === 1 ? sendPasteBodies[0][1] : ''; 758 const sendPaste = sendPasteBodies.length === 1 ? (sendPasteBodies[0].body ?? '') : '';
549 check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1); 759 check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1);
550 check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1); 760 check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1);
551 check( 761 check(