@octanejs/cli 0.0.12 → 0.1.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 +15 -0
- package/package.json +1 -1
- package/src/commands/{analyze.js → analyze/index.js} +182 -7
- package/src/commands/analyze/strong-coverage.js +304 -0
- package/src/commands/doctor/checks/config.js +1 -41
- package/src/data/octane-data.json +1397 -116
- package/src/kernel/octane-config.js +42 -0
- package/src/kernel/project.js +1 -1
- package/src/kernel/registry.js +1 -1
package/README.md
CHANGED
|
@@ -88,6 +88,21 @@ A file that will not parse is reported as an error and does not stop the rest of
|
|
|
88
88
|
the run. Exit code is `3` when anything error-severity was found, or when
|
|
89
89
|
`--strict` and there were warnings.
|
|
90
90
|
|
|
91
|
+
Modules that `compiler.strong` in `octane.config.ts` reaches are analyzed in
|
|
92
|
+
Strong mode, as the build compiles them.
|
|
93
|
+
|
|
94
|
+
### Strong coverage
|
|
95
|
+
|
|
96
|
+
`octane analyze --strong-baseline init` records every module that compiles
|
|
97
|
+
without Strong mode in `octane-strong-baseline.json`. While that file exists,
|
|
98
|
+
every run fails on a module that is neither Strong nor listed
|
|
99
|
+
(`OCTANE_STRONG_COVERAGE_REGRESSION`), such as one whose `"use strong"` was
|
|
100
|
+
deleted, and on a listed name that is now Strong or gone
|
|
101
|
+
(`OCTANE_STRONG_COVERAGE_STALE`). `--strong-baseline update` removes stale
|
|
102
|
+
names and never adds one, so a new exception is always a reviewed edit to the
|
|
103
|
+
file. See
|
|
104
|
+
[Keeping modules Strong](https://github.com/octanejs/octane/blob/main/docs/strong-compiler-checks.md#keeping-modules-strong).
|
|
105
|
+
|
|
91
106
|
## For agents and CI
|
|
92
107
|
|
|
93
108
|
Every command is fully drivable by flags and emits a single JSON document under
|
package/package.json
CHANGED
|
@@ -2,9 +2,20 @@ import { readFileSync } 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 { defineCommand } from '
|
|
6
|
-
import { CliError, EXIT } from '
|
|
7
|
-
import {
|
|
5
|
+
import { defineCommand } from '../../kernel/command.js';
|
|
6
|
+
import { CliError, EXIT, usageError } from '../../kernel/errors.js';
|
|
7
|
+
import { SOURCE_FILE_LIMIT } from '../../kernel/project.js';
|
|
8
|
+
import { SYMBOLS } from '../../kernel/ui.js';
|
|
9
|
+
import {
|
|
10
|
+
BASELINE_FILE,
|
|
11
|
+
STALE,
|
|
12
|
+
compareCoverage,
|
|
13
|
+
isOctaneModule,
|
|
14
|
+
loadStrongPolicy,
|
|
15
|
+
projectPath,
|
|
16
|
+
readBaseline,
|
|
17
|
+
writeBaseline,
|
|
18
|
+
} from './strong-coverage.js';
|
|
8
19
|
|
|
9
20
|
/**
|
|
10
21
|
* @typedef {Object} Finding
|
|
@@ -74,6 +85,13 @@ function describeSuggestions(suggestions) {
|
|
|
74
85
|
*/
|
|
75
86
|
const TRAILING_LOCATION = /\s*\(([^()\s]+):(\d+):(\d+)\)\s*$/;
|
|
76
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A compiler error that carries its own code, e.g. a Strong rule:
|
|
90
|
+
* `/abs/App.tsrx:4:21: [OCTANE_STRONG_EFFECT_STATE_UPDATE] Strong mode …`.
|
|
91
|
+
* The position is already in the finding's columns.
|
|
92
|
+
*/
|
|
93
|
+
const COMPILER_CODE = /^(?:[^\n]*?:\d+:\d+: )?\[(OCTANE_[A-Z0-9_]+)\] /;
|
|
94
|
+
|
|
77
95
|
/**
|
|
78
96
|
* Normalise a thrown compile failure into a finding.
|
|
79
97
|
*
|
|
@@ -88,7 +106,12 @@ const TRAILING_LOCATION = /\s*\(([^()\s]+):(\d+):(\d+)\)\s*$/;
|
|
|
88
106
|
* @returns {Finding}
|
|
89
107
|
*/
|
|
90
108
|
function thrownFailure(error, file, code) {
|
|
91
|
-
const
|
|
109
|
+
const thrown = error instanceof Error ? error.message : String(error);
|
|
110
|
+
// A coded compiler error names its rule; reporting it as a parse failure
|
|
111
|
+
// would hide the code that `--code` filters on and the docs index.
|
|
112
|
+
const coded = COMPILER_CODE.exec(thrown);
|
|
113
|
+
const message = coded ? thrown.slice(coded[0].length) : thrown;
|
|
114
|
+
code ??= coded?.[1];
|
|
92
115
|
const loc = /** @type {any} */ (error)?.loc;
|
|
93
116
|
if (loc) {
|
|
94
117
|
return {
|
|
@@ -136,26 +159,75 @@ export default defineCommand({
|
|
|
136
159
|
description: 'Only report this diagnostic code. Repeatable.',
|
|
137
160
|
},
|
|
138
161
|
strict: { type: 'boolean', description: 'Fail the run on warnings, not just errors.' },
|
|
162
|
+
'strong-baseline': {
|
|
163
|
+
type: 'string',
|
|
164
|
+
choices: ['init', 'update'],
|
|
165
|
+
placeholder: '<init|update>',
|
|
166
|
+
description:
|
|
167
|
+
`Write ${BASELINE_FILE}, the modules allowed to compile without Strong mode.\n` +
|
|
168
|
+
'init records every such module once; update only removes names that are no\n' +
|
|
169
|
+
'longer exceptions. While the file exists, every run fails on a module that\n' +
|
|
170
|
+
'is neither Strong nor listed, and on a listed name that is out of date.',
|
|
171
|
+
},
|
|
139
172
|
},
|
|
140
173
|
|
|
141
174
|
async run(ctx, input) {
|
|
142
175
|
const project = ctx.project();
|
|
143
176
|
const compile = await loadCompiler(project.root);
|
|
144
177
|
|
|
178
|
+
// Coverage is a property of the whole project: an explicit file list can
|
|
179
|
+
// show that one of its files regressed, but not that a recorded exception
|
|
180
|
+
// went stale, and it must never write the baseline.
|
|
181
|
+
const whole = input.positionals.length === 0;
|
|
182
|
+
const write = /** @type {'init' | 'update' | undefined} */ (input.flags['strong-baseline']);
|
|
183
|
+
if (write !== undefined && !whole) {
|
|
184
|
+
throw usageError('--strong-baseline measures the whole project; drop the file arguments.');
|
|
185
|
+
}
|
|
186
|
+
let baseline = readBaseline(project.root);
|
|
187
|
+
if (write === 'init' && baseline !== null) {
|
|
188
|
+
throw usageError(
|
|
189
|
+
`${BASELINE_FILE} already exists.`,
|
|
190
|
+
'`--strong-baseline update` removes names that are no longer exceptions; it never adds one.',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (write === 'update' && baseline === null) {
|
|
194
|
+
throw usageError(
|
|
195
|
+
`${BASELINE_FILE} does not exist.`,
|
|
196
|
+
'Record one with `--strong-baseline init`.',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const measureCoverage = baseline !== null || write !== undefined;
|
|
200
|
+
const { policy, reason } = await loadStrongPolicy(project, { required: measureCoverage });
|
|
201
|
+
|
|
145
202
|
const targets =
|
|
146
203
|
input.positionals.length > 0
|
|
147
204
|
? input.positionals.map((file) => path.resolve(ctx.cwd, file))
|
|
148
205
|
: project.tsrxFiles;
|
|
149
206
|
|
|
150
|
-
if (targets.length === 0) {
|
|
207
|
+
if (targets.length === 0 && !measureCoverage) {
|
|
151
208
|
ctx.ui.intro('octane analyze');
|
|
152
209
|
ctx.ui.outro('No .tsrx files found.');
|
|
153
210
|
return { json: { ok: true, analyzed: 0, findings: [] } };
|
|
154
211
|
}
|
|
155
212
|
|
|
156
213
|
ctx.ui.intro('octane analyze');
|
|
214
|
+
if (reason !== undefined) ctx.ui.log(ctx.ui.colors.yellow(`${SYMBOLS.warn} ${reason}`));
|
|
157
215
|
const spinner = ctx.ui.spinner(`Compiling ${targets.length} file(s)`);
|
|
158
216
|
|
|
217
|
+
/** @type {Map<string, string>} */
|
|
218
|
+
const sources = new Map();
|
|
219
|
+
/** @type {Map<string, import('./strong-coverage.js').StrongStatus>} */
|
|
220
|
+
const statuses = new Map();
|
|
221
|
+
// One parse per module answers both the compile options and coverage.
|
|
222
|
+
/** @param {string} absolute @param {string} source */
|
|
223
|
+
const statusOf = (absolute, source) => {
|
|
224
|
+
let status = statuses.get(absolute);
|
|
225
|
+
if (status === undefined && policy) {
|
|
226
|
+
status = policy.status(source, absolute);
|
|
227
|
+
statuses.set(absolute, status);
|
|
228
|
+
}
|
|
229
|
+
return status;
|
|
230
|
+
};
|
|
159
231
|
/** @type {Finding[]} */
|
|
160
232
|
const findings = [];
|
|
161
233
|
for (const absolute of targets) {
|
|
@@ -168,9 +240,18 @@ export default defineCommand({
|
|
|
168
240
|
findings.push(thrownFailure(error, file, 'OCTANE_READ_ERROR'));
|
|
169
241
|
continue;
|
|
170
242
|
}
|
|
243
|
+
sources.set(absolute, source);
|
|
171
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
|
+
}
|
|
172
253
|
try {
|
|
173
|
-
for (const diagnostic of compile(source, absolute,
|
|
254
|
+
for (const diagnostic of compile(source, absolute, options).diagnostics ?? []) {
|
|
174
255
|
findings.push({
|
|
175
256
|
file,
|
|
176
257
|
line: diagnostic.start?.line ?? 1,
|
|
@@ -196,6 +277,99 @@ export default defineCommand({
|
|
|
196
277
|
}
|
|
197
278
|
spinner.stop(`Compiled ${targets.length} file(s)`);
|
|
198
279
|
|
|
280
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
281
|
+
let strongCoverage;
|
|
282
|
+
if (measureCoverage && policy !== null) {
|
|
283
|
+
if (whole && project.sourceFiles.length >= SOURCE_FILE_LIMIT) {
|
|
284
|
+
throw new CliError(
|
|
285
|
+
`The project has more than ${SOURCE_FILE_LIMIT} source files, so Strong coverage cannot be measured completely.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const jsxImportSource = project.tsconfig?.config?.compilerOptions?.jsxImportSource;
|
|
289
|
+
/** @type {import('./strong-coverage.js').CoverageModule[]} */
|
|
290
|
+
const modules = [];
|
|
291
|
+
/** @type {Set<string>} */
|
|
292
|
+
const unmeasured = new Set();
|
|
293
|
+
for (const absolute of whole ? project.sourceFiles : targets) {
|
|
294
|
+
const file = projectPath(project.root, absolute);
|
|
295
|
+
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
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!isOctaneModule(absolute, source, jsxImportSource, policy)) continue;
|
|
306
|
+
try {
|
|
307
|
+
const status = /** @type {import('./strong-coverage.js').StrongStatus} */ (
|
|
308
|
+
statusOf(absolute, source)
|
|
309
|
+
);
|
|
310
|
+
modules.push({ file, absolute, status });
|
|
311
|
+
} catch {
|
|
312
|
+
// A module that does not parse fails its build and, as a target,
|
|
313
|
+
// this report. It is neither a regression nor proof of staleness.
|
|
314
|
+
unmeasured.add(file);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (write === 'init') {
|
|
319
|
+
const exceptions = modules.filter((module) => !module.status.strong);
|
|
320
|
+
if (!ctx.dryRun)
|
|
321
|
+
writeBaseline(
|
|
322
|
+
project.root,
|
|
323
|
+
exceptions.map((module) => module.file),
|
|
324
|
+
);
|
|
325
|
+
baseline = {
|
|
326
|
+
path: path.join(project.root, BASELINE_FILE),
|
|
327
|
+
text: '',
|
|
328
|
+
exceptions: exceptions.map((module) => module.file),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const current = /** @type {import('./strong-coverage.js').Baseline} */ (baseline);
|
|
332
|
+
const comparison = compareCoverage({
|
|
333
|
+
modules,
|
|
334
|
+
baseline: current,
|
|
335
|
+
policy,
|
|
336
|
+
complete: whole,
|
|
337
|
+
unmeasured,
|
|
338
|
+
display: (absolute) => displayPath(project.root, ctx.cwd, absolute),
|
|
339
|
+
});
|
|
340
|
+
let exceptions = current.exceptions;
|
|
341
|
+
let coverageFindings = comparison.findings;
|
|
342
|
+
if (write === 'update') {
|
|
343
|
+
const stale = new Set(comparison.stale);
|
|
344
|
+
exceptions = exceptions.filter((entry) => !stale.has(entry));
|
|
345
|
+
if (!ctx.dryRun) {
|
|
346
|
+
writeBaseline(project.root, exceptions);
|
|
347
|
+
coverageFindings = coverageFindings.filter((finding) => finding.code !== STALE);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
findings.push(...coverageFindings);
|
|
351
|
+
|
|
352
|
+
const strong = modules.filter((module) => module.status.strong).length;
|
|
353
|
+
ctx.ui.log(
|
|
354
|
+
`Strong coverage: ${strong} of ${modules.length} module(s), ${exceptions.length} recorded exception(s)`,
|
|
355
|
+
);
|
|
356
|
+
if (write !== undefined) {
|
|
357
|
+
const changed =
|
|
358
|
+
write === 'init'
|
|
359
|
+
? `${ctx.dryRun ? 'Would record' : 'Recorded'} ${exceptions.length} exception(s)`
|
|
360
|
+
: `${ctx.dryRun ? 'Would remove' : 'Removed'} ${comparison.stale.length} name(s)`;
|
|
361
|
+
ctx.ui.log(`${changed} in ${BASELINE_FILE}`);
|
|
362
|
+
}
|
|
363
|
+
strongCoverage = {
|
|
364
|
+
baseline: BASELINE_FILE,
|
|
365
|
+
modules: modules.length,
|
|
366
|
+
strong,
|
|
367
|
+
exceptions: exceptions.length,
|
|
368
|
+
regressions: comparison.regressions,
|
|
369
|
+
stale: comparison.stale,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
199
373
|
const selected = input.flags.code?.length
|
|
200
374
|
? findings.filter((finding) => input.flags.code.includes(finding.code))
|
|
201
375
|
: findings;
|
|
@@ -214,6 +388,7 @@ export default defineCommand({
|
|
|
214
388
|
analyzed: targets.length,
|
|
215
389
|
summary: { errors, warnings, hints },
|
|
216
390
|
findings: selected,
|
|
391
|
+
...(strongCoverage === undefined ? null : { strongCoverage }),
|
|
217
392
|
},
|
|
218
393
|
};
|
|
219
394
|
},
|
|
@@ -237,7 +412,7 @@ function displayPath(root, cwd, absolute) {
|
|
|
237
412
|
}
|
|
238
413
|
|
|
239
414
|
/**
|
|
240
|
-
* @param {import('
|
|
415
|
+
* @param {import('../../kernel/context.js').Ctx} ctx
|
|
241
416
|
* @param {Finding[]} findings
|
|
242
417
|
* @param {number} analyzed
|
|
243
418
|
*/
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { existsSync, 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 { CliError } from '../../kernel/errors.js';
|
|
6
|
+
import { resolveConfigLoader } from '../../kernel/octane-config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The checked-in list of modules allowed to compile without Strong mode.
|
|
10
|
+
*
|
|
11
|
+
* Strong is opt-in per module or per application, so the cheapest way around
|
|
12
|
+
* a Strong diagnostic is to stop being Strong: delete `"use strong"`, turn
|
|
13
|
+
* `compiler.strong` off, or move the code into another package. Each of those
|
|
14
|
+
* is a one-line change that is easy to miss in review. With this file in the
|
|
15
|
+
* project, every module Octane compiles has to be Strong or be named here, and
|
|
16
|
+
* `octane analyze` only ever removes names. A new exception therefore shows up
|
|
17
|
+
* as an edit to this one file, which a team can route to an owner.
|
|
18
|
+
*/
|
|
19
|
+
export const BASELINE_FILE = 'octane-strong-baseline.json';
|
|
20
|
+
|
|
21
|
+
export const REGRESSION = 'OCTANE_STRONG_COVERAGE_REGRESSION';
|
|
22
|
+
export const STALE = 'OCTANE_STRONG_COVERAGE_STALE';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{ strong: boolean, directive: boolean, config: boolean }} StrongStatus
|
|
26
|
+
*
|
|
27
|
+
* @typedef {Object} StrongPolicy
|
|
28
|
+
* @property {boolean} configured `compiler.strong` from octane.config
|
|
29
|
+
* @property {(source: string, absolute: string) => StrongStatus} status
|
|
30
|
+
* @property {(source: string) => string | null} jsxPragma the leading
|
|
31
|
+
* `@jsxImportSource` module, read by the compiler's own scanner
|
|
32
|
+
*
|
|
33
|
+
* @typedef {Object} CoverageModule
|
|
34
|
+
* @property {string} file project-relative, `/`-separated
|
|
35
|
+
* @property {string} absolute
|
|
36
|
+
* @property {StrongStatus} status
|
|
37
|
+
*
|
|
38
|
+
* @typedef {Object} Baseline
|
|
39
|
+
* @property {string} path
|
|
40
|
+
* @property {string} text
|
|
41
|
+
* @property {string[]} exceptions
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The project's Strong policy, decided by the project's own compiler.
|
|
46
|
+
*
|
|
47
|
+
* `compiler.strong` comes from evaluating octane.config, and the per-module
|
|
48
|
+
* answer comes from the installed bundler compiler's `strongModuleStatus`, the
|
|
49
|
+
* same decision its `transform` makes. Nothing here re-derives the directive
|
|
50
|
+
* prologue or the package-boundary rule.
|
|
51
|
+
*
|
|
52
|
+
* `required` is set when the answer is about to gate a run (a baseline exists
|
|
53
|
+
* or one is being written). A policy that cannot be determined then fails the
|
|
54
|
+
* run. Otherwise the policy only decides whether `compiler.strong` reaches a
|
|
55
|
+
* module, so a project without it needs nothing more (`{ policy: null }`), and
|
|
56
|
+
* one whose setting cannot be applied gets the `reason` to show beside the
|
|
57
|
+
* diagnostics it could still produce.
|
|
58
|
+
*
|
|
59
|
+
* @param {import('../../kernel/project.js').Project} project
|
|
60
|
+
* @param {{ required: boolean }} options
|
|
61
|
+
* @returns {Promise<{ policy: StrongPolicy | null, reason?: string }>}
|
|
62
|
+
*/
|
|
63
|
+
export async function loadStrongPolicy(project, { required }) {
|
|
64
|
+
/** @param {string} reason */
|
|
65
|
+
const unavailable = (reason) => {
|
|
66
|
+
if (required) throw new CliError(reason);
|
|
67
|
+
return { policy: null, reason };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let configured = false;
|
|
71
|
+
if (project.octaneConfigPath !== null) {
|
|
72
|
+
const file = path.basename(project.octaneConfigPath);
|
|
73
|
+
const load = await resolveConfigLoader(project.root);
|
|
74
|
+
if (!load) {
|
|
75
|
+
return unavailable(
|
|
76
|
+
`${file} could not be evaluated because @octanejs/app-core is not installed, so its compiler.strong setting is unknown.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
configured = (await load(project.root)).compiler?.strong === true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return unavailable(
|
|
83
|
+
`${file} failed to load, so its compiler.strong setting is unknown: ${
|
|
84
|
+
error instanceof Error ? error.message : String(error)
|
|
85
|
+
}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!required && !configured) return { policy: null };
|
|
91
|
+
|
|
92
|
+
let bundler;
|
|
93
|
+
try {
|
|
94
|
+
const require = createRequire(path.join(project.root, 'noop.js'));
|
|
95
|
+
bundler = await import(pathToFileURL(require.resolve('octane/compiler/bundler')).href);
|
|
96
|
+
} catch {
|
|
97
|
+
return unavailable('Could not resolve `octane/compiler/bundler` from this project.');
|
|
98
|
+
}
|
|
99
|
+
const compiler = bundler.createOctaneCompiler?.({ root: project.root, strong: configured });
|
|
100
|
+
if (
|
|
101
|
+
typeof compiler?.strongModuleStatus !== 'function' ||
|
|
102
|
+
typeof bundler.findLeadingJsxImportSourcePragma !== 'function'
|
|
103
|
+
) {
|
|
104
|
+
return unavailable(
|
|
105
|
+
'The installed octane cannot report which modules compile under Strong mode. Update octane.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
policy: {
|
|
111
|
+
configured,
|
|
112
|
+
status: (source, absolute) => compiler.strongModuleStatus(source, absolute),
|
|
113
|
+
jsxPragma: bundler.findLeadingJsxImportSourcePragma,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* An import of octane or one of its subpaths, capturing its clause. The clause
|
|
120
|
+
* may span lines but never reaches into the next `import`, so a semicolon-free
|
|
121
|
+
* module cannot borrow a later import's specifier.
|
|
122
|
+
*/
|
|
123
|
+
const OCTANE_IMPORT =
|
|
124
|
+
/^\s*import\s+((?:(?!\bimport\b)[^;])*?)\s*\bfrom\s*['"]octane(?:\/[^'"]*)?['"]/gm;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Does the module import a runtime binding from octane? TypeScript erases an
|
|
128
|
+
* `import type` clause and a named clause whose every specifier is
|
|
129
|
+
* `type`-marked, so neither leaves anything for the compiler to slot. A
|
|
130
|
+
* default or namespace binding, or any unmarked specifier, survives. Comments
|
|
131
|
+
* in the clause are not specifiers, so they are dropped before it is read.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} source
|
|
134
|
+
*/
|
|
135
|
+
function importsOctaneAtRuntime(source) {
|
|
136
|
+
for (const [, authored] of source.matchAll(OCTANE_IMPORT)) {
|
|
137
|
+
const clause = authored.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ' ').trim();
|
|
138
|
+
if (/^type\b/.test(clause)) continue;
|
|
139
|
+
const named = /^\{([\s\S]*)\}$/.exec(clause);
|
|
140
|
+
if (named === null) return true;
|
|
141
|
+
const specifiers = named[1]
|
|
142
|
+
.split(',')
|
|
143
|
+
.map((specifier) => specifier.trim())
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
if (!specifiers.every((specifier) => /^type\s/.test(specifier))) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {string | undefined} source a JSX import source
|
|
152
|
+
*/
|
|
153
|
+
function isOctaneJsxSource(source) {
|
|
154
|
+
return source === 'octane' || source === 'octane/strong' || !!source?.startsWith('@octanejs/');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Is this a module Octane compiles, and so one Strong can reach?
|
|
159
|
+
*
|
|
160
|
+
* `.tsrx` always is. A `.tsx` module is unless its own leading pragma, or the
|
|
161
|
+
* tsconfig `jsxImportSource`, hands its JSX to another library, as a
|
|
162
|
+
* React-hosted project does. A plain `.ts`/`.js` module is when it imports
|
|
163
|
+
* octane at runtime, which is when the compiler slots its hooks. Declaration
|
|
164
|
+
* files and other extensions never are.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} absolute
|
|
167
|
+
* @param {string} source
|
|
168
|
+
* @param {string | undefined} jsxImportSource from tsconfig
|
|
169
|
+
* @param {StrongPolicy} policy
|
|
170
|
+
*/
|
|
171
|
+
export function isOctaneModule(absolute, source, jsxImportSource, policy) {
|
|
172
|
+
if (absolute.endsWith('.d.ts')) return false;
|
|
173
|
+
const extension = path.extname(absolute);
|
|
174
|
+
if (extension === '.tsrx') return true;
|
|
175
|
+
if (extension === '.tsx') {
|
|
176
|
+
const pragma = policy.jsxPragma(source);
|
|
177
|
+
if (pragma !== null) return isOctaneJsxSource(pragma);
|
|
178
|
+
return jsxImportSource === undefined || isOctaneJsxSource(jsxImportSource);
|
|
179
|
+
}
|
|
180
|
+
if (extension === '.ts' || extension === '.js') return importsOctaneAtRuntime(source);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} root
|
|
186
|
+
* @param {string} absolute
|
|
187
|
+
*/
|
|
188
|
+
export function projectPath(root, absolute) {
|
|
189
|
+
return path.relative(root, absolute).split(path.sep).join('/');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} root
|
|
194
|
+
* @returns {Baseline | null}
|
|
195
|
+
*/
|
|
196
|
+
export function readBaseline(root) {
|
|
197
|
+
const file = path.join(root, BASELINE_FILE);
|
|
198
|
+
if (!existsSync(file)) return null;
|
|
199
|
+
const text = readFileSync(file, 'utf8');
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(text);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new CliError(
|
|
205
|
+
`${BASELINE_FILE} is not valid JSON: ${error instanceof Error ? error.message : error}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
parsed?.version !== 1 ||
|
|
210
|
+
!Array.isArray(parsed.exceptions) ||
|
|
211
|
+
parsed.exceptions.some((/** @type {unknown} */ entry) => typeof entry !== 'string')
|
|
212
|
+
) {
|
|
213
|
+
throw new CliError(
|
|
214
|
+
`${BASELINE_FILE} must be { "version": 1, "exceptions": [<project-relative paths>] }.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return { path: file, text, exceptions: parsed.exceptions };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Sorted and deduplicated, so the file only changes when its contents do.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} root
|
|
224
|
+
* @param {Iterable<string>} exceptions
|
|
225
|
+
*/
|
|
226
|
+
export function writeBaseline(root, exceptions) {
|
|
227
|
+
const body = { version: 1, exceptions: [...new Set(exceptions)].sort() };
|
|
228
|
+
writeFileSync(path.join(root, BASELINE_FILE), `${JSON.stringify(body, null, '\t')}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Compare the measured modules with the recorded exceptions.
|
|
233
|
+
*
|
|
234
|
+
* A non-Strong module that is not listed is a regression: something new, or
|
|
235
|
+
* something that stopped being Strong. A listed module that is Strong, gone, or
|
|
236
|
+
* no longer compiled by Octane is stale, and is an error too, because a stale
|
|
237
|
+
* name is a pre-approved slot for the next regression. Staleness is only known
|
|
238
|
+
* when every module was measured.
|
|
239
|
+
*
|
|
240
|
+
* @param {{
|
|
241
|
+
* modules: CoverageModule[],
|
|
242
|
+
* baseline: Baseline,
|
|
243
|
+
* policy: StrongPolicy,
|
|
244
|
+
* complete: boolean,
|
|
245
|
+
* unmeasured: ReadonlySet<string>,
|
|
246
|
+
* display: (absolute: string) => string,
|
|
247
|
+
* }} input
|
|
248
|
+
*/
|
|
249
|
+
export function compareCoverage({ modules, baseline, policy, complete, unmeasured, display }) {
|
|
250
|
+
const listed = new Set(baseline.exceptions);
|
|
251
|
+
const measured = new Map(modules.map((module) => [module.file, module]));
|
|
252
|
+
const baselineDisplay = display(baseline.path);
|
|
253
|
+
|
|
254
|
+
/** @type {import('./index.js').Finding[]} */
|
|
255
|
+
const findings = [];
|
|
256
|
+
/** @type {string[]} */
|
|
257
|
+
const regressions = [];
|
|
258
|
+
for (const module of modules) {
|
|
259
|
+
if (module.status.strong || listed.has(module.file)) continue;
|
|
260
|
+
regressions.push(module.file);
|
|
261
|
+
const fix =
|
|
262
|
+
policy.configured && !module.status.config
|
|
263
|
+
? 'compiler.strong in octane.config does not reach modules of another package, so add "use strong" before its imports'
|
|
264
|
+
: 'Add "use strong" before its imports, or enable compiler.strong in octane.config,';
|
|
265
|
+
findings.push({
|
|
266
|
+
file: display(module.absolute),
|
|
267
|
+
line: 1,
|
|
268
|
+
column: 1,
|
|
269
|
+
severity: 'error',
|
|
270
|
+
code: REGRESSION,
|
|
271
|
+
message: `This module compiles without Strong mode and is not a recorded exception. ${fix} and fix what Strong then reports. A new exception is a reviewed edit to ${BASELINE_FILE}; octane analyze never adds one.`,
|
|
272
|
+
suggestions: [],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @type {string[]} */
|
|
277
|
+
const stale = [];
|
|
278
|
+
if (complete) {
|
|
279
|
+
const lines = baseline.text.split('\n');
|
|
280
|
+
for (const entry of baseline.exceptions) {
|
|
281
|
+
const module = measured.get(entry);
|
|
282
|
+
if (unmeasured.has(entry) || (module !== undefined && !module.status.strong)) continue;
|
|
283
|
+
stale.push(entry);
|
|
284
|
+
const why =
|
|
285
|
+
module !== undefined
|
|
286
|
+
? 'now compiles with Strong mode'
|
|
287
|
+
: existsSync(path.join(path.dirname(baseline.path), entry))
|
|
288
|
+
? 'is not a module Octane compiles'
|
|
289
|
+
: 'no longer exists';
|
|
290
|
+
const index = lines.findIndex((line) => line.includes(JSON.stringify(entry)));
|
|
291
|
+
findings.push({
|
|
292
|
+
file: baselineDisplay,
|
|
293
|
+
line: index === -1 ? 1 : index + 1,
|
|
294
|
+
column: 1,
|
|
295
|
+
severity: 'error',
|
|
296
|
+
code: STALE,
|
|
297
|
+
message: `${entry} ${why}, so it is no longer an exception. Run \`octane analyze --strong-baseline update\` to remove it.`,
|
|
298
|
+
suggestions: [],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { findings, regressions, stale };
|
|
304
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
2
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
3
|
+
import { resolveConfigLoader } from '../../../kernel/octane-config.js';
|
|
5
4
|
import { readInstalled } from '../../../kernel/project.js';
|
|
6
5
|
import { fail, pass, skip, warn } from '../check.js';
|
|
7
6
|
|
|
@@ -10,45 +9,6 @@ const ADAPTERS = {
|
|
|
10
9
|
cloudflare: '@octanejs/adapter-cloudflare',
|
|
11
10
|
};
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
* Load the project's own config loader rather than shipping a second one.
|
|
15
|
-
*
|
|
16
|
-
* `@octanejs/app-core` is usually a transitive dependency of the bundler
|
|
17
|
-
* plugin, not a direct one, so a plain resolve from the project root misses it.
|
|
18
|
-
* Falling back to a resolve rooted at the plugin reproduces exactly the lookup
|
|
19
|
-
* the plugin itself performs.
|
|
20
|
-
*
|
|
21
|
-
* @param {string} root
|
|
22
|
-
* @returns {Promise<((root: string) => Promise<any>) | null>}
|
|
23
|
-
*/
|
|
24
|
-
async function resolveConfigLoader(root) {
|
|
25
|
-
const fromProject = createRequire(path.join(root, 'noop.js'));
|
|
26
|
-
const specifier = '@octanejs/app-core/config-loader';
|
|
27
|
-
|
|
28
|
-
/** @type {string | null} */
|
|
29
|
-
let entry = null;
|
|
30
|
-
try {
|
|
31
|
-
entry = fromProject.resolve(specifier);
|
|
32
|
-
} catch {
|
|
33
|
-
for (const plugin of [
|
|
34
|
-
'@octanejs/vite-plugin',
|
|
35
|
-
'@octanejs/rspack-plugin',
|
|
36
|
-
'@octanejs/rsbuild-plugin',
|
|
37
|
-
]) {
|
|
38
|
-
try {
|
|
39
|
-
entry = createRequire(fromProject.resolve(plugin)).resolve(specifier);
|
|
40
|
-
break;
|
|
41
|
-
} catch {
|
|
42
|
-
// Try the next plugin.
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (!entry) return null;
|
|
48
|
-
const module = await import(pathToFileURL(entry).href);
|
|
49
|
-
return module.loadOctaneConfig ?? null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
12
|
/**
|
|
53
13
|
* Loading the config means running esbuild over `octane.config.ts`. All three
|
|
54
14
|
* config checks need the same result, so it is computed once instead of three
|