@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.
@@ -0,0 +1,324 @@
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
+ * The installed compiler's leading `@jsxImportSource` scanner, for deciding
120
+ * which `.tsx` modules are Octane JSX when no Strong policy was loaded. `null`
121
+ * when the installed octane predates it.
122
+ *
123
+ * @param {string} root
124
+ * @returns {Promise<Pick<StrongPolicy, 'jsxPragma'> | null>}
125
+ */
126
+ export async function loadJsxPragma(root) {
127
+ try {
128
+ const require = createRequire(path.join(root, 'noop.js'));
129
+ const bundler = await import(pathToFileURL(require.resolve('octane/compiler/bundler')).href);
130
+ return typeof bundler.findLeadingJsxImportSourcePragma === 'function'
131
+ ? { jsxPragma: bundler.findLeadingJsxImportSourcePragma }
132
+ : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * An import of octane or one of its subpaths, capturing its clause. The clause
140
+ * may span lines but never reaches into the next `import`, so a semicolon-free
141
+ * module cannot borrow a later import's specifier.
142
+ */
143
+ const OCTANE_IMPORT =
144
+ /^\s*import\s+((?:(?!\bimport\b)[^;])*?)\s*\bfrom\s*['"]octane(?:\/[^'"]*)?['"]/gm;
145
+
146
+ /**
147
+ * Does the module import a runtime binding from octane? TypeScript erases an
148
+ * `import type` clause and a named clause whose every specifier is
149
+ * `type`-marked, so neither leaves anything for the compiler to slot. A
150
+ * default or namespace binding, or any unmarked specifier, survives. Comments
151
+ * in the clause are not specifiers, so they are dropped before it is read.
152
+ *
153
+ * @param {string} source
154
+ */
155
+ function importsOctaneAtRuntime(source) {
156
+ for (const [, authored] of source.matchAll(OCTANE_IMPORT)) {
157
+ const clause = authored.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ' ').trim();
158
+ if (/^type\b/.test(clause)) continue;
159
+ const named = /^\{([\s\S]*)\}$/.exec(clause);
160
+ if (named === null) return true;
161
+ const specifiers = named[1]
162
+ .split(',')
163
+ .map((specifier) => specifier.trim())
164
+ .filter(Boolean);
165
+ if (!specifiers.every((specifier) => /^type\s/.test(specifier))) return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * @param {string | undefined} source a JSX import source
172
+ */
173
+ function isOctaneJsxSource(source) {
174
+ return source === 'octane' || source === 'octane/strong' || !!source?.startsWith('@octanejs/');
175
+ }
176
+
177
+ /**
178
+ * Is this a module Octane compiles, and so one Strong can reach?
179
+ *
180
+ * `.tsrx` always is. A `.tsx` module is unless its own leading pragma, or the
181
+ * tsconfig `jsxImportSource`, hands its JSX to another library, as a
182
+ * React-hosted project does. A plain `.ts`/`.js` module is when it imports
183
+ * octane at runtime, which is when the compiler slots its hooks. Declaration
184
+ * files and other extensions never are.
185
+ *
186
+ * @param {string} absolute
187
+ * @param {string} source
188
+ * @param {string | undefined} jsxImportSource from tsconfig
189
+ * @param {Pick<StrongPolicy, 'jsxPragma'>} policy
190
+ */
191
+ export function isOctaneModule(absolute, source, jsxImportSource, policy) {
192
+ if (absolute.endsWith('.d.ts')) return false;
193
+ const extension = path.extname(absolute);
194
+ if (extension === '.tsrx') return true;
195
+ if (extension === '.tsx') {
196
+ const pragma = policy.jsxPragma(source);
197
+ if (pragma !== null) return isOctaneJsxSource(pragma);
198
+ return jsxImportSource === undefined || isOctaneJsxSource(jsxImportSource);
199
+ }
200
+ if (extension === '.ts' || extension === '.js') return importsOctaneAtRuntime(source);
201
+ return false;
202
+ }
203
+
204
+ /**
205
+ * @param {string} root
206
+ * @param {string} absolute
207
+ */
208
+ export function projectPath(root, absolute) {
209
+ return path.relative(root, absolute).split(path.sep).join('/');
210
+ }
211
+
212
+ /**
213
+ * @param {string} root
214
+ * @returns {Baseline | null}
215
+ */
216
+ export function readBaseline(root) {
217
+ const file = path.join(root, BASELINE_FILE);
218
+ if (!existsSync(file)) return null;
219
+ const text = readFileSync(file, 'utf8');
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(text);
223
+ } catch (error) {
224
+ throw new CliError(
225
+ `${BASELINE_FILE} is not valid JSON: ${error instanceof Error ? error.message : error}`,
226
+ );
227
+ }
228
+ if (
229
+ parsed?.version !== 1 ||
230
+ !Array.isArray(parsed.exceptions) ||
231
+ parsed.exceptions.some((/** @type {unknown} */ entry) => typeof entry !== 'string')
232
+ ) {
233
+ throw new CliError(
234
+ `${BASELINE_FILE} must be { "version": 1, "exceptions": [<project-relative paths>] }.`,
235
+ );
236
+ }
237
+ return { path: file, text, exceptions: parsed.exceptions };
238
+ }
239
+
240
+ /**
241
+ * Sorted and deduplicated, so the file only changes when its contents do.
242
+ *
243
+ * @param {string} root
244
+ * @param {Iterable<string>} exceptions
245
+ */
246
+ export function writeBaseline(root, exceptions) {
247
+ const body = { version: 1, exceptions: [...new Set(exceptions)].sort() };
248
+ writeFileSync(path.join(root, BASELINE_FILE), `${JSON.stringify(body, null, '\t')}\n`);
249
+ }
250
+
251
+ /**
252
+ * Compare the measured modules with the recorded exceptions.
253
+ *
254
+ * A non-Strong module that is not listed is a regression: something new, or
255
+ * something that stopped being Strong. A listed module that is Strong, gone, or
256
+ * no longer compiled by Octane is stale, and is an error too, because a stale
257
+ * name is a pre-approved slot for the next regression. Staleness is only known
258
+ * when every module was measured.
259
+ *
260
+ * @param {{
261
+ * modules: CoverageModule[],
262
+ * baseline: Baseline,
263
+ * policy: StrongPolicy,
264
+ * complete: boolean,
265
+ * unmeasured: ReadonlySet<string>,
266
+ * display: (absolute: string) => string,
267
+ * }} input
268
+ */
269
+ export function compareCoverage({ modules, baseline, policy, complete, unmeasured, display }) {
270
+ const listed = new Set(baseline.exceptions);
271
+ const measured = new Map(modules.map((module) => [module.file, module]));
272
+ const baselineDisplay = display(baseline.path);
273
+
274
+ /** @type {import('./index.js').Finding[]} */
275
+ const findings = [];
276
+ /** @type {string[]} */
277
+ const regressions = [];
278
+ for (const module of modules) {
279
+ if (module.status.strong || listed.has(module.file)) continue;
280
+ regressions.push(module.file);
281
+ const fix =
282
+ policy.configured && !module.status.config
283
+ ? 'compiler.strong in octane.config does not reach modules of another package, so add "use strong" before its imports'
284
+ : 'Add "use strong" before its imports, or enable compiler.strong in octane.config,';
285
+ findings.push({
286
+ file: display(module.absolute),
287
+ line: 1,
288
+ column: 1,
289
+ severity: 'error',
290
+ code: REGRESSION,
291
+ 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.`,
292
+ suggestions: [],
293
+ });
294
+ }
295
+
296
+ /** @type {string[]} */
297
+ const stale = [];
298
+ if (complete) {
299
+ const lines = baseline.text.split('\n');
300
+ for (const entry of baseline.exceptions) {
301
+ const module = measured.get(entry);
302
+ if (unmeasured.has(entry) || (module !== undefined && !module.status.strong)) continue;
303
+ stale.push(entry);
304
+ const why =
305
+ module !== undefined
306
+ ? 'now compiles with Strong mode'
307
+ : existsSync(path.join(path.dirname(baseline.path), entry))
308
+ ? 'is not a module Octane compiles'
309
+ : 'no longer exists';
310
+ const index = lines.findIndex((line) => line.includes(JSON.stringify(entry)));
311
+ findings.push({
312
+ file: baselineDisplay,
313
+ line: index === -1 ? 1 : index + 1,
314
+ column: 1,
315
+ severity: 'error',
316
+ code: STALE,
317
+ message: `${entry} ${why}, so it is no longer an exception. Run \`octane analyze --strong-baseline update\` to remove it.`,
318
+ suggestions: [],
319
+ });
320
+ }
321
+ }
322
+
323
+ return { findings, regressions, stale };
324
+ }
@@ -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
@@ -28,6 +28,69 @@ export function parseErrorReference(input) {
28
28
  return { code: code[1] ?? code[2], args };
29
29
  }
30
30
 
31
+ /**
32
+ * Find a Strong compiler diagnostic in what the user pasted: the code itself,
33
+ * the code without its `OCTANE_STRONG_` prefix, a whole compile error, or its
34
+ * documentation link.
35
+ *
36
+ * @param {string} input
37
+ * @returns {string | null} the full code, whether or not it is known
38
+ */
39
+ export function parseStrongReference(input) {
40
+ const code = /\bOCTANE_[A-Z0-9_]+\b/i.exec(input);
41
+ if (code) return code[0].toUpperCase();
42
+ const anchor = /#(octane-[a-z0-9-]+)/i.exec(input);
43
+ if (anchor) return anchor[1].toUpperCase().replaceAll('-', '_');
44
+ const short = /^\s*([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\s*$/i.exec(input);
45
+ return short ? `OCTANE_STRONG_${short[1].toUpperCase()}` : null;
46
+ }
47
+
48
+ /**
49
+ * @param {import('../kernel/context.js').Ctx} ctx
50
+ * @param {string} code
51
+ */
52
+ function explainStrong(ctx, code) {
53
+ const entry = DATA.strongDiagnostics?.diagnostics?.[code];
54
+ if (!entry) {
55
+ throw new CliError(`Unknown Strong diagnostic ${code}.`, {
56
+ hint: 'Upgrade @octanejs/cli if the code is newer, or see https://octanejs.dev/docs/strong-mode#diagnostic-reference',
57
+ });
58
+ }
59
+ /** @typedef {{ id: string, title: string, react: string, strong: string, codes: string[] }} Recipe */
60
+ /** @type {Recipe[]} */
61
+ const recipes = (DATA.strongDiagnostics.recipes ?? []).filter((/** @type {Recipe} */ recipe) =>
62
+ recipe.codes.includes(code),
63
+ );
64
+
65
+ ctx.ui.intro(code);
66
+ ctx.ui.log('');
67
+ ctx.ui.log(` ${entry.detects}`);
68
+ ctx.ui.log('');
69
+ ctx.ui.log(` ${ctx.ui.colors.bold('Replacement:')} ${entry.replacement}`);
70
+ for (const recipe of recipes) {
71
+ ctx.ui.log('');
72
+ ctx.ui.log(` ${ctx.ui.colors.bold(recipe.title)}`);
73
+ ctx.ui.log(` ${ctx.ui.colors.dim('React: ')} ${recipe.react}`);
74
+ ctx.ui.log(` ${ctx.ui.colors.dim('Strong:')} ${recipe.strong}`);
75
+ }
76
+ if (entry.severity === 'hint') {
77
+ ctx.ui.log('');
78
+ ctx.ui.log(` ${ctx.ui.colors.dim('severity: hint')}`);
79
+ }
80
+ ctx.ui.outro(entry.url);
81
+
82
+ return {
83
+ json: {
84
+ code,
85
+ severity: entry.severity,
86
+ detects: entry.detects,
87
+ replacement: entry.replacement,
88
+ recipes,
89
+ url: entry.url,
90
+ },
91
+ };
92
+ }
93
+
31
94
  /**
32
95
  * @param {string} template
33
96
  * @param {string[]} args
@@ -40,8 +103,9 @@ function fill(template, args) {
40
103
 
41
104
  export default defineCommand({
42
105
  description:
43
- 'Look up an Octane runtime error. Accepts a bare code, or the whole minified\n' +
44
- 'message a production build prints, arguments included.',
106
+ 'Look up an Octane runtime error or Strong compiler diagnostic. Accepts a bare\n' +
107
+ 'code, the whole minified message a production build prints, arguments\n' +
108
+ 'included, or a Strong code such as OCTANE_STRONG_RENDER_REF_READ.',
45
109
  positionals: [
46
110
  { name: 'error', description: 'An error code, URL, or pasted message.', required: true },
47
111
  ],
@@ -50,6 +114,13 @@ export default defineCommand({
50
114
  const raw = input.positionals.join(' ').trim();
51
115
  if (!raw) throw usageError('Nothing to explain.', 'Try: octane explain 3');
52
116
 
117
+ // Runtime errors are numbered and Strong diagnostics are named, so a
118
+ // pasted message names at most one kind.
119
+ const strong = parseStrongReference(raw);
120
+ if (strong !== null && (/\bOCTANE_/i.test(raw) || parseErrorReference(raw) === null)) {
121
+ return explainStrong(ctx, strong);
122
+ }
123
+
53
124
  const reference = parseErrorReference(raw);
54
125
  if (!reference) {
55
126
  throw usageError(`Could not find an error code in "${raw}".`, 'Try: octane explain 3');