@quenty/cli-output-helpers 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/outputHelper.d.ts +7 -0
  3. package/dist/outputHelper.d.ts.map +1 -1
  4. package/dist/outputHelper.js +23 -7
  5. package/dist/outputHelper.js.map +1 -1
  6. package/dist/outputHelper.test.js +25 -1
  7. package/dist/outputHelper.test.js.map +1 -1
  8. package/dist/reporting/github/annotations.d.ts +5 -2
  9. package/dist/reporting/github/annotations.d.ts.map +1 -1
  10. package/dist/reporting/github/annotations.js +31 -6
  11. package/dist/reporting/github/annotations.js.map +1 -1
  12. package/dist/reporting/github/annotations.test.js +45 -1
  13. package/dist/reporting/github/annotations.test.js.map +1 -1
  14. package/dist/reporting/github/index.d.ts +1 -1
  15. package/dist/reporting/github/index.d.ts.map +1 -1
  16. package/dist/reporting/github/index.js +1 -1
  17. package/dist/reporting/github/index.js.map +1 -1
  18. package/dist/reporting/index.d.ts +1 -1
  19. package/dist/reporting/index.d.ts.map +1 -1
  20. package/dist/reporting/index.js +1 -1
  21. package/dist/reporting/index.js.map +1 -1
  22. package/dist/reporting/stdout-result-reporter.d.ts.map +1 -1
  23. package/dist/reporting/stdout-result-reporter.js +4 -1
  24. package/dist/reporting/stdout-result-reporter.js.map +1 -1
  25. package/dist/reporting/stdout-result-reporter.test.js +5 -0
  26. package/dist/reporting/stdout-result-reporter.test.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/outputHelper.test.ts +32 -1
  29. package/src/outputHelper.ts +31 -7
  30. package/src/reporting/github/annotations.test.ts +54 -0
  31. package/src/reporting/github/annotations.ts +35 -9
  32. package/src/reporting/github/index.ts +1 -0
  33. package/src/reporting/index.ts +1 -0
  34. package/src/reporting/stdout-result-reporter.test.ts +8 -0
  35. package/src/reporting/stdout-result-reporter.ts +4 -1
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from 'vitest';
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
2
  import { OutputHelper } from './outputHelper.js';
3
3
 
4
4
  describe('OutputHelper.formatErrorChain', () => {
@@ -68,3 +68,34 @@ describe('OutputHelper.formatErrorChain', () => {
68
68
  expect(out).toContain(err.stack!);
69
69
  });
70
70
  });
71
+
72
+ describe('OutputHelper.setDiagnosticsToStderr', () => {
73
+ afterEach(() => {
74
+ OutputHelper.setDiagnosticsToStderr(false);
75
+ vi.restoreAllMocks();
76
+ });
77
+
78
+ it('writes diagnostics to stdout by default', () => {
79
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
80
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
81
+
82
+ OutputHelper.info('hello');
83
+
84
+ expect(log).toHaveBeenCalledTimes(1);
85
+ expect(error).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('moves info, warnings, hints and boxes to stderr', () => {
89
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
90
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
91
+ OutputHelper.setDiagnosticsToStderr(true);
92
+
93
+ OutputHelper.info('info');
94
+ OutputHelper.warn('warn');
95
+ OutputHelper.hint('hint');
96
+ OutputHelper.box('box');
97
+
98
+ expect(log).not.toHaveBeenCalled();
99
+ expect(error).toHaveBeenCalledTimes(4);
100
+ });
101
+ });
@@ -17,6 +17,16 @@ const _outputStorage = new AsyncLocalStorage<OutputBuffer>();
17
17
  */
18
18
  export class OutputHelper {
19
19
  private static _verbose: boolean = true;
20
+ private static _diagnosticsToStderr: boolean = false;
21
+
22
+ private static _writeDiagnostic(text: string): void {
23
+ if (this._diagnosticsToStderr) {
24
+ console.error(text);
25
+ } else {
26
+ console.log(text);
27
+ }
28
+ }
29
+
20
30
  /**
21
31
  * Formats the error with markup
22
32
  * @param message Message to format
@@ -169,7 +179,9 @@ export class OutputHelper {
169
179
  * @param message Message to write
170
180
  */
171
181
  public static info(message: string): void {
172
- console.log(this._hasAnsi(message) ? message : this.formatInfo(message));
182
+ this._writeDiagnostic(
183
+ this._hasAnsi(message) ? message : this.formatInfo(message)
184
+ );
173
185
  }
174
186
 
175
187
  /**
@@ -189,6 +201,14 @@ export class OutputHelper {
189
201
  return this._verbose;
190
202
  }
191
203
 
204
+ /**
205
+ * Sends info, warnings, hints, verbose output and boxes to stderr, for CLIs
206
+ * whose stdout carries only the command result.
207
+ */
208
+ public static setDiagnosticsToStderr(toStderr: boolean): void {
209
+ this._diagnosticsToStderr = toStderr;
210
+ }
211
+
192
212
  /**
193
213
  * Logs a verbose/intermediate message. Suppressed when verbose is false.
194
214
  * When running inside a buffered context (see runBuffered), messages are
@@ -206,7 +226,7 @@ export class OutputHelper {
206
226
  if (buffer) {
207
227
  buffer.lines.push(formatted);
208
228
  } else {
209
- console.log(formatted);
229
+ this._writeDiagnostic(formatted);
210
230
  }
211
231
  }
212
232
 
@@ -228,7 +248,9 @@ export class OutputHelper {
228
248
  * @param message Message to write
229
249
  */
230
250
  public static warn(message: string): void {
231
- console.log(this._hasAnsi(message) ? message : this.formatWarning(message));
251
+ this._writeDiagnostic(
252
+ this._hasAnsi(message) ? message : this.formatWarning(message)
253
+ );
232
254
  }
233
255
 
234
256
  /**
@@ -240,7 +262,7 @@ export class OutputHelper {
240
262
  * after it.
241
263
  */
242
264
  public static startGroup(title: string, useGroups = isCI()): void {
243
- console.log(
265
+ this._writeDiagnostic(
244
266
  useGroups ? `::group::${title}` : this.formatDim(`── ${title} ──`)
245
267
  );
246
268
  }
@@ -248,7 +270,7 @@ export class OutputHelper {
248
270
  /** Close the section opened by {@link startGroup}. */
249
271
  public static endGroup(useGroups = isCI()): void {
250
272
  if (useGroups) {
251
- console.log('::endgroup::');
273
+ this._writeDiagnostic('::endgroup::');
252
274
  }
253
275
  }
254
276
 
@@ -257,7 +279,9 @@ export class OutputHelper {
257
279
  * @param message Message to write
258
280
  */
259
281
  public static hint(message: string): void {
260
- console.log(this._hasAnsi(message) ? message : this.formatHint(message));
282
+ this._writeDiagnostic(
283
+ this._hasAnsi(message) ? message : this.formatHint(message)
284
+ );
261
285
  }
262
286
 
263
287
  /**
@@ -265,6 +289,6 @@ export class OutputHelper {
265
289
  * @param message
266
290
  */
267
291
  public static box(message: string, options?: BoxOptions): void {
268
- console.log(this.formatBox(message, options));
292
+ this._writeDiagnostic(this.formatBox(message, options));
269
293
  }
270
294
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
2
  import {
3
3
  type Diagnostic,
4
+ dedupeDiagnostics,
4
5
  formatAnnotation,
5
6
  emitAnnotations,
6
7
  summarizeDiagnostics,
@@ -18,6 +19,24 @@ function makeDiagnostic(overrides: Partial<Diagnostic> = {}): Diagnostic {
18
19
  }
19
20
 
20
21
  describe('formatAnnotation', () => {
22
+ it('omits file and line when the location is unknown', () => {
23
+ const result = formatAnnotation(
24
+ makeDiagnostic({
25
+ file: undefined,
26
+ line: undefined,
27
+ title: 'Runtime error',
28
+ })
29
+ );
30
+ expect(result).toBe('::error title=Runtime error::Something went wrong');
31
+ });
32
+
33
+ it('formats an annotation with no properties at all', () => {
34
+ const result = formatAnnotation(
35
+ makeDiagnostic({ file: undefined, line: undefined })
36
+ );
37
+ expect(result).toBe('::error::Something went wrong');
38
+ });
39
+
21
40
  it('formats a basic error annotation', () => {
22
41
  const result = formatAnnotation(makeDiagnostic());
23
42
  expect(result).toBe(
@@ -116,7 +135,42 @@ describe('summarizeDiagnostics', () => {
116
135
  });
117
136
  });
118
137
 
138
+ describe('dedupeDiagnostics', () => {
139
+ it('drops identical repeats', () => {
140
+ const result = dedupeDiagnostics([
141
+ makeDiagnostic({ title: 'Runtime error' }),
142
+ makeDiagnostic({ title: 'Runtime error' }),
143
+ makeDiagnostic({ file: undefined, line: undefined }),
144
+ makeDiagnostic({ file: undefined, line: undefined }),
145
+ ]);
146
+ expect(result).toHaveLength(2);
147
+ });
148
+
149
+ it('keeps distinct errors at the same file, line and title', () => {
150
+ const result = dedupeDiagnostics([
151
+ makeDiagnostic({ title: 'Runtime error', message: 'first' }),
152
+ makeDiagnostic({ title: 'Runtime error', message: 'second' }),
153
+ makeDiagnostic({ title: 'Runtime error', message: 'first', line: 11 }),
154
+ ]);
155
+ expect(result.map((d) => [d.line, d.message])).toEqual([
156
+ [10, 'first'],
157
+ [10, 'second'],
158
+ [11, 'first'],
159
+ ]);
160
+ });
161
+ });
162
+
119
163
  describe('formatAnnotationSummaryMarkdown', () => {
164
+ it('counts only real files and groups file-less diagnostics', () => {
165
+ const md = formatAnnotationSummaryMarkdown('Test Failures', [
166
+ makeDiagnostic({ file: 'src/a.lua' }),
167
+ makeDiagnostic({ file: undefined, line: undefined }),
168
+ ]);
169
+ expect(md).toContain('across 1 file:');
170
+ expect(md).toContain('<summary>No file (1)</summary>');
171
+ expect(md).not.toContain('undefined');
172
+ });
173
+
120
174
  it('returns "no issues" for empty diagnostics', () => {
121
175
  const md = formatAnnotationSummaryMarkdown('luau-lsp', []);
122
176
  expect(md).toContain('### luau-lsp');
@@ -16,8 +16,9 @@ import { isCI } from '../../cli-utils.js';
16
16
  export type DiagnosticSeverity = 'error' | 'warning' | 'notice';
17
17
 
18
18
  export interface Diagnostic {
19
- file: string;
20
- line: number;
19
+ /** Repo-relative path. Omit when the location is unknown. */
20
+ file?: string;
21
+ line?: number;
21
22
  endLine?: number;
22
23
  column?: number;
23
24
  endColumn?: number;
@@ -61,8 +62,12 @@ function _escapeMessage(value: string): string {
61
62
  export function formatAnnotation(diagnostic: Diagnostic): string {
62
63
  const props: string[] = [];
63
64
 
64
- props.push(`file=${_escapeProperty(diagnostic.file)}`);
65
- props.push(`line=${diagnostic.line}`);
65
+ if (diagnostic.file !== undefined) {
66
+ props.push(`file=${_escapeProperty(diagnostic.file)}`);
67
+ }
68
+ if (diagnostic.line !== undefined) {
69
+ props.push(`line=${diagnostic.line}`);
70
+ }
66
71
 
67
72
  if (diagnostic.endLine !== undefined) {
68
73
  props.push(`endLine=${diagnostic.endLine}`);
@@ -78,7 +83,25 @@ export function formatAnnotation(diagnostic: Diagnostic): string {
78
83
  }
79
84
 
80
85
  const message = _escapeMessage(diagnostic.message);
81
- return `::${diagnostic.severity} ${props.join(',')}::${message}`;
86
+ const properties = props.length > 0 ? ` ${props.join(',')}` : '';
87
+ return `::${diagnostic.severity}${properties}::${message}`;
88
+ }
89
+
90
+ /** Drop exact repeats; GitHub shows only 10 error annotations per step. */
91
+ export function dedupeDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] {
92
+ const seen = new Set<string>();
93
+ return diagnostics.filter((d) => {
94
+ const key = JSON.stringify([
95
+ d.severity,
96
+ d.file,
97
+ d.line,
98
+ d.title,
99
+ d.message,
100
+ ]);
101
+ if (seen.has(key)) return false;
102
+ seen.add(key);
103
+ return true;
104
+ });
82
105
  }
83
106
 
84
107
  /**
@@ -103,7 +126,9 @@ export function summarizeDiagnostics(
103
126
  const files = new Set<string>();
104
127
 
105
128
  for (const d of diagnostics) {
106
- files.add(d.file);
129
+ if (d.file !== undefined) {
130
+ files.add(d.file);
131
+ }
107
132
  switch (d.severity) {
108
133
  case 'error':
109
134
  errors++;
@@ -158,7 +183,7 @@ export function formatAnnotationSummaryMarkdown(
158
183
  } file${summary.fileCount !== 1 ? 's' : ''}: ${parts.join(', ')}\n\n`;
159
184
 
160
185
  // Group diagnostics by file
161
- const byFile = new Map<string, Diagnostic[]>();
186
+ const byFile = new Map<string | undefined, Diagnostic[]>();
162
187
  for (const d of diagnostics) {
163
188
  const list = byFile.get(d.file) ?? [];
164
189
  list.push(d);
@@ -176,7 +201,8 @@ export function formatAnnotationSummaryMarkdown(
176
201
  break;
177
202
  }
178
203
 
179
- md += `<details><summary><code>${file}</code> (${diags.length})</summary>\n\n`;
204
+ const label = file === undefined ? 'No file' : `<code>${file}</code>`;
205
+ md += `<details><summary>${label} (${diags.length})</summary>\n\n`;
180
206
  md += '| Line | Severity | Message |\n';
181
207
  md += '|------|----------|---------|\n';
182
208
 
@@ -189,7 +215,7 @@ export function formatAnnotationSummaryMarkdown(
189
215
  ? '`warning`'
190
216
  : '`notice`';
191
217
  const escapedMsg = d.message.replace(/\|/g, '\\|').replace(/\n/g, ' ');
192
- md += `| ${d.line} | ${sev} | ${escapedMsg} |\n`;
218
+ md += `| ${d.line ?? ''} | ${sev} | ${escapedMsg} |\n`;
193
219
  }
194
220
 
195
221
  if (diags.length > MAX_PER_FILE) {
@@ -25,6 +25,7 @@ export {
25
25
  type Diagnostic,
26
26
  type DiagnosticSummary,
27
27
  formatAnnotation,
28
+ dedupeDiagnostics,
28
29
  emitAnnotations,
29
30
  summarizeDiagnostics,
30
31
  formatAnnotationSummaryMarkdown,
@@ -104,6 +104,7 @@ export {
104
104
  type Diagnostic,
105
105
  type DiagnosticSummary,
106
106
  formatAnnotation,
107
+ dedupeDiagnostics,
107
108
  emitAnnotations,
108
109
  summarizeDiagnostics,
109
110
  formatAnnotationSummaryMarkdown,
@@ -25,6 +25,14 @@ describe('StdoutResultReporter', () => {
25
25
  expect(logSpy).toHaveBeenNthCalledWith(2, 'name=second');
26
26
  });
27
27
 
28
+ it('prints nothing when the result renders empty', () => {
29
+ const reporter = new StdoutResultReporter<unknown>({ render: () => '' });
30
+
31
+ reporter.onResult({});
32
+
33
+ expect(logSpy).not.toHaveBeenCalled();
34
+ });
35
+
28
36
  it('startAsync and stopAsync are no-ops', async () => {
29
37
  const reporter = new StdoutResultReporter<unknown>({ render: () => '' });
30
38
  await reporter.startAsync();
@@ -18,6 +18,9 @@ export class StdoutResultReporter<T = unknown> extends BaseResultReporter<T> {
18
18
  }
19
19
 
20
20
  override onResult(result: T): void {
21
- console.log(this._render(result));
21
+ const text = this._render(result);
22
+ if (text !== '') {
23
+ console.log(text);
24
+ }
22
25
  }
23
26
  }