@quenty/nevermore-cli 4.43.4 → 4.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build-scripts/{combine-test-places.luau → combine-test-places.lua} +3 -3
  3. package/build-scripts/{transform-inject-deploy-metadata.luau → transform-inject-deploy-metadata.lua} +3 -3
  4. package/dist/commands/batch-command/batch-deploy-command.js +4 -4
  5. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  6. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  7. package/dist/commands/batch-command/batch-test-command.js +76 -19
  8. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  9. package/dist/commands/deploy-command/deploy-init-utils.d.ts.map +1 -1
  10. package/dist/commands/deploy-command/deploy-init-utils.js +7 -1
  11. package/dist/commands/deploy-command/deploy-init-utils.js.map +1 -1
  12. package/dist/commands/deploy-command/deploy-init.d.ts.map +1 -1
  13. package/dist/commands/deploy-command/deploy-init.js +24 -7
  14. package/dist/commands/deploy-command/deploy-init.js.map +1 -1
  15. package/dist/commands/deploy-command/index.js +1 -1
  16. package/dist/commands/deploy-command/index.js.map +1 -1
  17. package/dist/utils/deploy/deploy-github-columns.d.ts +12 -2
  18. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  19. package/dist/utils/deploy/deploy-github-columns.js +11 -4
  20. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  21. package/dist/utils/deploy/deploy-metadata.js +1 -1
  22. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  23. package/dist/utils/job-context/base-job-context.d.ts +2 -2
  24. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  25. package/dist/utils/job-context/base-job-context.js +1 -1
  26. package/dist/utils/job-context/base-job-context.js.map +1 -1
  27. package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
  28. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  29. package/dist/utils/job-context/batch-script-job-context.js +59 -22
  30. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  31. package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
  32. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  33. package/dist/utils/job-context/cloud-job-context.js +10 -5
  34. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  35. package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
  36. package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
  37. package/dist/utils/job-context/cloud-job-context.test.js +91 -0
  38. package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
  39. package/dist/utils/job-context/job-context.d.ts +70 -2
  40. package/dist/utils/job-context/job-context.d.ts.map +1 -1
  41. package/dist/utils/job-context/local-job-context.d.ts +2 -2
  42. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  43. package/dist/utils/job-context/local-job-context.js +4 -2
  44. package/dist/utils/job-context/local-job-context.js.map +1 -1
  45. package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
  46. package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
  47. package/dist/utils/job-context/local-job-context.test.js +68 -0
  48. package/dist/utils/job-context/local-job-context.test.js.map +1 -0
  49. package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
  50. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  51. package/dist/utils/open-cloud/open-cloud-client.js +49 -13
  52. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  53. package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
  54. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  55. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
  56. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
  57. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
  58. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
  59. package/dist/utils/package-manager-utils.d.ts.map +1 -1
  60. package/dist/utils/package-manager-utils.js +32 -1
  61. package/dist/utils/package-manager-utils.js.map +1 -1
  62. package/dist/utils/package-manager-utils.test.js +25 -0
  63. package/dist/utils/package-manager-utils.test.js.map +1 -1
  64. package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
  65. package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
  66. package/dist/utils/testing/batch-run.e2e.test.js +343 -0
  67. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
  68. package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
  69. package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
  70. package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
  71. package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
  72. package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
  73. package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
  74. package/dist/utils/testing/log-fetch-stats.js +18 -0
  75. package/dist/utils/testing/log-fetch-stats.js.map +1 -0
  76. package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
  77. package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
  78. package/dist/utils/testing/log-fetch-stats.test.js +41 -0
  79. package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
  80. package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
  81. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  82. package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
  83. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  84. package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
  85. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  86. package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
  87. package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
  88. package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
  89. package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
  90. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
  91. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
  92. package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
  93. package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
  94. package/dist/utils/testing/runner/combined-project-generator.js +1 -1
  95. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  96. package/dist/utils/testing/runner/test-runner.d.ts +24 -0
  97. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  98. package/dist/utils/testing/runner/test-runner.js +166 -6
  99. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  100. package/dist/utils/testing/runner/test-runner.test.js +228 -2
  101. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  102. package/dist/utils/testing/structured-test-results.d.ts +77 -0
  103. package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
  104. package/dist/utils/testing/structured-test-results.js +140 -0
  105. package/dist/utils/testing/structured-test-results.js.map +1 -0
  106. package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
  107. package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
  108. package/dist/utils/testing/structured-test-results.test.js +179 -0
  109. package/dist/utils/testing/structured-test-results.test.js.map +1 -0
  110. package/package.json +6 -6
  111. package/src/commands/batch-command/batch-deploy-command.ts +4 -4
  112. package/src/commands/batch-command/batch-test-command.ts +94 -19
  113. package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
  114. package/src/commands/deploy-command/deploy-init.ts +41 -11
  115. package/src/commands/deploy-command/index.ts +1 -1
  116. package/src/utils/deploy/deploy-github-columns.ts +14 -4
  117. package/src/utils/deploy/deploy-metadata.ts +1 -1
  118. package/src/utils/job-context/base-job-context.ts +3 -2
  119. package/src/utils/job-context/batch-script-job-context.ts +98 -17
  120. package/src/utils/job-context/cloud-job-context.test.ts +112 -0
  121. package/src/utils/job-context/cloud-job-context.ts +12 -6
  122. package/src/utils/job-context/job-context.ts +71 -2
  123. package/src/utils/job-context/local-job-context.test.ts +89 -0
  124. package/src/utils/job-context/local-job-context.ts +7 -3
  125. package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
  126. package/src/utils/open-cloud/open-cloud-client.ts +94 -18
  127. package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
  128. package/src/utils/package-manager-utils.test.ts +27 -0
  129. package/src/utils/package-manager-utils.ts +34 -1
  130. package/src/utils/testing/batch-run.e2e.test.ts +425 -0
  131. package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
  132. package/src/utils/testing/log-fetch-stats.test.ts +48 -0
  133. package/src/utils/testing/log-fetch-stats.ts +55 -0
  134. package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
  135. package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
  136. package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
  137. package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
  138. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  139. package/src/utils/testing/runner/test-runner.test.ts +332 -2
  140. package/src/utils/testing/runner/test-runner.ts +231 -9
  141. package/src/utils/testing/structured-test-results.test.ts +267 -0
  142. package/src/utils/testing/structured-test-results.ts +199 -0
  143. package/templates/batch-test-runner.lua +242 -0
  144. package/tsconfig.tsbuildinfo +1 -1
  145. package/templates/batch-test-runner.luau +0 -137
  146. /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
  147. /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
@@ -1,10 +1,13 @@
1
1
  import { OutputHelper } from '@quenty/cli-output-helpers';
2
+ import { isCI } from '@quenty/cli-output-helpers/cli-utils';
2
3
  import {
3
4
  type ParsedTestCounts,
4
5
  evaluateTestOutcome,
5
6
  parseTestCounts,
6
7
  } from '../test-log-parser.js';
7
8
  import { formatTracebacks, parseTracebacks } from './traceback-parser.js';
9
+ import { type StructuredTestResults } from '../structured-test-results.js';
10
+ import { describeLogVolume, type LogFetchStats } from '../log-fetch-stats.js';
8
11
 
9
12
  export interface BatchPackageResult {
10
13
  slug: string;
@@ -21,17 +24,245 @@ export interface BatchPackageResult {
21
24
  * test — often after the test that scheduled it already passed.
22
25
  */
23
26
  tracebackCount: number;
27
+ /**
28
+ * Where `testCounts` came from. `returned` means the run handed them over as a
29
+ * value; `scraped` means they were read out of log text, which is the channel
30
+ * truncation destroys. Absent when there are no counts at all.
31
+ *
32
+ * Recorded because the two are otherwise indistinguishable in the output, and
33
+ * a structured channel that quietly stopped flowing looks exactly like one
34
+ * that never existed.
35
+ */
36
+ countsSource?: 'returned' | 'scraped';
37
+ /**
38
+ * True when this package's counts arrived but its log section did not.
39
+ *
40
+ * A narrower verdict than a package with a section gets: the counts say what
41
+ * happened, but with no text there is nothing to check for tracebacks, and
42
+ * nothing to read when something did go wrong. Reported so a pass that came
43
+ * back half-blind cannot be mistaken for an ordinary one.
44
+ */
45
+ logsLost?: boolean;
46
+ /**
47
+ * What this package's own run returned, recovered from the batch summary.
48
+ *
49
+ * One execution covers every package, so its return value belongs to none of
50
+ * them — the batch runner splits the per-package results into the summary
51
+ * instead, and this puts them back in the shape a single run's results have.
52
+ * Lossy by design: the summary carries counts, not failure text, which is
53
+ * per-package and already in that package's log section.
54
+ */
55
+ testResults?: StructuredTestResults;
56
+ }
57
+
58
+ export interface ParseBatchTestLogsOptions {
59
+ /**
60
+ * What the fetch that produced `rawLogs` had to do to collect them, when the
61
+ * caller knows. Not used to judge a package — it only tells a failure that
62
+ * has already happened how much of the run's output ever arrived.
63
+ */
64
+ logFetchStats?: LogFetchStats;
65
+ /**
66
+ * Where diagnostics go. When set, the parser reports through this instead of
67
+ * printing, so a caller that renders the run's output can hold them until
68
+ * after it: a remark about a log window reads as nonsense above the log it
69
+ * is describing.
70
+ */
71
+ onDiagnostic?: (level: BatchDiagnosticLevel, message: string) => void;
72
+ /**
73
+ * What the batch script returned, when the transport carries return values.
74
+ *
75
+ * The same summary the script also prints, taken from the one channel the
76
+ * engine's log buffer cannot truncate — an oversize return value fails the
77
+ * task outright rather than arriving short, so a summary that arrives here is
78
+ * whole. Preferred over the printed copy, which is only reached when this is
79
+ * absent (a local bridge run, or a task that ended without an output).
80
+ */
81
+ returnValues?: unknown[];
82
+ }
83
+
84
+ /**
85
+ * `group` and `endgroup` bracket a run of diagnostics that belong together, so
86
+ * a finding repeated across many packages can be collapsed into one block
87
+ * instead of one line each.
88
+ */
89
+ export type BatchDiagnosticLevel =
90
+ | 'info'
91
+ | 'warn'
92
+ | 'verbose'
93
+ | 'group'
94
+ | 'endgroup';
95
+
96
+ /**
97
+ * How one package in a listing turned out, phrased as its group title is.
98
+ *
99
+ * Kept short: this is a line in a list of dozens, not a report.
100
+ */
101
+ function describeSlugState(
102
+ slug: string,
103
+ results: Map<string, BatchPackageResult>,
104
+ slugMap: Map<string, string>
105
+ ): string {
106
+ let packageName: string | undefined;
107
+ for (const [name, candidate] of slugMap) {
108
+ if (candidate === slug) {
109
+ packageName = name;
110
+ break;
111
+ }
112
+ }
113
+
114
+ const result = packageName ? results.get(packageName) : undefined;
115
+ if (!result) {
116
+ return 'no result';
117
+ }
118
+
119
+ const counts = result.testCounts
120
+ ? ` (${result.testCounts.passed}/${result.testCounts.total})`
121
+ : '';
122
+ return `${result.success ? 'Passed' : 'FAILED'}${counts}`;
123
+ }
124
+
125
+ /**
126
+ * Print one diagnostic, honoring the grouping levels.
127
+ *
128
+ * `useGroups` is passed in rather than looked up, so the whole run agrees about
129
+ * whether it is emitting workflow commands. Asking `isCI()` separately here, in
130
+ * the renderer and in the reporter is three answers to one question.
131
+ */
132
+ export function emitDiagnostic(
133
+ level: BatchDiagnosticLevel,
134
+ message: string,
135
+ useGroups: boolean
136
+ ): void {
137
+ if (level === 'group') {
138
+ OutputHelper.startGroup(message, useGroups);
139
+ } else if (level === 'endgroup') {
140
+ OutputHelper.endGroup(useGroups);
141
+ } else if (level === 'warn') {
142
+ OutputHelper.warn(message);
143
+ } else if (level === 'info') {
144
+ OutputHelper.info(message);
145
+ } else {
146
+ OutputHelper.verbose(message);
147
+ }
148
+ }
149
+
150
+ /** Report through the caller's sink, or straight out when there is none. */
151
+ function makeReporter(
152
+ onDiagnostic?: (level: BatchDiagnosticLevel, message: string) => void
153
+ ): (level: BatchDiagnosticLevel, message: string) => void {
154
+ if (onDiagnostic) {
155
+ return onDiagnostic;
156
+ }
157
+
158
+ return (level, message) => emitDiagnostic(level, message, isCI());
24
159
  }
25
160
 
26
161
  const BEGIN_MARKER = '===BATCH_TEST_BEGIN ';
27
162
  const END_MARKER = '===BATCH_TEST_END ';
28
163
  const SUMMARY_MARKER = '===BATCH_TEST_SUMMARY===';
164
+ const MARKER_SUFFIX = '===';
165
+
166
+ // Matches "<slug> PASS|FAIL [<durationMs>]" — slugs have no whitespace.
167
+ const END_INNER_PATTERN = /^(\S+)\s+(?:PASS|FAIL)(?:\s+(\d+))?$/;
168
+
169
+ /**
170
+ * Counts the batch runner folds in from what a package's test script returned.
171
+ *
172
+ * Present only for a package whose script returns its results. Carried in the
173
+ * summary rather than left to the logs because the summary prints last and
174
+ * survives a truncated log window, which is where scraped counts are lost.
175
+ */
176
+ interface SummaryCounts {
177
+ passed: number;
178
+ failed: number;
179
+ skipped: number;
180
+ total: number;
181
+ suitesPassed: number;
182
+ suitesFailed: number;
183
+ suitesTotal: number;
184
+ }
29
185
 
30
186
  interface SummaryEntry {
31
187
  slug: string;
32
188
  success: boolean;
33
189
  durationMs?: number;
34
190
  error?: string;
191
+ counts?: SummaryCounts;
192
+ /** False for a smoke test, so zero counts are not read as "no tests found". */
193
+ ranJest?: boolean;
194
+ }
195
+
196
+ /** Read counts off a summary entry, ignoring anything malformed. */
197
+ function readSummaryCounts(entry: SummaryEntry): SummaryCounts | undefined {
198
+ const counts = entry.counts;
199
+ if (typeof counts !== 'object' || counts === null) {
200
+ return undefined;
201
+ }
202
+
203
+ const fields: (keyof SummaryCounts)[] = [
204
+ 'passed',
205
+ 'failed',
206
+ 'skipped',
207
+ 'total',
208
+ 'suitesPassed',
209
+ 'suitesFailed',
210
+ 'suitesTotal',
211
+ ];
212
+ for (const field of fields) {
213
+ if (!Number.isFinite(counts[field])) {
214
+ return undefined;
215
+ }
216
+ }
217
+ return counts;
218
+ }
219
+
220
+ interface BeginToken {
221
+ kind: 'begin';
222
+ slug: string;
223
+ }
224
+
225
+ interface EndToken {
226
+ kind: 'end';
227
+ slug: string;
228
+ durationMs?: number;
229
+ }
230
+
231
+ interface SummaryToken {
232
+ kind: 'summary';
233
+ /** Where the marker sat, so the JSON payload can be looked up around it. */
234
+ lineIndex: number;
235
+ }
236
+
237
+ interface SummaryPayloadToken {
238
+ kind: 'summaryPayload';
239
+ }
240
+
241
+ interface ContentToken {
242
+ kind: 'content';
243
+ /** The untrimmed line, since section logs are shown to a human verbatim. */
244
+ line: string;
245
+ }
246
+
247
+ /** One log line, classified. Every line becomes exactly one token. */
248
+ export type BatchLogToken =
249
+ | BeginToken
250
+ | EndToken
251
+ | SummaryToken
252
+ | SummaryPayloadToken
253
+ | ContentToken;
254
+
255
+ interface FoldedSections {
256
+ /** Log text of each section that closed, by slug. */
257
+ sections: Map<string, string>;
258
+ /** Sections closed by an END whose BEGIN never arrived — start of output lost. */
259
+ partialSections: Set<string>;
260
+ /** Durations read off END markers, by slug. */
261
+ markerDurations: Map<string, number>;
262
+ /** Slugs of ENDs that closed nothing, so claimed no output. */
263
+ strayEndSlugs: string[];
264
+ /** Slugs of sections a second BEGIN reopened, discarding their output. */
265
+ orphanedBeginSlugs: string[];
35
266
  }
36
267
 
37
268
  /**
@@ -50,10 +281,16 @@ interface SummaryEntry {
50
281
  */
51
282
  export function parseBatchTestLogs(
52
283
  rawLogs: string,
53
- slugMap: Map<string, string>
284
+ slugMap: Map<string, string>,
285
+ options: ParseBatchTestLogsOptions = {}
54
286
  ): Map<string, BatchPackageResult> {
55
287
  const results = new Map<string, BatchPackageResult>();
56
288
  const lines = rawLogs.split('\n');
289
+ // Reported by every diagnostic below. Each of them is some form of "the
290
+ // output is not what this run should have produced", and none can tell how
291
+ // much of it never arrived without knowing what the fetch collected.
292
+ const logVolume = describeLogVolume(rawLogs, options.logFetchStats);
293
+ const report = makeReporter(options.onDiagnostic);
57
294
 
58
295
  // Build reverse map: slug → packageName
59
296
  const slugToPackage = new Map<string, string>();
@@ -63,103 +300,48 @@ export function parseBatchTestLogs(
63
300
 
64
301
  // ── Pass 1: Extract per-package log sections from markers ──
65
302
 
66
- const logSections = new Map<string, string>();
67
- /** Sections closed by an END whose BEGIN never arrived — start of output lost. */
68
- const partialSections = new Set<string>();
69
- const markerDurations = new Map<string, number>();
70
- let currentSlug: string | null = null;
71
- let currentLines: string[] = [];
72
- let summaryLineIndex = -1;
73
- /** True between the summary marker and its JSON payload. */
74
- let summaryPayloadPending = false;
75
- let beginMarkersSeen = 0;
76
- let endMarkersSeen = 0;
77
- let strayEndMarkers = 0;
78
- /** Only one section can have lost its head: the one the log was cut inside. */
79
- let headSectionClaimed = false;
80
-
81
- // Matches "<slug> PASS|FAIL [<durationMs>]" — slugs have no whitespace.
82
- const endInnerPattern = /^(\S+)\s+(?:PASS|FAIL)(?:\s+(\d+))?$/;
83
-
84
- for (let i = 0; i < lines.length; i++) {
85
- const trimmed = lines[i].trimEnd();
86
-
87
- if (trimmed.startsWith(BEGIN_MARKER) && trimmed.endsWith('===')) {
88
- currentSlug = trimmed.slice(BEGIN_MARKER.length, -3);
89
- currentLines = [];
90
- beginMarkersSeen++;
91
- continue;
92
- }
93
-
94
- if (trimmed.startsWith(END_MARKER) && trimmed.endsWith('===')) {
95
- const inner = trimmed.slice(END_MARKER.length, -3);
96
- endMarkersSeen++;
97
- const match = endInnerPattern.exec(inner);
98
- const endSlug = match ? match[1] : inner;
99
- const durationStr = match?.[2];
100
-
101
- // A section normally closes on the END matching its own BEGIN.
102
- const closesOwnSection = endSlug === currentSlug;
103
-
104
- // Open Cloud keeps only the tail of a long run's log, so BEGIN — printed
105
- // first — can be lost while END and the summary survive. Recovering that
106
- // means letting an END with no open section claim what precedes it, but
107
- // only where the head can actually have been dropped: before any BEGIN
108
- // has survived, and only once. Past that point the log is well-formed,
109
- // and a BEGIN-less END is a message delivered out of order (the API does
110
- // not order them), which must not be allowed to claim another package's
111
- // output.
112
- const closesDroppedHead =
113
- currentSlug === null && beginMarkersSeen === 0 && !headSectionClaimed;
114
-
115
- if (closesOwnSection || closesDroppedHead) {
116
- logSections.set(endSlug, currentLines.join('\n'));
117
- if (closesDroppedHead) {
118
- partialSections.add(endSlug);
119
- headSectionClaimed = true;
120
- }
121
- if (durationStr !== undefined) {
122
- markerDurations.set(endSlug, parseInt(durationStr, 10));
123
- }
124
- currentSlug = null;
125
- currentLines = [];
126
- } else {
127
- // Reordered marker from another package. Ignore it without resetting
128
- // state, so the section it interrupted still closes on its own END.
129
- strayEndMarkers++;
130
- }
131
- continue;
132
- }
133
-
134
- if (trimmed === SUMMARY_MARKER) {
135
- // Keep scanning: the summary prints last but is not always delivered last,
136
- // and a section's END can follow it.
137
- if (summaryLineIndex < 0) {
138
- summaryLineIndex = i;
139
- }
140
- summaryPayloadPending = true;
141
- continue;
142
- }
143
-
144
- // The summary's JSON payload belongs to no section.
145
- if (summaryPayloadPending && trimmed.trimStart().startsWith('[')) {
146
- summaryPayloadPending = false;
147
- continue;
148
- }
149
-
150
- // Accumulate unconditionally: output that precedes the first surviving
151
- // BEGIN still belongs to whichever package's END closes it.
152
- currentLines.push(lines[i]);
153
- }
303
+ const tokens = tokenizeBatchLog(lines);
304
+ const {
305
+ sections: logSections,
306
+ partialSections,
307
+ markerDurations,
308
+ strayEndSlugs,
309
+ orphanedBeginSlugs,
310
+ } = foldTokensIntoSections(tokens);
311
+
312
+ // Diagnostics are read back off the token list rather than counted alongside
313
+ // the fold, so nothing the warnings report can also steer the parse.
314
+ const beginMarkersSeen = tokensOfKind(tokens, 'begin').length;
315
+ const endMarkersSeen = tokensOfKind(tokens, 'end').length;
316
+ const summaryLineIndex = tokensOfKind(tokens, 'summary')[0]?.lineIndex ?? -1;
317
+ const unknownEndSlugs = [
318
+ ...new Set(
319
+ tokensOfKind(tokens, 'end')
320
+ .map((token) => token.slug)
321
+ .filter((slug) => !slugToPackage.has(slug))
322
+ ),
323
+ ];
154
324
 
155
325
  // ── Pass 2: Parse the JSON summary for authoritative pcall results ──
156
326
 
157
327
  const summaryResults = new Map<string, boolean>();
158
328
  const summaryDurations = new Map<string, number>();
159
- if (summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length) {
160
- const entries = findSummaryEntries(lines, summaryLineIndex + 1);
329
+ const summaryErrors = new Map<string, string>();
330
+ const summaryCounts = new Map<string, SummaryCounts>();
331
+ const summaryRanJest = new Map<string, boolean>();
332
+
333
+ // The returned copy first: it is the same summary, from the channel that
334
+ // cannot arrive truncated. Only when there is none is the printed one read.
335
+ const returnedEntries = findReturnedSummaryEntries(options.returnValues);
336
+ if (
337
+ returnedEntries ||
338
+ (summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length)
339
+ ) {
340
+ const entries =
341
+ returnedEntries ?? findSummaryEntries(lines, summaryLineIndex + 1);
161
342
  if (entries === undefined) {
162
- OutputHelper.verbose(
343
+ report(
344
+ 'verbose',
163
345
  `[batch-log-parser] Failed to parse JSON summary after line ${summaryLineIndex}: ` +
164
346
  lines
165
347
  .slice(summaryLineIndex + 1, summaryLineIndex + 3)
@@ -172,33 +354,64 @@ export function parseBatchTestLogs(
172
354
  if (typeof entry.durationMs === 'number') {
173
355
  summaryDurations.set(entry.slug, entry.durationMs);
174
356
  }
357
+ if (typeof entry.error === 'string' && entry.error.length > 0) {
358
+ summaryErrors.set(entry.slug, entry.error);
359
+ }
360
+ const counts = readSummaryCounts(entry);
361
+ if (counts) {
362
+ summaryCounts.set(entry.slug, counts);
363
+ summaryRanJest.set(entry.slug, entry.ranJest === true);
364
+ }
175
365
  }
176
- // Log any pcall failures from the Luau template
366
+ // Log any failures the Luau template reported
177
367
  const failures = entries.filter((e) => !e.success);
178
368
  if (failures.length > 0) {
179
369
  console.error(
180
- `[batch-log-parser] Luau pcall failures: ${JSON.stringify(failures)}`
370
+ `[batch-log-parser] Luau runner failures: ${JSON.stringify(failures)}`
181
371
  );
182
372
  }
183
373
  console.error(
184
- `[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} pcall failures`
374
+ `[batch-log-parser] Parsed ${entries.length} summary entries, ${failures.length} reported failures`
185
375
  );
186
376
  }
187
377
  }
188
378
 
189
379
  // ── Warn when the batch produced no recognizable output ──
190
380
 
191
- if (strayEndMarkers > 0) {
192
- OutputHelper.verbose(
193
- `[batch-log-parser] Ignored ${strayEndMarkers} out-of-order END marker(s); ` +
381
+ if (strayEndSlugs.length > 0) {
382
+ report(
383
+ 'verbose',
384
+ `[batch-log-parser] Ignored ${strayEndSlugs.length} out-of-order END marker(s); ` +
194
385
  'their sections closed on their own boundaries.'
195
386
  );
196
387
  }
197
388
 
389
+ // Reported, not acted on: a slug the batch never asked for means the markers
390
+ // and the package list disagree, which no per-package verdict can express.
391
+ if (unknownEndSlugs.length > 0) {
392
+ report(
393
+ 'warn',
394
+ `[batch-log-parser] END marker(s) for slug(s) not in this batch: ` +
395
+ `${unknownEndSlugs.join(', ')}.`
396
+ );
397
+ }
398
+
399
+ if (orphanedBeginSlugs.length > 0) {
400
+ report(
401
+ 'warn',
402
+ `[batch-log-parser] ${orphanedBeginSlugs.length} section(s) reopened by a ` +
403
+ `second BEGIN before their own END arrived (${orphanedBeginSlugs.join(
404
+ ', '
405
+ )}); ` +
406
+ 'the output collected so far was discarded.'
407
+ );
408
+ }
409
+
198
410
  const noOutputAtAll = logSections.size === 0 && summaryResults.size === 0;
199
411
  if (noOutputAtAll) {
200
- OutputHelper.warn(
201
- `[batch-log-parser] No batch markers or summary found in logs (${lines.length} lines, ${rawLogs.length} chars). ` +
412
+ report(
413
+ 'warn',
414
+ `[batch-log-parser] No batch markers or summary found in logs (${logVolume}). ` +
202
415
  'The batch script may not have started or produced output.'
203
416
  );
204
417
  }
@@ -209,17 +422,19 @@ export function parseBatchTestLogs(
209
422
  // from pcall exit status alone, knowing nothing about what actually ran.
210
423
  const lostSectionOutput = logSections.size === 0 && summaryResults.size > 0;
211
424
  if (lostSectionOutput) {
212
- OutputHelper.warn(
213
- `[batch-log-parser] Received ${rawLogs.length} chars of output but could not ` +
214
- `attribute any of it to a package section ` +
215
- `(${beginMarkersSeen} BEGIN, ${endMarkersSeen} END markers; summary at line ` +
216
- `${summaryLineIndex} of ${lines.length}). ` +
425
+ report(
426
+ 'warn',
427
+ `[batch-log-parser] Received output but could not attribute any of it to ` +
428
+ `a package section (${logVolume}; ${beginMarkersSeen} BEGIN, ` +
429
+ `${endMarkersSeen} END markers; summary at line ${summaryLineIndex} of ` +
430
+ `${lines.length}). ` +
217
431
  'Test counts are not derived from unattributed output.'
218
432
  );
219
433
  // BEGIN is printed first and the summary last, so their positions separate
220
434
  // the two ways this happens: a log window that dropped the head keeps the
221
435
  // summary at the end, while reordered messages put it early.
222
- OutputHelper.warn(
436
+ report(
437
+ 'warn',
223
438
  summaryLineIndex >= lines.length - 2
224
439
  ? '[batch-log-parser] Summary sits at the end of the log — consistent with the head being dropped by a log size/retention limit.'
225
440
  : '[batch-log-parser] Summary sits early in the log — consistent with messages delivered out of order.'
@@ -238,6 +453,14 @@ export function parseBatchTestLogs(
238
453
  // attached once rather than repeated per package across a large log.
239
454
  let unattributedClaimed = false;
240
455
 
456
+ /** Packages whose run returned its counts, and those left to log scraping. */
457
+ const structuredSlugs: string[] = [];
458
+ const scrapedSlugs: string[] = [];
459
+ /** Packages reported as failed by a run whose counts show nothing failed. */
460
+ const unexplainedFailures: string[] = [];
461
+ /** Packages judged on returned counts alone, their log section having been lost. */
462
+ const sectionlessButCounted: string[] = [];
463
+
241
464
  for (const [packageName, slug] of slugMap) {
242
465
  const attributedLogs = logSections.get(slug);
243
466
  let sectionLogs = attributedLogs ?? '';
@@ -246,39 +469,78 @@ export function parseBatchTestLogs(
246
469
  unattributedClaimed = true;
247
470
  }
248
471
  const summarySuccess = summaryResults.get(slug);
472
+ const counts = summaryCounts.get(slug);
249
473
  const reasons: string[] = [];
250
474
 
251
- // The pcall result only proves the script did not throw. It is a floor, not
252
- // a verdict — everything below can fail a run it called successful.
475
+ // The summary verdict is a floor, not the whole verdict — everything below
476
+ // can fail a run it called successful. It covers both a script that threw
477
+ // and one whose returned results said it failed, so the reason comes from
478
+ // the runner rather than being guessed at here.
253
479
  let success = summarySuccess ?? false;
254
480
  if (summarySuccess === false) {
255
- reasons.push('the batch runner reported a Luau error');
481
+ reasons.push(
482
+ summaryErrors.get(slug) ?? 'the batch runner reported this as failed'
483
+ );
256
484
  } else if (summarySuccess === undefined) {
257
485
  // Absent from the summary is a different fault from failing in it, and
258
486
  // conflating them sends you hunting for a Luau error that never happened.
259
487
  reasons.push('this package is missing from the batch summary');
260
488
  }
261
489
 
490
+ // Returned counts are structural, so a summary that called this a pass
491
+ // while reporting failed tests is not believed — no log line involved.
492
+ if (success && counts && (counts.failed > 0 || counts.suitesFailed > 0)) {
493
+ success = false;
494
+ reasons.push(
495
+ `the batch summary reported a pass alongside ${counts.failed} failed ` +
496
+ `test(s) and ${counts.suitesFailed} failed test suite(s)`
497
+ );
498
+ }
499
+
500
+ // The reverse contradiction: failure reported over counts where nothing
501
+ // failed. Left as a failure but said out loud — a runner reading the wrong
502
+ // field produces exactly this, for every package at once.
503
+ if (
504
+ !success &&
505
+ counts &&
506
+ counts.failed === 0 &&
507
+ counts.suitesFailed === 0
508
+ ) {
509
+ unexplainedFailures.push(slug);
510
+ }
511
+
262
512
  if (attributedLogs !== undefined) {
513
+ // Demanding a jest report in the section is a stand-in for proof the
514
+ // runner ran, and it is the first thing a truncated log window costs.
515
+ // Returned counts are that proof directly, so they retire the stand-in —
516
+ // without this the whole structured channel changed nothing in a CI batch,
517
+ // where dozens of packages share one log window and a section keeps its
518
+ // END marker long after its jest summary was dropped.
263
519
  const outcome = evaluateTestOutcome(attributedLogs, {
264
- requireTestReport: true,
520
+ requireTestReport: counts === undefined,
265
521
  });
266
522
  if (!outcome.success) {
267
523
  success = false;
268
524
  reasons.push(...outcome.failureReasons);
269
525
  }
270
- } else {
526
+ } else if (counts === undefined) {
271
527
  // Judged unreadable rather than judged by content: whatever broke
272
528
  // attribution is reason enough not to trust which package a line is from.
273
529
  success = false;
274
530
  reasons.push(
275
531
  `no output could be attributed to this package ` +
276
- `(${rawLogs.length} chars received, ${beginMarkersSeen} BEGIN markers found)`
532
+ `(${logVolume}; ${beginMarkersSeen} BEGIN markers found)`
277
533
  );
534
+ } else {
535
+ // The counts say what happened even though the log does not. Tracebacks
536
+ // cannot be checked without the text, though, so this is a narrower
537
+ // verdict than a package with a section gets — hence the warning.
538
+ sectionlessButCounted.push(slug);
278
539
  }
279
540
 
280
541
  if (partialSections.has(slug)) {
281
- OutputHelper.warn(
542
+ report(
543
+ 'warn',
282
544
  `[batch-log-parser] ${slug}: section closed by its END marker with no BEGIN — ` +
283
545
  'the start of this output was dropped by the log window, so the section is partial.'
284
546
  );
@@ -290,14 +552,16 @@ export function parseBatchTestLogs(
290
552
  const tracebackCount = tracebacks.reduce((sum, t) => sum + t.count, 0);
291
553
  const owned = tracebacks.filter((t) => !t.owner || t.owner === slug);
292
554
  if (tracebacks.length > owned.length) {
293
- OutputHelper.verbose(
555
+ report(
556
+ 'verbose',
294
557
  `[batch-log-parser] ${slug}: ignored ${
295
558
  tracebacks.length - owned.length
296
559
  } traceback(s) belonging to other packages`
297
560
  );
298
561
  }
299
562
  if (owned.length > 0) {
300
- OutputHelper.warn(
563
+ report(
564
+ 'warn',
301
565
  `[batch-log-parser] ${slug}: ${owned.length} distinct traceback(s):\n` +
302
566
  formatTracebacks(owned)
303
567
  );
@@ -305,9 +569,41 @@ export function parseBatchTestLogs(
305
569
 
306
570
  const error = reasons.length > 0 ? reasons.join('; ') : undefined;
307
571
 
308
- const testCounts = attributedLogs
572
+ // Counts the runner returned outrank counts scraped from the section: the
573
+ // same numbers when the log survived, real numbers when it did not.
574
+ const scrapedCounts = attributedLogs
309
575
  ? parseTestCounts(attributedLogs)
310
576
  : undefined;
577
+ const testCounts = counts
578
+ ? { passed: counts.passed, failed: counts.failed, total: counts.total }
579
+ : scrapedCounts;
580
+ const countsSource = counts
581
+ ? ('returned' as const)
582
+ : scrapedCounts
583
+ ? ('scraped' as const)
584
+ : undefined;
585
+
586
+ if (counts) {
587
+ structuredSlugs.push(slug);
588
+ } else {
589
+ scrapedSlugs.push(slug);
590
+ }
591
+
592
+ // Two channels reporting the same run must agree. When they do not, one of
593
+ // them is lying about what happened and neither total can be trusted on its
594
+ // own — said out loud because a returned count that is quietly wrong reads
595
+ // like a clean run, which is how a broken structured read stays invisible.
596
+ if (counts && scrapedCounts && counts.total !== scrapedCounts.total) {
597
+ report(
598
+ 'warn',
599
+ `[batch-log-parser] ${slug}: returned counts disagree with the log — ` +
600
+ `the run returned ${counts.passed} passed / ${counts.failed} failed / ` +
601
+ `${counts.total} total, its jest report says ${scrapedCounts.passed} / ` +
602
+ `${scrapedCounts.failed} / ${scrapedCounts.total}. Reporting the returned ` +
603
+ `counts; one of the two channels is wrong.`
604
+ );
605
+ }
606
+
311
607
  // Prefer the JSON summary (authoritative, immune to log reordering);
312
608
  // fall back to the END-marker value if the summary was truncated.
313
609
  const durationMs = summaryDurations.get(slug) ?? markerDurations.get(slug);
@@ -318,13 +614,322 @@ export function parseBatchTestLogs(
318
614
  durationMs,
319
615
  testCounts,
320
616
  tracebackCount,
617
+ countsSource,
618
+ logsLost: attributedLogs === undefined && counts !== undefined,
619
+ testResults: counts
620
+ ? {
621
+ success: summarySuccess === true,
622
+ ranJest: summaryRanJest.get(slug) === true,
623
+ ...counts,
624
+ failures: [],
625
+ omittedFailures: 0,
626
+ error: summaryErrors.get(slug),
627
+ }
628
+ : undefined,
321
629
  error,
322
630
  });
323
631
  }
324
632
 
633
+ reportCountsProvenance(
634
+ structuredSlugs,
635
+ scrapedSlugs,
636
+ unexplainedFailures,
637
+ sectionlessButCounted,
638
+ report,
639
+ results,
640
+ slugMap
641
+ );
642
+
325
643
  return results;
326
644
  }
327
645
 
646
+ /**
647
+ * Say where this batch's counts came from, every run.
648
+ *
649
+ * The structured channel exists because Open Cloud truncates a long run's logs.
650
+ * A channel that is plumbed but not flowing produces output identical to one
651
+ * that is working, so silence here is not evidence of anything — the count is
652
+ * stated unconditionally and the fallback is a warning, not a debug line.
653
+ */
654
+ function reportCountsProvenance(
655
+ structuredSlugs: string[],
656
+ scrapedSlugs: string[],
657
+ unexplainedFailures: string[],
658
+ sectionlessButCounted: string[],
659
+ report: (level: BatchDiagnosticLevel, message: string) => void,
660
+ results: Map<string, BatchPackageResult>,
661
+ slugMap: Map<string, string>
662
+ ): void {
663
+ const total = structuredSlugs.length + scrapedSlugs.length;
664
+ if (total === 0) {
665
+ return;
666
+ }
667
+
668
+ // Not a failure — this is the case the structured channel was built for — but
669
+ // these packages were judged without their log text, so nothing checked them
670
+ // for tracebacks, which jest cannot count and only the log shows.
671
+ if (sectionlessButCounted.length > 0) {
672
+ report(
673
+ 'warn',
674
+ `[batch-log-parser] ${sectionlessButCounted.length} package(s) were judged on ` +
675
+ `their returned counts alone, with no log section to read: ` +
676
+ `${sectionlessButCounted.join(
677
+ ', '
678
+ )}. Their counts are exact; they were ` +
679
+ `not checked for Luau tracebacks, which only the log shows.`
680
+ );
681
+ }
682
+
683
+ report(
684
+ 'info',
685
+ `[batch-log-parser] Counts returned by the run for ${structuredSlugs.length} ` +
686
+ `of ${total} package(s); ${scrapedSlugs.length} scraped from logs.`
687
+ );
688
+
689
+ // Failing every package while every package's counts are clean is a signature,
690
+ // not a coincidence: it means the runner's verdict, not the tests, is wrong.
691
+ if (unexplainedFailures.length > 0) {
692
+ report(
693
+ 'warn',
694
+ `[batch-log-parser] ${unexplainedFailures.length} of ${total} package(s) were ` +
695
+ `failed by a run whose own counts show nothing failed: ` +
696
+ `${unexplainedFailures.join(
697
+ ', '
698
+ )}. The failures stand — a runner may know ` +
699
+ `something its counts cannot express — but a verdict no count supports is ` +
700
+ `far more likely to be the runner reading the wrong field.`
701
+ );
702
+ }
703
+
704
+ if (scrapedSlugs.length > 0) {
705
+ // One group rather than a line per package: the packages differ but the
706
+ // finding does not, and 78 restatements of it buried everything else. The
707
+ // state goes in the title so the group can be left collapsed.
708
+ report(
709
+ 'group',
710
+ `${scrapedSlugs.length} of ${total} package(s) fell back to log scraping ` +
711
+ `(no test results returned)`
712
+ );
713
+ report(
714
+ 'warn',
715
+ `Counts for these came from log text, the channel Open Cloud truncates on ` +
716
+ `long runs. A package that runs its tests through ` +
717
+ `NevermoreTestRunnerUtils reports them as a value without its script ` +
718
+ `changing, so these are packages doing something else (see ` +
719
+ `docs/testing/testing.md).`
720
+ );
721
+ // Each with its own state: this list is read to find the packages worth
722
+ // looking at, and a column of bare names says nothing about which those are.
723
+ for (const slug of scrapedSlugs) {
724
+ report(
725
+ 'info',
726
+ ` ${slug} — ${describeSlugState(slug, results, slugMap)}`
727
+ );
728
+ }
729
+ report('endgroup', '');
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Classify every log line, so section splitting reads tokens instead of text.
735
+ */
736
+ export function tokenizeBatchLog(lines: string[]): BatchLogToken[] {
737
+ const tokens: BatchLogToken[] = [];
738
+ /** True between the summary marker and its JSON payload. */
739
+ let summaryPayloadPending = false;
740
+
741
+ for (let i = 0; i < lines.length; i++) {
742
+ const trimmed = lines[i].trimEnd();
743
+
744
+ if (trimmed.startsWith(BEGIN_MARKER) && trimmed.endsWith(MARKER_SUFFIX)) {
745
+ tokens.push({
746
+ kind: 'begin',
747
+ slug: trimmed.slice(BEGIN_MARKER.length, -MARKER_SUFFIX.length),
748
+ });
749
+ continue;
750
+ }
751
+
752
+ if (trimmed.startsWith(END_MARKER) && trimmed.endsWith(MARKER_SUFFIX)) {
753
+ tokens.push(
754
+ parseEndToken(trimmed.slice(END_MARKER.length, -MARKER_SUFFIX.length))
755
+ );
756
+ continue;
757
+ }
758
+
759
+ if (trimmed === SUMMARY_MARKER) {
760
+ // Keep scanning: the summary prints last but is not always delivered last,
761
+ // and a section's END can follow it.
762
+ tokens.push({ kind: 'summary', lineIndex: i });
763
+ summaryPayloadPending = true;
764
+ continue;
765
+ }
766
+
767
+ // The summary's JSON payload belongs to no section.
768
+ if (summaryPayloadPending && trimmed.trimStart().startsWith('[')) {
769
+ summaryPayloadPending = false;
770
+ tokens.push({ kind: 'summaryPayload' });
771
+ continue;
772
+ }
773
+
774
+ tokens.push({ kind: 'content', line: lines[i] });
775
+ }
776
+
777
+ return tokens;
778
+ }
779
+
780
+ /**
781
+ * Read an END marker's inner text.
782
+ *
783
+ * The PASS|FAIL verdict and the duration are both optional, so inner text that
784
+ * does not parse is taken whole as the slug rather than dropped.
785
+ */
786
+ function parseEndToken(inner: string): EndToken {
787
+ const match = END_INNER_PATTERN.exec(inner);
788
+ if (!match) {
789
+ return { kind: 'end', slug: inner };
790
+ }
791
+
792
+ const durationStr = match[2];
793
+ return {
794
+ kind: 'end',
795
+ slug: match[1],
796
+ durationMs:
797
+ durationStr === undefined ? undefined : parseInt(durationStr, 10),
798
+ };
799
+ }
800
+
801
+ /**
802
+ * Fold tokens into per-package log sections.
803
+ *
804
+ * Sections close on END, not on the next BEGIN, because END is what survives:
805
+ * see the head-claiming rule below.
806
+ */
807
+ function foldTokensIntoSections(
808
+ tokens: readonly BatchLogToken[]
809
+ ): FoldedSections {
810
+ const sections = new Map<string, string>();
811
+ const partialSections = new Set<string>();
812
+ const markerDurations = new Map<string, number>();
813
+ const strayEndSlugs: string[] = [];
814
+ const orphanedBeginSlugs: string[] = [];
815
+
816
+ let openSlug: string | null = null;
817
+ let openLines: string[] = [];
818
+ /**
819
+ * Whether the log can still be one whose head was dropped.
820
+ *
821
+ * Open Cloud keeps only the tail of a long run's log, so BEGIN — printed
822
+ * first — can be lost while END and the summary survive. Recovering that
823
+ * means letting an END with no open section claim what precedes it, but
824
+ * only where the head can actually have been dropped: before any BEGIN
825
+ * has survived, and only once. Past that point the log is well-formed,
826
+ * and a BEGIN-less END is a message delivered out of order (the API does
827
+ * not order them), which must not be allowed to claim another package's
828
+ * output.
829
+ */
830
+ let headClaimable = true;
831
+
832
+ for (const token of tokens) {
833
+ switch (token.kind) {
834
+ case 'begin': {
835
+ if (openSlug !== null) {
836
+ orphanedBeginSlugs.push(openSlug);
837
+ }
838
+ openSlug = token.slug;
839
+ openLines = [];
840
+ headClaimable = false;
841
+ break;
842
+ }
843
+
844
+ case 'end': {
845
+ // A section normally closes on the END matching its own BEGIN.
846
+ const closesOwnSection = token.slug === openSlug;
847
+ // Mutually exclusive with the above: an unclaimed head means no BEGIN
848
+ // has arrived, so no section is open to match.
849
+ const closesDroppedHead = headClaimable;
850
+
851
+ if (!closesOwnSection && !closesDroppedHead) {
852
+ // Reordered marker from another package. Ignore it without resetting
853
+ // state, so the section it interrupted still closes on its own END.
854
+ strayEndSlugs.push(token.slug);
855
+ break;
856
+ }
857
+
858
+ sections.set(token.slug, openLines.join('\n'));
859
+ if (closesDroppedHead) {
860
+ partialSections.add(token.slug);
861
+ }
862
+ if (token.durationMs !== undefined) {
863
+ markerDurations.set(token.slug, token.durationMs);
864
+ }
865
+ openSlug = null;
866
+ openLines = [];
867
+ headClaimable = false;
868
+ break;
869
+ }
870
+
871
+ case 'content': {
872
+ // Accumulate unconditionally: output that precedes the first surviving
873
+ // BEGIN still belongs to whichever package's END closes it.
874
+ openLines.push(token.line);
875
+ break;
876
+ }
877
+
878
+ case 'summary':
879
+ case 'summaryPayload': {
880
+ break;
881
+ }
882
+ }
883
+ }
884
+
885
+ return {
886
+ sections,
887
+ partialSections,
888
+ markerDurations,
889
+ strayEndSlugs,
890
+ orphanedBeginSlugs,
891
+ };
892
+ }
893
+
894
+ /** Select the tokens of one kind, for counting and reporting. */
895
+ function tokensOfKind<K extends BatchLogToken['kind']>(
896
+ tokens: readonly BatchLogToken[],
897
+ kind: K
898
+ ): Extract<BatchLogToken, { kind: K }>[] {
899
+ return tokens.filter(
900
+ (token): token is Extract<BatchLogToken, { kind: K }> => token.kind === kind
901
+ );
902
+ }
903
+
904
+ /**
905
+ * Find the summary array among a task's return values.
906
+ *
907
+ * The batch script returns the same array it prints. Recognized by shape rather
908
+ * than by position: a run is free to return other things, and an array of
909
+ * objects that all carry a string `slug` is the summary and nothing else.
910
+ */
911
+ export function findReturnedSummaryEntries(
912
+ returnValues: unknown[] | undefined
913
+ ): SummaryEntry[] | undefined {
914
+ for (const value of returnValues ?? []) {
915
+ if (!Array.isArray(value) || value.length === 0) {
916
+ continue;
917
+ }
918
+
919
+ const looksLikeSummary = value.every(
920
+ (entry) =>
921
+ typeof entry === 'object' &&
922
+ entry !== null &&
923
+ typeof (entry as { slug?: unknown }).slug === 'string'
924
+ );
925
+ if (looksLikeSummary) {
926
+ return value as SummaryEntry[];
927
+ }
928
+ }
929
+
930
+ return undefined;
931
+ }
932
+
328
933
  /**
329
934
  * Find the summary array in the lines following the summary marker.
330
935
  *