@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,42 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ /**
6
+ * Load the project's own config loader rather than shipping a second one.
7
+ *
8
+ * `@octanejs/app-core` is usually a transitive dependency of the bundler
9
+ * plugin, not a direct one, so a plain resolve from the project root misses it.
10
+ * Falling back to a resolve rooted at the plugin reproduces exactly the lookup
11
+ * the plugin itself performs.
12
+ *
13
+ * @param {string} root
14
+ * @returns {Promise<((root: string) => Promise<any>) | null>}
15
+ */
16
+ export async function resolveConfigLoader(root) {
17
+ const fromProject = createRequire(path.join(root, 'noop.js'));
18
+ const specifier = '@octanejs/app-core/config-loader';
19
+
20
+ /** @type {string | null} */
21
+ let entry = null;
22
+ try {
23
+ entry = fromProject.resolve(specifier);
24
+ } catch {
25
+ for (const plugin of [
26
+ '@octanejs/vite-plugin',
27
+ '@octanejs/rspack-plugin',
28
+ '@octanejs/rsbuild-plugin',
29
+ ]) {
30
+ try {
31
+ entry = createRequire(fromProject.resolve(plugin)).resolve(specifier);
32
+ break;
33
+ } catch {
34
+ // Try the next plugin.
35
+ }
36
+ }
37
+ }
38
+
39
+ if (!entry) return null;
40
+ const module = await import(pathToFileURL(entry).href);
41
+ return module.loadOctaneConfig ?? null;
42
+ }
@@ -12,7 +12,7 @@ const IGNORED_DIRS = new Set([
12
12
  '.vercel',
13
13
  ]);
14
14
  const SOURCE_EXTENSIONS = new Set(['.tsrx', '.tsx', '.ts', '.jsx', '.js', '.mts', '.mjs']);
15
- const SOURCE_FILE_LIMIT = 5000;
15
+ export const SOURCE_FILE_LIMIT = 5000;
16
16
  const EXTENDS_LIMIT = 10;
17
17
 
18
18
  const LOCKFILES = /** @type {const} */ ([
@@ -27,7 +27,7 @@ export const COMMANDS = [
27
27
  {
28
28
  name: 'analyze',
29
29
  summary: 'Compile the project and report the Octane compiler diagnostics.',
30
- load: () => import('../commands/analyze.js'),
30
+ load: () => import('../commands/analyze/index.js'),
31
31
  },
32
32
  {
33
33
  name: 'info',
@@ -1,283 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { createRequire } from 'node:module';
3
- import path from 'node:path';
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';
8
-
9
- /**
10
- * @typedef {Object} Finding
11
- * @property {string} file relative to the project root
12
- * @property {number} line
13
- * @property {number} column 1-based, matching editor gutters
14
- * @property {'error' | 'warning' | 'hint'} severity
15
- * @property {string} code
16
- * @property {string} message
17
- * @property {string[]} suggestions
18
- */
19
-
20
- /**
21
- * Load the project's own compiler.
22
- *
23
- * The diagnostics belong to the compiler, and it is the compiler in the
24
- * project's node_modules that decides what this project's code means. Shipping
25
- * a second copy in the CLI would report on a different version than the one
26
- * that builds the app.
27
- *
28
- * @param {string} root
29
- * @returns {Promise<(source: string, filename: string, options?: object) => { diagnostics: readonly any[] }>}
30
- */
31
- async function loadCompiler(root) {
32
- const require = createRequire(path.join(root, 'noop.js'));
33
- let entry;
34
- try {
35
- entry = require.resolve('octane/compiler');
36
- } catch {
37
- throw new CliError('Could not resolve `octane/compiler` from this project.', {
38
- hint: 'Install the runtime first: pnpm add octane',
39
- });
40
- }
41
- const module = await import(pathToFileURL(entry).href);
42
- if (typeof module.compile !== 'function') {
43
- throw new CliError('The installed octane build exposes no compiler.');
44
- }
45
- return module.compile;
46
- }
47
-
48
- /**
49
- * Compiler suggestions are structured edits (a span plus the replacement), not
50
- * prose. Describe the ones whose shape is known and drop the rest, so the report
51
- * never prints `[object Object]`.
52
- *
53
- * @param {readonly any[] | undefined} suggestions
54
- * @returns {string[]}
55
- */
56
- function describeSuggestions(suggestions) {
57
- const described = [];
58
- for (const suggestion of suggestions ?? []) {
59
- if (typeof suggestion === 'string') described.push(suggestion);
60
- else if (typeof suggestion?.message === 'string') described.push(suggestion.message);
61
- else if (typeof suggestion?.attribute === 'string') {
62
- const at = suggestion.start
63
- ? ` at ${suggestion.start.line}:${suggestion.start.column + 1}`
64
- : '';
65
- described.push(`use \`${suggestion.attribute}\`${at}`);
66
- }
67
- }
68
- return described;
69
- }
70
-
71
- /**
72
- * Position appended by the compiler to a thrown message, e.g. `(App.tsrx:6:18)`.
73
- * Semantic errors carry their location this way rather than on `loc`.
74
- */
75
- const TRAILING_LOCATION = /\s*\(([^()\s]+):(\d+):(\d+)\)\s*$/;
76
-
77
- /**
78
- * Normalise a thrown compile failure into a finding.
79
- *
80
- * Two shapes reach here. A genuine parse failure carries a Babel-style `loc`.
81
- * A semantic failure (a slot-keyed hook in a plain JS loop, say) is thrown with
82
- * its position appended to the message instead, so recovering it keeps the
83
- * report pointing at the offending line rather than at 1:1.
84
- *
85
- * @param {unknown} error
86
- * @param {string} file
87
- * @param {string} [code] overrides the derived code, for a read failure
88
- * @returns {Finding}
89
- */
90
- function thrownFailure(error, file, code) {
91
- const message = error instanceof Error ? error.message : String(error);
92
- const loc = /** @type {any} */ (error)?.loc;
93
- if (loc) {
94
- return {
95
- file,
96
- line: loc.line ?? 1,
97
- column: (loc.column ?? 0) + 1,
98
- severity: 'error',
99
- code: code ?? 'OCTANE_PARSE_ERROR',
100
- message,
101
- suggestions: [],
102
- };
103
- }
104
-
105
- const trailing = TRAILING_LOCATION.exec(message);
106
- return {
107
- file,
108
- line: trailing ? Number(trailing[2]) : 1,
109
- column: trailing ? Number(trailing[3]) : 1,
110
- severity: 'error',
111
- // Not every throw is a parse failure; saying so sends people looking for
112
- // a syntax mistake that is not there.
113
- code: code ?? 'OCTANE_COMPILE_ERROR',
114
- // The position now has its own columns, so drop the duplicate tail.
115
- message: trailing ? message.slice(0, trailing.index) : message,
116
- suggestions: [],
117
- };
118
- }
119
-
120
- export default defineCommand({
121
- description:
122
- 'Compile the project and report every diagnostic the Octane compiler raises:\n' +
123
- 'native-event mistakes, hydration hazards, renderer-boundary and client-only errors.',
124
- positionals: [
125
- {
126
- name: 'path',
127
- description: 'Files to analyze. Defaults to every .tsrx in the project.',
128
- variadic: true,
129
- },
130
- ],
131
- flags: {
132
- code: {
133
- type: 'string',
134
- repeatable: true,
135
- placeholder: '<CODE>',
136
- description: 'Only report this diagnostic code. Repeatable.',
137
- },
138
- strict: { type: 'boolean', description: 'Fail the run on warnings, not just errors.' },
139
- },
140
-
141
- async run(ctx, input) {
142
- const project = ctx.project();
143
- const compile = await loadCompiler(project.root);
144
-
145
- const targets =
146
- input.positionals.length > 0
147
- ? input.positionals.map((file) => path.resolve(ctx.cwd, file))
148
- : project.tsrxFiles;
149
-
150
- if (targets.length === 0) {
151
- ctx.ui.intro('octane analyze');
152
- ctx.ui.outro('No .tsrx files found.');
153
- return { json: { ok: true, analyzed: 0, findings: [] } };
154
- }
155
-
156
- ctx.ui.intro('octane analyze');
157
- const spinner = ctx.ui.spinner(`Compiling ${targets.length} file(s)`);
158
-
159
- /** @type {Finding[]} */
160
- const findings = [];
161
- for (const absolute of targets) {
162
- const file = displayPath(project.root, ctx.cwd, absolute);
163
- let source;
164
- try {
165
- source = readFileSync(absolute, 'utf8');
166
- } catch (error) {
167
- // Unreadable is not unparseable; saying so sends people to the wrong fix.
168
- findings.push(thrownFailure(error, file, 'OCTANE_READ_ERROR'));
169
- continue;
170
- }
171
-
172
- try {
173
- for (const diagnostic of compile(source, absolute, {}).diagnostics ?? []) {
174
- findings.push({
175
- file,
176
- line: diagnostic.start?.line ?? 1,
177
- column: (diagnostic.start?.column ?? 0) + 1,
178
- severity:
179
- diagnostic.severity === 'error'
180
- ? 'error'
181
- : diagnostic.severity === 'hint'
182
- ? 'hint'
183
- : 'warning',
184
- code: diagnostic.code,
185
- // The compiler prefixes its own code; the report already has a
186
- // column for it.
187
- message: String(diagnostic.message).replace(`[${diagnostic.code}] `, ''),
188
- suggestions: describeSuggestions(diagnostic.suggestions),
189
- });
190
- }
191
- } catch (error) {
192
- // A file that will not compile is the most severe thing analyze can
193
- // find, and it must not stop the other files being reported.
194
- findings.push(thrownFailure(error, file));
195
- }
196
- }
197
- spinner.stop(`Compiled ${targets.length} file(s)`);
198
-
199
- const selected = input.flags.code?.length
200
- ? findings.filter((finding) => input.flags.code.includes(finding.code))
201
- : findings;
202
-
203
- render(ctx, selected, targets.length);
204
-
205
- const errors = selected.filter((finding) => finding.severity === 'error').length;
206
- const warnings = selected.filter((finding) => finding.severity === 'warning').length;
207
- const hints = selected.filter((finding) => finding.severity === 'hint').length;
208
- const failed = errors > 0 || (input.flags.strict && warnings > 0);
209
-
210
- return {
211
- exitCode: failed ? EXIT.DIAGNOSTIC : EXIT.OK,
212
- json: {
213
- ok: !failed,
214
- analyzed: targets.length,
215
- summary: { errors, warnings, hints },
216
- findings: selected,
217
- },
218
- };
219
- },
220
- });
221
-
222
- /**
223
- * Project-relative where that is meaningful, otherwise relative to the invoking
224
- * directory, otherwise absolute. An explicit path outside the project would
225
- * otherwise render as a wall of `../`.
226
- *
227
- * @param {string} root
228
- * @param {string} cwd
229
- * @param {string} absolute
230
- * @returns {string}
231
- */
232
- function displayPath(root, cwd, absolute) {
233
- const fromRoot = path.relative(root, absolute);
234
- if (!fromRoot.startsWith('..')) return fromRoot;
235
- const fromCwd = path.relative(cwd, absolute);
236
- return fromCwd.startsWith('..') ? absolute : fromCwd;
237
- }
238
-
239
- /**
240
- * @param {import('../kernel/context.js').Ctx} ctx
241
- * @param {Finding[]} findings
242
- * @param {number} analyzed
243
- */
244
- function render(ctx, findings, analyzed) {
245
- const { colors } = ctx.ui;
246
-
247
- if (findings.length === 0) {
248
- ctx.ui.outro(colors.green(`No diagnostics across ${analyzed} file(s).`));
249
- return;
250
- }
251
-
252
- for (const file of [...new Set(findings.map((finding) => finding.file))]) {
253
- ctx.ui.log('');
254
- ctx.ui.log(colors.bold(file));
255
-
256
- for (const finding of findings.filter((entry) => entry.file === file)) {
257
- const mark =
258
- finding.severity === 'error'
259
- ? colors.red(SYMBOLS.fail)
260
- : finding.severity === 'hint'
261
- ? colors.dim('hint')
262
- : colors.yellow(SYMBOLS.warn);
263
- const where = colors.dim(`${finding.line}:${finding.column}`);
264
- ctx.ui.log(` ${mark} ${where} ${finding.message}`);
265
- ctx.ui.log(` ${colors.dim(finding.code)}`);
266
- for (const suggestion of finding.suggestions) {
267
- ctx.ui.log(` ${colors.dim(`suggestion: ${suggestion}`)}`);
268
- }
269
- }
270
- }
271
-
272
- const errors = findings.filter((finding) => finding.severity === 'error').length;
273
- const warnings = findings.filter((finding) => finding.severity === 'warning').length;
274
- const hints = findings.filter((finding) => finding.severity === 'hint').length;
275
- /** @type {string[]} */
276
- const parts = [];
277
- if (errors > 0) parts.push(colors.red(`${errors} error${errors === 1 ? '' : 's'}`));
278
- if (warnings > 0) parts.push(colors.yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`));
279
- if (hints > 0) parts.push(colors.dim(`${hints} hint${hints === 1 ? '' : 's'}`));
280
-
281
- ctx.ui.log('');
282
- ctx.ui.log(`${parts.join(colors.dim(' · '))} ${colors.dim(`across ${analyzed} file(s)`)}`);
283
- }