@octanejs/cli 0.0.11 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/cli",
3
- "version": "0.0.11",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.2"
@@ -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 '../kernel/command.js';
6
- import { CliError, EXIT } from '../kernel/errors.js';
7
- import { SYMBOLS } from '../kernel/ui.js';
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 message = error instanceof Error ? error.message : String(error);
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, {}).diagnostics ?? []) {
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('../kernel/context.js').Ctx} ctx
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 { pathToFileURL } from 'node:url';
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