@octanejs/cli 0.1.0 → 0.2.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.
- package/README.md +30 -6
- package/package.json +1 -1
- package/src/commands/analyze/fix.js +88 -0
- package/src/commands/analyze/index.js +291 -64
- package/src/commands/analyze/strong-coverage.js +21 -1
- package/src/commands/explain.js +73 -2
- package/src/commands/init/index.js +39 -8
- package/src/data/octane-data.json +494 -117
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
|
|
70
|
-
compiler found, so the results are
|
|
71
|
-
new compiler diagnostics show up here
|
|
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
|
|
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
|
@@ -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<
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
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(
|
|
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(
|
|
642
|
+
ctx.ui.log(
|
|
643
|
+
` ${colors.dim(`suggestion: ${suggestion}${finding.edits ? ' (--fix)' : ''}`)}`,
|
|
644
|
+
);
|
|
443
645
|
}
|
|
444
646
|
}
|
|
445
647
|
}
|
|
446
648
|
|
|
447
|
-
const
|
|
448
|
-
const
|
|
449
|
-
const
|
|
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
|
+
}
|