@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.
- package/README.md +44 -5
- package/package.json +1 -1
- package/src/commands/analyze/fix.js +88 -0
- package/src/commands/analyze/index.js +685 -0
- package/src/commands/analyze/strong-coverage.js +324 -0
- package/src/commands/doctor/checks/config.js +1 -41
- package/src/commands/explain.js +73 -2
- package/src/data/octane-data.json +1745 -117
- package/src/kernel/octane-config.js +42 -0
- package/src/kernel/project.js +1 -1
- package/src/kernel/registry.js +1 -1
- package/src/commands/analyze.js +0 -283
|
@@ -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
|
+
}
|