@octanejs/cli 0.0.12 → 0.2.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.
@@ -0,0 +1,685 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { DATA } from '../../data/index.js';
6
+ import { defineCommand } from '../../kernel/command.js';
7
+ import { CliError, EXIT, usageError } from '../../kernel/errors.js';
8
+ import { SOURCE_FILE_LIMIT } from '../../kernel/project.js';
9
+ import { SYMBOLS } from '../../kernel/ui.js';
10
+ import {
11
+ BASELINE_FILE,
12
+ STALE,
13
+ compareCoverage,
14
+ isOctaneModule,
15
+ loadJsxPragma,
16
+ loadStrongPolicy,
17
+ projectPath,
18
+ readBaseline,
19
+ writeBaseline,
20
+ } from './strong-coverage.js';
21
+ import { applyFixes, pruneImports } from './fix.js';
22
+
23
+ /**
24
+ * @typedef {Object} Finding
25
+ * @property {string} file relative to the project root
26
+ * @property {number} line
27
+ * @property {number} column 1-based, matching editor gutters
28
+ * @property {'error' | 'warning' | 'hint'} severity
29
+ * @property {string} code
30
+ * @property {string} message
31
+ * @property {string[]} suggestions
32
+ * @property {{ start: number, end: number, text: string }[]} [edits] the
33
+ * source edits `--fix` applies, as offsets into the file's current text
34
+ * @property {string} [url] where the code is documented
35
+ * @property {true} [preview] only reported because `--strong-preview`
36
+ * compiled a module that is not Strong as if it were
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} Compiler
41
+ * @property {(source: string, filename: string, options?: object) => { diagnostics: readonly any[] }} compile
42
+ * @property {((source: string, filename: string, options?: object) => { diagnostics: any[], error: unknown }) | null} collectDiagnostics
43
+ * every diagnostic in a module, where `compile` throws the first error.
44
+ * Older compilers do not have it.
45
+ */
46
+
47
+ /**
48
+ * Load the project's own compiler.
49
+ *
50
+ * The diagnostics belong to the compiler, and it is the compiler in the
51
+ * project's node_modules that decides what this project's code means. Shipping
52
+ * a second copy in the CLI would report on a different version than the one
53
+ * that builds the app.
54
+ *
55
+ * @param {string} root
56
+ * @returns {Promise<Compiler>}
57
+ */
58
+ async function loadCompiler(root) {
59
+ const require = createRequire(path.join(root, 'noop.js'));
60
+ let entry;
61
+ try {
62
+ entry = require.resolve('octane/compiler');
63
+ } catch {
64
+ throw new CliError('Could not resolve `octane/compiler` from this project.', {
65
+ hint: 'Install the runtime first: pnpm add octane',
66
+ });
67
+ }
68
+ const module = await import(pathToFileURL(entry).href);
69
+ if (typeof module.compile !== 'function') {
70
+ throw new CliError('The installed octane build exposes no compiler.');
71
+ }
72
+ return {
73
+ compile: module.compile,
74
+ collectDiagnostics:
75
+ typeof module.collectDiagnostics === 'function' ? module.collectDiagnostics : null,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Strong codes and the native text `onChange` error Strong promotes, keyed to
81
+ * their documentation.
82
+ *
83
+ * @type {Record<string, { url: string }>}
84
+ */
85
+ const STRONG_CODES = DATA.strongDiagnostics?.diagnostics ?? {};
86
+
87
+ /**
88
+ * @param {any} diagnostic
89
+ * @param {string} file
90
+ * @returns {Finding}
91
+ */
92
+ function findingOf(diagnostic, file) {
93
+ /** @type {Finding} */
94
+ const finding = {
95
+ file,
96
+ line: diagnostic.start?.line ?? 1,
97
+ column: (diagnostic.start?.column ?? 0) + 1,
98
+ severity:
99
+ diagnostic.severity === 'error'
100
+ ? 'error'
101
+ : diagnostic.severity === 'hint'
102
+ ? 'hint'
103
+ : 'warning',
104
+ code: diagnostic.code,
105
+ // The compiler prefixes its own code; the report already has a column
106
+ // for it.
107
+ message: String(diagnostic.message).replace(`[${diagnostic.code}] `, ''),
108
+ suggestions: describeSuggestions(diagnostic.suggestions),
109
+ };
110
+ /** @type {{ start: number, end: number, text: string }[] | undefined} */
111
+ const edits = diagnostic.suggestions?.find((/** @type {any} */ suggestion) =>
112
+ Array.isArray(suggestion?.edits),
113
+ )?.edits;
114
+ if (edits?.length) {
115
+ finding.edits = edits.map(({ start, end, text }) => ({ start, end, text }));
116
+ }
117
+ const url = STRONG_CODES[diagnostic.code]?.url;
118
+ if (url !== undefined) finding.url = url;
119
+ return finding;
120
+ }
121
+
122
+ /**
123
+ * Compiler suggestions are structured edits (a span plus the replacement), not
124
+ * prose. Describe the ones whose shape is known and drop the rest, so the report
125
+ * never prints `[object Object]`.
126
+ *
127
+ * @param {readonly any[] | undefined} suggestions
128
+ * @returns {string[]}
129
+ */
130
+ function describeSuggestions(suggestions) {
131
+ const described = [];
132
+ for (const suggestion of suggestions ?? []) {
133
+ if (typeof suggestion === 'string') described.push(suggestion);
134
+ else if (typeof suggestion?.message === 'string') described.push(suggestion.message);
135
+ else if (typeof suggestion?.attribute === 'string') {
136
+ const at = suggestion.start
137
+ ? ` at ${suggestion.start.line}:${suggestion.start.column + 1}`
138
+ : '';
139
+ described.push(`use \`${suggestion.attribute}\`${at}`);
140
+ }
141
+ }
142
+ return described;
143
+ }
144
+
145
+ /**
146
+ * Position appended by the compiler to a thrown message, e.g. `(App.tsrx:6:18)`.
147
+ * Semantic errors carry their location this way rather than on `loc`.
148
+ */
149
+ const TRAILING_LOCATION = /\s*\(([^()\s]+):(\d+):(\d+)\)\s*$/;
150
+
151
+ /**
152
+ * A compiler error that carries its own code, e.g. a Strong rule:
153
+ * `/abs/App.tsrx:4:21: [OCTANE_STRONG_EFFECT_STATE_UPDATE] Strong mode …`.
154
+ * The position is already in the finding's columns.
155
+ */
156
+ const COMPILER_CODE = /^(?:[^\n]*?:\d+:\d+: )?\[(OCTANE_[A-Z0-9_]+)\] /;
157
+
158
+ /**
159
+ * A leading `"use strong"` directive, after any comments such as a JSX pragma.
160
+ * Only consulted without a Strong policy, when `compiler.strong` is off and
161
+ * the directive is the one way a module can be Strong.
162
+ */
163
+ const STRONG_DIRECTIVE =
164
+ /^(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*(?:(['"])use [^'"]*\1\s*;?\s*)*?(['"])use strong\2/;
165
+
166
+ /**
167
+ * Normalise a thrown compile failure into a finding.
168
+ *
169
+ * Two shapes reach here. A genuine parse failure carries a Babel-style `loc`.
170
+ * A semantic failure (a slot-keyed hook in a plain JS loop, say) is thrown with
171
+ * its position appended to the message instead, so recovering it keeps the
172
+ * report pointing at the offending line rather than at 1:1.
173
+ *
174
+ * @param {unknown} error
175
+ * @param {string} file
176
+ * @param {string} [code] overrides the derived code, for a read failure
177
+ * @returns {Finding}
178
+ */
179
+ function thrownFailure(error, file, code) {
180
+ const thrown = error instanceof Error ? error.message : String(error);
181
+ // A coded compiler error names its rule; reporting it as a parse failure
182
+ // would hide the code that `--code` filters on and the docs index.
183
+ const coded = COMPILER_CODE.exec(thrown);
184
+ const message = coded ? thrown.slice(coded[0].length) : thrown;
185
+ code ??= coded?.[1];
186
+ const loc = /** @type {any} */ (error)?.loc;
187
+ if (loc) {
188
+ return {
189
+ file,
190
+ line: loc.line ?? 1,
191
+ column: (loc.column ?? 0) + 1,
192
+ severity: 'error',
193
+ code: code ?? 'OCTANE_PARSE_ERROR',
194
+ message,
195
+ suggestions: [],
196
+ };
197
+ }
198
+
199
+ const trailing = TRAILING_LOCATION.exec(message);
200
+ return {
201
+ file,
202
+ line: trailing ? Number(trailing[2]) : 1,
203
+ column: trailing ? Number(trailing[3]) : 1,
204
+ severity: 'error',
205
+ // Not every throw is a parse failure; saying so sends people looking for
206
+ // a syntax mistake that is not there.
207
+ code: code ?? 'OCTANE_COMPILE_ERROR',
208
+ // The position now has its own columns, so drop the duplicate tail.
209
+ message: trailing ? message.slice(0, trailing.index) : message,
210
+ suggestions: [],
211
+ };
212
+ }
213
+
214
+ export default defineCommand({
215
+ description:
216
+ 'Compile the project and report every diagnostic the Octane compiler raises:\n' +
217
+ 'native-event mistakes, hydration hazards, renderer-boundary and client-only errors.',
218
+ positionals: [
219
+ {
220
+ name: 'path',
221
+ description:
222
+ 'Files to analyze. Defaults to every .tsrx in the project, and every .tsx whose\n' +
223
+ 'JSX belongs to Octane.',
224
+ variadic: true,
225
+ },
226
+ ],
227
+ flags: {
228
+ fix: {
229
+ type: 'boolean',
230
+ description:
231
+ 'Apply the edits the compiler suggests, such as useMemo(() => value, deps) to\n' +
232
+ "value and React's lazy ref initialization to useLazyRef, then report what\n" +
233
+ 'remains. --dry-run reports the fixes without writing them.',
234
+ },
235
+ 'strong-preview': {
236
+ type: 'boolean',
237
+ description:
238
+ 'Compile every module as if Strong mode were on and report what it would\n' +
239
+ 'reject, counted by code. Findings in modules that are not Strong yet do not\n' +
240
+ 'fail the run.',
241
+ },
242
+ code: {
243
+ type: 'string',
244
+ repeatable: true,
245
+ placeholder: '<CODE>',
246
+ description: 'Only report this diagnostic code. Repeatable.',
247
+ },
248
+ strict: { type: 'boolean', description: 'Fail the run on warnings, not just errors.' },
249
+ 'strong-baseline': {
250
+ type: 'string',
251
+ choices: ['init', 'update'],
252
+ placeholder: '<init|update>',
253
+ description:
254
+ `Write ${BASELINE_FILE}, the modules allowed to compile without Strong mode.\n` +
255
+ 'init records every such module once; update only removes names that are no\n' +
256
+ 'longer exceptions. While the file exists, every run fails on a module that\n' +
257
+ 'is neither Strong nor listed, and on a listed name that is out of date.',
258
+ },
259
+ },
260
+
261
+ async run(ctx, input) {
262
+ const project = ctx.project();
263
+ const compiler = await loadCompiler(project.root);
264
+ const preview = input.flags['strong-preview'] === true;
265
+
266
+ // Coverage is a property of the whole project: an explicit file list can
267
+ // show that one of its files regressed, but not that a recorded exception
268
+ // went stale, and it must never write the baseline.
269
+ const whole = input.positionals.length === 0;
270
+ const write = /** @type {'init' | 'update' | undefined} */ (input.flags['strong-baseline']);
271
+ if (write !== undefined && !whole) {
272
+ throw usageError('--strong-baseline measures the whole project; drop the file arguments.');
273
+ }
274
+ let baseline = readBaseline(project.root);
275
+ if (write === 'init' && baseline !== null) {
276
+ throw usageError(
277
+ `${BASELINE_FILE} already exists.`,
278
+ '`--strong-baseline update` removes names that are no longer exceptions; it never adds one.',
279
+ );
280
+ }
281
+ if (write === 'update' && baseline === null) {
282
+ throw usageError(
283
+ `${BASELINE_FILE} does not exist.`,
284
+ 'Record one with `--strong-baseline init`.',
285
+ );
286
+ }
287
+ const measureCoverage = baseline !== null || write !== undefined;
288
+ const { policy, reason } = await loadStrongPolicy(project, { required: measureCoverage });
289
+ const jsxImportSource = project.tsconfig?.config?.compilerOptions?.jsxImportSource;
290
+
291
+ /** @type {Map<string, string>} */
292
+ const sources = new Map();
293
+ /** @param {string} absolute */
294
+ const read = (absolute) => {
295
+ let source = sources.get(absolute);
296
+ if (source === undefined) {
297
+ source = readFileSync(absolute, 'utf8');
298
+ sources.set(absolute, source);
299
+ }
300
+ return source;
301
+ };
302
+
303
+ /** @type {string[]} */
304
+ const notes = [];
305
+ if (reason !== undefined) notes.push(reason);
306
+ let targets;
307
+ if (!whole) {
308
+ targets = input.positionals.map((file) => path.resolve(ctx.cwd, file));
309
+ } else {
310
+ // A .tsx module is analyzed when its JSX goes to Octane, by the same
311
+ // pragma and tsconfig rule the coverage baseline uses. Another
312
+ // framework's JSX in a mixed project is left alone.
313
+ const ownership = policy ?? (await loadJsxPragma(project.root));
314
+ const tsx = project.sourceFiles.filter((file) => file.endsWith('.tsx'));
315
+ targets = [...project.tsrxFiles];
316
+ if (ownership === null) {
317
+ if (tsx.length > 0) {
318
+ notes.push(
319
+ 'The installed octane cannot say which .tsx modules are Octane JSX, so only .tsrx files were analyzed. Name .tsx files explicitly, or update octane.',
320
+ );
321
+ }
322
+ } else {
323
+ for (const absolute of tsx) {
324
+ let source;
325
+ try {
326
+ source = read(absolute);
327
+ } catch {
328
+ continue;
329
+ }
330
+ if (isOctaneModule(absolute, source, jsxImportSource, ownership)) targets.push(absolute);
331
+ }
332
+ targets.sort();
333
+ }
334
+ }
335
+ if (compiler.collectDiagnostics === null) {
336
+ notes.push(
337
+ 'The installed octane reports only the first error in each file. Update octane to see every finding.',
338
+ );
339
+ }
340
+
341
+ if (targets.length === 0 && !measureCoverage) {
342
+ ctx.ui.intro('octane analyze');
343
+ ctx.ui.outro('No Octane modules found.');
344
+ return { json: { ok: true, analyzed: 0, findings: [] } };
345
+ }
346
+
347
+ ctx.ui.intro('octane analyze');
348
+ for (const note of notes) ctx.ui.log(ctx.ui.colors.yellow(`${SYMBOLS.warn} ${note}`));
349
+ const spinner = ctx.ui.spinner(`Compiling ${targets.length} file(s)`);
350
+
351
+ /** @type {Map<string, import('./strong-coverage.js').StrongStatus>} */
352
+ const statuses = new Map();
353
+ // One parse per module answers both the compile options and coverage.
354
+ /** @param {string} absolute @param {string} source */
355
+ const statusOf = (absolute, source) => {
356
+ let status = statuses.get(absolute);
357
+ if (status === undefined && policy) {
358
+ status = policy.status(source, absolute);
359
+ statuses.set(absolute, status);
360
+ }
361
+ return status;
362
+ };
363
+
364
+ /**
365
+ * Every finding in one module, under the policy the build applies:
366
+ * octane.config's compiler.strong reaches a module without a directive
367
+ * of its own. `--strong-preview` applies Strong to every module.
368
+ *
369
+ * @param {string} absolute
370
+ * @param {string} source
371
+ * @param {string} file
372
+ * @returns {Finding[]}
373
+ */
374
+ const analyzeModule = (absolute, source, file) => {
375
+ let strong = false;
376
+ try {
377
+ const status = statusOf(absolute, source);
378
+ strong = status?.strong ?? STRONG_DIRECTIVE.test(source);
379
+ } catch {
380
+ // Unparseable; the compile below reports it in its own terms.
381
+ }
382
+ const options = strong || preview ? { strong: true } : {};
383
+ /** @type {Finding[]} */
384
+ const found = [];
385
+ if (compiler.collectDiagnostics !== null) {
386
+ // A file that will not compile is the most severe thing analyze can
387
+ // find, and it must not stop the other files being reported.
388
+ try {
389
+ const result = compiler.collectDiagnostics(source, absolute, options);
390
+ for (const diagnostic of result.diagnostics) found.push(findingOf(diagnostic, file));
391
+ if (result.error != null) found.push(thrownFailure(result.error, file));
392
+ } catch (error) {
393
+ found.push(thrownFailure(error, file));
394
+ }
395
+ } else {
396
+ try {
397
+ for (const diagnostic of compiler.compile(source, absolute, options).diagnostics ?? []) {
398
+ found.push(findingOf(diagnostic, file));
399
+ }
400
+ } catch (error) {
401
+ found.push(thrownFailure(error, file));
402
+ }
403
+ }
404
+ if (preview && !strong) {
405
+ for (const finding of found) {
406
+ if (finding.code in STRONG_CODES) finding.preview = true;
407
+ }
408
+ }
409
+ return found;
410
+ };
411
+
412
+ /** @type {Finding[]} */
413
+ const findings = [];
414
+ let fixedFindings = 0;
415
+ /** @type {string[]} */
416
+ const fixedFiles = [];
417
+ for (const absolute of targets) {
418
+ const file = displayPath(project.root, ctx.cwd, absolute);
419
+ let source;
420
+ try {
421
+ source = read(absolute);
422
+ } catch (error) {
423
+ // Unreadable is not unparseable; saying so sends people to the wrong fix.
424
+ findings.push(thrownFailure(error, file, 'OCTANE_READ_ERROR'));
425
+ continue;
426
+ }
427
+ let found = analyzeModule(absolute, source, file);
428
+ if (input.flags.fix) {
429
+ const fixes = found.flatMap((finding) =>
430
+ finding.edits ? [{ code: finding.code, edits: finding.edits }] : [],
431
+ );
432
+ if (fixes.length > 0) {
433
+ const { text, applied } = applyFixes(source, fixes);
434
+ const next = pruneImports(text, ['useCallback', 'useMemo', 'useRef']);
435
+ if (applied.length > 0 && next !== source) {
436
+ fixedFindings += applied.length;
437
+ fixedFiles.push(file);
438
+ if (!ctx.dryRun) writeFileSync(absolute, next);
439
+ // Report what the fixed module still has, not what was fixed.
440
+ sources.set(absolute, next);
441
+ statuses.delete(absolute);
442
+ found = analyzeModule(absolute, next, file);
443
+ }
444
+ }
445
+ }
446
+ findings.push(...found);
447
+ }
448
+ spinner.stop(`Compiled ${targets.length} file(s)`);
449
+ if (input.flags.fix) {
450
+ ctx.ui.log(
451
+ fixedFindings === 0
452
+ ? 'No findings had an automatic fix.'
453
+ : `${ctx.dryRun ? 'Would fix' : 'Fixed'} ${fixedFindings} finding(s) in ${fixedFiles.length} file(s).`,
454
+ );
455
+ }
456
+
457
+ /** @type {Record<string, unknown> | undefined} */
458
+ let strongCoverage;
459
+ if (measureCoverage && policy !== null) {
460
+ if (whole && project.sourceFiles.length >= SOURCE_FILE_LIMIT) {
461
+ throw new CliError(
462
+ `The project has more than ${SOURCE_FILE_LIMIT} source files, so Strong coverage cannot be measured completely.`,
463
+ );
464
+ }
465
+ /** @type {import('./strong-coverage.js').CoverageModule[]} */
466
+ const modules = [];
467
+ /** @type {Set<string>} */
468
+ const unmeasured = new Set();
469
+ for (const absolute of whole ? project.sourceFiles : targets) {
470
+ const file = projectPath(project.root, absolute);
471
+ if (file.startsWith('../') || path.isAbsolute(file)) continue;
472
+ let source;
473
+ try {
474
+ source = read(absolute);
475
+ } catch {
476
+ unmeasured.add(file);
477
+ continue;
478
+ }
479
+ if (!isOctaneModule(absolute, source, jsxImportSource, policy)) continue;
480
+ try {
481
+ const status = /** @type {import('./strong-coverage.js').StrongStatus} */ (
482
+ statusOf(absolute, source)
483
+ );
484
+ modules.push({ file, absolute, status });
485
+ } catch {
486
+ // A module that does not parse fails its build and, as a target,
487
+ // this report. It is neither a regression nor proof of staleness.
488
+ unmeasured.add(file);
489
+ }
490
+ }
491
+
492
+ if (write === 'init') {
493
+ const exceptions = modules.filter((module) => !module.status.strong);
494
+ if (!ctx.dryRun)
495
+ writeBaseline(
496
+ project.root,
497
+ exceptions.map((module) => module.file),
498
+ );
499
+ baseline = {
500
+ path: path.join(project.root, BASELINE_FILE),
501
+ text: '',
502
+ exceptions: exceptions.map((module) => module.file),
503
+ };
504
+ }
505
+ const current = /** @type {import('./strong-coverage.js').Baseline} */ (baseline);
506
+ const comparison = compareCoverage({
507
+ modules,
508
+ baseline: current,
509
+ policy,
510
+ complete: whole,
511
+ unmeasured,
512
+ display: (absolute) => displayPath(project.root, ctx.cwd, absolute),
513
+ });
514
+ let exceptions = current.exceptions;
515
+ let coverageFindings = comparison.findings;
516
+ if (write === 'update') {
517
+ const stale = new Set(comparison.stale);
518
+ exceptions = exceptions.filter((entry) => !stale.has(entry));
519
+ if (!ctx.dryRun) {
520
+ writeBaseline(project.root, exceptions);
521
+ coverageFindings = coverageFindings.filter((finding) => finding.code !== STALE);
522
+ }
523
+ }
524
+ findings.push(...coverageFindings);
525
+
526
+ const strong = modules.filter((module) => module.status.strong).length;
527
+ ctx.ui.log(
528
+ `Strong coverage: ${strong} of ${modules.length} module(s), ${exceptions.length} recorded exception(s)`,
529
+ );
530
+ if (write !== undefined) {
531
+ const changed =
532
+ write === 'init'
533
+ ? `${ctx.dryRun ? 'Would record' : 'Recorded'} ${exceptions.length} exception(s)`
534
+ : `${ctx.dryRun ? 'Would remove' : 'Removed'} ${comparison.stale.length} name(s)`;
535
+ ctx.ui.log(`${changed} in ${BASELINE_FILE}`);
536
+ }
537
+ strongCoverage = {
538
+ baseline: BASELINE_FILE,
539
+ modules: modules.length,
540
+ strong,
541
+ exceptions: exceptions.length,
542
+ regressions: comparison.regressions,
543
+ stale: comparison.stale,
544
+ };
545
+ }
546
+
547
+ const selected = input.flags.code?.length
548
+ ? findings.filter((finding) => input.flags.code.includes(finding.code))
549
+ : findings;
550
+
551
+ render(ctx, selected, targets.length);
552
+
553
+ // Preview findings describe modules that are not Strong yet. They are an
554
+ // inventory, not a failure, so they are summarized apart.
555
+ const enforced = selected.filter((finding) => finding.preview !== true);
556
+ const errors = enforced.filter((finding) => finding.severity === 'error').length;
557
+ const warnings = enforced.filter((finding) => finding.severity === 'warning').length;
558
+ const hints = enforced.filter((finding) => finding.severity === 'hint').length;
559
+ const failed = errors > 0 || (input.flags.strict && warnings > 0);
560
+
561
+ /** @type {Record<string, unknown> | undefined} */
562
+ let strongPreview;
563
+ if (preview) {
564
+ /** @type {Record<string, number>} */
565
+ const byCode = {};
566
+ const modules = new Set();
567
+ for (const finding of selected) {
568
+ if (finding.preview !== true) continue;
569
+ byCode[finding.code] = (byCode[finding.code] ?? 0) + 1;
570
+ modules.add(finding.file);
571
+ }
572
+ strongPreview = {
573
+ findings: Object.values(byCode).reduce((a, b) => a + b, 0),
574
+ modules: modules.size,
575
+ byCode,
576
+ };
577
+ renderPreview(ctx, byCode, modules.size);
578
+ }
579
+
580
+ return {
581
+ exitCode: failed ? EXIT.DIAGNOSTIC : EXIT.OK,
582
+ json: {
583
+ ok: !failed,
584
+ analyzed: targets.length,
585
+ summary: { errors, warnings, hints },
586
+ findings: selected,
587
+ ...(strongCoverage === undefined ? null : { strongCoverage }),
588
+ ...(strongPreview === undefined ? null : { strongPreview }),
589
+ ...(input.flags.fix ? { fixed: { findings: fixedFindings, files: fixedFiles } } : null),
590
+ },
591
+ };
592
+ },
593
+ });
594
+
595
+ /**
596
+ * Project-relative where that is meaningful, otherwise relative to the invoking
597
+ * directory, otherwise absolute. An explicit path outside the project would
598
+ * otherwise render as a wall of `../`.
599
+ *
600
+ * @param {string} root
601
+ * @param {string} cwd
602
+ * @param {string} absolute
603
+ * @returns {string}
604
+ */
605
+ function displayPath(root, cwd, absolute) {
606
+ const fromRoot = path.relative(root, absolute);
607
+ if (!fromRoot.startsWith('..')) return fromRoot;
608
+ const fromCwd = path.relative(cwd, absolute);
609
+ return fromCwd.startsWith('..') ? absolute : fromCwd;
610
+ }
611
+
612
+ /**
613
+ * @param {import('../../kernel/context.js').Ctx} ctx
614
+ * @param {Finding[]} findings
615
+ * @param {number} analyzed
616
+ */
617
+ function render(ctx, findings, analyzed) {
618
+ const { colors } = ctx.ui;
619
+
620
+ if (findings.length === 0) {
621
+ ctx.ui.outro(colors.green(`No diagnostics across ${analyzed} file(s).`));
622
+ return;
623
+ }
624
+
625
+ for (const file of [...new Set(findings.map((finding) => finding.file))]) {
626
+ ctx.ui.log('');
627
+ ctx.ui.log(colors.bold(file));
628
+
629
+ for (const finding of findings.filter((entry) => entry.file === file)) {
630
+ const mark =
631
+ finding.severity === 'error'
632
+ ? colors.red(SYMBOLS.fail)
633
+ : finding.severity === 'hint'
634
+ ? colors.dim('hint')
635
+ : colors.yellow(SYMBOLS.warn);
636
+ const where = colors.dim(`${finding.line}:${finding.column}`);
637
+ ctx.ui.log(` ${finding.preview ? colors.dim('strong') : mark} ${where} ${finding.message}`);
638
+ ctx.ui.log(
639
+ ` ${colors.dim(finding.url ? `${finding.code} ${finding.url}` : finding.code)}`,
640
+ );
641
+ for (const suggestion of finding.suggestions) {
642
+ ctx.ui.log(
643
+ ` ${colors.dim(`suggestion: ${suggestion}${finding.edits ? ' (--fix)' : ''}`)}`,
644
+ );
645
+ }
646
+ }
647
+ }
648
+
649
+ const enforced = findings.filter((finding) => finding.preview !== true);
650
+ const errors = enforced.filter((finding) => finding.severity === 'error').length;
651
+ const warnings = enforced.filter((finding) => finding.severity === 'warning').length;
652
+ const hints = enforced.filter((finding) => finding.severity === 'hint').length;
653
+ const previewed = findings.length - enforced.length;
654
+ /** @type {string[]} */
655
+ const parts = [];
656
+ if (errors > 0) parts.push(colors.red(`${errors} error${errors === 1 ? '' : 's'}`));
657
+ if (warnings > 0) parts.push(colors.yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`));
658
+ if (hints > 0) parts.push(colors.dim(`${hints} hint${hints === 1 ? '' : 's'}`));
659
+ if (previewed > 0) parts.push(`${previewed} Strong preview finding${previewed === 1 ? '' : 's'}`);
660
+
661
+ ctx.ui.log('');
662
+ ctx.ui.log(`${parts.join(colors.dim(' · '))} ${colors.dim(`across ${analyzed} file(s)`)}`);
663
+ }
664
+
665
+ /**
666
+ * What adopting Strong would take, by code, most frequent first.
667
+ *
668
+ * @param {import('../../kernel/context.js').Ctx} ctx
669
+ * @param {Record<string, number>} byCode
670
+ * @param {number} modules
671
+ */
672
+ function renderPreview(ctx, byCode, modules) {
673
+ const { colors } = ctx.ui;
674
+ const entries = Object.entries(byCode).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
675
+ ctx.ui.log('');
676
+ if (entries.length === 0) {
677
+ ctx.ui.log(colors.green('Strong preview: nothing to change.'));
678
+ return;
679
+ }
680
+ ctx.ui.log(colors.bold(`Strong preview: ${modules} module(s) need changes`));
681
+ for (const [code, count] of entries) {
682
+ ctx.ui.log(` ${String(count).padStart(4)} ${code}`);
683
+ }
684
+ ctx.ui.log(colors.dim(' Explain a code with `octane explain <CODE>`.'));
685
+ }