@octanejs/cli 0.0.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/package.json +50 -0
  4. package/src/bin/octane.js +4 -0
  5. package/src/commands/add.js +138 -0
  6. package/src/commands/analyze.js +271 -0
  7. package/src/commands/bindings.js +55 -0
  8. package/src/commands/doctor/check.js +43 -0
  9. package/src/commands/doctor/checks/bundler.js +104 -0
  10. package/src/commands/doctor/checks/config.js +184 -0
  11. package/src/commands/doctor/checks/dependencies.js +120 -0
  12. package/src/commands/doctor/checks/environment.js +38 -0
  13. package/src/commands/doctor/checks/source.js +108 -0
  14. package/src/commands/doctor/checks/typescript.js +183 -0
  15. package/src/commands/doctor/index.js +118 -0
  16. package/src/commands/doctor/registry.js +32 -0
  17. package/src/commands/doctor/report.js +158 -0
  18. package/src/commands/explain.js +95 -0
  19. package/src/commands/info.js +54 -0
  20. package/src/commands/init/index.js +277 -0
  21. package/src/commands/init/templates.js +124 -0
  22. package/src/commands/mcp/add.js +241 -0
  23. package/src/commands/mcp/clients.js +281 -0
  24. package/src/commands/mcp/detect.js +58 -0
  25. package/src/commands/mcp/index.js +23 -0
  26. package/src/commands/mcp/remove.js +105 -0
  27. package/src/commands/mcp/server.js +46 -0
  28. package/src/commands/mcp/status.js +48 -0
  29. package/src/data/index.js +74 -0
  30. package/src/data/octane-data.json +953 -0
  31. package/src/index.js +4 -0
  32. package/src/kernel/args.js +181 -0
  33. package/src/kernel/banner.js +98 -0
  34. package/src/kernel/command.js +84 -0
  35. package/src/kernel/context.js +78 -0
  36. package/src/kernel/edit.js +238 -0
  37. package/src/kernel/errors.js +42 -0
  38. package/src/kernel/exec.js +62 -0
  39. package/src/kernel/help.js +97 -0
  40. package/src/kernel/install.js +43 -0
  41. package/src/kernel/jsonc.js +91 -0
  42. package/src/kernel/main.js +166 -0
  43. package/src/kernel/project.js +376 -0
  44. package/src/kernel/registry.js +52 -0
  45. package/src/kernel/semver.js +111 -0
  46. package/src/kernel/ui.js +155 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @typedef {'pass' | 'fail' | 'warn' | 'skip'} CheckStatus
3
+ */
4
+
5
+ /**
6
+ * @typedef {Object} CheckResult
7
+ * @property {CheckStatus} status
8
+ * @property {string} message one line, present tense, states the finding
9
+ * @property {string[]} [detail] evidence and the remedy, shown under the message
10
+ * @property {string} [docs] documentation slug, appended to the docs base URL
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} FixOutcome
15
+ * @property {boolean} changed
16
+ * @property {string} message
17
+ * @property {string[]} [files] paths written, relative to the project root
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} Check
22
+ * @property {string} id stable, dot-separated: `<category>.<subject>`
23
+ * @property {string} title
24
+ * @property {'environment' | 'dependencies' | 'bundler' | 'typescript' | 'config' | 'source'} category
25
+ * @property {'error' | 'warning' | 'info'} severity how a `fail` is scored
26
+ * @property {(project: import('../../kernel/project.js').Project) => boolean} [applies]
27
+ * @property {(ctx: import('../../kernel/context.js').Ctx, project: import('../../kernel/project.js').Project) => CheckResult | Promise<CheckResult>} run
28
+ * @property {(ctx: import('../../kernel/context.js').Ctx, project: import('../../kernel/project.js').Project) => FixOutcome | Promise<FixOutcome>} [fix]
29
+ * Present only when the repair is mechanical and unambiguous. A check without
30
+ * `fix` reports the remedy in `detail` instead of guessing at the edit.
31
+ */
32
+
33
+ /** @type {(message: string, extra?: Partial<CheckResult>) => CheckResult} */
34
+ export const pass = (message, extra) => ({ status: 'pass', message, ...extra });
35
+
36
+ /** @type {(message: string, extra?: Partial<CheckResult>) => CheckResult} */
37
+ export const fail = (message, extra) => ({ status: 'fail', message, ...extra });
38
+
39
+ /** @type {(message: string, extra?: Partial<CheckResult>) => CheckResult} */
40
+ export const warn = (message, extra) => ({ status: 'warn', message, ...extra });
41
+
42
+ /** @type {(message: string, extra?: Partial<CheckResult>) => CheckResult} */
43
+ export const skip = (message, extra) => ({ status: 'skip', message, ...extra });
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fail, pass, skip, warn } from '../check.js';
4
+
5
+ /**
6
+ * The two supported ways to compile Octane, and how to recognise each in a
7
+ * bundler config. `octane/compiler/*` compiles components only; the
8
+ * `@octanejs/*-plugin` packages add routing, SSR, and the production build.
9
+ */
10
+ const INTEGRATIONS = [
11
+ { specifier: 'octane/compiler/vite', mode: 'spa', bundler: 'vite' },
12
+ { specifier: 'octane/compiler/bundler', mode: 'spa', bundler: 'rspack' },
13
+ { specifier: '@octanejs/vite-plugin', mode: 'fullstack', bundler: 'vite' },
14
+ { specifier: '@octanejs/rspack-plugin', mode: 'fullstack', bundler: 'rspack' },
15
+ { specifier: '@octanejs/rsbuild-plugin', mode: 'fullstack', bundler: 'rsbuild' },
16
+ { specifier: '@octanejs/rspeedy-plugin', mode: 'fullstack', bundler: 'rspeedy' },
17
+ ];
18
+
19
+ /**
20
+ * @param {import('../../../kernel/project.js').Project} project
21
+ * @returns {string | null}
22
+ */
23
+ function readBundlerConfig(project) {
24
+ if (!project.bundlerConfigPath) return null;
25
+ try {
26
+ return readFileSync(project.bundlerConfigPath, 'utf8');
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /** @type {import('../check.js').Check[]} */
33
+ export const bundlerChecks = [
34
+ {
35
+ id: 'bundler.plugin-present',
36
+ title: 'Octane bundler plugin',
37
+ category: 'bundler',
38
+ severity: 'error',
39
+ run(ctx, project) {
40
+ if (!project.bundlerConfigPath) {
41
+ return project.tsrxFiles.length > 0
42
+ ? fail('No bundler config found', {
43
+ detail: [
44
+ '.tsrx files only compile through an Octane bundler plugin.',
45
+ 'Create a vite.config.ts that registers octane() from octane/compiler/vite.',
46
+ ],
47
+ })
48
+ : skip('No bundler config found');
49
+ }
50
+
51
+ const source = readBundlerConfig(project);
52
+ if (source === null)
53
+ return skip(`Could not read ${path.basename(project.bundlerConfigPath)}`);
54
+
55
+ const matched = INTEGRATIONS.filter((entry) => source.includes(entry.specifier));
56
+ const file = path.relative(project.root, project.bundlerConfigPath);
57
+
58
+ if (matched.length === 0) {
59
+ return fail(`No Octane plugin registered in ${file}`, {
60
+ detail: [
61
+ "For a client-only app: import { octane } from 'octane/compiler/vite';",
62
+ "For routing and SSR: import { octane } from '@octanejs/vite-plugin';",
63
+ ],
64
+ });
65
+ }
66
+
67
+ if (matched.length > 1) {
68
+ return warn(`${matched.length} Octane integrations referenced in ${file}`, {
69
+ detail: [
70
+ 'Register exactly one. The compiler plugin and the metaframework plugin both',
71
+ 'compile .tsrx, so running them together compiles every component twice.',
72
+ ...matched.map((entry) => ` ${entry.specifier}`),
73
+ ],
74
+ });
75
+ }
76
+
77
+ const [only] = matched;
78
+ return pass(
79
+ only.mode === 'fullstack'
80
+ ? `${only.specifier} (routing, SSR, production build)`
81
+ : `${only.specifier} (client-only compilation)`,
82
+ );
83
+ },
84
+ },
85
+ {
86
+ id: 'bundler.build-target',
87
+ title: 'Build target',
88
+ category: 'bundler',
89
+ severity: 'warning',
90
+ applies: (project) => project.bundlerConfigPath !== null,
91
+ run(ctx, project) {
92
+ const source = readBundlerConfig(project);
93
+ if (source === null) return skip('Could not read the bundler config');
94
+
95
+ const match = /\btarget\s*:\s*['"]([^'"]+)['"]/.exec(source);
96
+ if (!match) return skip('No explicit build target; the bundler default applies');
97
+ if (match[1] === 'esnext') return pass('target is "esnext"');
98
+
99
+ return warn(`Build target is "${match[1]}"`, {
100
+ detail: ['Octane emits modern output; "esnext" avoids a redundant downlevel pass.'],
101
+ });
102
+ },
103
+ },
104
+ ];
@@ -0,0 +1,184 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { readInstalled } from '../../../kernel/project.js';
6
+ import { fail, pass, skip, warn } from '../check.js';
7
+
8
+ const ADAPTERS = {
9
+ vercel: '@octanejs/adapter-vercel',
10
+ cloudflare: '@octanejs/adapter-cloudflare',
11
+ };
12
+
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
+ /**
53
+ * Loading the config means running esbuild over `octane.config.ts`. All three
54
+ * config checks need the same result, so it is computed once instead of three
55
+ * times per `doctor` run.
56
+ *
57
+ * Keyed on the project object rather than its path: `ctx.refreshProject()`
58
+ * hands out a new object after `--fix` writes, so a refreshed project reloads
59
+ * instead of serving a stale parse, and the entry dies with the project.
60
+ *
61
+ * @type {WeakMap<object, Promise<{ config: any } | { error: string }>>}
62
+ */
63
+ const loaded = new WeakMap();
64
+
65
+ /**
66
+ * @param {import('../../../kernel/project.js').Project} project
67
+ * @returns {Promise<{ config: any } | { error: string }>}
68
+ */
69
+ function loadConfig(project) {
70
+ let pending = loaded.get(project);
71
+ if (!pending) {
72
+ pending = (async () => {
73
+ const load = await resolveConfigLoader(project.root);
74
+ if (!load) return { error: 'unresolved' };
75
+ try {
76
+ return { config: await load(project.root) };
77
+ } catch (error) {
78
+ return { error: error instanceof Error ? error.message : String(error) };
79
+ }
80
+ })();
81
+ loaded.set(project, pending);
82
+ }
83
+ return pending;
84
+ }
85
+
86
+ /**
87
+ * Evaluating `octane.config.ts` means executing it, and it imports the very
88
+ * packages a not-yet-installed project is missing. Reporting that as a config
89
+ * error would just restate `deps.octane-installed` in a more confusing way.
90
+ *
91
+ * @type {(project: import('../../../kernel/project.js').Project) => boolean}
92
+ */
93
+ const hasOctaneConfig = (project) =>
94
+ project.octaneConfigPath !== null && readInstalled(project.root, 'octane') !== null;
95
+
96
+ /** @type {import('../check.js').Check[]} */
97
+ export const configChecks = [
98
+ {
99
+ id: 'config.loads',
100
+ title: 'octane.config loads',
101
+ category: 'config',
102
+ severity: 'error',
103
+ applies: hasOctaneConfig,
104
+ async run(ctx, project) {
105
+ const file = path.basename(/** @type {string} */ (project.octaneConfigPath));
106
+ const loaded = await loadConfig(project);
107
+
108
+ if ('error' in loaded) {
109
+ return loaded.error === 'unresolved'
110
+ ? skip('@octanejs/app-core is not installed, so the config was not evaluated')
111
+ : fail(`${file} failed to load`, { detail: [loaded.error] });
112
+ }
113
+
114
+ const routes = loaded.config.router?.routes ?? [];
115
+ return pass(`${file} loads with ${routes.length} route(s)`);
116
+ },
117
+ },
118
+ {
119
+ id: 'config.routes-resolve',
120
+ title: 'Route entries',
121
+ category: 'config',
122
+ severity: 'error',
123
+ applies: hasOctaneConfig,
124
+ async run(ctx, project) {
125
+ const loaded = await loadConfig(project);
126
+ if ('error' in loaded) return skip('The config could not be evaluated');
127
+
128
+ const routes = loaded.config.router?.routes ?? [];
129
+ if (routes.length === 0) return skip('The config declares no routes');
130
+
131
+ /** @type {string[]} */
132
+ const problems = [];
133
+ /** @type {Map<string, number>} */
134
+ const seen = new Map();
135
+
136
+ for (const route of routes) {
137
+ const entry = Array.isArray(route.entry) ? route.entry[1] : route.entry;
138
+ if (typeof entry === 'string' && !existsSync(path.join(project.root, entry))) {
139
+ problems.push(`${route.path} points at a missing entry: ${entry}`);
140
+ }
141
+ seen.set(route.path, (seen.get(route.path) ?? 0) + 1);
142
+ }
143
+
144
+ for (const [routePath, count] of seen) {
145
+ if (count > 1) problems.push(`${routePath} is declared ${count} times`);
146
+ }
147
+
148
+ const preHydrate = loaded.config.router?.preHydrate;
149
+ if (typeof preHydrate === 'string' && !existsSync(path.join(project.root, preHydrate))) {
150
+ problems.push(`preHydrate points at a missing file: ${preHydrate}`);
151
+ }
152
+
153
+ return problems.length === 0
154
+ ? pass(`${routes.length} route entr(ies) resolve`)
155
+ : fail(`${problems.length} route problem(s)`, {
156
+ detail: problems.map((line) => ` ${line}`),
157
+ });
158
+ },
159
+ },
160
+ {
161
+ id: 'config.adapter-installed',
162
+ title: 'Deployment adapter',
163
+ category: 'config',
164
+ severity: 'warning',
165
+ applies: hasOctaneConfig,
166
+ async run(ctx, project) {
167
+ const loaded = await loadConfig(project);
168
+ if ('error' in loaded) return skip('The config could not be evaluated');
169
+
170
+ const adapter = loaded.config.adapter;
171
+ const name = typeof adapter === 'string' ? adapter : adapter?.name;
172
+ if (!name) return skip('No deployment adapter configured');
173
+
174
+ const expected = ADAPTERS[/** @type {keyof typeof ADAPTERS} */ (name)];
175
+ if (!expected) return pass(`Adapter "${name}"`);
176
+
177
+ return readInstalled(project.root, expected)
178
+ ? pass(`Adapter "${name}" (${expected})`)
179
+ : warn(`Adapter "${name}" is configured but ${expected} is not installed`, {
180
+ detail: [`Add it with: pnpm add -D ${expected}`],
181
+ });
182
+ },
183
+ },
184
+ ];
@@ -0,0 +1,120 @@
1
+ import path from 'node:path';
2
+ import { findCopies, readInstalled } from '../../../kernel/project.js';
3
+ import { satisfies } from '../../../kernel/semver.js';
4
+ import { fail, pass, skip, warn } from '../check.js';
5
+
6
+ /** @type {import('../check.js').Check[]} */
7
+ export const dependencyChecks = [
8
+ {
9
+ id: 'deps.octane-installed',
10
+ title: 'Octane runtime',
11
+ category: 'dependencies',
12
+ severity: 'error',
13
+ run(ctx, project) {
14
+ const installed = readInstalled(project.root, 'octane');
15
+ if (installed) return pass(`octane ${installed.version}`);
16
+ return fail('octane is not installed', {
17
+ detail: [
18
+ project.declaredDependencies.octane
19
+ ? 'It is declared in package.json but missing from node_modules. Install dependencies.'
20
+ : 'Add it with: pnpm add octane',
21
+ ],
22
+ });
23
+ },
24
+ },
25
+ {
26
+ id: 'deps.octane-duplicates',
27
+ title: 'Single Octane runtime',
28
+ category: 'dependencies',
29
+ severity: 'error',
30
+ run(ctx, project) {
31
+ const copies = findCopies(project.root, 'octane');
32
+ if (copies.length <= 1) return pass('One copy of octane in the tree');
33
+
34
+ return fail(`${copies.length} copies of octane are installed`, {
35
+ detail: [
36
+ 'Hooks and context are keyed per runtime instance, so a second copy breaks',
37
+ 'them without raising an error. Deduplicate before debugging anything else.',
38
+ ...copies.map((copy) => ` ${copy.version} via ${copy.via} (${copy.realPath})`),
39
+ ],
40
+ });
41
+ },
42
+ },
43
+ {
44
+ id: 'deps.vite-peer',
45
+ title: 'Vite peer range',
46
+ category: 'dependencies',
47
+ severity: 'error',
48
+ applies: (project) => project.bundler === 'vite',
49
+ run(ctx, project) {
50
+ const octane = readInstalled(project.root, 'octane');
51
+ const vite = readInstalled(project.root, 'vite');
52
+ if (!octane || !vite) return skip('octane or vite is not installed');
53
+
54
+ const range = octane.manifest.peerDependencies?.vite;
55
+ if (!range) return skip('octane declares no vite peer');
56
+
57
+ const ok = satisfies(vite.version, range);
58
+ if (ok === null) return warn(`Could not evaluate vite ${vite.version} against "${range}"`);
59
+ return ok
60
+ ? pass(`vite ${vite.version} satisfies ${range}`)
61
+ : fail(`vite ${vite.version} does not satisfy octane's peer range ${range}`, {
62
+ detail: [`Install a matching Vite: pnpm add -D vite@"${range}"`],
63
+ });
64
+ },
65
+ },
66
+ {
67
+ id: 'deps.binding-peers',
68
+ title: 'Binding peer ranges',
69
+ category: 'dependencies',
70
+ severity: 'error',
71
+ applies: (project) => project.bindings.length > 0,
72
+ run(ctx, project) {
73
+ const octane = readInstalled(project.root, 'octane');
74
+ if (!octane) return skip('octane is not installed');
75
+
76
+ /** @type {string[]} */
77
+ const mismatched = [];
78
+ let checked = 0;
79
+
80
+ for (const name of project.bindings) {
81
+ const binding = readInstalled(project.root, name);
82
+ const range = binding?.manifest.peerDependencies?.octane;
83
+ if (!range) continue;
84
+ const ok = satisfies(octane.version, range);
85
+ if (ok === null) continue;
86
+ checked++;
87
+ if (!ok) mismatched.push(`${name}@${binding.version} wants octane ${range}`);
88
+ }
89
+
90
+ if (mismatched.length > 0) {
91
+ return fail(`${mismatched.length} binding(s) disagree with octane ${octane.version}`, {
92
+ detail: mismatched.map((line) => ` ${line}`),
93
+ });
94
+ }
95
+ return checked > 0
96
+ ? pass(`${checked} binding(s) agree with octane ${octane.version}`)
97
+ : skip('No binding declares a comparable octane peer range');
98
+ },
99
+ },
100
+ {
101
+ id: 'deps.tsrx-toolchain',
102
+ title: 'TSRX toolchain',
103
+ category: 'dependencies',
104
+ severity: 'error',
105
+ applies: (project) => project.tsrxFiles.length > 0,
106
+ run(ctx, project) {
107
+ if (readInstalled(project.root, '@tsrx/typescript-plugin')) {
108
+ return pass('@tsrx/typescript-plugin is installed');
109
+ }
110
+ const sample = path.relative(project.root, project.tsrxFiles[0]);
111
+ return fail('@tsrx/typescript-plugin is not installed', {
112
+ detail: [
113
+ `This project has ${project.tsrxFiles.length} .tsrx file(s), starting with ${sample}.`,
114
+ 'It supplies the tsrx-tsc binary and the editor language service.',
115
+ 'Add it with: pnpm add -D @tsrx/typescript-plugin',
116
+ ],
117
+ });
118
+ },
119
+ },
120
+ ];
@@ -0,0 +1,38 @@
1
+ import { fail, pass, warn } from '../check.js';
2
+
3
+ const MINIMUM_NODE_MAJOR = 22;
4
+
5
+ /** @type {import('../check.js').Check[]} */
6
+ export const environmentChecks = [
7
+ {
8
+ id: 'env.node-version',
9
+ title: 'Node.js version',
10
+ category: 'environment',
11
+ severity: 'error',
12
+ run(ctx) {
13
+ const version = ctx.env.OCTANE_DOCTOR_NODE ?? process.versions.node;
14
+ const major = Number(version.split('.')[0]);
15
+ return major >= MINIMUM_NODE_MAJOR
16
+ ? pass(`Node ${version}`)
17
+ : fail(`Node ${version} is below the supported baseline`, {
18
+ detail: [
19
+ `Every published Octane package declares "engines": { "node": ">=${MINIMUM_NODE_MAJOR}" }.`,
20
+ ],
21
+ });
22
+ },
23
+ },
24
+ {
25
+ id: 'env.package-manager',
26
+ title: 'Package manager',
27
+ category: 'environment',
28
+ severity: 'warning',
29
+ run(ctx, project) {
30
+ if (!project.packageManager) {
31
+ return warn('No lockfile found', {
32
+ detail: ['Install dependencies so the doctor can inspect the resolved tree.'],
33
+ });
34
+ }
35
+ return pass(`${project.packageManager}${project.lockfile ? ` (${project.lockfile})` : ''}`);
36
+ },
37
+ },
38
+ ];
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fail, pass, skip, warn } from '../check.js';
4
+
5
+ const IMPORTS_OCTANE = /from\s*['"]octane['"]/;
6
+ const OCTANE_PRAGMA = /@jsxImportSource\s+octane/;
7
+ const FORWARD_REF_FROM_OCTANE = /import\s*\{[^}]*\bforwardRef\b[^}]*\}\s*from\s*['"]octane['"]/;
8
+ const REACT_NODE = /\bReact\.ReactNode\b|\bReactNode\b/;
9
+
10
+ const REPORT_LIMIT = 10;
11
+
12
+ /**
13
+ * @param {string} file
14
+ * @returns {string | null}
15
+ */
16
+ function read(file) {
17
+ try {
18
+ return readFileSync(file, 'utf8');
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * @param {import('../../../kernel/project.js').Project} project
26
+ * @param {string[]} files
27
+ * @returns {string[]}
28
+ */
29
+ function relative(project, files) {
30
+ const shown = files
31
+ .slice(0, REPORT_LIMIT)
32
+ .map((file) => ` ${path.relative(project.root, file)}`);
33
+ if (files.length > REPORT_LIMIT) shown.push(` ...and ${files.length - REPORT_LIMIT} more`);
34
+ return shown;
35
+ }
36
+
37
+ /** @type {import('../check.js').Check[]} */
38
+ export const sourceChecks = [
39
+ {
40
+ id: 'source.jsx-pragma',
41
+ title: 'JSX pragma on .tsx files',
42
+ category: 'source',
43
+ severity: 'error',
44
+ // Only meaningful when the compiler option is not already project-wide:
45
+ // with `jsxImportSource: "octane"` in tsconfig, a per-file pragma is
46
+ // redundant rather than missing.
47
+ applies: (project) => project.tsconfig?.config.compilerOptions?.jsxImportSource !== 'octane',
48
+ run(ctx, project) {
49
+ const candidates = project.sourceFiles.filter((file) => file.endsWith('.tsx'));
50
+ if (candidates.length === 0) return skip('No .tsx files');
51
+
52
+ const missing = candidates.filter((file) => {
53
+ const source = read(file);
54
+ return source !== null && IMPORTS_OCTANE.test(source) && !OCTANE_PRAGMA.test(source);
55
+ });
56
+
57
+ if (missing.length === 0) return pass('Every Octane .tsx file declares its JSX source');
58
+
59
+ return fail(`${missing.length} .tsx file(s) import octane without the JSX pragma`, {
60
+ detail: [
61
+ 'Add `/** @jsxImportSource octane */` at the top, or set',
62
+ '"jsxImportSource": "octane" in tsconfig.json for the whole project.',
63
+ ...relative(project, missing),
64
+ ],
65
+ });
66
+ },
67
+ },
68
+ {
69
+ id: 'source.forward-ref',
70
+ title: 'No forwardRef import',
71
+ category: 'source',
72
+ severity: 'error',
73
+ run(ctx, project) {
74
+ const offenders = project.sourceFiles.filter((file) => {
75
+ const source = read(file);
76
+ return source !== null && FORWARD_REF_FROM_OCTANE.test(source);
77
+ });
78
+
79
+ if (offenders.length === 0) return pass('No forwardRef imported from octane');
80
+
81
+ return fail(`${offenders.length} file(s) import forwardRef from octane`, {
82
+ detail: [
83
+ 'Octane has no forwardRef. Refs are plain props: ref={cb}, ref={obj}, or ref={[a, b]}.',
84
+ ...relative(project, offenders),
85
+ ],
86
+ });
87
+ },
88
+ },
89
+ {
90
+ id: 'source.react-node-type',
91
+ title: 'Renderable type',
92
+ category: 'source',
93
+ severity: 'info',
94
+ applies: (project) => project.tsrxFiles.length > 0,
95
+ run(ctx, project) {
96
+ const offenders = project.tsrxFiles.filter((file) => {
97
+ const source = read(file);
98
+ return source !== null && REACT_NODE.test(source);
99
+ });
100
+
101
+ if (offenders.length === 0) return pass('Renderables are typed as OctaneNode');
102
+
103
+ return warn(`${offenders.length} .tsrx file(s) reference ReactNode`, {
104
+ detail: ['Use OctaneNode for renderables.', ...relative(project, offenders)],
105
+ });
106
+ },
107
+ },
108
+ ];