@molecule/api-ai-tools 1.10.6 → 1.10.7

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,526 @@
1
+ /**
2
+ * A standalone Playwright probe that launches a browser and never closes it — the
3
+ * resource-lifecycle hang `exec_command` did not yet classify.
4
+ *
5
+ * X1 xr-molecule-flash-m257 (+10:44 → +15:34) ran
6
+ * `cd /workspace/my-app/app && node /workspace/.mol-scratch/fav-probe.mjs 2>&1 | tail -5`. The
7
+ * 30-line script binds `const browser = await chromium.launch()`, walks a real page, reaches its
8
+ * last `console.log`, and ends without `browser.close()`, `disconnect()`, a disposal scope or
9
+ * `process.exit()`. Chromium is a live handle, so Node never exits: the tool waited its whole 290 s
10
+ * budget, returned `status: cut_at_budget` with empty stdout (the `| tail` held it), and then
11
+ * advised "run it in the background" — advice for a slow workload, not for a leaked handle.
12
+ *
13
+ * That is the same lifecycle class {@link foregroundServerCommand} already refuses: a foreground
14
+ * process that never returns. So the same preflight seam reads it, with a deliberately narrow
15
+ * closed-world proof:
16
+ *
17
+ * - the script is DIRECTLY executed (`node`/`tsx`/`bun`/`deno` naming a script file, never a
18
+ * test-runner invocation or a test/fixture file — those are the runner's business);
19
+ * - the source, with comments and string/template/regex literals blanked, binds a browser by a
20
+ * TOP-LEVEL (brace depth 0) `const|let|var <x> = await chromium|firefox|webkit.launch(`
21
+ * assignment, where the engine (or its namespace) is imported/required from `playwright`,
22
+ * `playwright-core` or `@playwright/test` — a locally bound stub is not Playwright;
23
+ * - every reference to `<x>` is a known browser method that does NOT close it (`newPage`,
24
+ * `newContext`, `on`, `version`, …), and the binding is never aliased, passed to a helper, or
25
+ * read as a property;
26
+ * - nothing in the source is a cleanup path (any `.close(`/`.disconnect(`/`.dispose(`, a `using`
27
+ * scope, a cleanup-named import), an escape (`process.exit`, `eval`, `new Function`, dynamic
28
+ * `import`), or a persistent-process intent (`launchServer`, `connect`, a server `.listen`, a
29
+ * timer or signal handler).
30
+ *
31
+ * Anything ambiguous is left alone. A wrong refusal costs a valid probe — the review that found
32
+ * the m257 leak also proved five harmless scripts a raw substring match would refuse (a local
33
+ * engine stub, the launch text in a string, in a comment, in a template literal, and inside a
34
+ * function that is never called) — and a missed leak costs the same budget the tool already had.
35
+ * This is a lifecycle diagnostic, never an egress or security gate.
36
+ *
37
+ * @module
38
+ */
39
+ /** A `const|let|var <name> = await [qualifier.]chromium|firefox|webkit.launch(` binding. */
40
+ const LAUNCH = /(?<![\w$.])(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*await\s+(?:([A-Za-z_$][\w$]*)\s*\.\s*)?(chromium|firefox|webkit)\s*\.\s*launch\s*\(/g;
41
+ /** A browser method that does not close the browser, so calling it leaves the handle alive. */
42
+ const SAFE_BROWSER_METHOD = '(?:newPage|newContext|version|isConnected|contexts|browserType|on|once|off|addListener|' +
43
+ 'removeListener|removeAllListeners|eventNames|listenerCount|rawListeners|setMaxListeners|' +
44
+ 'getMaxListeners|prependListener|prependOnceListener|startTracing|stopTracing)';
45
+ /** An explicit process exit: Node ends even with a live browser handle, so this is not a leak. */
46
+ const EXPLICIT_EXIT = /\bprocess\s*\.\s*exit\s*\(/;
47
+ /** A top-level `throw`: an uncaught exception ends Node even with a live browser handle. */
48
+ const THROW = /(?<![\w$.])throw\b/g;
49
+ /** Code a source-level reading cannot follow, so the script is left alone. */
50
+ const INDIRECT_CODE = /\b(?:eval|new\s+Function)\s*\(|\bimport\s*\(/;
51
+ /** A persistent process, not a probe that should have ended: a browser server or a listener. */
52
+ const PERSISTENT_INTENT = /\bsetInterval\s*\(|\bwhile\s*\(\s*true\s*\)|\bfor\s*\(\s*;\s*;\s*\)|\bprocess\s*\.\s*(?:on|once)\s*\(|\.\s*(?:connectOverCDP|launchServer|launchPersistentContext|connect)\s*\(|\.\s*listen\s*\(/;
53
+ /** A disposal scope (`using browser = …`) or any resource close: an ambiguity by construction. */
54
+ const DISPOSAL_SCOPE = /\b(?:await\s+)?using\s+[A-Za-z_$][\w$]*\s*=/;
55
+ const ANY_CLEANUP = /\.\s*(?:close|disconnect|dispose)\s*\(|[A-Za-z_$][\w$]*\s*\[\s*['"](?:close|disconnect|dispose)['"]\s*\]/;
56
+ /** An import whose name or module path suggests cleanup the source reading cannot see. */
57
+ const CLEANUP_HINT = /cleanup|teardown|close|dispose|shutdown|quit/i;
58
+ /** The packages whose `chromium`/`firefox`/`webkit` export really launches a browser. */
59
+ const PLAYWRIGHT_PACKAGE = '(?:playwright-core|playwright|@playwright/test)';
60
+ /** The characters after which a `/` starts a regex literal rather than a division. */
61
+ const REGEX_AFTER = new Set([
62
+ '',
63
+ '(',
64
+ '[',
65
+ '{',
66
+ ',',
67
+ ';',
68
+ ':',
69
+ '=',
70
+ '!',
71
+ '&',
72
+ '|',
73
+ '?',
74
+ '+',
75
+ '-',
76
+ '*',
77
+ '%',
78
+ '^',
79
+ '~',
80
+ '<',
81
+ '>',
82
+ '}',
83
+ ]);
84
+ /**
85
+ * A source with every comment and every string, template and regex literal blanked to spaces,
86
+ * preserving offsets and newlines so a match's position still indexes the original source.
87
+ *
88
+ * A launch mentioned in prose is not a launch: the m257 review proved scripts that only PRINT
89
+ * `const browser = await chromium.launch()` — in a string, a comment and a template literal — and
90
+ * a raw substring match refused all of them. Template `${…}` substitutions stay code (a `close()`
91
+ * written there is a real cleanup path), nested braces are tracked so the substitution's end is
92
+ * found, and regex literals are recognised by the token before them so a quote inside a character
93
+ * class cannot blank the rest of the file.
94
+ *
95
+ * @param source - The script's source.
96
+ * @returns The same text with literal/comment spans replaced by spaces.
97
+ */
98
+ function codeOnly(source) {
99
+ const out = source.split('');
100
+ const blank = (from, to) => {
101
+ for (let i = from; i < to; i++)
102
+ if (out[i] !== '\n')
103
+ out[i] = ' ';
104
+ };
105
+ const stack = [{ mode: 'code', braces: 0 }];
106
+ const top = () => stack[stack.length - 1];
107
+ /** The last non-whitespace CODE character: decides whether a `/` opens a regex. */
108
+ let lastCode = '';
109
+ /** The last identifier word and the word being read — a module specifier follows `from`. */
110
+ let lastWord = '';
111
+ let word = '';
112
+ /** A specifier (`from 'playwright'`, `require('playwright')`) stays readable to provenance. */
113
+ const specifierAhead = () => lastWord === 'from' || lastWord === 'require' || lastWord === 'import';
114
+ let i = 0;
115
+ while (i < source.length) {
116
+ const frame = top();
117
+ const char = source[i] ?? '';
118
+ const next = source[i + 1];
119
+ if (frame.mode === 'code') {
120
+ if (char === '/' && next === '/') {
121
+ stack.push({ mode: 'line' });
122
+ blank(i, i + 2);
123
+ i += 2;
124
+ continue;
125
+ }
126
+ if (char === '/' && next === '*') {
127
+ stack.push({ mode: 'block' });
128
+ blank(i, i + 2);
129
+ i += 2;
130
+ continue;
131
+ }
132
+ if (char === "'" || char === '"') {
133
+ stack.push({ mode: 'string', quote: char, keep: specifierAhead() });
134
+ if (!specifierAhead())
135
+ out[i] = ' ';
136
+ i += 1;
137
+ continue;
138
+ }
139
+ if (char === '`') {
140
+ stack.push({ mode: 'template' });
141
+ out[i] = ' ';
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (char === '/' && REGEX_AFTER.has(lastCode)) {
146
+ stack.push({ mode: 'regex', inClass: false });
147
+ out[i] = ' ';
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if (frame.braces > 0) {
152
+ if (char === '{')
153
+ frame.braces += 1;
154
+ else if (char === '}') {
155
+ frame.braces -= 1;
156
+ if (frame.braces === 0) {
157
+ stack.pop();
158
+ out[i] = ' ';
159
+ i += 1;
160
+ continue;
161
+ }
162
+ }
163
+ }
164
+ if (/[\w$]/.test(char))
165
+ word += char;
166
+ else if (word !== '') {
167
+ lastWord = word;
168
+ word = '';
169
+ }
170
+ if (!/\s/.test(char))
171
+ lastCode = char;
172
+ i += 1;
173
+ continue;
174
+ }
175
+ if (frame.mode === 'line') {
176
+ if (char === '\n') {
177
+ stack.pop();
178
+ i += 1;
179
+ continue;
180
+ }
181
+ out[i] = ' ';
182
+ i += 1;
183
+ continue;
184
+ }
185
+ if (frame.mode === 'block') {
186
+ if (char === '*' && next === '/') {
187
+ blank(i, i + 2);
188
+ stack.pop();
189
+ i += 2;
190
+ continue;
191
+ }
192
+ if (char !== '\n')
193
+ out[i] = ' ';
194
+ i += 1;
195
+ continue;
196
+ }
197
+ if (frame.mode === 'string') {
198
+ if (char === '\\') {
199
+ if (!frame.keep)
200
+ blank(i, i + 2);
201
+ i += 2;
202
+ continue;
203
+ }
204
+ if (char === frame.quote) {
205
+ if (!frame.keep)
206
+ out[i] = ' ';
207
+ stack.pop();
208
+ // A string is an operand: the next `/` is division, not a regex.
209
+ lastCode = 'x';
210
+ i += 1;
211
+ continue;
212
+ }
213
+ if (!frame.keep && char !== '\n')
214
+ out[i] = ' ';
215
+ i += 1;
216
+ continue;
217
+ }
218
+ if (frame.mode === 'template') {
219
+ if (char === '\\') {
220
+ blank(i, i + 2);
221
+ i += 2;
222
+ continue;
223
+ }
224
+ if (char === '`') {
225
+ out[i] = ' ';
226
+ stack.pop();
227
+ lastCode = 'x';
228
+ i += 1;
229
+ continue;
230
+ }
231
+ if (char === '$' && next === '{') {
232
+ // The substitution is code: keep it visible and track its closing brace.
233
+ blank(i, i + 2);
234
+ stack.push({ mode: 'code', braces: 1 });
235
+ lastCode = '{';
236
+ i += 2;
237
+ continue;
238
+ }
239
+ if (char !== '\n')
240
+ out[i] = ' ';
241
+ i += 1;
242
+ continue;
243
+ }
244
+ // Regex literal: blank it, honour escapes and character classes, then its flags.
245
+ if (char === '\\') {
246
+ blank(i, i + 2);
247
+ i += 2;
248
+ continue;
249
+ }
250
+ if (char === '[')
251
+ frame.inClass = true;
252
+ else if (char === ']')
253
+ frame.inClass = false;
254
+ else if (char === '/' && !frame.inClass) {
255
+ out[i] = ' ';
256
+ stack.pop();
257
+ lastCode = 'x';
258
+ i += 1;
259
+ while (i < source.length && /[a-z]/i.test(source[i] ?? '')) {
260
+ out[i] = ' ';
261
+ i += 1;
262
+ }
263
+ continue;
264
+ }
265
+ if (char !== '\n')
266
+ out[i] = ' ';
267
+ i += 1;
268
+ }
269
+ return out.join('');
270
+ }
271
+ /**
272
+ * How many `{ … }` blocks are open at one offset of the code-only text.
273
+ *
274
+ * @param code - The {@link codeOnly} text.
275
+ * @param at - The offset to ask about.
276
+ * @returns The open-block count (`0` at the module's top level).
277
+ */
278
+ function braceDepthAt(code, at) {
279
+ let depth = 0;
280
+ for (let i = 0; i < at; i += 1) {
281
+ const char = code[i] ?? '';
282
+ if (char === '{')
283
+ depth += 1;
284
+ else if (char === '}')
285
+ depth = Math.max(0, depth - 1);
286
+ }
287
+ return depth;
288
+ }
289
+ /**
290
+ * Whether the script throws at its own top level — an uncaught exception that terminates Node
291
+ * before the leaked handle can keep it alive. A `throw` inside a function or a `try` block is not
292
+ * an exit path for every run, so it is left where it is.
293
+ *
294
+ * @param code - The {@link codeOnly} text.
295
+ * @returns True when a `throw` statement sits at brace depth 0.
296
+ */
297
+ function topLevelThrow(code) {
298
+ for (const match of code.matchAll(THROW)) {
299
+ if (braceDepthAt(code, match.index ?? 0) === 0)
300
+ return true;
301
+ }
302
+ return false;
303
+ }
304
+ /**
305
+ * Whether a declaration is the first thing on its line — only whitespace between it and the
306
+ * previous newline.
307
+ *
308
+ * This is the conservative guard for the one shape the lexer cannot read: a regex literal whose
309
+ * opening `/` follows a `)` (`if (x) /…/.test(s)`) reads as division, so the launch text inside it
310
+ * survives blanking. A mid-line top-level launch is a deliberate false negative; every positive
311
+ * shape (the m257 probe, `import * as pw` + `pw.chromium.launch`, a destructured require) declares
312
+ * at line start.
313
+ *
314
+ * @param code - The {@link codeOnly} text.
315
+ * @param at - The offset of the declaration.
316
+ * @returns True when the declaration starts its line.
317
+ */
318
+ function startsLine(code, at) {
319
+ for (let i = at - 1; i >= 0; i -= 1) {
320
+ const char = code[i] ?? '';
321
+ if (char === '\n')
322
+ return true;
323
+ if (!/\s/.test(char))
324
+ return false;
325
+ }
326
+ return true;
327
+ }
328
+ /**
329
+ * Escape one text for a literal use inside a regular expression.
330
+ *
331
+ * @param text - The text to escape.
332
+ * @returns The escaped text.
333
+ */
334
+ function escapeRegExp(text) {
335
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
+ }
337
+ /** Every reference to one variable, as a whole word not reached through another object. */
338
+ function occurrences(variable) {
339
+ return new RegExp(`(?<![\\w$.])${escapeRegExp(variable)}(?![\\w$])`, 'g');
340
+ }
341
+ /** Every direct call of a non-closing browser method on one variable. */
342
+ function safeCalls(variable) {
343
+ return new RegExp(`(?<![\\w$.])${escapeRegExp(variable)}\\s*\\.\\s*${SAFE_BROWSER_METHOD}\\s*\\(`, 'g');
344
+ }
345
+ /**
346
+ * Whether a name is declared locally (`const chromium = { launch… }`) — a stub, not Playwright.
347
+ *
348
+ * A local declaration of the engine or of the namespace it is reached through makes the source
349
+ * ambiguous, so it is left alone. The declaration in `const { chromium } = require(…)` is a
350
+ * destructuring pattern, not this shape, and is handled by {@link namedRequireBinding}.
351
+ *
352
+ * @param code - The {@link codeOnly} text.
353
+ * @param name - The binding to look for.
354
+ * @returns True when the name is shadowed by a local declaration.
355
+ */
356
+ function locallyDeclared(code, name) {
357
+ return new RegExp(`\\b(?:const|let|var|function|class)\\s+${escapeRegExp(name)}(?![\\w$])`).test(code);
358
+ }
359
+ /**
360
+ * Whether a name is bound by a named import or a destructured require from a Playwright package.
361
+ *
362
+ * @param code - The {@link codeOnly} text.
363
+ * @param name - The binding to look for.
364
+ * @returns True when the name comes from Playwright.
365
+ */
366
+ function namedPlaywrightBinding(code, name) {
367
+ const importRe = new RegExp(`\\bimport\\s*\\{([^}]*)\\}\\s*from\\s*['"]${PLAYWRIGHT_PACKAGE}['"]`, 'g');
368
+ for (const match of code.matchAll(importRe)) {
369
+ if ((match[1] ?? '').split(',').some((part) => part.trim() === name))
370
+ return true;
371
+ }
372
+ const requireRe = new RegExp(`\\b(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*require\\s*\\(\\s*['"]${PLAYWRIGHT_PACKAGE}['"]\\s*\\)`, 'g');
373
+ for (const match of code.matchAll(requireRe)) {
374
+ if ((match[1] ?? '').split(',').some((part) => part.trim() === name))
375
+ return true;
376
+ }
377
+ return false;
378
+ }
379
+ /**
380
+ * Whether a name is bound to a Playwright package as a namespace/default import or a whole
381
+ * `require` (`import * as pw from 'playwright'`, `const pw = require('playwright')`).
382
+ *
383
+ * @param code - The {@link codeOnly} text.
384
+ * @param name - The namespace binding to look for.
385
+ * @returns True when the name is a Playwright namespace.
386
+ */
387
+ function namespacePlaywrightBinding(code, name) {
388
+ const importRe = new RegExp(`\\bimport\\s+(?:\\*\\s+as\\s+)?([A-Za-z_$][\\w$]*)\\s+from\\s*['"]${PLAYWRIGHT_PACKAGE}['"]`, 'g');
389
+ for (const match of code.matchAll(importRe)) {
390
+ if (match[1] === name)
391
+ return true;
392
+ }
393
+ const requireRe = new RegExp(`\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\s*\\(\\s*['"]${PLAYWRIGHT_PACKAGE}['"]\\s*\\)`, 'g');
394
+ for (const match of code.matchAll(requireRe)) {
395
+ if (match[1] === name)
396
+ return true;
397
+ }
398
+ return false;
399
+ }
400
+ /**
401
+ * Whether the launched engine really is Playwright's: the bare `chromium`/`firefox`/`webkit` bound
402
+ * by a named import/require, or a namespace bound to a Playwright package with the engine read off
403
+ * it. A local stub with a `launch()` method is not a browser and is left alone.
404
+ *
405
+ * @param code - The {@link codeOnly} text.
406
+ * @param qualifier - The namespace word before the engine (`pw` in `pw.chromium.launch`), or null.
407
+ * @param engine - The engine word (`chromium`, `firefox`, `webkit`).
408
+ * @returns True when the engine comes from a Playwright package.
409
+ */
410
+ function playwrightEngine(code, qualifier, engine) {
411
+ if (qualifier === null) {
412
+ if (locallyDeclared(code, engine))
413
+ return false;
414
+ return namedPlaywrightBinding(code, engine);
415
+ }
416
+ if (locallyDeclared(code, qualifier))
417
+ return false;
418
+ return namedPlaywrightBinding(code, qualifier) || namespacePlaywrightBinding(code, qualifier);
419
+ }
420
+ /**
421
+ * Whether the source imports a helper whose job may be to close the browser — a cleanup path this
422
+ * reading cannot follow, so the script must be left alone.
423
+ *
424
+ * The check covers both the imported names and the module path (`./helpers/cleanup`), because the
425
+ * two ways a helper is named are both evidence. It is deliberately over-inclusive: a false
426
+ * negative here keeps a valid probe running.
427
+ *
428
+ * @param code - The {@link codeOnly} text.
429
+ * @returns True when an import names a cleanup helper.
430
+ */
431
+ function importsCleanupHelper(code) {
432
+ for (const match of code.matchAll(/\bimport\s+([^;]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
433
+ if (CLEANUP_HINT.test(match[1] ?? '') || CLEANUP_HINT.test(match[2] ?? ''))
434
+ return true;
435
+ }
436
+ for (const match of code.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
437
+ if (CLEANUP_HINT.test(match[1] ?? ''))
438
+ return true;
439
+ }
440
+ return false;
441
+ }
442
+ /**
443
+ * The browser variable a standalone script launches and never closes, when that can be proven from
444
+ * the source alone.
445
+ *
446
+ * The proof is a closed world: one plain TOP-LEVEL launch binding whose engine is genuinely
447
+ * Playwright's, every reference to it a known non-closing method call, and no cleanup, escape or
448
+ * persistent-process path anywhere in the source. A source with two launches is read one binding
449
+ * at a time — a proven leak in either one is enough, because that handle alone keeps Node alive.
450
+ *
451
+ * @param source - The script's source, as read from disk.
452
+ * @returns The leaking binding, or null when the script is clean or ambiguous.
453
+ */
454
+ export function standaloneBrowserLeak(source) {
455
+ if (typeof source !== 'string' || source.trim() === '')
456
+ return null;
457
+ const code = codeOnly(source);
458
+ if (EXPLICIT_EXIT.test(code) || topLevelThrow(code) || INDIRECT_CODE.test(code))
459
+ return null;
460
+ if (PERSISTENT_INTENT.test(code) || DISPOSAL_SCOPE.test(code))
461
+ return null;
462
+ if (ANY_CLEANUP.test(code) || importsCleanupHelper(code))
463
+ return null;
464
+ for (const match of code.matchAll(LAUNCH)) {
465
+ const variable = match[1];
466
+ const qualifier = match[2] ?? null;
467
+ const engine = match[3];
468
+ if (variable === undefined || variable === '' || engine === undefined)
469
+ continue;
470
+ // A launch inside a function/block is not proof it ever ran: leave it alone.
471
+ if (braceDepthAt(code, match.index ?? 0) !== 0)
472
+ continue;
473
+ // A top-level launch must start its line: a mid-line match can be text the lexer could not
474
+ // classify (a regex literal after `)`), so it is not a closed world.
475
+ if (!startsLine(code, match.index ?? 0))
476
+ continue;
477
+ if (!playwrightEngine(code, qualifier, engine))
478
+ continue;
479
+ // The declaration is the one reference the count below does not have to explain.
480
+ const total = [...code.matchAll(occurrences(variable))].length;
481
+ const direct = [...code.matchAll(safeCalls(variable))].length;
482
+ // Anything besides the declaration and a direct non-closing call is an unknown escape: an
483
+ // alias, a helper argument, a property read. Not a closed world.
484
+ if (total === direct + 1)
485
+ return { variable };
486
+ }
487
+ return null;
488
+ }
489
+ /**
490
+ * The refusal for a foreground command that would run a leaking browser probe: it never returns,
491
+ * and the one move that makes it return is closing the browser (or letting the Playwright runner
492
+ * own it), never moving the leak to the background.
493
+ *
494
+ * @param probe - The leaked probe, from `foregroundBrowserProbe`.
495
+ * @param budgetSeconds - The tool's budget, which the probe would consume whole.
496
+ * @param piped - Whether the command pipes through `tail`/`head`, which withholds the probe's output.
497
+ * @returns The refusal sentence.
498
+ */
499
+ export function browserProbeRefusal(probe, budgetSeconds, piped = false) {
500
+ return (`\`${probe.script}\` binds the launched browser to \`${probe.variable}\`. ` +
501
+ 'This probe launches a browser and never closes it, so Node will stay alive after its last ' +
502
+ 'statement. Put the page work in try { … } finally { await ' +
503
+ `${probe.variable}.close() }, or use a Playwright test whose browser fixture is cleaned up by ` +
504
+ 'the runner. ' +
505
+ `Run in the foreground it would hold this tool for its whole ${budgetSeconds} s budget and ` +
506
+ 'return nothing.' +
507
+ (piped
508
+ ? ' Its `| tail` pipe would also withhold its output until it exits: run it without the pipe so its progress streams.'
509
+ : ''));
510
+ }
511
+ /**
512
+ * The over-budget advice for a leak discovered at cut time (the preflight could not read the script
513
+ * before the run — e.g. the same command wrote it). It names the lifecycle fix and says explicitly
514
+ * that the background is not one: a leaked handle hangs there too.
515
+ *
516
+ * @param probe - The leaked probe.
517
+ * @returns The sentence the cut result carries.
518
+ */
519
+ export function browserProbeOverBudgetAdvice(probe) {
520
+ return (`\`${probe.script}\` binds the launched browser to \`${probe.variable}\`. ` +
521
+ 'This probe launches a browser and never closes it, so Node will stay alive after its last ' +
522
+ 'statement. It is not a bigger unit to split, and starting it in the background only moves the ' +
523
+ 'hang. Put the page work in try { … } finally { await ' +
524
+ `${probe.variable}.close() }, or use a Playwright test whose browser fixture is cleaned up by ` +
525
+ 'the runner.');
526
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"command-position.d.ts","sourceRoot":"","sources":["../src/command-position.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,OAAO,EAAE,MAAM,CAAA;IACf,qFAAqF;IACrF,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IACvB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAgSD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AAmoBD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,EAAE,CAIjE;AAmCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,EAAE,cAAc,GAAG,IAAI,CAAA;IAC9B,0FAA0F;IAC1F,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;;OAIG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAE/E;AAmDD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,GAAG,EAAE,MAAM,GAAG,IAAI,GACjB,MAAM,GAAG,IAAI,CAyDf;AA4GD;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,GAC1C;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAoD7C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAK/E"}
1
+ {"version":3,"file":"command-position.d.ts","sourceRoot":"","sources":["../src/command-position.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,OAAO,EAAE,MAAM,CAAA;IACf,qFAAqF;IACrF,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IACvB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAgSD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AA6qBD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,EAAE,CAIjE;AAmCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,EAAE,cAAc,GAAG,IAAI,CAAA;IAC9B,0FAA0F;IAC1F,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;;OAIG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAE/E;AAmDD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,GAAG,EAAE,MAAM,GAAG,IAAI,GACjB,MAAM,GAAG,IAAI,CAyDf;AA4GD;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,GAC1C;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAoD7C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAK/E"}
@@ -537,6 +537,23 @@ function commandStart(words) {
537
537
  }
538
538
  return { at, nested: null, timeoutSeconds, program: null, bound, envInsert, envReset };
539
539
  }
540
+ /** Whether a shell metacharacter is escaped by an odd run of preceding backslashes. */
541
+ function escapedAt(command, at) {
542
+ let slashes = 0;
543
+ while (at > 0 && command[--at] === '\\')
544
+ slashes += 1;
545
+ return slashes % 2 === 1;
546
+ }
547
+ /** A hash opens a comment only at the start of a shell word (outside quotes/expansions). */
548
+ function commentStartsAt(command, at) {
549
+ if (command[at] !== '#')
550
+ return false;
551
+ let before = at - 1;
552
+ // Backslash-newline is removed before tokenization: `word\\\n#suffix` is one word.
553
+ while (command[before] === '\n' && escapedAt(command, before))
554
+ before -= 2;
555
+ return before < 0 || (/[ \t\n;&|()<>]/.test(command[before] ?? '') && !escapedAt(command, before));
556
+ }
540
557
  /**
541
558
  * A command with every heredoc BODY blanked to spaces, so its length and every offset in it are
542
559
  * unchanged.
@@ -577,6 +594,10 @@ function blankHeredocBodies(command) {
577
594
  let quote = null;
578
595
  for (let i = lineStart; i < lineEnd; i++) {
579
596
  const char = command[i] ?? '';
597
+ if (char === '\\' && quote !== "'") {
598
+ i += 1;
599
+ continue;
600
+ }
580
601
  if (quote !== null) {
581
602
  if (char === quote)
582
603
  quote = null;
@@ -587,7 +608,7 @@ function blankHeredocBodies(command) {
587
608
  continue;
588
609
  }
589
610
  // A `#` that starts a word opens a comment: the rest of the line is not shell words.
590
- if (char === '#' && (i === lineStart || /\s/.test(command[i - 1] ?? '')))
611
+ if (commentStartsAt(command, i))
591
612
  break;
592
613
  if (char !== '<' || command[i + 1] !== '<')
593
614
  continue;
@@ -718,6 +739,16 @@ function splitSegments(command) {
718
739
  stack.pop();
719
740
  continue;
720
741
  }
742
+ // Comments are shell syntax only in a command context, including an unquoted `$(`.
743
+ // Blank their bytes so quotes/operators cannot affect this walk, while every later
744
+ // position and depth index stays aligned with the original command. Keep the newline.
745
+ if ((top === undefined || top === 'sub') && commentStartsAt(source, i)) {
746
+ const newline = source.indexOf('\n', i);
747
+ const end = newline < 0 ? source.length : newline;
748
+ put(' '.repeat(end - i), -1);
749
+ i = end - 1;
750
+ continue;
751
+ }
721
752
  // A command substitution, wherever a shell opens one — inside double quotes too, and never
722
753
  // inside single quotes (handled above). The `(` is consumed here so the depth is counted once.
723
754
  if (char === '$' && next === '(') {
@@ -726,6 +757,12 @@ function splitSegments(command) {
726
757
  i += 1;
727
758
  continue;
728
759
  }
760
+ if (char === '$' && next === '{') {
761
+ stack.push('parameter');
762
+ put('${', -1);
763
+ i += 1;
764
+ continue;
765
+ }
729
766
  // Inside double quotes: a `"` closes them, a backslash escapes the next character (so
730
767
  // `"\$("` opens nothing), and everything else is literal — a `'`, a `|`, a newline included.
731
768
  if (top === 'double') {
@@ -749,6 +786,19 @@ function splitSegments(command) {
749
786
  put(char, -1);
750
787
  continue;
751
788
  }
789
+ if (char === '\\' && next !== undefined) {
790
+ // An escaped character is literal, including inside an unquoted substitution.
791
+ put(char + next, -1);
792
+ i += 1;
793
+ continue;
794
+ }
795
+ // A parameter's `#` is expansion syntax or literal data, never a shell comment.
796
+ if (top === 'parameter') {
797
+ if (char === '}')
798
+ stack.pop();
799
+ put(char, -1);
800
+ continue;
801
+ }
752
802
  // Inside a substitution or a subshell of one: its own parentheses nest, its `|`/`;`/`&&`
753
803
  // belong to the nested command, and its newlines are that command's too.
754
804
  if (top === 'sub') {
@@ -759,12 +809,6 @@ function splitSegments(command) {
759
809
  put(char, -1);
760
810
  continue;
761
811
  }
762
- if (char === '\\' && next !== undefined) {
763
- // An escaped character is a literal (`\>` writes no file).
764
- put(char + next, -1);
765
- i += 1;
766
- continue;
767
- }
768
812
  // Grouping belongs to the shell, not to a word: a statement written inside `( … )` or
769
813
  // `{ … }` writes through that group, so the depth travels with every character a caller
770
814
  // reading a redirect may ask about.
package/dist/cut-run.d.ts CHANGED
@@ -167,6 +167,13 @@ export interface CutRun {
167
167
  * is slow, so the sentence must not send it to the background (xr-molecule-flash-m200 +03:45).
168
168
  */
169
169
  inlineScript?: string | null;
170
+ /**
171
+ * The variable a leaked standalone browser launch is bound to, when the command ran such a probe
172
+ * and the cut reader could only classify it after the run (xr-molecule-flash-m257). A leaked
173
+ * handle is not a slow workload: the background is the one place it must not be sent, so the
174
+ * sentence names the close instead.
175
+ */
176
+ browserProbe?: string | null;
170
177
  }
171
178
  /**
172
179
  * The run a command's result records as stopped by a budget, or null when the result is a run that
@@ -1 +1 @@
1
- {"version":3,"file":"cut-run.d.ts","sourceRoot":"","sources":["../src/cut-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAMH;;;GAGG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAE5C,kFAAkF;AAClF,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,OAAO,aAAa,CAAA;AAE1D;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,eAAe,CAGhE;AAiBD;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;CAClB;AAkDD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA4B9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAKD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,GACb,MAAM,GAAG,IAAI,CAIf;AAED,0BAA0B;AAC1B,MAAM,MAAM,KAAK;AACf,sEAAsE;AACpE,aAAa;AACf,oDAAoD;GAClD,iBAAiB;AACnB;;;;GAIG;GACD,cAAc,CAAA;AAElB,wDAAwD;AACxD,MAAM,WAAW,MAAM;IACrB,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAA;IACf,kFAAkF;IAClF,SAAS,EAAE,KAAK,CAAA;IAChB;;;;;;OAMG;IACH,UAAU,EAAE,OAAO,CAAA;IACnB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,qCAAqC;IACrC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAsDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,aAAa,SAAI,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,MAAM,GAAG,IAAI,CA4Ff;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAkB1D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAqClD"}
1
+ {"version":3,"file":"cut-run.d.ts","sourceRoot":"","sources":["../src/cut-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAMH;;;GAGG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAE5C,kFAAkF;AAClF,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,OAAO,aAAa,CAAA;AAE1D;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,eAAe,CAGhE;AAiBD;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;CAClB;AAkDD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA4B9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAKD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,GACb,MAAM,GAAG,IAAI,CAIf;AAED,0BAA0B;AAC1B,MAAM,MAAM,KAAK;AACf,sEAAsE;AACpE,aAAa;AACf,oDAAoD;GAClD,iBAAiB;AACnB;;;;GAIG;GACD,cAAc,CAAA;AAElB,wDAAwD;AACxD,MAAM,WAAW,MAAM;IACrB,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAA;IACf,kFAAkF;IAClF,SAAS,EAAE,KAAK,CAAA;IAChB;;;;;;OAMG;IACH,UAAU,EAAE,OAAO,CAAA;IACnB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,qCAAqC;IACrC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAsDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,aAAa,SAAI,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,MAAM,GAAG,IAAI,CA4Ff;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAmB1D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAyClD"}