@rage-against-the-pixel/unity-cli 1.8.3 → 3.0.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.
package/dist/logging.js CHANGED
@@ -1,40 +1,511 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.Logger = exports.LogLevel = void 0;
37
- const fs = __importStar(require("fs"));
4
+ exports.mergeLogEntriesPreferringSeverity = mergeLogEntriesPreferringSeverity;
5
+ exports.testStatusFromState = testStatusFromState;
6
+ exports.utpToTestResultSummary = utpToTestResultSummary;
7
+ exports.buildTestResultsTableMarkdown = buildTestResultsTableMarkdown;
8
+ exports.buildUnitTestJobSummaryMarkdown = buildUnitTestJobSummaryMarkdown;
9
+ exports.truncateStringToUtf8ByteLength = truncateStringToUtf8ByteLength;
10
+ exports.stripSummaryNoiseFromLogMessage = stripSummaryNoiseFromLogMessage;
11
+ const utp_1 = require("./utp");
12
+ const github_actions_ci_1 = require("./github-actions-ci");
13
+ const logger_provider_1 = require("./logger-provider");
14
+ /** Severity order for display: Error first, then Warning, then Info. Undefined treats as Warning. */
15
+ function severityRank(s) {
16
+ if (s === utp_1.Severity.Error || s === utp_1.Severity.Exception || s === utp_1.Severity.Assert)
17
+ return 0;
18
+ if (s === utp_1.Severity.Warning || s === undefined)
19
+ return 1;
20
+ return 2; // Info
21
+ }
22
+ function dedupeKey(e) {
23
+ const msg = (e.message || '').trim();
24
+ const file = (e.file || e.fileName || '').replace(/\\/g, '/');
25
+ const line = e.line ?? e.lineNumber ?? 0;
26
+ return `${msg}\n${file}\n${line}`;
27
+ }
28
+ /**
29
+ * Returns true if the path looks absolute (Unix / or Windows X:/).
30
+ */
31
+ function isAbsolutePath(file) {
32
+ const norm = file.replace(/\\/g, '/');
33
+ if (norm.startsWith('/'))
34
+ return true;
35
+ return /^[a-zA-Z]:\//.test(norm);
36
+ }
37
+ /**
38
+ * Returns true if the entry's file is under the project path (or entry has no file).
39
+ * Relative paths (e.g. Assets/..., Packages/...) are always kept so Unity UTP log/compiler
40
+ * entries with relative file paths still appear in the summary.
41
+ */
42
+ function isEntryUnderProjectPath(e, projectPath) {
43
+ const file = (e.file || e.fileName || '').trim();
44
+ if (!file)
45
+ return true;
46
+ const normFile = file.replace(/\\/g, '/');
47
+ if (!isAbsolutePath(normFile))
48
+ return true;
49
+ const normProject = projectPath.replace(/\\/g, '/');
50
+ const base = normProject.endsWith('/') ? normProject : normProject + '/';
51
+ return normFile === normProject || normFile.startsWith(base);
52
+ }
53
+ /**
54
+ * Returns true if the entry's file looks like a Unity engine path (should be omitted when not using projectPath).
55
+ */
56
+ function isUnityEnginePath(file) {
57
+ const norm = file.replace(/\\/g, '/');
58
+ if (UNITY_ENGINE_PATH_PREFIXES.some(p => norm.startsWith(p)))
59
+ return true;
60
+ if (norm.includes('/Runtime/') || norm.includes('\\Runtime\\'))
61
+ return true;
62
+ if (!norm.endsWith('.cpp'))
63
+ return false;
64
+ const underProject = norm.includes('/Assets/') || norm.includes('/Packages/') || norm.includes('/Library/PackageCache/');
65
+ return !underProject;
66
+ }
67
+ /**
68
+ * Merges LogEntry/Compiler rows by message+file+line; on collision keeps the more severe entry.
69
+ * Exported for unit tests.
70
+ */
71
+ function mergeLogEntriesPreferringSeverity(candidates) {
72
+ const byKey = new Map();
73
+ for (const e of candidates) {
74
+ const key = dedupeKey(e);
75
+ const existing = byKey.get(key);
76
+ if (!existing || severityRank(e.severity) < severityRank(existing.severity)) {
77
+ byKey.set(key, e);
78
+ }
79
+ }
80
+ const merged = [...byKey.values()];
81
+ merged.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
82
+ return merged;
83
+ }
84
+ /**
85
+ * Builds one merged list from LogEntry and Compiler entries.
86
+ * Deduplicated by message+file+line (keeping worse severity on collision), sorted by severity.
87
+ */
88
+ function buildMergedLogList(filtered) {
89
+ const candidates = filtered.filter(e => e.type === 'LogEntry' || e.type === 'Compiler');
90
+ return mergeLogEntriesPreferringSeverity(candidates);
91
+ }
92
+ /**
93
+ * Filters merged list to project-relevant entries only.
94
+ * When projectPath is set: keep entries with no file or file under projectPath.
95
+ * When projectPath is not set: exclude Unity engine paths only (keep PackageCache and project paths).
96
+ */
97
+ function filterMergedByPath(merged, options) {
98
+ if (options?.projectPath != null && options.projectPath !== '') {
99
+ return merged.filter(e => isEntryUnderProjectPath(e, options.projectPath));
100
+ }
101
+ return merged.filter(e => {
102
+ const file = (e.file || e.fileName || '').trim();
103
+ if (!file)
104
+ return true;
105
+ return !isUnityEnginePath(file);
106
+ });
107
+ }
108
+ /** Groups merged log by severity for foldouts (Error, Warning, Info). Missing severity is grouped as Warning. */
109
+ function groupBySeverity(merged) {
110
+ const errorCritical = [];
111
+ const warning = [];
112
+ const info = [];
113
+ for (const e of merged) {
114
+ if (e.severity === utp_1.Severity.Error || e.severity === utp_1.Severity.Exception || e.severity === utp_1.Severity.Assert) {
115
+ errorCritical.push(e);
116
+ }
117
+ else if (e.severity === utp_1.Severity.Warning || e.severity === undefined) {
118
+ warning.push(e);
119
+ }
120
+ else {
121
+ info.push(e);
122
+ }
123
+ }
124
+ return { errorCritical, warning, info };
125
+ }
126
+ /** Maps UTPTestStatus.state to display status (Unity/NUnit-style: 0 Inconclusive, 1 Passed, 2 Failed, 3 Skipped). */
127
+ function testStatusFromState(state) {
128
+ switch (state) {
129
+ case 1: return '✅';
130
+ case 2: return '❌';
131
+ case 3: return '⏭️';
132
+ case 0:
133
+ default: return '◯';
134
+ }
135
+ }
136
+ /** Converts a single TestStatus UTP to TestResultSummary. Exported for CLI use. */
137
+ function utpToTestResultSummary(e) {
138
+ const state = e.state;
139
+ const durationMs = e.duration ?? (e.durationMicroseconds != null ? e.durationMicroseconds / 1000 : 0);
140
+ const description = (e.name || e.description || '-').trim();
141
+ const msg = (e.message || '').trim();
142
+ const summary = {
143
+ status: testStatusFromState(state),
144
+ durationMs,
145
+ description,
146
+ };
147
+ if (msg !== '') {
148
+ summary.message = msg;
149
+ }
150
+ const file = (e.file || e.fileName || '').trim();
151
+ const line = e.line ?? e.lineNumber;
152
+ if (file !== '') {
153
+ summary.file = file.replace(/\\/g, '/');
154
+ }
155
+ if (line !== undefined && line > 0) {
156
+ summary.line = line;
157
+ }
158
+ return summary;
159
+ }
160
+ /** Collects TestStatus entries from telemetry into TestResultSummary rows. */
161
+ function collectTestResults(filtered) {
162
+ return filtered.filter(e => e.type === 'TestStatus').map(utpToTestResultSummary);
163
+ }
164
+ function escapeMarkdownTableCell(value) {
165
+ return value
166
+ .replace(/\\/g, '\\\\')
167
+ .replace(/\|/g, '\\|');
168
+ }
169
+ /** Builds a markdown table string for test results (Status | Duration | Test). Exported for CLI use. */
170
+ function buildTestResultsTableMarkdown(testResults, byteLimit, prefix) {
171
+ if (testResults.length === 0)
172
+ return '';
173
+ const p = prefix ?? '';
174
+ let out = p + `### Test results\n\n`;
175
+ out += `| Status | Duration | Test |\n`;
176
+ out += `|--------|----------|------|\n`;
177
+ let shown = 0;
178
+ for (const row of testResults) {
179
+ const durationStr = row.durationMs >= 1000
180
+ ? `${(row.durationMs / 1000).toFixed(1)}s`
181
+ : `${Math.round(row.durationMs)} ms`;
182
+ const rawDesc = row.description.length > 80 ? row.description.slice(0, 77) + '…' : row.description;
183
+ const desc = escapeMarkdownTableCell(rawDesc);
184
+ const line = `| ${escapeMarkdownTableCell(row.status)} | ${escapeMarkdownTableCell(durationStr)} | ${desc} |\n`;
185
+ if (Buffer.byteLength(out + line, 'utf8') > byteLimit)
186
+ break;
187
+ out += line;
188
+ shown++;
189
+ }
190
+ if (shown < testResults.length) {
191
+ out += `| … | … | … and ${testResults.length - shown} more |\n`;
192
+ }
193
+ out += `\n`;
194
+ return out;
195
+ }
196
+ function summarizeTestOutcomes(testResults) {
197
+ let passed = 0;
198
+ let failed = 0;
199
+ let skipped = 0;
200
+ let inconclusive = 0;
201
+ let totalDurationMs = 0;
202
+ for (const t of testResults) {
203
+ totalDurationMs += t.durationMs;
204
+ switch (t.status) {
205
+ case '✅':
206
+ passed++;
207
+ break;
208
+ case '❌':
209
+ failed++;
210
+ break;
211
+ case '⏭️':
212
+ skipped++;
213
+ break;
214
+ default:
215
+ inconclusive++;
216
+ break;
217
+ }
218
+ }
219
+ return { passed, failed, skipped, inconclusive, totalDurationMs };
220
+ }
221
+ /**
222
+ * Rich unit-test markdown block used by workflow summary and stdout.
223
+ * Keeps byte-budget behavior and truncation hints.
224
+ */
225
+ function buildUnitTestJobSummaryMarkdown(testResults, byteLimit, prefix) {
226
+ if (testResults.length === 0)
227
+ return '';
228
+ const p = prefix ?? '';
229
+ let out = p + '### Unit test results\n\n';
230
+ const counts = summarizeTestOutcomes(testResults);
231
+ const durationStr = counts.totalDurationMs >= 1000
232
+ ? `${(counts.totalDurationMs / 1000).toFixed(1)}s`
233
+ : `${Math.round(counts.totalDurationMs)} ms`;
234
+ out += `**${testResults.length}** tests - **${counts.passed}** ✓, **${counts.failed}** ✗, **${counts.skipped}** skipped, **${counts.inconclusive}** inconclusive - **${durationStr}** total\n\n`;
235
+ out += '| Test | Result | Time | Message |\n';
236
+ out += '| --- | --- | --- | --- |\n';
237
+ const ordered = [...testResults].sort((a, b) => {
238
+ const aFail = a.status === '❌' ? 0 : 1;
239
+ const bFail = b.status === '❌' ? 0 : 1;
240
+ if (aFail !== bFail)
241
+ return aFail - bFail;
242
+ return b.durationMs - a.durationMs;
243
+ });
244
+ let shown = 0;
245
+ for (const row of ordered) {
246
+ const durationText = row.durationMs >= 1000 ? `${(row.durationMs / 1000).toFixed(1)}s` : `${Math.round(row.durationMs)} ms`;
247
+ const loc = row.file && row.line ? ` (${row.file}:${row.line})` : '';
248
+ const rawName = `${row.description}${loc}`;
249
+ const name = escapeMarkdownTableCell(rawName.length > 90 ? `${rawName.slice(0, 87)}…` : rawName);
250
+ const msgRaw = (row.message ?? '').replace(/\r?\n/g, ' ').trim();
251
+ const msg = escapeMarkdownTableCell(msgRaw.length > 120 ? `${msgRaw.slice(0, 117)}…` : msgRaw);
252
+ const line = `| ${name} | ${escapeMarkdownTableCell(row.status)} | ${escapeMarkdownTableCell(durationText)} | ${msg} |\n`;
253
+ if (Buffer.byteLength(out + line, 'utf8') > byteLimit)
254
+ break;
255
+ out += line;
256
+ shown++;
257
+ }
258
+ if (shown < ordered.length) {
259
+ out += `| … | … | … | … and ${ordered.length - shown} more |\n`;
260
+ }
261
+ out += '\n';
262
+ return out;
263
+ }
264
+ function buildActionTimelineTableMarkdown(completedActions, byteLimit, prefix) {
265
+ if (completedActions.length === 0)
266
+ return { markdown: '', truncated: false };
267
+ const p = prefix ?? '';
268
+ let out = p + '| Status | Duration | Errors | Action |\n';
269
+ out += '| --- | --- | --- | --- |\n';
270
+ let shown = 0;
271
+ for (const a of completedActions) {
272
+ const durationMs = a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : undefined);
273
+ const errCount = Array.isArray(a.errors) ? a.errors.length : 0;
274
+ const status = errCount > 0 ? '❌' : '✅';
275
+ const action = truncateStr(toSingleLineText(a.description || a.name || '-'), 120);
276
+ const row = `| ${escapeMarkdownTableCell(status)} | ${escapeMarkdownTableCell(formatDurationMsForSummary(durationMs))} | ${errCount} | ${escapeMarkdownTableCell(action)} |\n`;
277
+ if (Buffer.byteLength(out + row, 'utf8') > byteLimit)
278
+ break;
279
+ out += row;
280
+ shown++;
281
+ }
282
+ const truncated = shown < completedActions.length;
283
+ if (truncated) {
284
+ out += `| ... | ... | ... | ... and ${completedActions.length - shown} more actions |\n`;
285
+ }
286
+ out += '\n';
287
+ return { markdown: out, truncated };
288
+ }
289
+ function buildActionTimelineCodeblockMarkdown(completedActions, byteLimit, prefix) {
290
+ if (completedActions.length === 0)
291
+ return '';
292
+ const p = prefix ?? '';
293
+ let out = p + '```text\n';
294
+ let timelineShown = 0;
295
+ for (const a of completedActions) {
296
+ const durationMs = a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : undefined);
297
+ const errCount = Array.isArray(a.errors) ? a.errors.length : 0;
298
+ const status = errCount > 0 ? '❌' : '✅';
299
+ const desc = toSingleLineText(a.description || a.name || '-');
300
+ const durationStr = formatDurationMsForSummary(durationMs);
301
+ const row = `${status} ${durationStr} ${errCount} - ${desc}\n`;
302
+ if (Buffer.byteLength(out + row, 'utf8') > byteLimit)
303
+ break;
304
+ out += row;
305
+ timelineShown++;
306
+ }
307
+ if (timelineShown < completedActions.length) {
308
+ out += `... and ${completedActions.length - timelineShown} more actions\n`;
309
+ }
310
+ out += '```\n\n';
311
+ return out;
312
+ }
313
+ function truncateStr(s, max) {
314
+ return s.length <= max ? s : s.slice(0, max) + '…';
315
+ }
316
+ /**
317
+ * Truncates s to fit within maxBytes in UTF-8. If truncated, appends an ellipsis (…).
318
+ * If s already fits, returns s unchanged.
319
+ * Exported for unit tests.
320
+ */
321
+ function truncateStringToUtf8ByteLength(s, maxBytes) {
322
+ if (maxBytes <= 0)
323
+ return '';
324
+ const ellipsis = '…';
325
+ const ellBytes = Buffer.byteLength(ellipsis, 'utf8');
326
+ if (Buffer.byteLength(s, 'utf8') <= maxBytes)
327
+ return s;
328
+ if (maxBytes <= ellBytes) {
329
+ let end = 0;
330
+ for (let i = 1; i <= s.length; i++) {
331
+ const sub = s.slice(0, i);
332
+ if (Buffer.byteLength(sub, 'utf8') > maxBytes)
333
+ break;
334
+ end = i;
335
+ }
336
+ return s.slice(0, end);
337
+ }
338
+ let low = 0;
339
+ let high = s.length;
340
+ while (low < high) {
341
+ const mid = Math.floor((low + high + 1) / 2);
342
+ const sub = s.slice(0, mid);
343
+ if (Buffer.byteLength(sub, 'utf8') + ellBytes <= maxBytes)
344
+ low = mid;
345
+ else
346
+ high = mid - 1;
347
+ }
348
+ return s.slice(0, low) + ellipsis;
349
+ }
350
+ /**
351
+ * Appends one formatted log line per entry, truncating each line only when it would exceed the
352
+ * remaining bytes in the workflow summary (byteLimit is total cap for the final string starting from out).
353
+ */
354
+ function appendWorkflowSummaryLogLines(out, entries, byteLimit) {
355
+ let o = out;
356
+ let shown = 0;
357
+ const newline = '\n';
358
+ const nlBytes = Buffer.byteLength(newline, 'utf8');
359
+ for (let i = 0; i < entries.length; i++) {
360
+ const entry = entries[i];
361
+ if (entry === undefined) {
362
+ return { out: o, shown, omitted: entries.length - shown };
363
+ }
364
+ const room = byteLimit - Buffer.byteLength(o, 'utf8');
365
+ if (room < nlBytes) {
366
+ return { out: o, shown, omitted: entries.length - shown };
367
+ }
368
+ const rawLine = formatLogEntryLine(entry, Number.POSITIVE_INFINITY).replace(/\n$/, '');
369
+ const maxContentBytes = room - nlBytes;
370
+ const lineBody = Buffer.byteLength(rawLine, 'utf8') <= maxContentBytes
371
+ ? rawLine
372
+ : truncateStringToUtf8ByteLength(rawLine, maxContentBytes);
373
+ o += lineBody + newline;
374
+ shown++;
375
+ }
376
+ return { out: o, shown, omitted: 0 };
377
+ }
378
+ function toSingleLineText(value) {
379
+ return value
380
+ .replace(/\r?\n+/g, ' ')
381
+ .replace(/\s+/g, ' ')
382
+ .trim();
383
+ }
384
+ function formatDurationMsForSummary(ms) {
385
+ if (ms === undefined || !Number.isFinite(ms)) {
386
+ return '-';
387
+ }
388
+ if (ms < 1000) {
389
+ return `${Math.round(ms)}ms`;
390
+ }
391
+ return `${(ms / 1000).toFixed(1)}s`;
392
+ }
393
+ /** Unity/CI noise shown in logs; omit from workflow summary foldouts and counts. */
394
+ const SUMMARY_NOISE_ACCESS_TOKEN = 'Access token is unavailable; failed to update';
395
+ /**
396
+ * Removes known noise phrases from a log message for summary display.
397
+ * Exported for unit tests.
398
+ */
399
+ function stripSummaryNoiseFromLogMessage(message) {
400
+ const flat = toSingleLineText(message);
401
+ if (!flat)
402
+ return '';
403
+ const pattern = SUMMARY_NOISE_ACCESS_TOKEN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
404
+ const out = flat.replace(new RegExp(pattern, 'gi'), ' ').replace(/\s+/g, ' ').trim();
405
+ return out;
406
+ }
407
+ function filterNoiseFromSummaryLogEntries(entries) {
408
+ const out = [];
409
+ for (const e of entries) {
410
+ const stripped = stripSummaryNoiseFromLogMessage(e.message || '');
411
+ if (stripped === '')
412
+ continue;
413
+ const originalFlat = toSingleLineText(e.message || '');
414
+ if (stripped !== originalFlat) {
415
+ out.push({ ...e, message: stripped });
416
+ }
417
+ else {
418
+ out.push(e);
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ function renderBuildActionsFoldoutMarkdown(completedActions, maxBytes) {
424
+ const n = completedActions.length;
425
+ const open = `<details open><summary>Build actions (${n})</summary>\n\n`;
426
+ const close = `</details>\n\n`;
427
+ const overhead = Buffer.byteLength(open + close, 'utf8');
428
+ const innerBudget = Math.max(0, maxBytes - overhead);
429
+ const table = buildActionTimelineTableMarkdown(completedActions, innerBudget, '');
430
+ const inner = !table.truncated
431
+ ? table.markdown
432
+ : buildActionTimelineCodeblockMarkdown(completedActions, innerBudget, '');
433
+ return open + inner + close;
434
+ }
435
+ /** Paths to treat as Unity engine (omit from summary when using heuristic filter). */
436
+ const UNITY_ENGINE_PATH_PREFIXES = [
437
+ 'Runtime/',
438
+ './Runtime/',
439
+ 'Modules/',
440
+ './Modules/',
441
+ ];
442
+ /**
443
+ * Normalizes a log message for display by stripping a redundant file:line prefix
444
+ * when it matches the entry's file/line so the path appears only once.
445
+ * Returns the normalized message and optional column if present in the prefix.
446
+ */
447
+ function normalizeMessageForDisplay(message, file, line) {
448
+ const trimmed = message.trim();
449
+ const normFile = file.replace(/\\/g, '/');
450
+ if (!normFile && line === undefined)
451
+ return { message: trimmed };
452
+ // path(line,col): e.g. Assets/File.cs(2,8): error ...
453
+ const parenColon = trimmed.match(/^(.+?)\((\d+),(\d+)\):\s*/);
454
+ if (parenColon && parenColon[1] != null && parenColon[2] != null && parenColon[3] != null) {
455
+ const fullMatch = parenColon[0];
456
+ const msgPath = parenColon[1].replace(/\\/g, '/');
457
+ const msgLine = parseInt(parenColon[2], 10);
458
+ const msgCol = parseInt(parenColon[3], 10);
459
+ const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile);
460
+ if (pathMatches && (line === undefined || line === msgLine)) {
461
+ return { message: trimmed.slice(fullMatch.length).trim(), column: msgCol };
462
+ }
463
+ }
464
+ // path(line): e.g. Assets/File.cs(2): ...
465
+ const parenOnly = trimmed.match(/^(.+?)\((\d+)\):\s*/);
466
+ if (parenOnly && parenOnly[1] != null && parenOnly[2] != null) {
467
+ const fullMatch = parenOnly[0];
468
+ const msgPath = parenOnly[1].replace(/\\/g, '/');
469
+ const msgLine = parseInt(parenOnly[2], 10);
470
+ const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile);
471
+ if (pathMatches && (line === undefined || line === msgLine)) {
472
+ return { message: trimmed.slice(fullMatch.length).trim() };
473
+ }
474
+ }
475
+ // path:line: e.g. path/to/file.cs:10:
476
+ const pathLineColon = trimmed.match(/^(.+?):(\d+):\s*/);
477
+ if (pathLineColon && pathLineColon[1] != null && pathLineColon[2] != null) {
478
+ const fullMatch = pathLineColon[0];
479
+ const msgPath = pathLineColon[1].replace(/\\/g, '/');
480
+ const msgLine = parseInt(pathLineColon[2], 10);
481
+ const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile);
482
+ if (pathMatches && (line === undefined || line === msgLine)) {
483
+ return { message: trimmed.slice(fullMatch.length).trim() };
484
+ }
485
+ }
486
+ return { message: trimmed };
487
+ }
488
+ /**
489
+ * One line per entry: path(line,col): &lt;message&gt; or path(line): &lt;message&gt; when column is missing.
490
+ * When file/line are missing, outputs: - &lt;message&gt;.
491
+ */
492
+ function formatLogEntryLine(e, maxMsgLen = Number.POSITIVE_INFINITY) {
493
+ const file = (e.file || e.fileName || '').replace(/\\/g, '/');
494
+ const line = e.line ?? e.lineNumber;
495
+ const hasLocation = file && (line !== undefined && line > 0);
496
+ const rawMsg = toSingleLineText(e.message || '');
497
+ const { message: normalizedMsg, column } = hasLocation
498
+ ? normalizeMessageForDisplay(rawMsg, file, line)
499
+ : { message: rawMsg, column: undefined };
500
+ const msg = Number.isFinite(maxMsgLen) && maxMsgLen >= 0 && maxMsgLen < Number.POSITIVE_INFINITY
501
+ ? truncateStr(normalizedMsg, maxMsgLen)
502
+ : normalizedMsg;
503
+ if (hasLocation) {
504
+ const loc = column !== undefined ? `${file}(${line},${column})` : `${file}(${line})`;
505
+ return `${loc}: ${msg}\n`;
506
+ }
507
+ return `${msg}\n`;
508
+ }
38
509
  var LogLevel;
39
510
  (function (LogLevel) {
40
511
  LogLevel["DEBUG"] = "debug";
@@ -46,22 +517,16 @@ var LogLevel;
46
517
  })(LogLevel || (exports.LogLevel = LogLevel = {}));
47
518
  class Logger {
48
519
  logLevel = LogLevel.INFO;
49
- _ci;
520
+ _provider;
50
521
  static instance = new Logger();
51
522
  constructor() {
523
+ this._provider = process.env.GITHUB_ACTIONS === 'true'
524
+ ? new github_actions_ci_1.GitHubActionsLoggerProvider()
525
+ : new logger_provider_1.LocalCliLoggerProvider();
52
526
  if (process.env.GITHUB_ACTIONS === 'true') {
53
- this._ci = 'GITHUB_ACTIONS';
54
527
  this.logLevel = process.env.ACTIONS_STEP_DEBUG === 'true' ? LogLevel.DEBUG : LogLevel.CI;
55
528
  }
56
529
  }
57
- printLine(message, lineColor, optionalParams = []) {
58
- if (lineColor && lineColor.length > 0) {
59
- process.stdout.write(`${lineColor}${message}\x1b[0m\n`, ...optionalParams);
60
- }
61
- else {
62
- process.stdout.write(`${message}\n`, ...optionalParams);
63
- }
64
- }
65
530
  /**
66
531
  * Logs a message to the console.
67
532
  * @param level The log level for this message.
@@ -70,78 +535,24 @@ class Logger {
70
535
  */
71
536
  log(level, message, optionalParams = []) {
72
537
  if (this.shouldLog(level)) {
73
- switch (this._ci) {
74
- case 'GITHUB_ACTIONS': {
75
- switch (level) {
76
- case LogLevel.DEBUG: {
77
- message.toString().split('\n').forEach((line) => {
78
- process.stdout.write(`::debug::${line}\n`, ...optionalParams);
79
- });
80
- break;
81
- }
82
- case LogLevel.CI:
83
- case LogLevel.INFO: {
84
- process.stdout.write(`${message}\n`, ...optionalParams);
85
- break;
86
- }
87
- default: {
88
- process.stdout.write(`::${level}::${message}\n`, ...optionalParams);
89
- break;
90
- }
91
- }
92
- break;
93
- }
94
- default: {
95
- const stringColor = {
96
- [LogLevel.DEBUG]: '\x1b[35m', // Purple
97
- [LogLevel.INFO]: undefined, // No color / White
98
- [LogLevel.CI]: undefined, // No color / White
99
- [LogLevel.UTP]: undefined, // No color / White
100
- [LogLevel.WARN]: '\x1b[33m', // Yellow
101
- [LogLevel.ERROR]: '\x1b[31m', // Red
102
- }[level] || undefined; // Default to no color / White
103
- this.printLine(message, stringColor, optionalParams);
104
- break;
105
- }
106
- }
538
+ this._provider.log(level, message, optionalParams);
107
539
  }
108
540
  }
109
541
  /**
110
542
  * Starts a log group. In CI environments that support grouping, this will create a collapsible group.
111
543
  */
112
544
  startGroup(message, optionalParams = [], logLevel = LogLevel.INFO) {
113
- switch (this._ci) {
114
- case 'GITHUB_ACTIONS': {
115
- // if there is newline in message, only use the first line for group title
116
- // then print the rest of the lines inside the group in cyan color
117
- const firstLine = message.toString().split('\n')[0];
118
- const restLines = message.toString().split('\n').slice(1);
119
- process.stdout.write(`::group::${firstLine}\n`, ...optionalParams);
120
- restLines.forEach(line => {
121
- this.printLine(line, '\x1b[36m', ...optionalParams);
122
- });
123
- break;
124
- }
125
- default: {
126
- // No grouping in standard console
127
- this.log(logLevel, message, optionalParams);
128
- break;
129
- }
545
+ if (this._provider.isCi) {
546
+ this._provider.startGroup(message, optionalParams);
547
+ return;
130
548
  }
549
+ this.log(logLevel, message, optionalParams);
131
550
  }
132
551
  /**
133
552
  * Ends a log group. In CI environments that support grouping, this will end the current group.
134
553
  */
135
554
  endGroup() {
136
- switch (this._ci) {
137
- case 'GITHUB_ACTIONS': {
138
- process.stdout.write(`::endgroup::\n`);
139
- break;
140
- }
141
- default: {
142
- break; // No grouping in standard console
143
- }
144
- }
555
+ this._provider.endGroup();
145
556
  }
146
557
  /**
147
558
  * Logs a message with CI level.
@@ -175,59 +586,37 @@ class Logger {
175
586
  * @param title The title of the annotation.
176
587
  */
177
588
  annotate(logLevel, message, file, line, endLine, column, endColumn, title) {
178
- let annotation = '';
179
- switch (this._ci) {
180
- case 'GITHUB_ACTIONS': {
181
- const level = {
182
- [LogLevel.CI]: 'notice',
183
- [LogLevel.INFO]: 'notice',
184
- [LogLevel.DEBUG]: 'notice',
185
- [LogLevel.UTP]: 'notice',
186
- [LogLevel.WARN]: 'warning',
187
- [LogLevel.ERROR]: 'error',
188
- }[logLevel] ?? 'notice';
189
- const parts = [];
190
- const appendPart = (key, value) => {
191
- if (value === undefined || value === null) {
192
- return;
193
- }
194
- const stringValue = value.toString();
195
- if (stringValue.length === 0) {
196
- return;
197
- }
198
- parts.push(`${key}=${this.escapeGitHubCommandValue(stringValue)}`);
199
- };
200
- appendPart('file', file);
201
- if (line !== undefined && line > 0) {
202
- appendPart('line', line);
203
- }
204
- if (endLine !== undefined && endLine > 0) {
205
- appendPart('endLine', endLine);
206
- }
207
- if (column !== undefined && column > 0) {
208
- appendPart('col', column);
209
- }
210
- if (endColumn !== undefined && endColumn > 0) {
211
- appendPart('endColumn', endColumn);
212
- }
213
- appendPart('title', title);
214
- const metadata = parts.length > 0 ? ` ${parts.join(',')}` : '';
215
- annotation = `::${level}${metadata}::${this.escapeGitHubCommandValue(message)}`;
216
- break;
217
- }
589
+ const level = {
590
+ [LogLevel.CI]: 'notice',
591
+ [LogLevel.INFO]: 'notice',
592
+ [LogLevel.DEBUG]: 'notice',
593
+ [LogLevel.UTP]: 'notice',
594
+ [LogLevel.WARN]: 'warning',
595
+ [LogLevel.ERROR]: 'error',
596
+ }[logLevel] ?? 'notice';
597
+ const options = {};
598
+ if (file !== undefined && file !== '') {
599
+ options.file = file;
218
600
  }
219
- if (annotation.length > 0) {
220
- process.stdout.write(`${annotation}\n`);
601
+ if (line !== undefined) {
602
+ options.line = line;
221
603
  }
222
- else {
223
- this.log(logLevel, message);
604
+ if (endLine !== undefined) {
605
+ options.endLine = endLine;
224
606
  }
225
- }
226
- escapeGitHubCommandValue(value) {
227
- return value
228
- .replace(/%/g, '%25')
229
- .replace(/\r/g, '%0D')
230
- .replace(/\n/g, '%0A');
607
+ if (column !== undefined) {
608
+ options.column = column;
609
+ }
610
+ if (endColumn !== undefined) {
611
+ options.endColumn = endColumn;
612
+ }
613
+ if (title !== undefined && title !== '') {
614
+ options.title = title;
615
+ }
616
+ const backendLevel = level === 'error'
617
+ ? github_actions_ci_1.GitHubAnnotationLevel.Error
618
+ : (level === 'warning' ? github_actions_ci_1.GitHubAnnotationLevel.Warning : github_actions_ci_1.GitHubAnnotationLevel.Notice);
619
+ this._provider.annotate(backendLevel, message, options);
231
620
  }
232
621
  shouldLog(level) {
233
622
  if (level === LogLevel.CI) {
@@ -241,12 +630,79 @@ class Logger {
241
630
  * @param message The string to mask.
242
631
  */
243
632
  CI_mask(message) {
244
- switch (this._ci) {
245
- case 'GITHUB_ACTIONS': {
246
- process.stdout.write(`::add-mask::${message}\n`);
247
- break;
633
+ this._provider.mask(message);
634
+ }
635
+ /**
636
+ * Masks a credential value in CI environments before it appears in logs.
637
+ * This is a convenience wrapper around CI_mask for credential values.
638
+ * @param value The credential value to mask.
639
+ */
640
+ maskCredential(value) {
641
+ if (value && value.length > 0) {
642
+ this.CI_mask(value);
643
+ }
644
+ }
645
+ /**
646
+ * Logs command-line options with sensitive information scrubbed.
647
+ * Automatically removes passwords, tokens, emails, and other credentials from the output.
648
+ * @param options The options object to log (typically from commander.js).
649
+ * @param optionalParams Additional parameters to log.
650
+ */
651
+ debugOptions(options, ...optionalParams) {
652
+ // Avoid expensive scrubbing and stringification when debug logging is disabled.
653
+ if (this.logLevel !== LogLevel.DEBUG) {
654
+ return;
655
+ }
656
+ const scrubbed = this.scrubSensitiveData(options);
657
+ this.debug(JSON.stringify(scrubbed), ...optionalParams);
658
+ }
659
+ /**
660
+ * List of sensitive option keys that should be scrubbed from debug output.
661
+ */
662
+ SENSITIVE_KEYS = [
663
+ 'password',
664
+ 'email',
665
+ 'serial',
666
+ 'token',
667
+ 'config',
668
+ 'organization',
669
+ 'username',
670
+ 'servicesConfig',
671
+ 'serviceaccountkey',
672
+ ];
673
+ /**
674
+ * Scrubs sensitive information from an object for safe logging.
675
+ * Creates a deep clone of the object and replaces sensitive values with [REDACTED].
676
+ * @param obj The object to scrub (typically command-line options).
677
+ * @returns A new object with sensitive values replaced.
678
+ */
679
+ scrubSensitiveData(obj) {
680
+ if (obj === null || obj === undefined) {
681
+ return obj;
682
+ }
683
+ if (typeof obj !== 'object') {
684
+ return obj;
685
+ }
686
+ if (Array.isArray(obj)) {
687
+ return obj.map((item) => this.scrubSensitiveData(item));
688
+ }
689
+ const scrubbedObj = {};
690
+ for (const key in obj) {
691
+ if (obj.hasOwnProperty(key)) {
692
+ const lowerKey = key.toLowerCase();
693
+ const isSensitive = this.SENSITIVE_KEYS.some(sensitiveKey => lowerKey.includes(sensitiveKey.toLowerCase()));
694
+ if (isSensitive) {
695
+ scrubbedObj[key] = '[REDACTED]';
696
+ }
697
+ else if (typeof obj[key] === 'object') {
698
+ scrubbedObj[key] = this.scrubSensitiveData(obj[key]);
699
+ }
700
+ else {
701
+ scrubbedObj[key] = obj[key];
702
+ }
248
703
  }
249
704
  }
705
+ return scrubbedObj;
250
706
  }
251
707
  /**
252
708
  * Sets an environment variable in CI environments that support it.
@@ -254,44 +710,182 @@ class Logger {
254
710
  * @param value The value of the environment variable.
255
711
  */
256
712
  CI_setEnvironmentVariable(name, value) {
257
- switch (this._ci) {
258
- case 'GITHUB_ACTIONS': {
259
- // needs to be appended to the temporary file specified in the GITHUB_ENV environment variable
260
- const githubEnv = process.env.GITHUB_ENV;
261
- // echo "MY_ENV_VAR=myValue" >> $GITHUB_ENV
262
- if (githubEnv) {
263
- fs.appendFileSync(githubEnv, `${name}=${value}\n`, { encoding: 'utf8' });
264
- }
713
+ this._provider.setEnvironmentVariable(name, value);
714
+ }
715
+ CI_setOutput(name, value) {
716
+ this._provider.setOutput(name, value);
717
+ }
718
+ static formatDurationMs(ms) {
719
+ if (ms === undefined || !Number.isFinite(ms)) {
720
+ return '-';
721
+ }
722
+ if (ms < 1000) {
723
+ return `${Math.round(ms)}ms`;
724
+ }
725
+ return `${(ms / 1000).toFixed(1)}s`;
726
+ }
727
+ static truncateStr(s, max) {
728
+ return s.length <= max ? s : s.slice(0, max) + '…';
729
+ }
730
+ static truncateSummaryToByteLimit(summary, byteLimit) {
731
+ const footer = `\n***Summary truncated due to size limits.***\n`;
732
+ const footerSize = Buffer.byteLength(footer, 'utf8');
733
+ const lines = summary.split('\n');
734
+ let rebuilt = '';
735
+ for (const line of lines) {
736
+ const nextSize = Buffer.byteLength(rebuilt + line + '\n', 'utf8') + footerSize;
737
+ if (nextSize > byteLimit) {
265
738
  break;
266
739
  }
740
+ rebuilt += `${line}\n`;
267
741
  }
742
+ return rebuilt + footer;
268
743
  }
269
- CI_setOutput(name, value) {
270
- switch (this._ci) {
271
- case 'GITHUB_ACTIONS': {
272
- // needs to be appended to the temporary file specified in the GITHUB_OUTPUT environment variable
273
- const githubOutput = process.env.GITHUB_OUTPUT;
274
- // echo "myOutput=myValue" >> $GITHUB_OUTPUT
275
- if (githubOutput) {
276
- fs.appendFileSync(githubOutput, `${name}=${value}\n`, { encoding: 'utf8' });
277
- }
744
+ /**
745
+ * Returns the markdown byte limit for a given output target.
746
+ * Workflow summary may be backend constrained; stdout is intentionally uncapped.
747
+ */
748
+ getMarkdownByteLimit(target) {
749
+ return this._provider.getMarkdownByteLimit(target);
750
+ }
751
+ CI_appendWorkflowSummary(name, telemetry, options) {
752
+ if (telemetry.length === 0) {
753
+ return;
754
+ }
755
+ if (this.getMarkdownByteLimit('workflow-summary') === Number.POSITIVE_INFINITY) {
756
+ return;
757
+ }
758
+ const excludedTypes = new Set(['MemoryLeaks', 'MemoryLeak']);
759
+ const filtered = telemetry.filter(entry => !excludedTypes.has(entry.type || ''));
760
+ if (filtered.length === 0) {
761
+ return;
762
+ }
763
+ const completedActions = filtered.filter(e => e.type === 'Action' && e.phase === 'End');
764
+ const testResults = collectTestResults(filtered);
765
+ const additional = options?.additionalLogEntries ?? [];
766
+ const merged = mergeLogEntriesPreferringSeverity([
767
+ ...buildMergedLogList(filtered),
768
+ ...additional.filter(e => e.type === 'LogEntry' || e.type === 'Compiler'),
769
+ ]);
770
+ const pathFiltered = filterMergedByPath(merged, options);
771
+ const summaryLogs = filterNoiseFromSummaryLogEntries(pathFiltered);
772
+ const bySeverity = groupBySeverity(summaryLogs);
773
+ const limit = this.getMarkdownByteLimit('workflow-summary');
774
+ const builders = [
775
+ () => this.buildSummaryTimelineAndMergedLog(name, completedActions, bySeverity, testResults, limit),
776
+ () => this.buildSummaryCollapsibleWithMergedLog(name, completedActions, bySeverity, testResults, limit),
777
+ () => this.buildSummaryTimelineAndCounts(name, completedActions, summaryLogs.length, testResults, limit),
778
+ ];
779
+ let summary = '';
780
+ for (const build of builders) {
781
+ summary = build();
782
+ if (Buffer.byteLength(summary, 'utf8') <= limit) {
278
783
  break;
279
784
  }
280
785
  }
786
+ if (Buffer.byteLength(summary, 'utf8') > limit) {
787
+ summary = Logger.truncateSummaryToByteLimit(summary, limit);
788
+ }
789
+ this._provider.appendStepSummary(summary);
281
790
  }
282
- CI_appendWorkflowSummary(telemetry) {
283
- switch (this._ci) {
284
- case 'GITHUB_ACTIONS': {
285
- const githubSummary = process.env.GITHUB_STEP_SUMMARY;
286
- if (githubSummary) {
287
- let table = `| Key | Value |\n| --- | ----- |\n`;
288
- telemetry.forEach(item => {
289
- table += `| ${item.key} | ${item.value} |\n`;
290
- });
291
- fs.appendFileSync(githubSummary, table, { encoding: 'utf8' });
292
- }
791
+ /**
792
+ * Builds summary: stats + action table + unit-test block + severity foldouts.
793
+ */
794
+ buildSummaryTimelineAndMergedLog(name, completedActions, bySeverity, testResults, byteLimit) {
795
+ let out = `## ${name} Summary\n\n`;
796
+ const totalDurationMs = completedActions.reduce((sum, a) => sum + (a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : 0)), 0);
797
+ const totalSec = totalDurationMs / 1000;
798
+ const totalStr = totalSec >= 60 ? `${Math.round(totalSec / 60)}m ${Math.round(totalSec % 60)}s` : `${totalSec.toFixed(1)}s`;
799
+ out += `Errors: ${bySeverity.errorCritical.length}\n`;
800
+ out += `Warnings: ${bySeverity.warning.length}\n`;
801
+ out += `Total duration: ${totalStr}\n`;
802
+ out += `Actions: ${completedActions.length}\n`;
803
+ if (testResults.length > 0) {
804
+ out += `Tests: ${testResults.length}\n`;
805
+ }
806
+ out += '\n';
807
+ if (completedActions.length > 0) {
808
+ const remaining = byteLimit - Buffer.byteLength(out, 'utf8');
809
+ out += renderBuildActionsFoldoutMarkdown(completedActions, remaining);
810
+ }
811
+ if (testResults.length > 0) {
812
+ const remaining = byteLimit - Buffer.byteLength(out, 'utf8');
813
+ out += buildUnitTestJobSummaryMarkdown(testResults, remaining, '');
814
+ }
815
+ const limit = byteLimit;
816
+ const appendFoldout = (title, entries, dropSuffix, openByDefault) => {
817
+ if (entries.length === 0)
818
+ return;
819
+ const openAttr = openByDefault ? ' open' : '';
820
+ out += `<details${openAttr}><summary>${title} (${entries.length})</summary>\n\n`;
821
+ out += '```text\n';
822
+ const appended = appendWorkflowSummaryLogLines(out, entries, limit);
823
+ out = appended.out;
824
+ if (appended.omitted > 0) {
825
+ out += `... and ${appended.omitted} more ${dropSuffix}\n`;
293
826
  }
827
+ out += '```\n\n';
828
+ out += `</details>\n\n`;
829
+ };
830
+ appendFoldout('Error', bySeverity.errorCritical, '(see annotations).', true);
831
+ appendFoldout('Warning', bySeverity.warning, '(truncated; see full log).');
832
+ appendFoldout('Info', bySeverity.info, '(truncated; see full log).');
833
+ return out;
834
+ }
835
+ /**
836
+ * Builds summary with timeline in a <details> and merged log foldouts by severity.
837
+ * Used when primary builder would exceed size limit.
838
+ */
839
+ buildSummaryCollapsibleWithMergedLog(name, completedActions, bySeverity, testResults, byteLimit) {
840
+ let out = `## ${name} Summary\n\n`;
841
+ if (completedActions.length > 0) {
842
+ const remaining = byteLimit - Buffer.byteLength(out, 'utf8');
843
+ out += renderBuildActionsFoldoutMarkdown(completedActions, remaining);
844
+ }
845
+ if (testResults.length > 0) {
846
+ const remaining = byteLimit - Buffer.byteLength(out, 'utf8');
847
+ out += buildUnitTestJobSummaryMarkdown(testResults, remaining, '');
848
+ }
849
+ const limit = byteLimit;
850
+ const appendFoldout = (title, entries, dropSuffix, openByDefault) => {
851
+ if (entries.length === 0)
852
+ return;
853
+ const openAttr = openByDefault ? ' open' : '';
854
+ out += `<details${openAttr}><summary>${title} (${entries.length})</summary>\n\n`;
855
+ out += '```text\n';
856
+ const appended = appendWorkflowSummaryLogLines(out, entries, limit);
857
+ out = appended.out;
858
+ if (appended.omitted > 0)
859
+ out += `... and ${appended.omitted} more ${dropSuffix}\n`;
860
+ out += '```\n\n';
861
+ out += `</details>\n\n`;
862
+ };
863
+ appendFoldout('Error', bySeverity.errorCritical, '(see annotations).', true);
864
+ appendFoldout('Warning', bySeverity.warning, '(truncated; see full log).');
865
+ appendFoldout('Info', bySeverity.info, '(truncated; see full log).');
866
+ return out;
867
+ }
868
+ /**
869
+ * Fallback: list timeline (when actions exist) + unit-test block (when present) + compact count lines.
870
+ * Used when even collapsible summary would exceed 1 MB.
871
+ */
872
+ buildSummaryTimelineAndCounts(name, completedActions, logCount, testResults, byteLimit) {
873
+ let out = `## ${name} Summary\n\n`;
874
+ if (completedActions.length > 0) {
875
+ const remaining = byteLimit - Buffer.byteLength(out, 'utf8');
876
+ out += renderBuildActionsFoldoutMarkdown(completedActions, remaining);
877
+ }
878
+ if (testResults.length > 0) {
879
+ const remaining = byteLimit - Buffer.byteLength(out, 'utf8');
880
+ out += buildUnitTestJobSummaryMarkdown(testResults, remaining, '');
881
+ }
882
+ out += `Log entries: ${logCount}\n`;
883
+ out += `Actions: ${completedActions.length}\n`;
884
+ if (testResults.length > 0) {
885
+ out += `Tests: ${testResults.length}\n`;
294
886
  }
887
+ out += `\nSee annotations for details.\n`;
888
+ return out;
295
889
  }
296
890
  }
297
891
  exports.Logger = Logger;