@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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ pnpm dlx @octanejs/cli doctor
25
25
  | `octane analyze` | Compile the project and report every Octane compiler diagnostic, with its code and suggested edit. |
26
26
  | `octane add <package>` | Install a binding, by its own name or by the React package it ports, and print its divergences. |
27
27
  | `octane bindings [query]` | List and search the `@octanejs/*` bindings. |
28
- | `octane explain <error>` | Decode a runtime error code, including the minified production message. |
28
+ | `octane explain <error>` | Decode a runtime error code, including the minified production message, or explain a Strong diagnostic such as `OCTANE_STRONG_RENDER_REF_READ`. |
29
29
  | `octane info` | Environment and project details worth pasting into a bug report. |
30
30
  | `octane mcp add` | Register the Octane MCP server with Claude Code, Codex, Cursor, or VS Code. |
31
31
 
@@ -66,15 +66,19 @@ Anything else is reported with the exact remedy rather than guessed at.
66
66
  ## `octane analyze`
67
67
 
68
68
  Where `doctor` checks how the project is wired, `analyze` checks the code. It
69
- compiles every `.tsrx` through the project's own `octane` and reports what the
70
- compiler found, so the results are exactly what a build would warn about, and
71
- new compiler diagnostics show up here without a CLI change.
69
+ compiles every `.tsrx`, and every `.tsx` whose JSX goes to Octane, through the
70
+ project's own `octane` and reports what the compiler found, so the results are
71
+ exactly what a build would warn about, and new compiler diagnostics show up here
72
+ without a CLI change. Every Strong violation in a file is reported, not only the
73
+ first one the build stops at.
72
74
 
73
75
  ```bash
74
- octane analyze # every .tsrx in the project
76
+ octane analyze # every Octane module in the project
75
77
  octane analyze src/App.tsrx # just these
76
78
  octane analyze --code OCTANE_HYDRATE_SPLIT_STYLE
77
79
  octane analyze --strict # warnings fail the run too
80
+ octane analyze --strong-preview # what Strong mode would reject, by code
81
+ octane analyze --fix # apply the compiler's suggested edits
78
82
  ```
79
83
 
80
84
  ```
@@ -88,6 +92,41 @@ A file that will not parse is reported as an error and does not stop the rest of
88
92
  the run. Exit code is `3` when anything error-severity was found, or when
89
93
  `--strict` and there were warnings.
90
94
 
95
+ Modules that `compiler.strong` in `octane.config.ts` reaches are analyzed in
96
+ Strong mode, as the build compiles them. A `.tsx` module whose leading
97
+ `@jsxImportSource` pragma, or the tsconfig's `jsxImportSource`, names another
98
+ library is left out unless you name it.
99
+
100
+ ### Migrating to Strong mode
101
+
102
+ `--strong-preview` compiles every module as if Strong mode were on, reports what
103
+ it would reject, and ends with a count per code. Findings in modules that are not
104
+ Strong yet do not fail the run, so it works as an inventory before you opt in.
105
+
106
+ `--fix` applies the edits the compiler suggests and then reports what remains:
107
+ React's lazy ref initialization (`if (ref.current === null) ref.current = …`)
108
+ becomes `useLazyRef`, `useMemo(() => value, deps)` becomes `value`, and
109
+ `useCallback(fn, deps)` becomes `fn`. With `--dry-run` it reports the fixes
110
+ without writing them. Under `--json`, each finding with a fix carries its
111
+ `edits` as `{ start, end, text }` offsets into the file.
112
+
113
+ `octane explain <CODE>` prints what a Strong diagnostic detects, its
114
+ replacement, and the migration recipes for it. It also accepts a pasted compile
115
+ error or its docs link. Every code is documented at
116
+ [octanejs.dev/docs/strong-mode](https://octanejs.dev/docs/strong-mode#diagnostic-reference).
117
+
118
+ ### Strong coverage
119
+
120
+ `octane analyze --strong-baseline init` records every module that compiles
121
+ without Strong mode in `octane-strong-baseline.json`. While that file exists,
122
+ every run fails on a module that is neither Strong nor listed
123
+ (`OCTANE_STRONG_COVERAGE_REGRESSION`), such as one whose `"use strong"` was
124
+ deleted, and on a listed name that is now Strong or gone
125
+ (`OCTANE_STRONG_COVERAGE_STALE`). `--strong-baseline update` removes stale
126
+ names and never adds one, so a new exception is always a reviewed edit to the
127
+ file. See
128
+ [Keeping modules Strong](https://github.com/octanejs/octane/blob/main/docs/strong-compiler-checks.md#keeping-modules-strong).
129
+
91
130
  ## For agents and CI
92
131
 
93
132
  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.12",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.2"
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Apply the compiler's structured suggestions to a module's source.
3
+ *
4
+ * A suggestion's edits are one unit: the lazy-ref rewrite adds an import,
5
+ * replaces the `useRef` call, and deletes the `if`, and applying only part of
6
+ * that would leave the module broken. A suggestion is skipped whole when any
7
+ * of its edits overlaps an edit already accepted; running `--fix` again picks
8
+ * it up from the rewritten source. An edit identical to an accepted one, such
9
+ * as two rewrites adding the same import, is applied once.
10
+ *
11
+ * @typedef {{ start: number, end: number, text: string }} Edit
12
+ * @typedef {{ code: string, edits: Edit[] }} Fix
13
+ */
14
+
15
+ /**
16
+ * @param {Edit} a
17
+ * @param {Edit} b
18
+ */
19
+ function overlaps(a, b) {
20
+ if (a.start === a.end && b.start === b.end) return a.start === b.start;
21
+ return a.start < b.end && b.start < a.end;
22
+ }
23
+
24
+ /**
25
+ * @param {string} source
26
+ * @param {readonly Fix[]} fixes
27
+ * @returns {{ text: string, applied: Fix[] }}
28
+ */
29
+ export function applyFixes(source, fixes) {
30
+ /** @type {Edit[]} */
31
+ const accepted = [];
32
+ /** @type {Fix[]} */
33
+ const applied = [];
34
+ for (const fix of fixes) {
35
+ const fresh = fix.edits.filter(
36
+ (edit) =>
37
+ !accepted.some(
38
+ (other) =>
39
+ other.start === edit.start && other.end === edit.end && other.text === edit.text,
40
+ ),
41
+ );
42
+ if (fresh.some((edit) => accepted.some((other) => overlaps(edit, other)))) continue;
43
+ accepted.push(...fresh);
44
+ applied.push(fix);
45
+ }
46
+ let text = source;
47
+ for (const edit of accepted.sort((a, b) => b.start - a.start)) {
48
+ text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
49
+ }
50
+ return { text, applied };
51
+ }
52
+
53
+ /** The hooks a fix can replace, whose imports it may leave unused. */
54
+ const REPLACED_HOOKS = new Set(['useCallback', 'useMemo', 'useRef']);
55
+
56
+ const OCTANE_NAMED_IMPORT =
57
+ /import\s*\{([^}]*)\}\s*from\s*(['"])octane\2([^\S\n]*;)?([^\S\n]*\n)?/g;
58
+
59
+ /**
60
+ * Drop `useMemo`, `useCallback`, and `useRef` from the `octane` import when
61
+ * the fixes removed their last use. A name that still appears anywhere else,
62
+ * even in a comment, keeps its import: an unused import is harmless, a missing
63
+ * one is not.
64
+ *
65
+ * @param {string} text
66
+ * @param {readonly string[]} candidates hook names a fix replaced
67
+ */
68
+ export function pruneImports(text, candidates) {
69
+ const names = candidates.filter((name) => REPLACED_HOOKS.has(name));
70
+ if (names.length === 0) return text;
71
+ return text.replace(
72
+ OCTANE_NAMED_IMPORT,
73
+ (statement, /** @type {string} */ list, quote, semicolon = '', newline = '') => {
74
+ const specifiers = list
75
+ .split(',')
76
+ .map((specifier) => specifier.trim())
77
+ .filter(Boolean);
78
+ const kept = specifiers.filter((specifier) => {
79
+ if (!names.includes(specifier)) return true;
80
+ const uses = text.match(new RegExp(`\\b${specifier}\\b`, 'g'))?.length ?? 0;
81
+ return uses > 1;
82
+ });
83
+ if (kept.length === specifiers.length) return statement;
84
+ if (kept.length === 0) return '';
85
+ return `import { ${kept.join(', ')} } from ${quote}octane${quote}${semicolon}${newline}`;
86
+ },
87
+ );
88
+ }