@molecule/api-ai-tools 1.10.4 → 1.10.6

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.
package/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-19T06:38:37.559Z
6
+ Generated: 2026-10-19T13:08:52.848Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-tools
@@ -584,6 +584,13 @@ selector. xr-molecule-flash-m169 (+13:09) spent its fix rounds on the wrong comp
584
584
  markup detail while `GET /api/tags` was returning the missing "trending" and `PopularTags`
585
585
  sliced the response to its first 20 entries (the m99 top-N class, in the frontend).
586
586
 
587
+ The text must be a value the response held, never a substring of one: a whole JSON string or
588
+ number value, or a word bounded by non-alphanumerics. A text of 2 characters or fewer, or one
589
+ made of digits only, must be a whole JSON value. xr-molecule-flash-m208's pagination assertion
590
+ waited for "2" and the raw substring hit `pag1792394785820` in a tags response, so the note led
591
+ with a response that had nothing to do with the failure; when no response holds the text that
592
+ way, the field is absent and the note leads with its next finding.
593
+
587
594
  ```typescript
588
595
  interface TraceMissingText {
589
596
  /** The text the assertion waited for (`trending`). */
@@ -765,6 +772,15 @@ interface TraceArtifact {
765
772
  * leaving the run to probe computed styles (xr-molecule-flash-m197).
766
773
  */
767
774
  hiddenBox?: TraceHiddenBox
775
+ /**
776
+ * The method+path groups this test's page sent {@link TRACE_NOTE_LOOP_REPEATS} times or more,
777
+ * most-repeated first — a render loop the note leads with (xr-molecule-flash-m215: 2,106
778
+ * `GET /api/articles/<slug>` in one test, printed as four identical `→ 200` lines). Counted over
779
+ * every network entry of the failing page, not the capped {@link TraceArtifact.requests} list.
780
+ *
781
+ * Absent when no group reached the threshold, and on traces read before this field existed.
782
+ */
783
+ repeated?: TraceRequestRepeat[]
768
784
  }
769
785
  ```
770
786
 
@@ -819,6 +835,13 @@ interface TraceRunReport {
819
835
  * Empty when the caller could not read the app's config.
820
836
  */
821
837
  authEndpoints?: readonly string[]
838
+ /**
839
+ * The app source files whose code sends a repeated request, read by the caller from the
840
+ * project (`app/src/**`) before this note is written. A loop's sentence names the component to
841
+ * read instead of leaving the run to search for it (xr-molecule-flash-m215's `Editor.tsx`).
842
+ * Empty when the caller could not read the app or no file states the path.
843
+ */
844
+ callSites?: readonly { method: string; path: string; file: string }[]
822
845
  }
823
846
  ```
824
847
 
@@ -4112,6 +4135,21 @@ How many requests the note names per test.
4112
4135
  const MAX_TRACE_NOTE_REQUESTS: 4
4113
4136
  ```
4114
4137
 
4138
+ #### `TRACE_NOTE_LOOP_REPEATS`
4139
+
4140
+ How many times ONE test may send the same method+path before the note calls it a render loop.
4141
+
4142
+ xr-molecule-flash-m215 (+27:27 → +29:42): an Editor effect with `[seeded, slug, t]` in its
4143
+ dependency list re-fetched `GET /api/articles/<slug>` 2,106 times in one test. The trace reader
4144
+ caps and de-duplicates its request list, so the note printed four identical `→ 200` lines and
4145
+ the run patched the component's seeding, rebuilt and re-ran before unzipping the trace by hand.
4146
+ Twenty is far above the retries a correct page makes (a few on a flaky call) and far below any
4147
+ render loop.
4148
+
4149
+ ```typescript
4150
+ const TRACE_NOTE_LOOP_REPEATS: 20
4151
+ ```
4152
+
4115
4153
  #### `TRACE_NOTE_URL_CHARS`
4116
4154
 
4117
4155
  How long a URL the note prints, before the middle is cut.
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CA2U1C,CAAA"}
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CA8U1C,CAAA"}
package/dist/schemas.js CHANGED
@@ -28,6 +28,9 @@ export const TOOL_SCHEMAS = {
28
28
  'file before editing it. When you need more than one file — surveying a project, ' +
29
29
  'gathering the files behind one feature — pass them all as `paths` in ONE call rather ' +
30
30
  'than calling this tool once per file: same content, a fraction of the time. ' +
31
+ 'Independent calls go in ONE response: read every file you need together (one `paths` ' +
32
+ 'call, or several `read_file` calls in the same response) — they run at once. Only a call ' +
33
+ 'that needs another call’s result waits for it. ' +
31
34
  'For a @molecule package\u2019s docs call read_molecule_doc instead — one call, the whole ' +
32
35
  'document. A file that fits one read result comes back whole; do not page through it with ' +
33
36
  'offset/limit.',
@@ -64,7 +67,7 @@ export const TOOL_SCHEMAS = {
64
67
  },
65
68
  write_file: {
66
69
  name: 'write_file',
67
- description: "Create or overwrite a file with full content. Use edit_file for small targeted changes instead. The result gives the file's line count (and a small file's numbered lines).",
70
+ description: "Create or overwrite a file with full content. Use edit_file for small targeted changes instead. The result gives the file's line count (and a small file's numbered lines). Independent calls go in ONE response: write every file you already know the content of together — one write_file call per file, different paths, in the same response — instead of one file per round trip. Only a call that needs another call's result waits for it.",
68
71
  parameters: {
69
72
  type: 'object',
70
73
  properties: {
@@ -82,7 +85,7 @@ export const TOOL_SCHEMAS = {
82
85
  },
83
86
  edit_file: {
84
87
  name: 'edit_file',
85
- description: 'Make targeted search-and-replace edits to a file. Preferred over write_file for small changes. Each old_string must match exactly once in the file, unless that replacement sets replace_all to change every occurrence (a rename, a repeated call). The result shows each edited region as it now reads, with line numbers, so there is no need to re-read the file to check an edit.',
88
+ description: 'Make targeted search-and-replace edits to a file. Preferred over write_file for small changes. Each old_string must match exactly once in the file, unless that replacement sets replace_all to change every occurrence (a rename, a repeated call). The result shows each edited region as it now reads, with line numbers, so there is no need to re-read the file to check an edit. Independent calls go in ONE response: edits to different files you already know how to make go together in the same response, one edit_file call per file. Only a call that needs another call’s result waits for it.',
86
89
  parameters: {
87
90
  type: 'object',
88
91
  properties: {
@@ -181,6 +181,13 @@ export interface TraceContextEvidence {
181
181
  * selector. xr-molecule-flash-m169 (+13:09) spent its fix rounds on the wrong component and on a
182
182
  * markup detail while `GET /api/tags` was returning the missing "trending" and `PopularTags`
183
183
  * sliced the response to its first 20 entries (the m99 top-N class, in the frontend).
184
+ *
185
+ * The text must be a value the response held, never a substring of one: a whole JSON string or
186
+ * number value, or a word bounded by non-alphanumerics. A text of 2 characters or fewer, or one
187
+ * made of digits only, must be a whole JSON value. xr-molecule-flash-m208's pagination assertion
188
+ * waited for "2" and the raw substring hit `pag1792394785820` in a tags response, so the note led
189
+ * with a response that had nothing to do with the failure; when no response holds the text that
190
+ * way, the field is absent and the note leads with its next finding.
184
191
  */
185
192
  export interface TraceMissingText {
186
193
  /** The text the assertion waited for (`trending`). */
@@ -256,6 +263,27 @@ export interface TraceHiddenBox {
256
263
  /** True when the element has no text and no element children at all. */
257
264
  empty?: true;
258
265
  }
266
+ /**
267
+ * One method+path a test's page sent many times — the trace of a render loop.
268
+ *
269
+ * Counted over EVERY network entry the failing page recorded, before the trace reader's per-trace
270
+ * cap and its method+path+status dedupe, because those two bounds are exactly what hid m215's
271
+ * 2,106-request loop: the note could only ever see four of them.
272
+ */
273
+ interface TraceRequestRepeat {
274
+ /** The HTTP method (`GET`). */
275
+ method: string;
276
+ /** The path, query and fragment dropped (`/api/articles/test-article-1`). */
277
+ path: string;
278
+ /** How many times the page sent it in this test. */
279
+ count: number;
280
+ /** The last response status recorded. */
281
+ status?: number;
282
+ /** The browser's resource type (`fetch`, `xhr`, `document`). */
283
+ type?: string;
284
+ /** How long the repeated requests spanned, on the trace's own clock (ms). */
285
+ ms?: number;
286
+ }
259
287
  /** What one trace says: the test it belongs to, the requests it made, and its console lines. */
260
288
  export interface TraceArtifact {
261
289
  /** The failure's spec file, relative to the run's directory, when the trace names one. */
@@ -348,6 +376,15 @@ export interface TraceArtifact {
348
376
  * leaving the run to probe computed styles (xr-molecule-flash-m197).
349
377
  */
350
378
  hiddenBox?: TraceHiddenBox;
379
+ /**
380
+ * The method+path groups this test's page sent {@link TRACE_NOTE_LOOP_REPEATS} times or more,
381
+ * most-repeated first — a render loop the note leads with (xr-molecule-flash-m215: 2,106
382
+ * `GET /api/articles/<slug>` in one test, printed as four identical `→ 200` lines). Counted over
383
+ * every network entry of the failing page, not the capped {@link TraceArtifact.requests} list.
384
+ *
385
+ * Absent when no group reached the threshold, and on traces read before this field existed.
386
+ */
387
+ repeated?: TraceRequestRepeat[];
351
388
  }
352
389
  /** How many traces one read reports, newest first. */
353
390
  export declare const MAX_TRACE_ARTIFACTS = 12;
@@ -366,6 +403,17 @@ export declare const MAX_TRACED_BODY_CHARS = 300;
366
403
  export declare const MAX_TRACE_NOTE_TESTS = 5;
367
404
  /** How many requests the note names per test. */
368
405
  export declare const MAX_TRACE_NOTE_REQUESTS = 4;
406
+ /**
407
+ * How many times ONE test may send the same method+path before the note calls it a render loop.
408
+ *
409
+ * xr-molecule-flash-m215 (+27:27 → +29:42): an Editor effect with `[seeded, slug, t]` in its
410
+ * dependency list re-fetched `GET /api/articles/<slug>` 2,106 times in one test. The trace reader
411
+ * caps and de-duplicates its request list, so the note printed four identical `→ 200` lines and
412
+ * the run patched the component's seeding, rebuilt and re-ran before unzipping the trace by hand.
413
+ * Twenty is far above the retries a correct page makes (a few on a flaky call) and far below any
414
+ * render loop.
415
+ */
416
+ export declare const TRACE_NOTE_LOOP_REPEATS = 20;
369
417
  /** How long a URL the note prints, before the middle is cut. */
370
418
  export declare const TRACE_NOTE_URL_CHARS = 96;
371
419
  /** How much of the sent body the note prints — enough to see a `{"data":…}` wrapper. */
@@ -511,6 +559,17 @@ export interface TraceRunReport {
511
559
  * Empty when the caller could not read the app's config.
512
560
  */
513
561
  authEndpoints?: readonly string[];
562
+ /**
563
+ * The app source files whose code sends a repeated request, read by the caller from the
564
+ * project (`app/src/**`) before this note is written. A loop's sentence names the component to
565
+ * read instead of leaving the run to search for it (xr-molecule-flash-m215's `Editor.tsx`).
566
+ * Empty when the caller could not read the app or no file states the path.
567
+ */
568
+ callSites?: readonly {
569
+ method: string;
570
+ path: string;
571
+ file: string;
572
+ }[];
514
573
  }
515
574
  /**
516
575
  * Whether a request PATH asks the app to mint a credential — a login, a registration, a token
@@ -624,12 +683,16 @@ export declare function hiddenBoxNote(items: readonly TraceArtifact[]): string |
624
683
  * config names; the traces add what their own requests show) and never assumed to be `/api`.
625
684
  *
626
685
  * A 401/403 on the app's own origin is answered from the request's own credential record instead
627
- * ({@link authRefusalSentence}): no `Authorization` header means the CLIENT never attached the
628
- * token (the shared HTTP client's `setAuthToken` is what the auth bond mirrors it into), while a
629
- * header the API then refused moves the answer to the server. The handler sentence is kept for
630
- * the rest — 404/422/500 — and dropped when every failure was a credential refusal, because a
631
- * trace that proves the header was never sent cannot be explained by the handler.
632
- * xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
686
+ * ({@link authRefusalSentence}): a header the API then refused moves the answer to the server. No
687
+ * header is read together with the trace's session evidence — the failing test's identity, a
688
+ * credential exchange it answered 2xx, a request the client already attached a token to — and only
689
+ * positive evidence may prescribe the client's token wiring; otherwise the sentence is
690
+ * observational and, for a navigation failure, names the URL transition and the route guard
691
+ * (xr-molecule-flash-m221: a signed-out redirect test's `GET /api/articles/feed → 401` sent the
692
+ * model into replacing a working HTTP client, which lost the session token for ~28 minutes). The
693
+ * handler sentence is kept for the rest — 404/422/500 — and dropped when every failure was a
694
+ * credential refusal, because a trace that proves the header was never sent cannot be explained by
695
+ * the handler. xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
633
696
  * `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
634
697
  *
635
698
  * A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
@@ -703,4 +766,5 @@ export declare function diagnosisTraceCommand(args: {
703
766
  cwd: string;
704
767
  readFile: (path: string) => Promise<string>;
705
768
  }): Promise<DiagnosisTraceRewrite>;
769
+ export {};
706
770
  //# sourceMappingURL=trace-artifacts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"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"}
@@ -52,6 +52,17 @@ const MAX_TRACED_WEBSOCKET_CHARS = 600;
52
52
  export const MAX_TRACE_NOTE_TESTS = 5;
53
53
  /** How many requests the note names per test. */
54
54
  export const MAX_TRACE_NOTE_REQUESTS = 4;
55
+ /**
56
+ * How many times ONE test may send the same method+path before the note calls it a render loop.
57
+ *
58
+ * xr-molecule-flash-m215 (+27:27 → +29:42): an Editor effect with `[seeded, slug, t]` in its
59
+ * dependency list re-fetched `GET /api/articles/<slug>` 2,106 times in one test. The trace reader
60
+ * caps and de-duplicates its request list, so the note printed four identical `→ 200` lines and
61
+ * the run patched the component's seeding, rebuilt and re-ran before unzipping the trace by hand.
62
+ * Twenty is far above the retries a correct page makes (a few on a flaky call) and far below any
63
+ * render loop.
64
+ */
65
+ export const TRACE_NOTE_LOOP_REPEATS = 20;
55
66
  /** How long a URL the note prints, before the middle is cut. */
56
67
  export const TRACE_NOTE_URL_CHARS = 96;
57
68
  /** How much of the sent body the note prints — enough to see a `{"data":…}` wrapper. */
@@ -111,6 +122,7 @@ const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
111
122
  const MAX_ACTIONS = ${MAX_TRACED_ACTIONS}
112
123
  const MAX_BODY = ${MAX_TRACED_BODY_CHARS}
113
124
  const MAX_WS = ${MAX_TRACED_WEBSOCKET_CHARS}
125
+ const LOOP_REPEATS = ${TRACE_NOTE_LOOP_REPEATS}
114
126
  const MAX_URL = 200
115
127
  // A console line that is not an error but still has to reach the note (m148): one a molecule
116
128
  // package wrote — it names the package, the \`[molecule/…]\` prefix, or the \`[scope]\` prefix
@@ -236,6 +248,51 @@ function waitedTextOf(message) {
236
248
  return quoted(/Expected(?: string)?:\\s*(["'])((?:(?!\\1).)*)\\1/)
237
249
  }
238
250
 
251
+ // Whether a response body CARRIES an awaited text as a value of its own rather than as a substring.
252
+ // m208's pagination assertion waited for the page-link text "2", and that one character occurs
253
+ // inside every tag name the tags response returned (\`pag1792394785820\`): the note led with
254
+ // "The response GET /api/tags contained \\"2\\".", a fact with no lead in it, and the trace row that
255
+ // did explain the failure (a page size of 20 where the suite paginates 10) came after it. A
256
+ // response counts only when the text is a whole JSON string (or number) value it returned, or a
257
+ // word with a non-alphanumeric on both sides. A text of 2 characters or fewer, or one made of
258
+ // digits only, is not a word — it must be a whole JSON value, or no response is named at all and
259
+ // the note leads with its next finding.
260
+ function responseCarriesText(body, text) {
261
+ if (!body || !text) return false
262
+ if (jsonValueEquals(body, text)) return true
263
+ if (text.length <= 2 || /^[0-9]+$/.test(text)) return false
264
+ const alnum = function (ch) { return ch !== '' && /[A-Za-z0-9]/.test(ch) }
265
+ let at = body.indexOf(text)
266
+ while (at !== -1) {
267
+ const before = at === 0 ? '' : body[at - 1]
268
+ const after = at + text.length >= body.length ? '' : body[at + text.length]
269
+ if (!alnum(before) && !alnum(after)) return true
270
+ at = body.indexOf(text, at + 1)
271
+ }
272
+ return false
273
+ }
274
+
275
+ // Whether the body is JSON that holds \`text\` as a whole string value, or as a number whose written
276
+ // form is \`text\` (\`{"page":2}\` carries "2"). A body the trace read cut short does not parse and so
277
+ // proves nothing: only the word-bounded reading above is left, and a short text gets no match.
278
+ function jsonValueEquals(body, text) {
279
+ let parsed
280
+ try { parsed = JSON.parse(body) } catch (e) { return false }
281
+ const walk = function (value, depth) {
282
+ if (depth > 8 || value === null || value === undefined) return false
283
+ if (typeof value === 'string') return value === text
284
+ if (typeof value === 'number') return String(value) === text
285
+ if (Array.isArray(value)) {
286
+ for (let i = 0; i < value.length; i++) if (walk(value[i], depth + 1)) return true
287
+ return false
288
+ }
289
+ if (typeof value !== 'object') return false
290
+ for (const key of Object.keys(value)) if (walk(value[key], depth + 1)) return true
291
+ return false
292
+ }
293
+ return walk(parsed, 0)
294
+ }
295
+
239
296
  // The attributes of one serialized element's opening tag (\`class="a b" id="c"\`). The trace's
240
297
  // snapshots carry them as an object already; the ASSERTION's error prints the tag as text.
241
298
  function attrsOf(source) {
@@ -811,6 +868,64 @@ for (const one of found.slice(0, MAX_TRACES)) {
811
868
  /^\\/icons\\//.test(p) ||
812
869
  /^\\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/.test(p)
813
870
  }
871
+ // The request's identity for the loop count and the action's own list: the path without its
872
+ // query, so 2,106 GETs of the same article are ONE group.
873
+ function requestPathOf(r) {
874
+ return String(r.path).split(/[?#]/)[0]
875
+ }
876
+ // Method+path repeated at least LOOP_REPEATS times by ONE page: a render loop, not a retry
877
+ // (m215). Counted over every network entry of the failing page, BEFORE the per-trace cap and
878
+ // dedupe, so the note can name the real number instead of the four copies the cap kept.
879
+ function repeatedOf(list) {
880
+ const groups = {}
881
+ const order = []
882
+ for (const r of list) {
883
+ if (shellRequest(r)) continue
884
+ const p = requestPathOf(r)
885
+ const key = r.method + ' ' + p
886
+ let group = groups[key]
887
+ if (group === undefined) {
888
+ group = { method: r.method, path: p, count: 0, status: r.status, type: r.type || undefined, first: undefined, last: undefined }
889
+ groups[key] = group
890
+ order.push(key)
891
+ }
892
+ group.count += 1
893
+ group.status = r.status
894
+ if (typeof r.startedAt === 'number') {
895
+ if (group.first === undefined || r.startedAt < group.first) group.first = r.startedAt
896
+ if (group.last === undefined || r.startedAt > group.last) group.last = r.startedAt
897
+ }
898
+ }
899
+ const out = []
900
+ for (const key of order) {
901
+ const group = groups[key]
902
+ if (group.count < LOOP_REPEATS) continue
903
+ out.push({
904
+ method: group.method,
905
+ path: group.path,
906
+ count: group.count,
907
+ ...(group.status > 0 ? { status: group.status } : {}),
908
+ ...(group.type ? { type: group.type } : {}),
909
+ ...(group.first !== undefined && group.last !== undefined
910
+ ? { ms: Math.max(0, Math.round(group.last - group.first)) }
911
+ : {}),
912
+ })
913
+ }
914
+ out.sort(function (a, b) { return b.count - a.count })
915
+ return out.slice(0, 3)
916
+ }
917
+ // One entry per method+path, in recording order: a loop's four copies are one line in the row.
918
+ function dedupeByPath(list) {
919
+ const seen = {}
920
+ const out = []
921
+ for (const r of list) {
922
+ const key = r.method + ' ' + requestPathOf(r)
923
+ if (seen[key] === true) continue
924
+ seen[key] = true
925
+ out.push(r)
926
+ }
927
+ return out
928
+ }
814
929
  // A failed request: a 4xx/5xx, or no response at all WITH Playwright's error text (a refused
815
930
  // connection). No response and no text is an aborted/pending request, not a failure. A 404 on
816
931
  // the page shell is excepted, for the reason above.
@@ -964,6 +1079,12 @@ for (const one of found.slice(0, MAX_TRACES)) {
964
1079
  // carries tag names in its \`tagList\`, while the tags response is the collection whose membership
965
1080
  // the assertion tests.
966
1081
  //
1082
+ // The text must be a VALUE the response held, not a substring of one (m208): a one-character
1083
+ // awaited text ("2", the page link the pagination assertion waits for) occurs inside every
1084
+ // \`pag…\` tag name, and the note led with "The response GET /api/tags contained \\"2\\"." while the
1085
+ // row that explained the failure said the page size was 20. {@link responseCarriesText} decides,
1086
+ // and when no response holds it that way no response is named.
1087
+ //
967
1088
  // The "page drops it before rendering" claim is made only when the trace PROVES it: a successful
968
1089
  // GET of the collection that STARTED AFTER the last navigation, and the awaited text absent from
969
1090
  // the page's own failure snapshot. A creation POST says nothing about what a collection render
@@ -984,7 +1105,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
984
1105
  if (r.type !== 'fetch' && r.type !== 'xhr') continue
985
1106
  if (r.status < 200 || r.status >= 400) continue
986
1107
  const got = gotText(buf, entries, r)
987
- if (!got || got.indexOf(waitedText) === -1) continue
1108
+ if (!got || !responseCarriesText(got, waitedText)) continue
988
1109
  if (best === null || got.length < best.got.length) best = { request: r, got: got }
989
1110
  // The collection read the claim may rest on: a GET that started after the last navigation
990
1111
  // (a request that started before it belongs to the page load, not to the failure).
@@ -1043,7 +1164,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
1043
1164
  api: lastAction.api,
1044
1165
  start: lastAction.start,
1045
1166
  end: lastAction.end,
1046
- requests: actionIndexes.slice(0, MAX_NOTE).map(function (i) { return wire(requests[i]) }),
1167
+ requests: dedupeByPath(actionIndexes.map(function (i) { return requests[i] })).slice(0, MAX_NOTE).map(function (r) { return wire(r) }),
1047
1168
  ...(pageBefore === undefined ? {} : { pageBefore: pageBefore }),
1048
1169
  ...(pageAfter === undefined ? {} : { pageAfter: pageAfter }),
1049
1170
  }
@@ -1238,6 +1359,8 @@ for (const one of found.slice(0, MAX_TRACES)) {
1238
1359
  hiddenBox === null
1239
1360
  )
1240
1361
  continue
1362
+ // Counted over the page's EVERY network entry, before the cap and dedupe above (m215).
1363
+ const repeated = repeatedOf(requests)
1241
1364
  process.stdout.write('TRACE ' + JSON.stringify({
1242
1365
  dir: path.relative(ROOT, path.dirname(one.file)),
1243
1366
  spec: spec,
@@ -1248,6 +1371,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
1248
1371
  // tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
1249
1372
  // plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
1250
1373
  requests: chosen.slice(0, MAX_REQUESTS),
1374
+ ...(repeated.length === 0 ? {} : { repeated: repeated }),
1251
1375
  ...(missingText === null ? {} : { missingText: missingText }),
1252
1376
  ...(readContradiction === null ? {} : { readContradiction: readContradiction }),
1253
1377
  ...(hiddenBox === null ? {} : { hiddenBox: hiddenBox }),
@@ -1396,6 +1520,38 @@ function parseLastAction(value) {
1396
1520
  ...(typeof raw.pageAfter === 'string' ? { pageAfter: raw.pageAfter } : {}),
1397
1521
  };
1398
1522
  }
1523
+ /**
1524
+ * The repeated method+path groups out of a `TRACE` line's json.
1525
+ *
1526
+ * @param value - The line's `repeated`.
1527
+ * @returns The groups, most-repeated first, as the reader printed them.
1528
+ */
1529
+ function parseRepeated(value) {
1530
+ if (!Array.isArray(value))
1531
+ return [];
1532
+ const out = [];
1533
+ for (const entry of value) {
1534
+ if (typeof entry !== 'object' || entry === null)
1535
+ continue;
1536
+ const raw = entry;
1537
+ if (typeof raw.method !== 'string' || typeof raw.path !== 'string')
1538
+ continue;
1539
+ const count = Number(raw.count);
1540
+ if (!Number.isFinite(count) || count < 2)
1541
+ continue;
1542
+ out.push({
1543
+ method: raw.method,
1544
+ path: raw.path,
1545
+ count,
1546
+ ...(Number.isFinite(Number(raw.status)) && Number(raw.status) > 0
1547
+ ? { status: Number(raw.status) }
1548
+ : {}),
1549
+ ...(typeof raw.type === 'string' && raw.type ? { type: raw.type } : {}),
1550
+ ...(Number.isFinite(Number(raw.ms)) && Number(raw.ms) >= 0 ? { ms: Number(raw.ms) } : {}),
1551
+ });
1552
+ }
1553
+ return out;
1554
+ }
1399
1555
  /**
1400
1556
  * The traces {@link traceArtifactsScript} printed, parsed.
1401
1557
  *
@@ -1420,6 +1576,7 @@ export function parseTraceArtifacts(stdout) {
1420
1576
  const missingText = parseMissingText(raw.missingText);
1421
1577
  const readContradiction = parseReadContradiction(raw.readContradiction);
1422
1578
  const hiddenBox = parseHiddenBox(raw.hiddenBox);
1579
+ const repeated = parseRepeated(raw.repeated);
1423
1580
  const otherContexts = Array.isArray(raw.otherContexts)
1424
1581
  ? raw.otherContexts
1425
1582
  .filter((c) => typeof c === 'object' && c !== null)
@@ -1458,6 +1615,7 @@ export function parseTraceArtifacts(stdout) {
1458
1615
  ...(missingText === null ? {} : { missingText }),
1459
1616
  ...(readContradiction === null ? {} : { readContradiction }),
1460
1617
  ...(hiddenBox === null ? {} : { hiddenBox }),
1618
+ ...(repeated.length === 0 ? {} : { repeated }),
1461
1619
  });
1462
1620
  }
1463
1621
  catch (_error) {
@@ -1529,6 +1687,109 @@ function requestWithBody(request, gotChars) {
1529
1687
  const got = request.got ? ` got ${bodyLine(redactSecrets(request.got), gotChars)}` : '';
1530
1688
  return `${requestLabel(request)} → ${status}${sent}${got}`;
1531
1689
  }
1690
+ /**
1691
+ * Whether two request paths name the same route: equal, or a `:param` segment on either side
1692
+ * matching the other's value (the app's code writes `/articles/:slug`, the trace records
1693
+ * `/articles/test-article-1`).
1694
+ *
1695
+ * @param a - One path.
1696
+ * @param b - The other.
1697
+ * @returns True when they name the same route.
1698
+ */
1699
+ function sameTracePath(a, b) {
1700
+ const A = a.split('/').filter(Boolean);
1701
+ const B = b.split('/').filter(Boolean);
1702
+ return (A.length === B.length &&
1703
+ A.every((segment, i) => segment === B[i] || segment.startsWith(':') || B[i].startsWith(':')));
1704
+ }
1705
+ /**
1706
+ * The requests of one trace, one entry per method+path, with the trace's own repeat count.
1707
+ *
1708
+ * A loop is why this exists: the reader keeps at most four copies of a method+path, so the row
1709
+ * must print ONE line with the real number (m215's `×2106`) rather than four identical lines.
1710
+ *
1711
+ * @param requests - The requests to print, in the note's own order.
1712
+ * @param repeated - The trace's repeated groups ({@link TraceArtifact.repeated}).
1713
+ * @returns One entry per method+path, first occurrence first.
1714
+ */
1715
+ function collapseRequests(requests, repeated) {
1716
+ const counts = new Map();
1717
+ for (const group of repeated ?? [])
1718
+ counts.set(`${group.method} ${group.path}`, {
1719
+ count: group.count,
1720
+ ...(group.ms === undefined ? {} : { ms: group.ms }),
1721
+ });
1722
+ const out = [];
1723
+ const seen = new Map();
1724
+ for (const request of requests) {
1725
+ const key = `${request.method} ${request.path}`;
1726
+ const existing = seen.get(key);
1727
+ if (existing !== undefined) {
1728
+ // The trace's own count already includes every copy; only a group the reader did not count
1729
+ // is counted here from the copies it kept.
1730
+ if (!counts.has(key))
1731
+ existing.count += 1;
1732
+ continue;
1733
+ }
1734
+ const traced = counts.get(key);
1735
+ const entry = {
1736
+ request,
1737
+ count: Math.max(traced?.count ?? 1, 1),
1738
+ ...(traced?.ms === undefined ? {} : { ms: traced.ms }),
1739
+ };
1740
+ seen.set(key, entry);
1741
+ out.push(entry);
1742
+ }
1743
+ return out;
1744
+ }
1745
+ /**
1746
+ * One collapsed request as the note writes it: `GET /api/articles/x → 200 ×2106 (in 4.1 s)`.
1747
+ *
1748
+ * The count is printed only when the page sent the request more than once, so an ordinary row
1749
+ * reads exactly as before.
1750
+ *
1751
+ * @param shown - The request and its count.
1752
+ * @param gotChars - How much of the response body to print, when the body matters.
1753
+ * @returns The request's clause.
1754
+ */
1755
+ function shownRequest(shown, gotChars) {
1756
+ const line = gotChars === undefined ? requestLine(shown.request) : requestWithBody(shown.request, gotChars);
1757
+ if (shown.count <= 1)
1758
+ return line;
1759
+ return `${line} ×${shown.count}${shown.ms === undefined ? '' : ` (in ${(shown.ms / 1000).toFixed(1)} s)`}`;
1760
+ }
1761
+ /**
1762
+ * The sentence a render loop leads the note with.
1763
+ *
1764
+ * The loop is not a flaky API: an effect whose dependency list holds a value that is new on every
1765
+ * render re-fetches forever, and the fix is that dependency list, never the test or the handler.
1766
+ * m215's model found this only by unzipping a trace by hand after a wasted fix and a re-run.
1767
+ *
1768
+ * @param group - The repeated group.
1769
+ * @param file - The app source file whose code sends it, when the caller read one.
1770
+ * @returns The sentence.
1771
+ */
1772
+ function requestLoopSentence(group, file) {
1773
+ return (`\`${group.method} ${group.path}\` was sent ${group.count.toLocaleString('en-US')} times ` +
1774
+ `in this test — a render loop: an effect whose dependency changes on every render (an ` +
1775
+ `object/array/function created in render, or state the effect itself sets) re-fetches ` +
1776
+ `forever; read ${file === null ? 'the component that fetches it.' : `\`${file}\`.`}`);
1777
+ }
1778
+ /**
1779
+ * The app file the caller read for a repeated request, exact path first then a parameter match.
1780
+ *
1781
+ * @param group - The repeated group.
1782
+ * @param callSites - What the caller read from the app's source.
1783
+ * @returns The file, or null when no call stated the path.
1784
+ */
1785
+ function callSiteOf(group, callSites) {
1786
+ const sites = callSites ?? [];
1787
+ const exact = sites.find((site) => site.method === group.method && site.path === group.path);
1788
+ if (exact !== undefined)
1789
+ return exact.file;
1790
+ const matched = sites.find((site) => site.method === group.method && sameTracePath(site.path, group.path));
1791
+ return matched?.file ?? null;
1792
+ }
1532
1793
  /**
1533
1794
  * The failing step's own request as the note writes it first: `the failing step sent DELETE
1534
1795
  * /api/profiles/x/follow → 200 got {"profile":{…}}`.
@@ -1968,6 +2229,107 @@ function sameOriginPath(request) {
1968
2229
  return null;
1969
2230
  return raw.replace(/\/+$/, '');
1970
2231
  }
2232
+ /** The API prefix a no-response request is judged by when the caller read none from the app. */
2233
+ const DEFAULT_API_PREFIX = '/api';
2234
+ /**
2235
+ * Whether a same-origin path is on the app's own API.
2236
+ *
2237
+ * The caller reads the app's client config (`['/api']`); a trace with no such config is judged by
2238
+ * the conventional `/api` mount. The boundary is explicit so `/apiary` is not a child of `/api`.
2239
+ *
2240
+ * @param path - A same-origin path, without a query or trailing slash.
2241
+ * @param prefixes - The API prefixes the caller read, or empty.
2242
+ * @returns True when the path is under one of them.
2243
+ */
2244
+ function isApiPath(path, prefixes) {
2245
+ const list = prefixes.length === 0 ? [DEFAULT_API_PREFIX] : prefixes;
2246
+ return list.some((prefix) => {
2247
+ const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
2248
+ return path === base || path.startsWith(`${base}/`);
2249
+ });
2250
+ }
2251
+ /**
2252
+ * The API requests a failing run's traces recorded with NO response, one entry per method+path.
2253
+ *
2254
+ * A request still in flight is not a server failure on its own ({@link unansweredRequest}), and
2255
+ * the note says so. Several API paths left unanswered in one walk is a different fact: the server
2256
+ * stopped answering while the tests ran, and the closing "the cause is in the page" reading is
2257
+ * then false (xr-molecule-flash-m215 +36:28: three API paths got no response and two `page.goto`
2258
+ * calls timed out across a platform restart, and the note sent the run at avatar components and
2259
+ * token signing).
2260
+ *
2261
+ * @param items - The traces the note is about.
2262
+ * @param apiPrefixes - The API prefixes the caller read, or empty.
2263
+ * @returns One entry per distinct method+path, in the order the traces recorded them.
2264
+ */
2265
+ function unansweredApiRequests(items, apiPrefixes) {
2266
+ const groups = new Map();
2267
+ for (const item of items) {
2268
+ // The step's own requests are also in `item.requests` on a modern trace, but an older reader
2269
+ // put them only on the action: count each method+path once per trace, not once per list.
2270
+ const seen = new Set();
2271
+ for (const request of [...item.requests, ...(item.lastAction?.requests ?? [])]) {
2272
+ if (!unansweredRequest(request))
2273
+ continue;
2274
+ const path = sameOriginPath(request);
2275
+ if (path === null || !isApiPath(path, apiPrefixes))
2276
+ continue;
2277
+ const key = `${request.method} ${path}`;
2278
+ if (seen.has(key))
2279
+ continue;
2280
+ seen.add(key);
2281
+ const known = groups.get(key);
2282
+ if (known === undefined)
2283
+ groups.set(key, { method: request.method, path, count: 1 });
2284
+ else
2285
+ known.count += 1;
2286
+ }
2287
+ }
2288
+ return [...groups.values()];
2289
+ }
2290
+ /**
2291
+ * Whether the runner reported this traced test's failure as a navigation that ran out of budget.
2292
+ *
2293
+ * The runner's own error names the call (`page.goto`, `page.reload`), and that report is the only
2294
+ * signal used: m153 showed a `Frame.goto` that never finished on a trace whose failure was a 15 s
2295
+ * `page.waitForSelector` timeout (the test was cut off mid-navigation), so an unfinished frame
2296
+ * action alone would call a selector failure a page-load failure.
2297
+ *
2298
+ * @param item - One traced failure.
2299
+ * @param failures - The runner's report, one entry per failing test.
2300
+ * @returns True when this failure is a navigation that never finished.
2301
+ */
2302
+ function navigationTimedOut(item, failures) {
2303
+ const failure = runFailureOf(item, failures);
2304
+ return failure !== undefined && navigationMethod(failure.operation) !== null;
2305
+ }
2306
+ /**
2307
+ * The reading a run gets when the server stopped answering: the API requests with no response and
2308
+ * the page loads that never finished, named once so both the lead and the closing sentence carry
2309
+ * the same fact.
2310
+ *
2311
+ * @param requests - The API requests the traces recorded with no response.
2312
+ * @param navigations - How many traced failures were a navigation that never finished.
2313
+ * @param capital - True to start a sentence, false to continue one.
2314
+ * @returns The sentence, without a trailing period run or a next step.
2315
+ */
2316
+ function serverSilentReading(requests, navigations, capital) {
2317
+ const shown = requests
2318
+ .slice(0, 3)
2319
+ .map((request) => `${request.method} ${request.path}${request.count > 1 ? ` ×${request.count}` : ''}`);
2320
+ const more = requests.length > shown.length ? ` and ${requests.length - shown.length} more` : '';
2321
+ const asked = requests.length === 0
2322
+ ? ''
2323
+ : `${requests.length} API ${requests.length === 1 ? 'request' : 'requests'} got no response` +
2324
+ (shown.length === 0 ? '' : ` (${shown.join(', ')}${more})`);
2325
+ const loads = navigations === 0
2326
+ ? ''
2327
+ : `${navigations} page ${navigations === 1 ? 'load' : 'loads'} never finished`;
2328
+ const what = [asked, loads].filter((part) => part !== '').join(' and ');
2329
+ return (`${capital ? 'The' : 'the'} server did not answer: ${what}. That is the app — a restart, a ` +
2330
+ 'cold start, or a request the server never finishes — being unavailable while these tests ' +
2331
+ 'ran, not page state.');
2332
+ }
1971
2333
  /**
1972
2334
  * The path segments a request ASKS FOR a credential through — a login, a registration, a token
1973
2335
  * endpoint — as opposed to a protected resource that presents one.
@@ -2049,15 +2411,109 @@ function isCredentialExchange(request, authEndpoints) {
2049
2411
  return true;
2050
2412
  return credentialExchangeBody(request.sent);
2051
2413
  }
2414
+ /** Whether two request records from one trace are the same entry (the action's copy and its own). */
2415
+ function sameRequestRecord(left, right) {
2416
+ return (left.method === right.method &&
2417
+ left.path === right.path &&
2418
+ left.status === right.status &&
2419
+ left.startedAt === right.startedAt);
2420
+ }
2421
+ /**
2422
+ * The positive sign-in evidence a trace holds for THIS refused request's browser context, or null.
2423
+ *
2424
+ * Two readings, strongest first in the sentence but either is proof a token existed: a credential
2425
+ * exchange the trace answered 2xx (the test's own registration/login), or any request the client
2426
+ * attached an `Authorization` header to. A request from another browser context is not evidence
2427
+ * about this one (m160), and a candidate with a clock is only prior when it started before the
2428
+ * refusal. On a trace without clocks (an older Playwright, a synthetic fixture) the trace-level
2429
+ * fact still stands, and the sentence says what the trace recorded rather than "before".
2430
+ *
2431
+ * @param item - The failing test's trace.
2432
+ * @param request - The refused request.
2433
+ * @param authEndpoints - The endpoints the app's auth config names.
2434
+ * @returns The evidence, or null when the trace proves nothing about a session.
2435
+ */
2436
+ function signInEvidenceFor(item, request, authEndpoints) {
2437
+ // The action's own request list and the artifact's list overlap; dedupe by record identity.
2438
+ const candidates = [...(item.lastAction?.requests ?? []), ...item.requests];
2439
+ for (const candidate of candidates) {
2440
+ if (sameRequestRecord(candidate, request))
2441
+ continue;
2442
+ if (request.context !== undefined &&
2443
+ candidate.context !== undefined &&
2444
+ candidate.context !== request.context)
2445
+ continue;
2446
+ if (typeof candidate.startedAt === 'number' && typeof request.startedAt === 'number') {
2447
+ if (candidate.startedAt >= request.startedAt)
2448
+ continue;
2449
+ if (candidate.sentAuthorization === true)
2450
+ return { request: candidate, kind: 'authorization', prior: true };
2451
+ if (candidate.status >= 200 &&
2452
+ candidate.status < 300 &&
2453
+ isCredentialExchange(candidate, authEndpoints))
2454
+ return { request: candidate, kind: 'exchange', prior: true };
2455
+ continue;
2456
+ }
2457
+ // No clock to order by: the trace still proves the context had a session, never that it was
2458
+ // before this request.
2459
+ if (candidate.sentAuthorization === true)
2460
+ return { request: candidate, kind: 'authorization', prior: false };
2461
+ if (candidate.status >= 200 &&
2462
+ candidate.status < 300 &&
2463
+ isCredentialExchange(candidate, authEndpoints))
2464
+ return { request: candidate, kind: 'exchange', prior: false };
2465
+ }
2466
+ return null;
2467
+ }
2468
+ /**
2469
+ * How the signed-in sentence names its evidence: what the trace sent and got, and what that proves.
2470
+ *
2471
+ * @param evidence - The evidence {@link signInEvidenceFor} found.
2472
+ * @returns The clause the sentence embeds.
2473
+ */
2474
+ function signInEvidenceLine(evidence) {
2475
+ const status = evidence.request.status > 0 ? ` → ${evidence.request.status}` : '';
2476
+ const when = evidence.prior ? ' before this request' : '';
2477
+ return evidence.kind === 'exchange'
2478
+ ? `\`${requestLabel(evidence.request)}${status}\` is a sign-in this browser context completed${when}`
2479
+ : `the client attached an \`Authorization\` header to \`${requestLabel(evidence.request)}\` in this browser context${when}`;
2480
+ }
2481
+ /**
2482
+ * The route-guard reading for a refused request with no session evidence behind it.
2483
+ *
2484
+ * xr-molecule-flash-m221's whole detour: the test navigates to a protected URL while signed out and
2485
+ * asserts the redirect its route owes. Nothing about the client is wrong; the trace already shows
2486
+ * the URL transition, so the sentence names it and the guard the test's assertion measures.
2487
+ *
2488
+ * @param context - Where the refused request sits ({@link AuthRefusalContext}).
2489
+ * @returns The clause, or '' when the last action was not a navigation.
2490
+ */
2491
+ function routeGuardSentence(context) {
2492
+ if (context.pageAfter === undefined)
2493
+ return '';
2494
+ if (context.lastAction === undefined)
2495
+ return '';
2496
+ if (!/\.(?:goto|reload|goBack|goForward)$/.test(context.lastAction))
2497
+ return '';
2498
+ const where = context.where === null ? ' The test’s last action' : ` In \`${context.where}\` the last action`;
2499
+ const moved = context.pageBefore !== undefined && context.pageBefore !== context.pageAfter
2500
+ ? `moved from ${context.pageBefore} to ${context.pageAfter}`
2501
+ : `stayed at ${context.pageAfter}`;
2502
+ return (`${where} was \`${context.lastAction}\` and the page ${moved}. If the assertion waits for a ` +
2503
+ 'redirect (a `toHaveURL` after visiting a protected route), the missing step is that route’s ' +
2504
+ 'guard: read where the page or router redirects a visitor with no stored session for that ' +
2505
+ 'path, not the HTTP client.');
2506
+ }
2052
2507
  /**
2053
2508
  * The sentence a 401/403 on the app's own API gets: which half of the wire refused the request.
2054
2509
  *
2055
2510
  * A fixed "read the handler this route reaches" line is the wrong half for a credential refusal.
2056
2511
  * The request's own `Authorization` header — recorded by the trace reader as its scheme WORD, never
2057
- * its value — decides: absent means the CLIENT never attached the token (a session store that the
2058
- * HTTP client does not read, a bare `fetch`, an auth bond the app replaced), while present means
2059
- * the SERVER refused a token it was sent (scheme or issuer). A 403 is a permission answer, not a
2060
- * credential one, and says so.
2512
+ * its value — decides half of it: present means the SERVER refused a token it was sent (scheme or
2513
+ * issuer). Absent means no token reached the server, and the CAUSE is read from the trace's
2514
+ * session evidence ({@link signInEvidenceFor}): only a context that had signed in can have lost a
2515
+ * token on the client, and only then may the note prescribe HTTP-client rewiring. A 403 is a
2516
+ * permission answer, not a credential one, and says so.
2061
2517
  *
2062
2518
  * xr-molecule-flash-m148 (+29:18): `POST /api/articles/…/favorite → 401 {"errors":{"token":["is
2063
2519
  * missing"]}}` was answered with the handler sentence for six local walks, while the trace proved
@@ -2079,6 +2535,17 @@ function isCredentialExchange(request, authEndpoints) {
2079
2535
  * categorical handler sentence is gone from the no-header branch too, because a route that
2080
2536
  * requires a token where the app allows anonymous access is the handler's own mistake.
2081
2537
  *
2538
+ * xr-molecule-flash-m221 (+20:13): `url-navigation.spec.ts` navigates to `/?feed=following` while
2539
+ * SIGNED OUT and asserts the `/login` redirect. Its `GET /api/articles/feed → 401` carried no
2540
+ * header because the test never signed in, and the old categorical sentence — "the client did not
2541
+ * attach the token" — sent the model into replacing a working HTTP client; the replacement lost the
2542
+ * session token for ~28 minutes. So the no-header sentence is OBSERVATIONAL unless the trace holds
2543
+ * positive evidence that this context had a session ({@link signInEvidenceFor}); only then may it
2544
+ * prescribe the client wiring, and it names that evidence and the failing test. Without evidence it
2545
+ * says the refusal is expected for a signed-out test, and for a navigation failure names the URL
2546
+ * transition the trace recorded and the route guard the assertion measures
2547
+ * ({@link routeGuardSentence}).
2548
+ *
2082
2549
  * The trace cannot name the file that made the call: a real HAR entry carries the request, response,
2083
2550
  * `_resourceType`, `_monotonicTime` and a frame ref, but no initiator stack. So the sentence names
2084
2551
  * the shared client and the auth bond instead of guessing a file.
@@ -2088,10 +2555,14 @@ function isCredentialExchange(request, authEndpoints) {
2088
2555
  * request that carried no `Authorization` header.
2089
2556
  * @param authEndpoints - The endpoints the app's auth config names, so a credential exchange is
2090
2557
  * recognised even under a path the segment list does not.
2558
+ * @param context - The failing test's identity, the positive session evidence the trace holds, and
2559
+ * where its last action left the page ({@link AuthRefusalContext}).
2091
2560
  * @returns The sentence the note appends.
2092
2561
  */
2093
- function authRefusalSentence(request, unbondedClient = false, authEndpoints = []) {
2562
+ function authRefusalSentence(request, unbondedClient = false, authEndpoints = [], context = { where: null, signIn: null }) {
2094
2563
  const label = requestLabel(request);
2564
+ const where = context.where === null ? '' : `In \`${context.where}\`, `;
2565
+ const evidence = context.signIn;
2095
2566
  // The header's scheme, when it parsed; presence alone is still the server's half of the wire.
2096
2567
  const carried = request.authorization !== undefined
2097
2568
  ? `with \`Authorization: ${request.authorization} …\``
@@ -2120,16 +2591,25 @@ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []
2120
2591
  'and read the client per request instead of capturing it at setup.'
2121
2592
  : '';
2122
2593
  if (request.status === 403) {
2123
- return request.sentAuthorization === true
2124
- ? `The browser sent \`${label}\` ${carried} and the API answered 403 — the token was ` +
2594
+ if (request.sentAuthorization === true)
2595
+ return (`The browser sent \`${label}\` ${carried} and the API answered 403 — the token was ` +
2125
2596
  'accepted but this user may not do it: read the route’s role/permission check in your ' +
2126
- 'own source, and the account the session belongs to.'
2127
- : `The browser sent \`${label}\` and the API answered 403 — the request carried no ` +
2128
- '`Authorization` header, so either the client did not attach the token (check that the ' +
2129
- 'request goes through the app’s HTTP client that the auth bond configures, and that the ' +
2130
- 'token is stored where the client reads it) or the route refuses this user: read the ' +
2131
- 'route’s role/permission check in your own source.' +
2132
- unbonded;
2597
+ 'own source, and the account the session belongs to.');
2598
+ if (evidence !== null)
2599
+ return (`${where}the browser sent \`${label}\` with no \`Authorization\` header and this context ` +
2600
+ `had already signed in — ${signInEvidenceLine(evidence)} — so a token existed and this ` +
2601
+ 'request did not carry it: check that the request goes through the app’s HTTP client ' +
2602
+ 'that the auth bond configures, and that the token is stored where the client reads it. ' +
2603
+ 'If the request did carry one, the route refuses this user: read the route’s ' +
2604
+ 'role/permission check in your own source.' +
2605
+ unbonded);
2606
+ return (`${where}the browser sent \`${label}\` and the API answered 403 — the request carried no ` +
2607
+ '`Authorization` header, so the route refused it without a viewer. If this test is signed ' +
2608
+ "out, that refusal is expected: inspect the page's redirect/guard instead. Diagnose token " +
2609
+ 'attachment only if this same page/context had completed sign-in or held a restored session ' +
2610
+ 'before this request; otherwise read the route’s role/permission check in your own source.' +
2611
+ routeGuardSentence(context) +
2612
+ unbonded);
2133
2613
  }
2134
2614
  if (request.sentAuthorization === true) {
2135
2615
  return (`The browser sent \`${label}\` ${carried} and the API refused it: the token was sent and ` +
@@ -2137,14 +2617,34 @@ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []
2137
2617
  'token’s issuer differs from what the server verifies; read the verify middleware this ' +
2138
2618
  'route reaches.');
2139
2619
  }
2140
- return (`The browser sent \`${label}\` with NO \`Authorization\` header — the client did not attach ` +
2141
- 'the token: check that the request goes through the app’s HTTP client that the auth bond ' +
2142
- 'configures (not a bare `fetch`), and that the token is stored where the client reads it. ' +
2143
- 'The shared client carries it through `setAuthToken` — the auth bond ' +
2144
- '(`createDefaultAuthClientWithHttpSync` in `@molecule/app-bonds-default-react`) mirrors the ' +
2145
- 'auth client’s token there, and a session store of your own has to call it too. ' +
2620
+ // The trace holds positive proof this context had a session: the token exists, this request
2621
+ // did not carry it, and the fix is the client's connection to the app's own token source. The
2622
+ // wording names the API the run needs (m221 +56:20: registering `setAuthTokenSource` is what
2623
+ // finally made the induced regression's tests pass).
2624
+ if (evidence !== null) {
2625
+ return (`${where}the browser sent \`${label}\` with no \`Authorization\` header, and this context ` +
2626
+ `had already signed in — ${signInEvidenceLine(evidence)} — so the token exists and this ` +
2627
+ 'request did not carry it. In the HTTP bond, register ' +
2628
+ '`setAuthTokenSource(() => <your existing synchronous in-memory token getter>)` before ' +
2629
+ 'requests run, and update that same source on sign-in, session restore and sign-out. Do ' +
2630
+ 'not initialize a second auth client to obtain another token: the getter must return the ' +
2631
+ 'real token synchronously (`null` when signed out), with the stored session restored before ' +
2632
+ 'protected requests.' +
2633
+ (request.sentCookie
2634
+ ? ' The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
2635
+ 'that is the credential the server did not accept. '
2636
+ : '') +
2637
+ unbonded);
2638
+ }
2639
+ // No session evidence: the refusal may be exactly what the test set up. Observational, and the
2640
+ // route guard is the next read when the failing action was a navigation (m221's whole detour).
2641
+ return (`This request carried no \`Authorization\` header and the API answered ${request.status}. ` +
2642
+ "If this test is signed out, that refusal is expected: inspect the page's redirect/guard " +
2643
+ 'instead. Diagnose token attachment only if this same page/context had completed sign-in or ' +
2644
+ 'held a restored session before this request.' +
2645
+ routeGuardSentence(context) +
2146
2646
  (request.sentCookie
2147
- ? 'The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
2647
+ ? ' The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
2148
2648
  'that is the credential the server did not accept. '
2149
2649
  : '') +
2150
2650
  unbonded);
@@ -2152,18 +2652,21 @@ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []
2152
2652
  /**
2153
2653
  * The note's credential-refusal advice, one sentence per distinct shape — at most two.
2154
2654
  *
2155
- * The shapes are `(status, Authorization scheme, cookie present, credential exchange)`: m148's ten
2156
- * walks repeated one shape (`401`, no header) on every failing test, and repeating its sentence per
2157
- * row would bury the fact rather than state it. A login and a protected request that both carry no
2158
- * header are DIFFERENT shapes — one is the credentials being checked, the other is a token that was
2159
- * never attached (m165).
2655
+ * The shapes are `(status, Authorization scheme, cookie present, credential exchange, session
2656
+ * evidence)`: m148's ten walks repeated one shape (`401`, no header) on every failing test, and
2657
+ * repeating its sentence per row would bury the fact rather than state it. A login and a protected
2658
+ * request that both carry no header are DIFFERENT shapes — one is the credentials being checked,
2659
+ * the other is a request that presented no token (m165). A protected request WITH session evidence
2660
+ * and one without are different shapes too: only the first can say a token was lost on the client
2661
+ * (m221), and collapsing them would prescribe the HTTP-client rewiring for a signed-out route test.
2160
2662
  *
2161
2663
  * @param items - The traces the note is about.
2162
2664
  * @param authEndpoints - The endpoints the app's auth config names.
2665
+ * @param failures - The runner's report, so the sentence names the failing test it is about.
2163
2666
  * @returns The sentences to append (each led by a space), or '' when no same-origin 401/403 was
2164
2667
  * traced.
2165
2668
  */
2166
- function authRefusalAdvice(items, authEndpoints = []) {
2669
+ function authRefusalAdvice(items, authEndpoints = [], failures = []) {
2167
2670
  const sentences = [];
2168
2671
  const seen = new Set();
2169
2672
  // A page that logged `no client is bonded` explains every request it sent without a
@@ -2181,11 +2684,22 @@ function authRefusalAdvice(items, authEndpoints = []) {
2181
2684
  if (isLogoutRequest(request))
2182
2685
  continue;
2183
2686
  const exchange = isCredentialExchange(request, authEndpoints);
2184
- const key = `${request.status} ${exchange ? 'exchange' : request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
2687
+ const signIn = exchange
2688
+ ? null
2689
+ : request.sentAuthorization === true
2690
+ ? null
2691
+ : signInEvidenceFor(item, request, authEndpoints);
2692
+ const key = `${request.status} ${exchange ? 'exchange' : request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''} ${signIn === null ? 'unverified' : `signed-in:${signIn.kind}`}`;
2185
2693
  if (seen.has(key))
2186
2694
  continue;
2187
2695
  seen.add(key);
2188
- sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints));
2696
+ sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints, {
2697
+ where: item.spec === null ? null : failureWhereOf(item, failures),
2698
+ signIn,
2699
+ pageBefore: item.lastAction?.pageBefore,
2700
+ pageAfter: item.lastAction?.pageAfter,
2701
+ lastAction: item.lastAction?.api,
2702
+ }));
2189
2703
  if (sentences.length === 2)
2190
2704
  return ` ${sentences.join(' ')}`;
2191
2705
  }
@@ -2773,12 +3287,16 @@ export function hiddenBoxNote(items) {
2773
3287
  * config names; the traces add what their own requests show) and never assumed to be `/api`.
2774
3288
  *
2775
3289
  * A 401/403 on the app's own origin is answered from the request's own credential record instead
2776
- * ({@link authRefusalSentence}): no `Authorization` header means the CLIENT never attached the
2777
- * token (the shared HTTP client's `setAuthToken` is what the auth bond mirrors it into), while a
2778
- * header the API then refused moves the answer to the server. The handler sentence is kept for
2779
- * the rest — 404/422/500 — and dropped when every failure was a credential refusal, because a
2780
- * trace that proves the header was never sent cannot be explained by the handler.
2781
- * xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
3290
+ * ({@link authRefusalSentence}): a header the API then refused moves the answer to the server. No
3291
+ * header is read together with the trace's session evidence — the failing test's identity, a
3292
+ * credential exchange it answered 2xx, a request the client already attached a token to — and only
3293
+ * positive evidence may prescribe the client's token wiring; otherwise the sentence is
3294
+ * observational and, for a navigation failure, names the URL transition and the route guard
3295
+ * (xr-molecule-flash-m221: a signed-out redirect test's `GET /api/articles/feed → 401` sent the
3296
+ * model into replacing a working HTTP client, which lost the session token for ~28 minutes). The
3297
+ * handler sentence is kept for the rest — 404/422/500 — and dropped when every failure was a
3298
+ * credential refusal, because a trace that proves the header was never sent cannot be explained by
3299
+ * the handler. xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
2782
3300
  * `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
2783
3301
  *
2784
3302
  * A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
@@ -2859,7 +3377,7 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
2859
3377
  // Every recorded failure is the logout endpoint answering 4xx: the row's own sentence explains
2860
3378
  // it, and neither the handler lead nor the credential reading applies (m156 +24:02).
2861
3379
  const onlyLogoutFailures = failedRequests.length > 0 && failedRequests.every((request) => isLogoutRequest(request));
2862
- const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? []);
3380
+ const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? [], failures);
2863
3381
  // Set-up traffic the trace shows going to an origin other than the app's (m165): the rows it
2864
3382
  // creates are not in this app's database, so the note says it before any app-side reading.
2865
3383
  const foreignOrigins = foreignOriginApiRequests(traced, apiPrefixes);
@@ -2879,6 +3397,18 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
2879
3397
  ...new Set(stalls.flatMap((stall) => (stall === null ? [] : [stall.reported]))),
2880
3398
  ];
2881
3399
  const anyStalled = stalls.some((stall) => stall !== null);
3400
+ // The server stopped answering while the tests ran (m215): several API requests with no
3401
+ // response, or a navigation that never finished. A single request still in flight is not this
3402
+ // (m112), and the note must never close with "the cause is in the page" beside it — the run
3403
+ // then rewrites the page while the API was the thing that never answered.
3404
+ const silentApiRequests = unansweredApiRequests(traced, apiPrefixes);
3405
+ const silentNavigations = traced.filter((item) => navigationTimedOut(item, failures)).length;
3406
+ const serverSilent = silentApiRequests.length >= 2 || silentNavigations > 0;
3407
+ // A single unanswered request stays what it always was (m112). Two or more distinct API paths
3408
+ // are a fact about the server, and it leads the note before any row.
3409
+ const serverSilentLead = silentApiRequests.length < 2
3410
+ ? ''
3411
+ : `${serverSilentReading(silentApiRequests, silentNavigations, true)}\n\n`;
2882
3412
  const rows = [];
2883
3413
  // Rows whose failing action sent nothing and left the page on a new route: the page is still
2884
3414
  // showing the previous route's data. Counted so the note can say it once for the whole run.
@@ -2943,11 +3473,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
2943
3473
  parts.push(`${head}, and it sent no request`);
2944
3474
  }
2945
3475
  else {
2946
- const shown = stepRequests.slice(0, room);
3476
+ const shown = collapseRequests(stepRequests, item.repeated).slice(0, room);
2947
3477
  room -= shown.length;
2948
3478
  // The response body is the point (m148: the register `POST /api/users` answered 201 and
2949
- // the page rendered signed-out anyway) — printed whatever the status.
2950
- parts.push(`${head}, and it sent ${shown.map((request) => requestWithBody(request, TRACE_NOTE_LEAD_GOT_CHARS)).join('; ')}`);
3479
+ // the page rendered signed-out anyway) — printed whatever the status. A method+path the
3480
+ // page sent many times prints ONCE with its count (m215's four identical `→ 200` lines).
3481
+ parts.push(`${head}, and it sent ${shown.map((entry) => shownRequest(entry, TRACE_NOTE_LEAD_GOT_CHARS)).join('; ')}`);
2951
3482
  }
2952
3483
  // What the page did next: a navigation shows in the main frame's URL even when the app moved
2953
3484
  // client-side and the network log recorded no document request.
@@ -2971,13 +3502,16 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
2971
3502
  parts.push(logoutIgnoredSentence(stepLast, step.pageAfter));
2972
3503
  }
2973
3504
  else if (lead !== undefined) {
2974
- parts.push(leadRequestLine(lead));
3505
+ const [leadShown] = collapseRequests([lead], item.repeated);
3506
+ parts.push(leadShown === undefined
3507
+ ? leadRequestLine(lead)
3508
+ : `the failing step sent ${shownRequest(leadShown, TRACE_NOTE_LEAD_GOT_CHARS)}`);
2975
3509
  room -= 1;
2976
3510
  }
2977
3511
  if (failed.length > 0) {
2978
- const shown = failed.slice(0, room);
3512
+ const shown = collapseRequests(failed, item.repeated).slice(0, room);
2979
3513
  room -= shown.length;
2980
- parts.push(`failed: ${shown.map(requestLine).join('; ')}`);
3514
+ parts.push(`failed: ${shown.map((entry) => shownRequest(entry)).join('; ')}`);
2981
3515
  }
2982
3516
  if (stalled.length > 0 && room > 0) {
2983
3517
  const shown = stalled.slice(0, room);
@@ -3004,7 +3538,10 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
3004
3538
  const label = step !== undefined && !stalledNav && stepRequests.length === 0
3005
3539
  ? 'also answered (started before the action)'
3006
3540
  : 'answered';
3007
- parts.push(`${label}: ${answered.slice(0, room).map(requestLine).join('; ')}`);
3541
+ parts.push(`${label}: ${collapseRequests(answered, item.repeated)
3542
+ .slice(0, room)
3543
+ .map((entry) => shownRequest(entry))
3544
+ .join('; ')}`);
3008
3545
  }
3009
3546
  if (item.console.length > 0)
3010
3547
  parts.push(`console: ${item.console
@@ -3145,7 +3682,20 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
3145
3682
  .map((reset) => [`${reset.request.method} ${reset.request.path}\u0000${reset.field}`, reset])).values(),
3146
3683
  ];
3147
3684
  const formResetLead = formResets.length === 0 ? '' : `${formResets.slice(0, 2).map(formResetSentence).join(' ')}\n\n`;
3148
- return (readContradictionLead +
3685
+ // One method+path the page sent twenty times or more in a single test is a render loop, not a
3686
+ // flaky API, and it LEADS the note (m215: 2,106 GETs printed as four identical `→ 200` lines
3687
+ // sent the run at the wrong component; it found the loop only by unzipping the trace by hand).
3688
+ // The most-repeated group wins when a run has more than one.
3689
+ const loopGroup = traced
3690
+ .flatMap((item) => item.repeated ?? [])
3691
+ .filter((group) => group.count >= TRACE_NOTE_LOOP_REPEATS)
3692
+ .sort((left, right) => right.count - left.count)[0];
3693
+ const loopLead = loopGroup === undefined
3694
+ ? ''
3695
+ : `${requestLoopSentence(loopGroup, callSiteOf(loopGroup, report.callSites))}\n\n`;
3696
+ return (serverSilentLead +
3697
+ loopLead +
3698
+ readContradictionLead +
3149
3699
  formResetLead +
3150
3700
  missingTextLead +
3151
3701
  transportLead +
@@ -3204,13 +3754,20 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
3204
3754
  ? // The trace's own snapshot explains why the element Playwright resolved has no box:
3205
3755
  // a fact about the element, so it replaces the "look at the page" advice (m197).
3206
3756
  hiddenBox
3207
- : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
3208
- "page, not the API. Start with what the test's last action sent (the first line of each " +
3209
- 'row) and what the page did next: a request that answered (2xx included) while the page ' +
3210
- 'still did not show the state the assertion waited on is the fact to read — its status ' +
3211
- 'and response body, not the selector. If the request is not the one the test meant — an ' +
3212
- 'unfollow sent where the test clicked follow — the state the action read was stale ' +
3213
- 'before it.') +
3757
+ : serverSilent
3758
+ ? // The server did not answer (m215): a page-state reading would send the run at
3759
+ // the markup while nothing answered it. Same fact as the lead, and the one action.
3760
+ 'No listed request failed — none got a 4xx/5xx or a network error — but ' +
3761
+ `${serverSilentReading(silentApiRequests, silentNavigations, false)} ` +
3762
+ `Re-run only these tests against ${target} (\`--last-failed\`) before changing ` +
3763
+ 'any code; change the app only if they fail again with requests that answered.'
3764
+ : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
3765
+ "page, not the API. Start with what the test's last action sent (the first line of each " +
3766
+ 'row) and what the page did next: a request that answered (2xx included) while the page ' +
3767
+ 'still did not show the state the assertion waited on is the fact to read — its status ' +
3768
+ 'and response body, not the selector. If the request is not the one the test meant — an ' +
3769
+ 'unfollow sent where the test clicked follow — the state the action read was stale ' +
3770
+ 'before it.') +
3214
3771
  prefixAdvice +
3215
3772
  (wrapped
3216
3773
  ? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.10.4",
3
+ "version": "1.10.6",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",