@octanejs/cli 0.1.0 → 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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ pnpm dlx @octanejs/cli doctor
25
25
  | `octane analyze` | Compile the project and report every Octane compiler diagnostic, with its code and suggested edit. |
26
26
  | `octane add <package>` | Install a binding, by its own name or by the React package it ports, and print its divergences. |
27
27
  | `octane bindings [query]` | List and search the `@octanejs/*` bindings. |
28
- | `octane explain <error>` | Decode a runtime error code, including the minified production message. |
28
+ | `octane explain <error>` | Decode a runtime error code, including the minified production message, or explain a Strong diagnostic such as `OCTANE_STRONG_RENDER_REF_READ`. |
29
29
  | `octane info` | Environment and project details worth pasting into a bug report. |
30
30
  | `octane mcp add` | Register the Octane MCP server with Claude Code, Codex, Cursor, or VS Code. |
31
31
 
@@ -66,15 +66,19 @@ Anything else is reported with the exact remedy rather than guessed at.
66
66
  ## `octane analyze`
67
67
 
68
68
  Where `doctor` checks how the project is wired, `analyze` checks the code. It
69
- compiles every `.tsrx` through the project's own `octane` and reports what the
70
- compiler found, so the results are exactly what a build would warn about, and
71
- new compiler diagnostics show up here without a CLI change.
69
+ compiles every `.tsrx`, and every `.tsx` whose JSX goes to Octane, through the
70
+ project's own `octane` and reports what the compiler found, so the results are
71
+ exactly what a build would warn about, and new compiler diagnostics show up here
72
+ without a CLI change. Every Strong violation in a file is reported, not only the
73
+ first one the build stops at.
72
74
 
73
75
  ```bash
74
- octane analyze # every .tsrx in the project
76
+ octane analyze # every Octane module in the project
75
77
  octane analyze src/App.tsrx # just these
76
78
  octane analyze --code OCTANE_HYDRATE_SPLIT_STYLE
77
79
  octane analyze --strict # warnings fail the run too
80
+ octane analyze --strong-preview # what Strong mode would reject, by code
81
+ octane analyze --fix # apply the compiler's suggested edits
78
82
  ```
79
83
 
80
84
  ```
@@ -89,7 +93,27 @@ the run. Exit code is `3` when anything error-severity was found, or when
89
93
  `--strict` and there were warnings.
90
94
 
91
95
  Modules that `compiler.strong` in `octane.config.ts` reaches are analyzed in
92
- Strong mode, as the build compiles them.
96
+ Strong mode, as the build compiles them. A `.tsx` module whose leading
97
+ `@jsxImportSource` pragma, or the tsconfig's `jsxImportSource`, names another
98
+ library is left out unless you name it.
99
+
100
+ ### Migrating to Strong mode
101
+
102
+ `--strong-preview` compiles every module as if Strong mode were on, reports what
103
+ it would reject, and ends with a count per code. Findings in modules that are not
104
+ Strong yet do not fail the run, so it works as an inventory before you opt in.
105
+
106
+ `--fix` applies the edits the compiler suggests and then reports what remains:
107
+ React's lazy ref initialization (`if (ref.current === null) ref.current = …`)
108
+ becomes `useLazyRef`, `useMemo(() => value, deps)` becomes `value`, and
109
+ `useCallback(fn, deps)` becomes `fn`. With `--dry-run` it reports the fixes
110
+ without writing them. Under `--json`, each finding with a fix carries its
111
+ `edits` as `{ start, end, text }` offsets into the file.
112
+
113
+ `octane explain <CODE>` prints what a Strong diagnostic detects, its
114
+ replacement, and the migration recipes for it. It also accepts a pasted compile
115
+ error or its docs link. Every code is documented at
116
+ [octanejs.dev/docs/strong-mode](https://octanejs.dev/docs/strong-mode#diagnostic-reference).
93
117
 
94
118
  ### Strong coverage
95
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.2"
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Apply the compiler's structured suggestions to a module's source.
3
+ *
4
+ * A suggestion's edits are one unit: the lazy-ref rewrite adds an import,
5
+ * replaces the `useRef` call, and deletes the `if`, and applying only part of
6
+ * that would leave the module broken. A suggestion is skipped whole when any
7
+ * of its edits overlaps an edit already accepted; running `--fix` again picks
8
+ * it up from the rewritten source. An edit identical to an accepted one, such
9
+ * as two rewrites adding the same import, is applied once.
10
+ *
11
+ * @typedef {{ start: number, end: number, text: string }} Edit
12
+ * @typedef {{ code: string, edits: Edit[] }} Fix
13
+ */
14
+
15
+ /**
16
+ * @param {Edit} a
17
+ * @param {Edit} b
18
+ */
19
+ function overlaps(a, b) {
20
+ if (a.start === a.end && b.start === b.end) return a.start === b.start;
21
+ return a.start < b.end && b.start < a.end;
22
+ }
23
+
24
+ /**
25
+ * @param {string} source
26
+ * @param {readonly Fix[]} fixes
27
+ * @returns {{ text: string, applied: Fix[] }}
28
+ */
29
+ export function applyFixes(source, fixes) {
30
+ /** @type {Edit[]} */
31
+ const accepted = [];
32
+ /** @type {Fix[]} */
33
+ const applied = [];
34
+ for (const fix of fixes) {
35
+ const fresh = fix.edits.filter(
36
+ (edit) =>
37
+ !accepted.some(
38
+ (other) =>
39
+ other.start === edit.start && other.end === edit.end && other.text === edit.text,
40
+ ),
41
+ );
42
+ if (fresh.some((edit) => accepted.some((other) => overlaps(edit, other)))) continue;
43
+ accepted.push(...fresh);
44
+ applied.push(fix);
45
+ }
46
+ let text = source;
47
+ for (const edit of accepted.sort((a, b) => b.start - a.start)) {
48
+ text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
49
+ }
50
+ return { text, applied };
51
+ }
52
+
53
+ /** The hooks a fix can replace, whose imports it may leave unused. */
54
+ const REPLACED_HOOKS = new Set(['useCallback', 'useMemo', 'useRef']);
55
+
56
+ const OCTANE_NAMED_IMPORT =
57
+ /import\s*\{([^}]*)\}\s*from\s*(['"])octane\2([^\S\n]*;)?([^\S\n]*\n)?/g;
58
+
59
+ /**
60
+ * Drop `useMemo`, `useCallback`, and `useRef` from the `octane` import when
61
+ * the fixes removed their last use. A name that still appears anywhere else,
62
+ * even in a comment, keeps its import: an unused import is harmless, a missing
63
+ * one is not.
64
+ *
65
+ * @param {string} text
66
+ * @param {readonly string[]} candidates hook names a fix replaced
67
+ */
68
+ export function pruneImports(text, candidates) {
69
+ const names = candidates.filter((name) => REPLACED_HOOKS.has(name));
70
+ if (names.length === 0) return text;
71
+ return text.replace(
72
+ OCTANE_NAMED_IMPORT,
73
+ (statement, /** @type {string} */ list, quote, semicolon = '', newline = '') => {
74
+ const specifiers = list
75
+ .split(',')
76
+ .map((specifier) => specifier.trim())
77
+ .filter(Boolean);
78
+ const kept = specifiers.filter((specifier) => {
79
+ if (!names.includes(specifier)) return true;
80
+ const uses = text.match(new RegExp(`\\b${specifier}\\b`, 'g'))?.length ?? 0;
81
+ return uses > 1;
82
+ });
83
+ if (kept.length === specifiers.length) return statement;
84
+ if (kept.length === 0) return '';
85
+ return `import { ${kept.join(', ')} } from ${quote}octane${quote}${semicolon}${newline}`;
86
+ },
87
+ );
88
+ }
@@ -1,7 +1,8 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
+ import { DATA } from '../../data/index.js';
5
6
  import { defineCommand } from '../../kernel/command.js';
6
7
  import { CliError, EXIT, usageError } from '../../kernel/errors.js';
7
8
  import { SOURCE_FILE_LIMIT } from '../../kernel/project.js';
@@ -11,11 +12,13 @@ import {
11
12
  STALE,
12
13
  compareCoverage,
13
14
  isOctaneModule,
15
+ loadJsxPragma,
14
16
  loadStrongPolicy,
15
17
  projectPath,
16
18
  readBaseline,
17
19
  writeBaseline,
18
20
  } from './strong-coverage.js';
21
+ import { applyFixes, pruneImports } from './fix.js';
19
22
 
20
23
  /**
21
24
  * @typedef {Object} Finding
@@ -26,6 +29,19 @@ import {
26
29
  * @property {string} code
27
30
  * @property {string} message
28
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.
29
45
  */
30
46
 
31
47
  /**
@@ -37,7 +53,7 @@ import {
37
53
  * that builds the app.
38
54
  *
39
55
  * @param {string} root
40
- * @returns {Promise<(source: string, filename: string, options?: object) => { diagnostics: readonly any[] }>}
56
+ * @returns {Promise<Compiler>}
41
57
  */
42
58
  async function loadCompiler(root) {
43
59
  const require = createRequire(path.join(root, 'noop.js'));
@@ -53,7 +69,54 @@ async function loadCompiler(root) {
53
69
  if (typeof module.compile !== 'function') {
54
70
  throw new CliError('The installed octane build exposes no compiler.');
55
71
  }
56
- return module.compile;
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;
57
120
  }
58
121
 
59
122
  /**
@@ -92,6 +155,14 @@ const TRAILING_LOCATION = /\s*\(([^()\s]+):(\d+):(\d+)\)\s*$/;
92
155
  */
93
156
  const COMPILER_CODE = /^(?:[^\n]*?:\d+:\d+: )?\[(OCTANE_[A-Z0-9_]+)\] /;
94
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
+
95
166
  /**
96
167
  * Normalise a thrown compile failure into a finding.
97
168
  *
@@ -147,11 +218,27 @@ export default defineCommand({
147
218
  positionals: [
148
219
  {
149
220
  name: 'path',
150
- description: 'Files to analyze. Defaults to every .tsrx in the project.',
221
+ description:
222
+ 'Files to analyze. Defaults to every .tsrx in the project, and every .tsx whose\n' +
223
+ 'JSX belongs to Octane.',
151
224
  variadic: true,
152
225
  },
153
226
  ],
154
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
+ },
155
242
  code: {
156
243
  type: 'string',
157
244
  repeatable: true,
@@ -173,7 +260,8 @@ export default defineCommand({
173
260
 
174
261
  async run(ctx, input) {
175
262
  const project = ctx.project();
176
- const compile = await loadCompiler(project.root);
263
+ const compiler = await loadCompiler(project.root);
264
+ const preview = input.flags['strong-preview'] === true;
177
265
 
178
266
  // Coverage is a property of the whole project: an explicit file list can
179
267
  // show that one of its files regressed, but not that a recorded exception
@@ -198,24 +286,68 @@ export default defineCommand({
198
286
  }
199
287
  const measureCoverage = baseline !== null || write !== undefined;
200
288
  const { policy, reason } = await loadStrongPolicy(project, { required: measureCoverage });
289
+ const jsxImportSource = project.tsconfig?.config?.compilerOptions?.jsxImportSource;
201
290
 
202
- const targets =
203
- input.positionals.length > 0
204
- ? input.positionals.map((file) => path.resolve(ctx.cwd, file))
205
- : project.tsrxFiles;
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
+ }
206
340
 
207
341
  if (targets.length === 0 && !measureCoverage) {
208
342
  ctx.ui.intro('octane analyze');
209
- ctx.ui.outro('No .tsrx files found.');
343
+ ctx.ui.outro('No Octane modules found.');
210
344
  return { json: { ok: true, analyzed: 0, findings: [] } };
211
345
  }
212
346
 
213
347
  ctx.ui.intro('octane analyze');
214
- if (reason !== undefined) ctx.ui.log(ctx.ui.colors.yellow(`${SYMBOLS.warn} ${reason}`));
348
+ for (const note of notes) ctx.ui.log(ctx.ui.colors.yellow(`${SYMBOLS.warn} ${note}`));
215
349
  const spinner = ctx.ui.spinner(`Compiling ${targets.length} file(s)`);
216
350
 
217
- /** @type {Map<string, string>} */
218
- const sources = new Map();
219
351
  /** @type {Map<string, import('./strong-coverage.js').StrongStatus>} */
220
352
  const statuses = new Map();
221
353
  // One parse per module answers both the compile options and coverage.
@@ -228,54 +360,99 @@ export default defineCommand({
228
360
  }
229
361
  return status;
230
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
+
231
412
  /** @type {Finding[]} */
232
413
  const findings = [];
414
+ let fixedFindings = 0;
415
+ /** @type {string[]} */
416
+ const fixedFiles = [];
233
417
  for (const absolute of targets) {
234
418
  const file = displayPath(project.root, ctx.cwd, absolute);
235
419
  let source;
236
420
  try {
237
- source = readFileSync(absolute, 'utf8');
421
+ source = read(absolute);
238
422
  } catch (error) {
239
423
  // Unreadable is not unparseable; saying so sends people to the wrong fix.
240
424
  findings.push(thrownFailure(error, file, 'OCTANE_READ_ERROR'));
241
425
  continue;
242
426
  }
243
- sources.set(absolute, source);
244
-
245
- // Analyze under the policy the build applies: octane.config's
246
- // compiler.strong reaches this module without a directive of its own.
247
- let options = {};
248
- try {
249
- if (statusOf(absolute, source)?.config) options = { strong: true };
250
- } catch {
251
- // Unparseable; the compile below reports it in its own terms.
252
- }
253
- try {
254
- for (const diagnostic of compile(source, absolute, options).diagnostics ?? []) {
255
- findings.push({
256
- file,
257
- line: diagnostic.start?.line ?? 1,
258
- column: (diagnostic.start?.column ?? 0) + 1,
259
- severity:
260
- diagnostic.severity === 'error'
261
- ? 'error'
262
- : diagnostic.severity === 'hint'
263
- ? 'hint'
264
- : 'warning',
265
- code: diagnostic.code,
266
- // The compiler prefixes its own code; the report already has a
267
- // column for it.
268
- message: String(diagnostic.message).replace(`[${diagnostic.code}] `, ''),
269
- suggestions: describeSuggestions(diagnostic.suggestions),
270
- });
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
+ }
271
444
  }
272
- } catch (error) {
273
- // A file that will not compile is the most severe thing analyze can
274
- // find, and it must not stop the other files being reported.
275
- findings.push(thrownFailure(error, file));
276
445
  }
446
+ findings.push(...found);
277
447
  }
278
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
+ }
279
456
 
280
457
  /** @type {Record<string, unknown> | undefined} */
281
458
  let strongCoverage;
@@ -285,7 +462,6 @@ export default defineCommand({
285
462
  `The project has more than ${SOURCE_FILE_LIMIT} source files, so Strong coverage cannot be measured completely.`,
286
463
  );
287
464
  }
288
- const jsxImportSource = project.tsconfig?.config?.compilerOptions?.jsxImportSource;
289
465
  /** @type {import('./strong-coverage.js').CoverageModule[]} */
290
466
  const modules = [];
291
467
  /** @type {Set<string>} */
@@ -293,14 +469,12 @@ export default defineCommand({
293
469
  for (const absolute of whole ? project.sourceFiles : targets) {
294
470
  const file = projectPath(project.root, absolute);
295
471
  if (file.startsWith('../') || path.isAbsolute(file)) continue;
296
- let source = sources.get(absolute);
297
- if (source === undefined) {
298
- try {
299
- source = readFileSync(absolute, 'utf8');
300
- } catch {
301
- unmeasured.add(file);
302
- continue;
303
- }
472
+ let source;
473
+ try {
474
+ source = read(absolute);
475
+ } catch {
476
+ unmeasured.add(file);
477
+ continue;
304
478
  }
305
479
  if (!isOctaneModule(absolute, source, jsxImportSource, policy)) continue;
306
480
  try {
@@ -376,11 +550,33 @@ export default defineCommand({
376
550
 
377
551
  render(ctx, selected, targets.length);
378
552
 
379
- const errors = selected.filter((finding) => finding.severity === 'error').length;
380
- const warnings = selected.filter((finding) => finding.severity === 'warning').length;
381
- const hints = selected.filter((finding) => finding.severity === 'hint').length;
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;
382
559
  const failed = errors > 0 || (input.flags.strict && warnings > 0);
383
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
+
384
580
  return {
385
581
  exitCode: failed ? EXIT.DIAGNOSTIC : EXIT.OK,
386
582
  json: {
@@ -389,6 +585,8 @@ export default defineCommand({
389
585
  summary: { errors, warnings, hints },
390
586
  findings: selected,
391
587
  ...(strongCoverage === undefined ? null : { strongCoverage }),
588
+ ...(strongPreview === undefined ? null : { strongPreview }),
589
+ ...(input.flags.fix ? { fixed: { findings: fixedFindings, files: fixedFiles } } : null),
392
590
  },
393
591
  };
394
592
  },
@@ -436,23 +634,52 @@ function render(ctx, findings, analyzed) {
436
634
  ? colors.dim('hint')
437
635
  : colors.yellow(SYMBOLS.warn);
438
636
  const where = colors.dim(`${finding.line}:${finding.column}`);
439
- ctx.ui.log(` ${mark} ${where} ${finding.message}`);
440
- ctx.ui.log(` ${colors.dim(finding.code)}`);
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
+ );
441
641
  for (const suggestion of finding.suggestions) {
442
- ctx.ui.log(` ${colors.dim(`suggestion: ${suggestion}`)}`);
642
+ ctx.ui.log(
643
+ ` ${colors.dim(`suggestion: ${suggestion}${finding.edits ? ' (--fix)' : ''}`)}`,
644
+ );
443
645
  }
444
646
  }
445
647
  }
446
648
 
447
- const errors = findings.filter((finding) => finding.severity === 'error').length;
448
- const warnings = findings.filter((finding) => finding.severity === 'warning').length;
449
- const hints = findings.filter((finding) => finding.severity === 'hint').length;
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;
450
654
  /** @type {string[]} */
451
655
  const parts = [];
452
656
  if (errors > 0) parts.push(colors.red(`${errors} error${errors === 1 ? '' : 's'}`));
453
657
  if (warnings > 0) parts.push(colors.yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`));
454
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'}`);
455
660
 
456
661
  ctx.ui.log('');
457
662
  ctx.ui.log(`${parts.join(colors.dim(' · '))} ${colors.dim(`across ${analyzed} file(s)`)}`);
458
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
+ }