@molecule/api-ai-tools 1.8.2 → 1.9.1

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.
@@ -21,8 +21,14 @@
21
21
  * Not command position, and therefore not an invocation: a runner named as an argument
22
22
  * (`head -30 run-api-tests-hurl.sh`, `grep -n playwright e2e/README.md`,
23
23
  * `git show HEAD:run-api-tests.sh`), and a runner named in a heredoc body — a file a command
24
- * WRITES is the caller's business, so a command with a heredoc goes through the caller's own
25
- * stripper first (`withoutHeredocs`, in `./suite-overlap.js`).
24
+ * WRITES is not a command it runs, so the bodies are blanked to spaces before anything is split
25
+ * ({@link blankHeredocBodies}); offsets stay exact, because the blanking preserves every position.
26
+ *
27
+ * The parser also answers WHERE each statement sits ({@link locatedCommandSegments}): `start`,
28
+ * `end`, `programOffset` and `assignmentOffset` are the offsets a caller splices at. They are
29
+ * token positions from this walk, never a search for re-joined text — the words are joined back
30
+ * with single spaces for matching, so `command.indexOf(segment.text)` fails on irregular spacing,
31
+ * on grouping braces, and on anything a heredoc body shifts (xr-molecule-flash-m105 +08:12).
26
32
  *
27
33
  * @module
28
34
  */
@@ -90,6 +96,24 @@ export interface LocatedCommandSegment {
90
96
  * inside a nested `sh -c` string, for the same reason as {@link start}.
91
97
  */
92
98
  assignmentOffset: number | null;
99
+ /**
100
+ * The offset in the command one past the statement's last word — where an option of this
101
+ * statement's program belongs, before whatever closes it. Null inside a nested `sh -c` string.
102
+ */
103
+ end: number | null;
104
+ /**
105
+ * The offset in the command of the word in command position — the program, once every wrapper
106
+ * in front of it is taken away (`npx --prefix /app <here>playwright test`). Null inside a
107
+ * nested `sh -c` string, and for a statement that only sets variables.
108
+ */
109
+ programOffset: number | null;
110
+ /**
111
+ * The separator the shell split at immediately before this statement — `|`, `||`, `&&`, `;`,
112
+ * a newline or `&` — or '' when it is the first statement of the command. It is the parse's own
113
+ * answer, so it is known even where the offsets are not, and a caller deciding whether two
114
+ * statements are one pipeline (`|`, never `;`) asks it instead of reading the text between them.
115
+ */
116
+ separator: string;
93
117
  }
94
118
  /**
95
119
  * Every statement of a command, with its own assignments and where it sits — assignment-only
@@ -1 +1 @@
1
- {"version":3,"file":"command-position.d.ts","sourceRoot":"","sources":["../src/command-position.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B;;;OAGG;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;AA2MD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AAkVD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,EAAE,CAIjE;AAED;;;;;;;;;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;CAChC;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAE/E;AAqDD;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE;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;;;OAGG;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;AA2MD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AA+cD;;;;;;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;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAE/E;AA4ED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAK/E"}
@@ -21,8 +21,14 @@
21
21
  * Not command position, and therefore not an invocation: a runner named as an argument
22
22
  * (`head -30 run-api-tests-hurl.sh`, `grep -n playwright e2e/README.md`,
23
23
  * `git show HEAD:run-api-tests.sh`), and a runner named in a heredoc body — a file a command
24
- * WRITES is the caller's business, so a command with a heredoc goes through the caller's own
25
- * stripper first (`withoutHeredocs`, in `./suite-overlap.js`).
24
+ * WRITES is not a command it runs, so the bodies are blanked to spaces before anything is split
25
+ * ({@link blankHeredocBodies}); offsets stay exact, because the blanking preserves every position.
26
+ *
27
+ * The parser also answers WHERE each statement sits ({@link locatedCommandSegments}): `start`,
28
+ * `end`, `programOffset` and `assignmentOffset` are the offsets a caller splices at. They are
29
+ * token positions from this walk, never a search for re-joined text — the words are joined back
30
+ * with single spaces for matching, so `command.indexOf(segment.text)` fails on irregular spacing,
31
+ * on grouping braces, and on anything a heredoc body shifts (xr-molecule-flash-m105 +08:12).
26
32
  *
27
33
  * @module
28
34
  */
@@ -380,6 +386,123 @@ function commandStart(words) {
380
386
  }
381
387
  return { at, nested: null, timeoutSeconds };
382
388
  }
389
+ /**
390
+ * A command with every heredoc BODY blanked to spaces, so its length and every offset in it are
391
+ * unchanged.
392
+ *
393
+ * A heredoc body is data the command writes, not a command it runs: `cat > playwright.config.ts
394
+ * <<'EOF' … import { defineConfig, devices } from '@playwright/test' … EOF` writes a file and runs
395
+ * `cat`. Read as shell, though, every line of the body is a statement — and the words joined back
396
+ * for matching are not the words written, so a caller that placed a statement by searching for
397
+ * them found nothing, in a command whose real statements sat all along. On X1
398
+ * xr-molecule-flash-m105 (+08:12) the body's `import { defineConfig, devices } …` alone made
399
+ * `suiteRunPipedAfterRunner` answer "piped" for a run with no pipe left, and the run's exit code
400
+ * was dropped for the rest of the session.
401
+ *
402
+ * The body runs from the line after the operator to the line whose whole content is the delimiter
403
+ * (leading TABS stripped for `<<-`, as `man bash` defines it); the terminator line is blanked too,
404
+ * and its newline kept, so the statements around it still split at the same places. Newlines
405
+ * inside the body are kept for the same reason. `<<<` is a herestring, not a heredoc, and a `<<`
406
+ * inside quotes or a comment opens nothing.
407
+ *
408
+ * @param command - The shell command.
409
+ * @returns The command with the bodies blanked to spaces, the same length as the input.
410
+ */
411
+ function blankHeredocBodies(command) {
412
+ if (!command.includes('<<'))
413
+ return command;
414
+ const chars = command.split('');
415
+ /** Overwrite a range with spaces, keeping its newlines so statements still split there. */
416
+ const blank = (from, to) => {
417
+ for (let i = from; i < to; i++)
418
+ if (chars[i] !== '\n')
419
+ chars[i] = ' ';
420
+ };
421
+ let lineStart = 0;
422
+ while (lineStart < command.length) {
423
+ const lineEndRaw = command.indexOf('\n', lineStart);
424
+ const lineEnd = lineEndRaw < 0 ? command.length : lineEndRaw;
425
+ const openers = [];
426
+ let quote = null;
427
+ for (let i = lineStart; i < lineEnd; i++) {
428
+ const char = command[i] ?? '';
429
+ if (quote !== null) {
430
+ if (char === quote)
431
+ quote = null;
432
+ continue;
433
+ }
434
+ if (char === '"' || char === "'") {
435
+ quote = char;
436
+ continue;
437
+ }
438
+ // A `#` that starts a word opens a comment: the rest of the line is not shell words.
439
+ if (char === '#' && (i === lineStart || /\s/.test(command[i - 1] ?? '')))
440
+ break;
441
+ if (char !== '<' || command[i + 1] !== '<')
442
+ continue;
443
+ // `<<<` is a herestring: it opens no body.
444
+ if (command[i + 2] === '<') {
445
+ i += 2;
446
+ continue;
447
+ }
448
+ let at = i + 2;
449
+ const stripTabs = command[at] === '-';
450
+ if (stripTabs)
451
+ at += 1;
452
+ while (command[at] === ' ' || command[at] === '\t')
453
+ at += 1;
454
+ let delimiter;
455
+ const opener = command[at];
456
+ if (opener === '"' || opener === "'") {
457
+ const close = command.indexOf(opener, at + 1);
458
+ const wordEnd = close < 0 || close > lineEnd ? lineEnd : close;
459
+ delimiter = command.slice(at + 1, wordEnd);
460
+ at = wordEnd + 1;
461
+ }
462
+ else {
463
+ let wordEnd = at;
464
+ while (wordEnd < lineEnd && !/\s/.test(command[wordEnd] ?? ''))
465
+ wordEnd += 1;
466
+ delimiter = command.slice(at, wordEnd);
467
+ at = wordEnd;
468
+ }
469
+ if (delimiter !== '')
470
+ openers.push({ delimiter, stripTabs });
471
+ i = at - 1;
472
+ }
473
+ if (openers.length === 0) {
474
+ if (lineEndRaw < 0)
475
+ break;
476
+ lineStart = lineEnd + 1;
477
+ continue;
478
+ }
479
+ // The bodies follow the line that opened them, in the order the operators were written.
480
+ let cursor = lineEndRaw < 0 ? command.length : lineEnd + 1;
481
+ for (const heredoc of openers) {
482
+ let bodyEnd = command.length;
483
+ let after = command.length;
484
+ let from = cursor;
485
+ while (from < command.length) {
486
+ const nl = command.indexOf('\n', from);
487
+ const line = command.slice(from, nl < 0 ? command.length : nl);
488
+ const compared = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
489
+ if (compared === heredoc.delimiter) {
490
+ bodyEnd = from;
491
+ after = nl < 0 ? command.length : nl + 1;
492
+ break;
493
+ }
494
+ if (nl < 0)
495
+ break;
496
+ from = nl + 1;
497
+ }
498
+ blank(cursor, bodyEnd);
499
+ blank(bodyEnd, after);
500
+ cursor = after;
501
+ }
502
+ lineStart = cursor;
503
+ }
504
+ return chars.join('');
505
+ }
383
506
  /**
384
507
  * A command's statements and pipeline stages, split where the shell splits them — inside quotes
385
508
  * and inside a command substitution, a `|`, `;` or `&&` belongs to that command, not to this one.
@@ -400,19 +523,21 @@ function commandStart(words) {
400
523
  * no runner, and every run of it reads as nothing).
401
524
  *
402
525
  * @param command - The shell command.
403
- * @returns Each segment's text and the offset in the command its first character sits at, in
404
- * order, with empty ones dropped.
526
+ * @returns Each statement's text, the offset its first character sits at, and the separator that
527
+ * ended it (`|`, `&&`, `;`, a newline, … — '' after the last one), in order, with empty ones
528
+ * dropped.
405
529
  */
406
530
  function splitSegments(command) {
531
+ const source = blankHeredocBodies(command);
407
532
  const parts = [];
408
533
  let buffer = '';
409
534
  /** The offset in the command of the current buffer's first character. */
410
535
  let bufferStart = 0;
411
536
  /** The nestings this position sits inside, outermost first. */
412
537
  const stack = [];
413
- for (let i = 0; i < command.length; i++) {
414
- const char = command[i] ?? '';
415
- const next = command[i + 1];
538
+ for (let i = 0; i < source.length; i++) {
539
+ const char = source[i] ?? '';
540
+ const next = source[i + 1];
416
541
  const top = stack[stack.length - 1];
417
542
  // A single-quoted string expands nothing at all: no escape, no substitution, and no quote
418
543
  // but its own ends it.
@@ -469,33 +594,46 @@ function splitSegments(command) {
469
594
  continue;
470
595
  }
471
596
  if ((char === '&' && next === '&') || (char === '|' && next === '|')) {
472
- parts.push({ text: buffer, start: bufferStart });
597
+ parts.push({ text: buffer, start: bufferStart, after: char + next });
473
598
  buffer = '';
474
599
  i += 1;
475
600
  bufferStart = i + 1;
476
601
  continue;
477
602
  }
478
603
  if (char === ';' || char === '|' || char === '\n') {
479
- parts.push({ text: buffer, start: bufferStart });
604
+ parts.push({ text: buffer, start: bufferStart, after: char });
480
605
  buffer = '';
481
606
  bufferStart = i + 1;
482
607
  continue;
483
608
  }
484
- if (char === '&' && command[i - 1] !== '>' && next !== '>') {
485
- parts.push({ text: buffer, start: bufferStart });
609
+ if (char === '&' && source[i - 1] !== '>' && next !== '>') {
610
+ parts.push({ text: buffer, start: bufferStart, after: '&' });
486
611
  buffer = '';
487
612
  bufferStart = i + 1;
488
613
  continue;
489
614
  }
490
615
  buffer += char;
491
616
  }
492
- parts.push({ text: buffer, start: bufferStart });
493
- return parts
494
- .map((part) => ({
495
- text: part.text.trim(),
496
- start: part.start + (part.text.length - part.text.trimStart().length),
497
- }))
498
- .filter((part) => part.text !== '');
617
+ parts.push({ text: buffer, start: bufferStart, after: '' });
618
+ const out = [];
619
+ // The separator of the last part, kept across the empty ones so a statement's own separator
620
+ // survives the blank lines around it (`a &&\n b` and a heredoc's blanked body alike).
621
+ let lastAfter = null;
622
+ for (const part of parts) {
623
+ const text = part.text.trim();
624
+ if (text === '') {
625
+ lastAfter = part.after;
626
+ continue;
627
+ }
628
+ out.push({
629
+ text,
630
+ start: part.start + (part.text.length - part.text.trimStart().length),
631
+ after: part.after,
632
+ before: lastAfter ?? '',
633
+ });
634
+ lastAfter = part.after;
635
+ }
636
+ return out;
499
637
  }
500
638
  /**
501
639
  * One segment's words: quotes kept whole, grouping parentheses dropped, and a `(` written
@@ -559,6 +697,42 @@ function leadingAssignments(text) {
559
697
  export function commandSegments(command) {
560
698
  return locatedSegmentsOf(command, 0).flatMap((located) => located.segment === null ? [] : [located.segment]);
561
699
  }
700
+ /**
701
+ * Where a statement's word sits in its own raw text, as {@link segmentWords} counts the words.
702
+ *
703
+ * The words are matched for READING (quotes whole, grouping braces dropped, a leading `(` taken
704
+ * off the first word); a caller that writes into the statement needs the offset it was written
705
+ * at. Both come from this one walk, so the two can never disagree about which word is which.
706
+ *
707
+ * @param text - One statement's text.
708
+ * @param index - The word's index in {@link segmentWords}'s list.
709
+ * @returns The word's offset in `text`, or null when there is no such word.
710
+ */
711
+ function wordOffset(text, index) {
712
+ const placement = new RegExp(WORD.source, 'g');
713
+ let kept = 0;
714
+ let first = true;
715
+ for (let match = placement.exec(text); match !== null; match = placement.exec(text)) {
716
+ if (GROUPING.has(match[0]))
717
+ continue;
718
+ if (first) {
719
+ first = false;
720
+ const bare = match[0].replace(/^[({]+/, '');
721
+ // `segmentWords` shifts a first word that was nothing but grouping away, so the NEXT word
722
+ // is not stripped and is counted from zero.
723
+ if (bare === '')
724
+ continue;
725
+ if (kept === index)
726
+ return match.index + (match[0].length - bare.length);
727
+ kept += 1;
728
+ continue;
729
+ }
730
+ if (kept === index)
731
+ return match.index;
732
+ kept += 1;
733
+ }
734
+ return null;
735
+ }
562
736
  /**
563
737
  * Every statement of a command, with its own assignments and where it sits — assignment-only
564
738
  * statements included, because the variables those persist are exactly what a later statement of
@@ -587,9 +761,31 @@ function locatedSegmentsOf(command, depth) {
587
761
  const words = segmentWords(part.text);
588
762
  const start = commandStart(words);
589
763
  const leading = leadingAssignments(part.text);
764
+ /** Every offset in this statement is an offset in the command it was written in. */
765
+ const placed = {
766
+ start: part.start,
767
+ end: part.start + part.text.length,
768
+ assignmentOffset: part.start + leading.end,
769
+ programOffset: words[start.at] === undefined ? null : part.start + (wordOffset(part.text, start.at) ?? 0),
770
+ };
590
771
  if (start.nested !== null) {
591
- for (const inner of locatedSegmentsOf(start.nested, depth + 1))
592
- out.push({ ...inner, start: null, assignmentOffset: null });
772
+ const inner = locatedSegmentsOf(start.nested, depth + 1);
773
+ for (let i = 0; i < inner.length; i += 1) {
774
+ const entry = inner[i];
775
+ if (!entry)
776
+ continue;
777
+ out.push({
778
+ ...entry,
779
+ // A nested statement's positions are positions in the nested string, not in this
780
+ // command. The FIRST one keeps this statement's own separator: the shell reads the
781
+ // nested string where this statement stands.
782
+ start: null,
783
+ end: null,
784
+ assignmentOffset: null,
785
+ programOffset: null,
786
+ separator: i === 0 ? part.before : entry.separator,
787
+ });
788
+ }
593
789
  continue;
594
790
  }
595
791
  const word = words[start.at];
@@ -600,8 +796,9 @@ function locatedSegmentsOf(command, depth) {
600
796
  out.push({
601
797
  segment: null,
602
798
  assignments: leading.words,
603
- start: part.start,
604
- assignmentOffset: part.start + leading.end,
799
+ ...placed,
800
+ programOffset: null,
801
+ separator: part.before,
605
802
  });
606
803
  continue;
607
804
  }
@@ -615,8 +812,8 @@ function locatedSegmentsOf(command, depth) {
615
812
  timeoutSeconds: start.timeoutSeconds,
616
813
  },
617
814
  assignments: leading.words,
618
- start: part.start,
619
- assignmentOffset: part.start + leading.end,
815
+ ...placed,
816
+ separator: part.before,
620
817
  });
621
818
  }
622
819
  return out;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What a failing request actually hit, read off the runner's own output.
3
+ *
4
+ * Three layers fail in three different ways, and only one of them is the app's code:
5
+ *
6
+ * - **connection** — the request never got an answer at all: the transport timed out, was
7
+ * refused, reset, or could not resolve the name or negotiate TLS. Nothing about a handler,
8
+ * a header or CORS was measured, so a re-run is the only thing that tells a cold start (a
9
+ * machine deployed minutes ago whose boot finishes after the URL is live) from a real outage.
10
+ * - **status** — a response arrived and the HTTP status disagreed with what the suite asserts.
11
+ * - **assertion** — a response arrived and an assertion over its body failed.
12
+ *
13
+ * X0 xr-molecule-flash-m110 (+18:42 → +20:09) is the case this exists for: one Hurl file on the
14
+ * freshly deployed URL failed with `(28) Operation timed out … 0 bytes received` while the other
15
+ * twelve passed and 138 requests executed, and every note on the result read it as an assertion.
16
+ * The executor rewrote a CORS middleware, bisected a second server and latency-compared the two,
17
+ * then re-ran the same file — which passed in 3 s. A single re-run was the whole fix, and the one
18
+ * fact that would have said so is the failure's LAYER.
19
+ *
20
+ * ONE classifier, used by the causes note ({@link ./utilities.ts} `failureCausesNote`) and by the
21
+ * platform's own proof result (`molecule-dev` `suite-proof.ts`), so the two can never disagree
22
+ * about whether a failure was the app's to fix.
23
+ *
24
+ * @module
25
+ */
26
+ /**
27
+ * The layer a failure came from: the transport (no answer at all), the response's status, or an
28
+ * assertion over a response that arrived.
29
+ */
30
+ export type RunFailureKind = 'connection' | 'status' | 'assertion';
31
+ /** What the transport did when a request never got an answer. */
32
+ export type ConnectionFailureKind = 'timeout' | 'refused' | 'reset' | 'dns' | 'tls' | 'empty';
33
+ /** One failure a runner printed, classified by the layer that failed. */
34
+ export interface RunFailureClass {
35
+ /** The layer that failed. */
36
+ kind: RunFailureKind;
37
+ /** For a `connection` failure, what the transport did. */
38
+ connection?: ConnectionFailureKind;
39
+ /** How long the request waited before the transport gave up, in seconds, when printed. */
40
+ seconds?: number;
41
+ /** The `file:line` the runner printed for the failure, when it printed one. */
42
+ at?: string;
43
+ }
44
+ /**
45
+ * Classify ONE failure's text — a Hurl `error:` block, or a Playwright failure message/body.
46
+ *
47
+ * The transport is read first: a block that carries a connection error AND an assertion is a
48
+ * request whose assertion never ran, so it is a connection failure. Only a block that carries no
49
+ * failure signature at all answers null — unknown is never a connection failure, because a
50
+ * misread transport hides a real app failure behind "just re-run it".
51
+ *
52
+ * @param text - One failure's text, as the runner printed it.
53
+ * @returns The classification, or null when the text carries no failure this reads.
54
+ */
55
+ export declare function classifyRunFailure(text: string): RunFailureClass | null;
56
+ /**
57
+ * Whether every one of the classified failures is a connection failure — the only shape in which
58
+ * no response the app produced was measured.
59
+ *
60
+ * @param failures - The run's classifications, in printed order.
61
+ * @returns True when there is at least one and none of them is the app's.
62
+ */
63
+ export declare function allConnectionFailures(failures: ReadonlyArray<RunFailureClass | null>): failures is ReadonlyArray<RunFailureClass & {
64
+ kind: 'connection';
65
+ }>;
66
+ //# sourceMappingURL=failure-kind.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"failure-kind.d.ts","sourceRoot":"","sources":["../src/failure-kind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,QAAQ,GAAG,WAAW,CAAA;AAElE,iEAAiE;AACjE,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,GAAG,KAAK,GAAG,OAAO,CAAA;AAE7F,yEAAyE;AACzE,MAAM,WAAW,eAAe;IAC9B,6BAA6B;IAC7B,IAAI,EAAE,cAAc,CAAA;IACpB,0DAA0D;IAC1D,UAAU,CAAC,EAAE,qBAAqB,CAAA;IAClC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,+EAA+E;IAC/E,EAAE,CAAC,EAAE,MAAM,CAAA;CACZ;AA0ED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAoBvE;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,aAAa,CAAC,eAAe,GAAG,IAAI,CAAC,GAC9C,QAAQ,IAAI,aAAa,CAAC,eAAe,GAAG;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,CAAC,CAErE"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What a failing request actually hit, read off the runner's own output.
3
+ *
4
+ * Three layers fail in three different ways, and only one of them is the app's code:
5
+ *
6
+ * - **connection** — the request never got an answer at all: the transport timed out, was
7
+ * refused, reset, or could not resolve the name or negotiate TLS. Nothing about a handler,
8
+ * a header or CORS was measured, so a re-run is the only thing that tells a cold start (a
9
+ * machine deployed minutes ago whose boot finishes after the URL is live) from a real outage.
10
+ * - **status** — a response arrived and the HTTP status disagreed with what the suite asserts.
11
+ * - **assertion** — a response arrived and an assertion over its body failed.
12
+ *
13
+ * X0 xr-molecule-flash-m110 (+18:42 → +20:09) is the case this exists for: one Hurl file on the
14
+ * freshly deployed URL failed with `(28) Operation timed out … 0 bytes received` while the other
15
+ * twelve passed and 138 requests executed, and every note on the result read it as an assertion.
16
+ * The executor rewrote a CORS middleware, bisected a second server and latency-compared the two,
17
+ * then re-ran the same file — which passed in 3 s. A single re-run was the whole fix, and the one
18
+ * fact that would have said so is the failure's LAYER.
19
+ *
20
+ * ONE classifier, used by the causes note ({@link ./utilities.ts} `failureCausesNote`) and by the
21
+ * platform's own proof result (`molecule-dev` `suite-proof.ts`), so the two can never disagree
22
+ * about whether a failure was the app's to fix.
23
+ *
24
+ * @module
25
+ */
26
+ /**
27
+ * Transport-level failures, most specific first: the patterns curl/Hurl and Chromium/Node print
28
+ * for a request that never got an answer. A message can carry more than one (`page.goto: Timeout
29
+ * 30000ms exceeded … net::ERR_CONNECTION_REFUSED`), and the more specific reading wins.
30
+ */
31
+ const CONNECTION_FAILURES = [
32
+ // Hurl/curl's numbered errors (curl's exit codes, printed as `(28) Operation timed out …`).
33
+ ['empty', /\(\s*52\)\s*Empty reply from server/],
34
+ ['timeout', /\(\s*28\)\s*Operation timed out|Operation timed out after \d+/],
35
+ ['dns', /\(\s*6\)\s*Could not resolve host|Could not resolve host/],
36
+ [
37
+ 'tls',
38
+ /\(\s*(?:35|51|58|59|60|77|83|90|91)\)\s|SSL connect error|SSL certificate problem|self[- ]signed certificate|UNABLE_TO_VERIFY_LEAF_SIGNATURE|CERT_HAS_EXPIRED/i,
39
+ ],
40
+ [
41
+ 'refused',
42
+ /\(\s*7\)\s*(?:Failed to connect|Couldn't connect)|Failed to connect to\s|Couldn't connect to server/,
43
+ ],
44
+ ['reset', /\(\s*5[56]\)\s*(?:Recv|Send) failure|Connection reset by peer|socket hang up/],
45
+ // The browser's own net errors, and the Node errors underneath them.
46
+ ['timeout', /net::ERR_(?:CONNECTION_)?TIMED_OUT|\bETIMEDOUT\b/],
47
+ ['dns', /net::ERR_NAME_(?:NOT_RESOLVED|RESOLUTION_FAILED)|\bENOTFOUND\b|\bEAI_AGAIN\b/],
48
+ ['refused', /net::ERR_CONNECTION_REFUSED|\bECONNREFUSED\b/],
49
+ ['empty', /net::ERR_EMPTY_RESPONSE/],
50
+ ['reset', /net::ERR_CONNECTION_(?:RESET|CLOSED|ABORTED)|\bECONNRESET\b/],
51
+ ['tls', /net::ERR_(?:CERT|SSL|BAD_SSL)\w*/i],
52
+ ];
53
+ /** A status assertion: the response arrived and its code is what failed. */
54
+ const STATUS_FAILURE = /^\s*error:\s*Assert status code\b|actual value is <\d{3}>|\bHTTP(?:\/[\d.]+)?\s+[1-5]\d{2}\b|Expected[: ]+[1-5]\d{2}\b|\.status\(\)/m;
55
+ /** Any other assertion over a response that arrived. */
56
+ const ASSERTION_FAILURE = /^\s*error:\s*Assert\b|\bAssertionError\b|\bexpect\(|\bExpected[: ]|\bReceived[: ]|\bjsonpath\b|^\s*Error:/m;
57
+ /** Hurl's `--> file:line:col`, the location of the failing request or assertion. */
58
+ const HURL_AT = /-->\s+(\S+?):(\d+)(?::\d+)?\s*$/m;
59
+ /** A spec file and line, the location a Playwright block names. */
60
+ const SPEC_AT = /([\w./~-]+\.(?:spec|test)\.[cm]?[jt]sx?):(\d+)(?::\d+)?/;
61
+ /** How long a request waited, as curl prints it (`Operation timed out after 20002 milliseconds`). */
62
+ const WAITED_MS = /Operation timed out after (\d+) milliseconds/;
63
+ /** How long a request waited, as Playwright prints it (`Timeout 30000ms exceeded`). */
64
+ const WAITED_PLAYWRIGHT = /(?<!Test )\bTimeout (\d+)ms exceeded/;
65
+ /**
66
+ * The `file:line` a failure block names, when it names one.
67
+ *
68
+ * @param text - The failure block.
69
+ * @returns The location, or undefined when the block carries none.
70
+ */
71
+ function failureLocation(text) {
72
+ const hurl = HURL_AT.exec(text);
73
+ if (hurl)
74
+ return `${hurl[1]}:${hurl[2]}`;
75
+ const spec = SPEC_AT.exec(text);
76
+ return spec ? `${spec[1]}:${spec[2]}` : undefined;
77
+ }
78
+ /**
79
+ * How long the request waited before it gave up, in seconds, when the runner printed it.
80
+ *
81
+ * @param text - The failure block.
82
+ * @returns The seconds, rounded, or undefined.
83
+ */
84
+ function waitedSeconds(text) {
85
+ const ms = WAITED_MS.exec(text) ?? WAITED_PLAYWRIGHT.exec(text);
86
+ return ms ? Math.round(Number(ms[1]) / 1000) : undefined;
87
+ }
88
+ /**
89
+ * Classify ONE failure's text — a Hurl `error:` block, or a Playwright failure message/body.
90
+ *
91
+ * The transport is read first: a block that carries a connection error AND an assertion is a
92
+ * request whose assertion never ran, so it is a connection failure. Only a block that carries no
93
+ * failure signature at all answers null — unknown is never a connection failure, because a
94
+ * misread transport hides a real app failure behind "just re-run it".
95
+ *
96
+ * @param text - One failure's text, as the runner printed it.
97
+ * @returns The classification, or null when the text carries no failure this reads.
98
+ */
99
+ export function classifyRunFailure(text) {
100
+ const body = String(text ?? '');
101
+ if (body.trim().length === 0)
102
+ return null;
103
+ for (const [connection, pattern] of CONNECTION_FAILURES) {
104
+ if (!pattern.test(body))
105
+ continue;
106
+ const result = { kind: 'connection', connection };
107
+ const at = failureLocation(body);
108
+ if (at !== undefined)
109
+ result.at = at;
110
+ const seconds = waitedSeconds(body);
111
+ if (seconds !== undefined)
112
+ result.seconds = seconds;
113
+ return result;
114
+ }
115
+ const at = failureLocation(body);
116
+ const kind = STATUS_FAILURE.test(body)
117
+ ? 'status'
118
+ : ASSERTION_FAILURE.test(body)
119
+ ? 'assertion'
120
+ : null;
121
+ if (kind === null)
122
+ return null;
123
+ return at === undefined ? { kind } : { kind, at };
124
+ }
125
+ /**
126
+ * Whether every one of the classified failures is a connection failure — the only shape in which
127
+ * no response the app produced was measured.
128
+ *
129
+ * @param failures - The run's classifications, in printed order.
130
+ * @returns True when there is at least one and none of them is the app's.
131
+ */
132
+ export function allConnectionFailures(failures) {
133
+ return failures.length > 0 && failures.every((failure) => failure?.kind === 'connection');
134
+ }
package/dist/index.d.ts CHANGED
@@ -134,9 +134,11 @@ export * from './backends/sandbox.js';
134
134
  export * from './browser-guard.js';
135
135
  export * from './command-position.js';
136
136
  export * from './cut-run.js';
137
+ export * from './failure-kind.js';
137
138
  export * from './input-normalizer.js';
138
139
  export * from './no-test-run.js';
139
140
  export * from './schemas.js';
141
+ export * from './sleep-peek.js';
140
142
  export * from './suite-overlap.js';
141
143
  export * from './system-prompt.js';
142
144
  export * from './tools.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkIG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,sBAAsB,CAAA;AACpC,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkIG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,mBAAmB,CAAA;AACjC,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,sBAAsB,CAAA;AACpC,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
package/dist/index.js CHANGED
@@ -134,9 +134,11 @@ export * from './backends/sandbox.js';
134
134
  export * from './browser-guard.js';
135
135
  export * from './command-position.js';
136
136
  export * from './cut-run.js';
137
+ export * from './failure-kind.js';
137
138
  export * from './input-normalizer.js';
138
139
  export * from './no-test-run.js';
139
140
  export * from './schemas.js';
141
+ export * from './sleep-peek.js';
140
142
  export * from './suite-overlap.js';
141
143
  export * from './system-prompt.js';
142
144
  export * from './tools.js';