@pyreon/compiler 0.16.0 → 0.19.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1919 -1152
- package/lib/types/index.d.ts +257 -93
- package/package.json +13 -12
- package/src/defer-inline.ts +686 -0
- package/src/index.ts +14 -1
- package/src/jsx.ts +164 -6
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/defer-inline.test.ts +387 -0
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/jsx.test.ts +23 -3
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
package/src/index.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
// @pyreon/compiler — JSX reactive transform for Pyreon
|
|
2
2
|
|
|
3
|
-
export type {
|
|
3
|
+
export type { DeferInlineResult, DeferInlineWarning } from './defer-inline'
|
|
4
|
+
export { transformDeferInline } from './defer-inline'
|
|
5
|
+
export type {
|
|
6
|
+
CompilerWarning,
|
|
7
|
+
ReactivityKind,
|
|
8
|
+
ReactivitySpan,
|
|
9
|
+
TransformResult,
|
|
10
|
+
} from './jsx'
|
|
4
11
|
export { transformJSX, transformJSX_JS } from './jsx'
|
|
12
|
+
export type {
|
|
13
|
+
AnalyzeReactivityResult,
|
|
14
|
+
ReactivityFinding,
|
|
15
|
+
ReactivityFindingKind,
|
|
16
|
+
} from './reactivity-lens'
|
|
17
|
+
export { analyzeReactivity, formatReactivityLens } from './reactivity-lens'
|
|
5
18
|
export type { ComponentInfo, IslandInfo, ProjectContext, RouteInfo } from './project-scanner'
|
|
6
19
|
export { generateContext } from './project-scanner'
|
|
7
20
|
export type {
|
package/src/jsx.ts
CHANGED
|
@@ -39,7 +39,13 @@ import { loadNativeBinding } from './load-native'
|
|
|
39
39
|
// environment, WASM runtime like StackBlitz, missing per-platform package).
|
|
40
40
|
//
|
|
41
41
|
// See `load-native.ts` for the resolution logic.
|
|
42
|
-
type NativeTransformFn = (
|
|
42
|
+
type NativeTransformFn = (
|
|
43
|
+
code: string,
|
|
44
|
+
filename: string,
|
|
45
|
+
ssr: boolean,
|
|
46
|
+
knownSignals: string[] | null,
|
|
47
|
+
reactivityLens: boolean,
|
|
48
|
+
) => TransformResult
|
|
43
49
|
const nativeBinding = loadNativeBinding(import.meta.url)
|
|
44
50
|
const nativeTransformJsx: NativeTransformFn | null = nativeBinding
|
|
45
51
|
? (nativeBinding.transformJsx as NativeTransformFn)
|
|
@@ -60,6 +66,41 @@ export interface CompilerWarning {
|
|
|
60
66
|
| 'circular-prop-derived'
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Reactivity-lens kinds. Each is a RECORD of a codegen decision the compiler
|
|
71
|
+
* already made — never an approximation. Positive claims (`reactive*`) are
|
|
72
|
+
* emitted ONLY where the compiler provably wrapped/tracked the span; absence
|
|
73
|
+
* of a span is "not asserted", never an implicit static claim. `static-text`
|
|
74
|
+
* is the high-precision negative: the literal `else` branch of the
|
|
75
|
+
* reactive-vs-static text decision (the "this `{x}` is baked once / dead"
|
|
76
|
+
* footgun signal when the author expected reactivity).
|
|
77
|
+
*/
|
|
78
|
+
export type ReactivityKind =
|
|
79
|
+
| 'reactive' // expression re-evaluates on signal change (_bind/_bindText/`() =>` wrap)
|
|
80
|
+
| 'reactive-prop' // component prop tracked into the child (_rp(() => …))
|
|
81
|
+
| 'reactive-attr' // DOM attribute re-applied on signal change
|
|
82
|
+
| 'static-text' // text expression baked once into the DOM, never re-renders
|
|
83
|
+
| 'hoisted-static' // JSX hoisted to module scope, never re-evaluated
|
|
84
|
+
|
|
85
|
+
export interface ReactivitySpan {
|
|
86
|
+
/** Source byte offset (start) of the spanned expression in the INPUT. */
|
|
87
|
+
start: number
|
|
88
|
+
/** Source byte offset (end). */
|
|
89
|
+
end: number
|
|
90
|
+
/** 1-based start line. */
|
|
91
|
+
line: number
|
|
92
|
+
/** 0-based start column. */
|
|
93
|
+
column: number
|
|
94
|
+
/** 1-based end line. */
|
|
95
|
+
endLine: number
|
|
96
|
+
/** 0-based end column. */
|
|
97
|
+
endColumn: number
|
|
98
|
+
/** Which codegen decision this span records. */
|
|
99
|
+
kind: ReactivityKind
|
|
100
|
+
/** Human-readable, editor-facing one-liner explaining the decision. */
|
|
101
|
+
detail: string
|
|
102
|
+
}
|
|
103
|
+
|
|
63
104
|
export interface TransformResult {
|
|
64
105
|
/** Transformed source code (JSX preserved, only expression containers modified) */
|
|
65
106
|
code: string
|
|
@@ -67,6 +108,13 @@ export interface TransformResult {
|
|
|
67
108
|
usesTemplates?: boolean
|
|
68
109
|
/** Compiler warnings for common mistakes */
|
|
69
110
|
warnings: CompilerWarning[]
|
|
111
|
+
/**
|
|
112
|
+
* Reactivity-lens spans — populated ONLY when `TransformOptions.reactivityLens`
|
|
113
|
+
* is `true`. Additive: codegen output is byte-identical whether or not this is
|
|
114
|
+
* collected. Each span is a faithful record of a reactivity decision the
|
|
115
|
+
* compiler made for that source range. See {@link ReactivitySpan}.
|
|
116
|
+
*/
|
|
117
|
+
reactivityLens?: ReactivitySpan[]
|
|
70
118
|
}
|
|
71
119
|
|
|
72
120
|
// Props that should never be wrapped in a reactive getter
|
|
@@ -103,6 +151,15 @@ export interface TransformOptions {
|
|
|
103
151
|
* // {count} in JSX → {() => count()}
|
|
104
152
|
*/
|
|
105
153
|
knownSignals?: string[]
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Collect the {@link ReactivitySpan} sidecar (`TransformResult.reactivityLens`).
|
|
157
|
+
* Default `false`. Purely additive — the emitted `code` is byte-identical
|
|
158
|
+
* whether this is on or off (asserted by the compiler equivalence tests).
|
|
159
|
+
* The lens records reactivity decisions the compiler ALREADY makes for
|
|
160
|
+
* codegen; it never runs a second analysis pass.
|
|
161
|
+
*/
|
|
162
|
+
reactivityLens?: boolean
|
|
106
163
|
}
|
|
107
164
|
|
|
108
165
|
// ─── oxc ESTree helpers ───────────────────────────────────────────────────────
|
|
@@ -189,7 +246,13 @@ export function transformJSX(
|
|
|
189
246
|
// of crashing the Vite dev server.
|
|
190
247
|
if (nativeTransformJsx) {
|
|
191
248
|
try {
|
|
192
|
-
return nativeTransformJsx(
|
|
249
|
+
return nativeTransformJsx(
|
|
250
|
+
code,
|
|
251
|
+
filename,
|
|
252
|
+
options.ssr === true,
|
|
253
|
+
options.knownSignals ?? null,
|
|
254
|
+
options.reactivityLens === true,
|
|
255
|
+
)
|
|
193
256
|
} catch {
|
|
194
257
|
// Native transform failed — fall through to JS implementation
|
|
195
258
|
}
|
|
@@ -227,6 +290,25 @@ export function transformJSX_JS(
|
|
|
227
290
|
warnings.push({ message, line, column, code: warnCode })
|
|
228
291
|
}
|
|
229
292
|
|
|
293
|
+
// ── Reactivity lens (opt-in, additive — never affects `result`) ───────────
|
|
294
|
+
const collectLens = options.reactivityLens === true
|
|
295
|
+
const reactivityLens: ReactivitySpan[] = []
|
|
296
|
+
function lens(start: number, end: number, kind: ReactivityKind, detail: string): void {
|
|
297
|
+
if (!collectLens) return
|
|
298
|
+
const a = locate(start)
|
|
299
|
+
const b = locate(end)
|
|
300
|
+
reactivityLens.push({
|
|
301
|
+
start,
|
|
302
|
+
end,
|
|
303
|
+
line: a.line,
|
|
304
|
+
column: a.column,
|
|
305
|
+
endLine: b.line,
|
|
306
|
+
endColumn: b.column,
|
|
307
|
+
kind,
|
|
308
|
+
detail,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
230
312
|
// ── Parent + children maps (built once, eliminates repeated Object.keys) ──
|
|
231
313
|
const parentMap = new WeakMap<object, N>()
|
|
232
314
|
const childrenMap = new WeakMap<object, N[]>()
|
|
@@ -273,6 +355,7 @@ export function transformJSX_JS(
|
|
|
273
355
|
let hoistIdx = 0
|
|
274
356
|
let needsTplImport = false
|
|
275
357
|
let needsRpImport = false
|
|
358
|
+
let needsWrapSpreadImport = false
|
|
276
359
|
let needsBindTextImportGlobal = false
|
|
277
360
|
let needsBindDirectImportGlobal = false
|
|
278
361
|
let needsBindImportGlobal = false
|
|
@@ -287,6 +370,12 @@ export function transformJSX_JS(
|
|
|
287
370
|
const name = `_$h${hoistIdx++}`
|
|
288
371
|
const text = code.slice(node.start as number, node.end as number)
|
|
289
372
|
hoists.push({ name, text })
|
|
373
|
+
lens(
|
|
374
|
+
node.start as number,
|
|
375
|
+
node.end as number,
|
|
376
|
+
'hoisted-static',
|
|
377
|
+
'static — hoisted once to module scope, never re-evaluated',
|
|
378
|
+
)
|
|
290
379
|
return name
|
|
291
380
|
}
|
|
292
381
|
return null
|
|
@@ -300,6 +389,7 @@ export function transformJSX_JS(
|
|
|
300
389
|
? `() => (${sliced})`
|
|
301
390
|
: `() => ${sliced}`
|
|
302
391
|
replacements.push({ start, end, text })
|
|
392
|
+
lens(start, end, 'reactive', 'live — re-evaluates whenever its signals change')
|
|
303
393
|
}
|
|
304
394
|
|
|
305
395
|
function hoistOrWrap(expr: N): void {
|
|
@@ -344,6 +434,46 @@ export function transformJSX_JS(
|
|
|
344
434
|
}
|
|
345
435
|
}
|
|
346
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Wrap component-JSX spread arguments with `_wrapSpread(...)` so
|
|
439
|
+
* getter-shaped reactive props survive esbuild's JS-level spread emit.
|
|
440
|
+
*
|
|
441
|
+
* esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
|
|
442
|
+
* The JS spread fires every getter on `source` and stores the resolved
|
|
443
|
+
* values — collapsing compiler-emitted reactive props (`_rp` thunks
|
|
444
|
+
* later converted to getters by `makeReactiveProps`) to static values
|
|
445
|
+
* before the receiving component sees them.
|
|
446
|
+
*
|
|
447
|
+
* `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
|
|
448
|
+
* so the JS-level spread carries function values instead. The runtime
|
|
449
|
+
* `makeReactiveProps` step converts them back to getters on the
|
|
450
|
+
* component's props object — preserving the live signal subscription.
|
|
451
|
+
*
|
|
452
|
+
* Lowercase tags (DOM elements) go through the template path's
|
|
453
|
+
* `_applyProps` which already handles spread reactively — no need to
|
|
454
|
+
* wrap there.
|
|
455
|
+
*/
|
|
456
|
+
function handleJsxSpreadAttribute(attr: N, parentElement: N): void {
|
|
457
|
+
const tagName = jsxTagName(parentElement)
|
|
458
|
+
const isComponent =
|
|
459
|
+
tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
|
|
460
|
+
if (!isComponent) return
|
|
461
|
+
const arg = attr.argument
|
|
462
|
+
if (!arg) return
|
|
463
|
+
// Skip already-wrapped sources (idempotent compilation guard).
|
|
464
|
+
if (
|
|
465
|
+
arg.type === 'CallExpression' &&
|
|
466
|
+
arg.callee?.type === 'Identifier' &&
|
|
467
|
+
arg.callee.name === '_wrapSpread'
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
const start = arg.start as number
|
|
471
|
+
const end = arg.end as number
|
|
472
|
+
const sliced = sliceExpr(arg)
|
|
473
|
+
replacements.push({ start, end, text: `_wrapSpread(${sliced})` })
|
|
474
|
+
needsWrapSpreadImport = true
|
|
475
|
+
}
|
|
476
|
+
|
|
347
477
|
function handleJsxAttribute(node: N, parentElement: N): void {
|
|
348
478
|
const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
|
|
349
479
|
if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
|
|
@@ -370,6 +500,7 @@ export function transformJSX_JS(
|
|
|
370
500
|
const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
|
|
371
501
|
replacements.push({ start, end, text: `_rp(() => ${inner})` })
|
|
372
502
|
needsRpImport = true
|
|
503
|
+
lens(start, end, 'reactive-prop', 'live prop — signal reads here are tracked into the component')
|
|
373
504
|
}
|
|
374
505
|
} else {
|
|
375
506
|
hoistOrWrap(expr)
|
|
@@ -733,6 +864,7 @@ export function transformJSX_JS(
|
|
|
733
864
|
checkForWarnings(node)
|
|
734
865
|
for (const attr of jsxAttrs(node)) {
|
|
735
866
|
if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
|
|
867
|
+
else if (attr.type === 'JSXSpreadAttribute') handleJsxSpreadAttribute(attr, node)
|
|
736
868
|
}
|
|
737
869
|
for (const child of jsxChildren(node)) {
|
|
738
870
|
if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
|
|
@@ -761,7 +893,9 @@ export function transformJSX_JS(
|
|
|
761
893
|
|
|
762
894
|
walkNode(program)
|
|
763
895
|
|
|
764
|
-
if (replacements.length === 0 && hoists.length === 0)
|
|
896
|
+
if (replacements.length === 0 && hoists.length === 0) {
|
|
897
|
+
return collectLens ? { code, warnings, reactivityLens } : { code, warnings }
|
|
898
|
+
}
|
|
765
899
|
|
|
766
900
|
replacements.sort((a, b) => a.start - b.start)
|
|
767
901
|
const outParts: string[] = []
|
|
@@ -793,11 +927,16 @@ export function transformJSX_JS(
|
|
|
793
927
|
output
|
|
794
928
|
}
|
|
795
929
|
|
|
796
|
-
if (needsRpImport) {
|
|
797
|
-
|
|
930
|
+
if (needsRpImport || needsWrapSpreadImport) {
|
|
931
|
+
const coreImports: string[] = []
|
|
932
|
+
if (needsRpImport) coreImports.push('_rp')
|
|
933
|
+
if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
|
|
934
|
+
output = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + output
|
|
798
935
|
}
|
|
799
936
|
|
|
800
|
-
return
|
|
937
|
+
return collectLens
|
|
938
|
+
? { code: output, usesTemplates: needsTplImport, warnings, reactivityLens }
|
|
939
|
+
: { code: output, usesTemplates: needsTplImport, warnings }
|
|
801
940
|
|
|
802
941
|
// ── Template emission helpers ─────────────────────────────────────────────
|
|
803
942
|
|
|
@@ -972,6 +1111,12 @@ export function transformJSX_JS(
|
|
|
972
1111
|
bindLines.push(attrSetter(htmlAttrName, varName, expr))
|
|
973
1112
|
return
|
|
974
1113
|
}
|
|
1114
|
+
lens(
|
|
1115
|
+
exprNode.start as number,
|
|
1116
|
+
exprNode.end as number,
|
|
1117
|
+
'reactive-attr',
|
|
1118
|
+
`live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`,
|
|
1119
|
+
)
|
|
975
1120
|
const directRef = tryDirectSignalRef(exprNode)
|
|
976
1121
|
if (directRef) {
|
|
977
1122
|
needsBindDirectImport = true
|
|
@@ -1176,9 +1321,22 @@ export function transformJSX_JS(
|
|
|
1176
1321
|
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
|
|
1177
1322
|
return '<!>'
|
|
1178
1323
|
}
|
|
1324
|
+
const cx = child.expression
|
|
1179
1325
|
if (isReactive) {
|
|
1326
|
+
lens(
|
|
1327
|
+
cx.start as number,
|
|
1328
|
+
cx.end as number,
|
|
1329
|
+
'reactive',
|
|
1330
|
+
'live — this text re-renders whenever its signals change',
|
|
1331
|
+
)
|
|
1180
1332
|
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1181
1333
|
}
|
|
1334
|
+
lens(
|
|
1335
|
+
cx.start as number,
|
|
1336
|
+
cx.end as number,
|
|
1337
|
+
'static-text',
|
|
1338
|
+
'baked once into the DOM — never re-renders (no signal read here)',
|
|
1339
|
+
)
|
|
1182
1340
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1183
1341
|
}
|
|
1184
1342
|
|
package/src/load-native.ts
CHANGED
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
+
|
|
3
|
+
export default defineManifest({
|
|
4
|
+
name: '@pyreon/compiler',
|
|
5
|
+
title: 'JSX Reactive Transform',
|
|
6
|
+
tagline:
|
|
7
|
+
'JSX reactive transform (Rust native + JS fallback) plus the Reactivity-Lens sidecar, React→Pyreon migration, and project audits',
|
|
8
|
+
description:
|
|
9
|
+
"Pyreon's JSX-to-reactive transform. `transformJSX` dispatches to a Rust native binary (napi-rs, 3.7-8.9× faster) and falls back per-call to the pure-JS `transformJSX_JS` when the binary is unavailable (CI, WASM, wrong platform); the two backends are asserted byte-identical by 180+ cross-backend equivalence tests. Emits `_tpl()` (cloneNode templates) + per-text-node `_bind()`, hoists static JSX, inlines `const`-from-`props`, and auto-calls bare signal references in JSX. Also ships the experimental Reactivity-Lens sidecar (`analyzeReactivity` — surfaces the compiler's own per-expression reactive/static decision back to editors), React-pattern detection + one-shot migration, the Pyreon anti-pattern detector behind the MCP `validate` tool, and the syntactic project audits powering `pyreon doctor` (test-environment / islands / SSG).",
|
|
10
|
+
category: 'universal',
|
|
11
|
+
features: [
|
|
12
|
+
'Dual-backend transformJSX — Rust native (napi-rs) with automatic per-call JS fallback, byte-identical output',
|
|
13
|
+
'Reactivity-Lens: analyzeReactivity / formatReactivityLens surface the compiler’s reactive-vs-static decision (experimental)',
|
|
14
|
+
'Scope-aware signal auto-call: bare {count} → {() => count()}, shadowing-correct, knownSignals seeds cross-module',
|
|
15
|
+
'detectReactPatterns + migrateReactCode — "coming from React" diagnostics + one-shot codemod',
|
|
16
|
+
'detectPyreonPatterns — 14 "using Pyreon wrong" anti-pattern codes (the MCP validate detector)',
|
|
17
|
+
'Project audits: auditTestEnvironment / auditIslands / auditSsg (power pyreon doctor)',
|
|
18
|
+
'transformDeferInline — <Defer> namespace-import inlining pass',
|
|
19
|
+
'generateContext — project scanner producing the AI .pyreon/context.json',
|
|
20
|
+
],
|
|
21
|
+
api: [
|
|
22
|
+
{
|
|
23
|
+
name: 'transformJSX',
|
|
24
|
+
kind: 'function',
|
|
25
|
+
signature:
|
|
26
|
+
'transformJSX(code: string, filename?: string, options?: TransformOptions): TransformResult',
|
|
27
|
+
summary:
|
|
28
|
+
'The production entry point. Tries the Rust native binary first (3.7-8.9× faster) and falls back per-call to `transformJSX_JS` inside a try/catch so a native panic never crashes the Vite dev server. Output (`{ code, usesTemplates?, warnings, reactivityLens? }`) is byte-identical across both backends. `options.ssr` skips the `_tpl()` template optimization so `@pyreon/runtime-server` can walk the VNode tree; `options.knownSignals` seeds cross-module signal auto-call; `options.reactivityLens` collects the additive `ReactivitySpan[]` sidecar (codegen is byte-identical whether or not it is collected).',
|
|
29
|
+
example: `import { transformJSX } from "@pyreon/compiler"
|
|
30
|
+
|
|
31
|
+
const { code, warnings } = transformJSX(
|
|
32
|
+
"export const App = () => <div>{count()}</div>",
|
|
33
|
+
"App.tsx",
|
|
34
|
+
{ knownSignals: ["count"] },
|
|
35
|
+
)`,
|
|
36
|
+
mistakes: [
|
|
37
|
+
'Expecting `transformJSX` to throw on a native panic — it never does; it silently falls back to the JS backend (correctness-equivalent, just slower)',
|
|
38
|
+
'Passing user component source WITHOUT `ssr: true` when feeding the result to `@pyreon/runtime-server` — SSR needs the `h()` VNode tree, not `_tpl()` clone templates',
|
|
39
|
+
'Assuming bare `{count}` is auto-called for an IMPORTED signal without seeding `knownSignals` — the compiler only tracks `const count = signal(...)` declared in the same file unless told otherwise',
|
|
40
|
+
],
|
|
41
|
+
seeAlso: ['transformJSX_JS', 'analyzeReactivity'],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'transformJSX_JS',
|
|
45
|
+
kind: 'function',
|
|
46
|
+
signature:
|
|
47
|
+
'transformJSX_JS(code: string, filename?: string, options?: TransformOptions): TransformResult',
|
|
48
|
+
summary:
|
|
49
|
+
'The pure-JS reactive pass (parses via `oxc-parser`). Same signature and byte-identical output to the native path — `transformJSX` calls it as the fallback. Call it directly only when you need backend-deterministic output (the Reactivity-Lens forces this path so the sidecar is always emitted regardless of whether the native binary is installed).',
|
|
50
|
+
example: `import { transformJSX_JS } from "@pyreon/compiler"
|
|
51
|
+
|
|
52
|
+
// Backend-deterministic — never dispatches to the native binary.
|
|
53
|
+
const { code } = transformJSX_JS("<div>{name()}</div>", "x.tsx")`,
|
|
54
|
+
seeAlso: ['transformJSX'],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'analyzeReactivity',
|
|
58
|
+
kind: 'function',
|
|
59
|
+
signature:
|
|
60
|
+
"analyzeReactivity(code: string, filename?: string, options?: { knownSignals?: string[] }): AnalyzeReactivityResult",
|
|
61
|
+
summary:
|
|
62
|
+
"Reactivity-Lens entry point (experimental). The compiler ALREADY decides per-expression whether code is reactive while emitting codegen; this surfaces that ground truth back to the author instead of discarding it. Returns `{ findings, spans }` — `findings` merges the structural codegen decisions (`reactive` / `reactive-prop` / `reactive-attr` / `static-text` / `hoisted-static`) with the EXISTING `detectPyreonPatterns` footguns (`kind: 'footgun'`, carrying the detector `code`) under one (line, column)-sorted taxonomy. Forces the JS backend so the sidecar is always present. Absence of a span is “not asserted”, never an implicit static claim.",
|
|
63
|
+
example: `import { analyzeReactivity, formatReactivityLens } from "@pyreon/compiler"
|
|
64
|
+
|
|
65
|
+
const result = analyzeReactivity(
|
|
66
|
+
"const A = (props) => <div>{props.name}</div>",
|
|
67
|
+
"A.tsx",
|
|
68
|
+
)
|
|
69
|
+
for (const f of result.findings) console.log(f.line, f.kind, f.detail)
|
|
70
|
+
console.log(formatReactivityLens(code, result)) // annotated-source debug view`,
|
|
71
|
+
mistakes: [
|
|
72
|
+
'Treating the absence of a span as a static guarantee — the Lens is asymmetric: positive spans are RECORDS of a codegen branch; silence means "not analyzed", not "proven static"',
|
|
73
|
+
'Expecting it to reflect the native backend — it deliberately forces `transformJSX_JS`; codegen is byte-identical so the analysis is sound, native just does not emit the sidecar at production bundle time (it is an editor-only feature)',
|
|
74
|
+
'Calling it on a hot build path — it is an authoring-time / LSP tool, not part of the production transform pipeline',
|
|
75
|
+
],
|
|
76
|
+
stability: 'experimental',
|
|
77
|
+
seeAlso: ['formatReactivityLens', 'detectPyreonPatterns', 'transformJSX_JS'],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'formatReactivityLens',
|
|
81
|
+
kind: 'function',
|
|
82
|
+
signature:
|
|
83
|
+
'formatReactivityLens(code: string, result: AnalyzeReactivityResult): string',
|
|
84
|
+
summary:
|
|
85
|
+
'Renders an `analyzeReactivity` result as an annotated-source CLI / debug view — each spanned expression gets an inline `live` / `static` / `live·prop` / `hoisted` / footgun tag. The LSP surface in `@pyreon/lint --lsp` consumes the structured `findings` directly (inlay hints + diagnostics); this string renderer is for terminals and bug reports.',
|
|
86
|
+
example: `import { analyzeReactivity, formatReactivityLens } from "@pyreon/compiler"
|
|
87
|
+
|
|
88
|
+
const r = analyzeReactivity(src, "App.tsx")
|
|
89
|
+
process.stdout.write(formatReactivityLens(src, r))`,
|
|
90
|
+
stability: 'experimental',
|
|
91
|
+
seeAlso: ['analyzeReactivity'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'detectReactPatterns',
|
|
95
|
+
kind: 'function',
|
|
96
|
+
signature:
|
|
97
|
+
"detectReactPatterns(code: string, filename?: string): ReactDiagnostic[]",
|
|
98
|
+
summary:
|
|
99
|
+
'AST-based detector for "coming from React" mistakes — `useState` / `useEffect`, `className` / `htmlFor`, `onChange` on inputs, `.value` writes on signals, React-package imports. Pairs with `detectPyreonPatterns` inside the MCP `validate` tool; the merged result is sorted by line + column.',
|
|
100
|
+
example: `import { detectReactPatterns } from "@pyreon/compiler"
|
|
101
|
+
|
|
102
|
+
const diags = detectReactPatterns("const [n,setN] = useState(0)", "x.tsx")
|
|
103
|
+
console.log(diags[0]?.code) // "react-use-state"`,
|
|
104
|
+
seeAlso: ['migrateReactCode', 'detectPyreonPatterns', 'hasReactPatterns'],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'migrateReactCode',
|
|
108
|
+
kind: 'function',
|
|
109
|
+
signature:
|
|
110
|
+
"migrateReactCode(code: string, filename?: string): MigrationResult",
|
|
111
|
+
summary:
|
|
112
|
+
'One-shot React→Pyreon codemod — `useState`→`signal`, `useEffect`→`effect`/`onMount`, `className`→`class`, etc. Returns the rewritten code plus the list of applied `MigrationChange`s. Mechanical only: shapes it cannot safely rewrite are left as `detectReactPatterns` diagnostics for the human.',
|
|
113
|
+
example: `import { migrateReactCode } from "@pyreon/compiler"
|
|
114
|
+
|
|
115
|
+
const { code, changes } = migrateReactCode(reactSource, "C.tsx")`,
|
|
116
|
+
seeAlso: ['detectReactPatterns'],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'hasReactPatterns',
|
|
120
|
+
kind: 'function',
|
|
121
|
+
signature: 'hasReactPatterns(code: string): boolean',
|
|
122
|
+
summary:
|
|
123
|
+
'Fast regex pre-filter — returns whether `code` is worth a full `detectReactPatterns` AST walk. Cheap gate for batch scanners; never reports diagnostics itself.',
|
|
124
|
+
example: `import { hasReactPatterns, detectReactPatterns } from "@pyreon/compiler"
|
|
125
|
+
|
|
126
|
+
if (hasReactPatterns(src)) report(detectReactPatterns(src, file))`,
|
|
127
|
+
seeAlso: ['detectReactPatterns'],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'diagnoseError',
|
|
131
|
+
kind: 'function',
|
|
132
|
+
signature: 'diagnoseError(error: string): ErrorDiagnosis | null',
|
|
133
|
+
summary:
|
|
134
|
+
'Maps a raw runtime/build error string to a structured `ErrorDiagnosis` (likely cause + actionable fix) for known Pyreon failure shapes. Returns `null` when the error is unrecognised — callers fall back to the raw message.',
|
|
135
|
+
example: `import { diagnoseError } from "@pyreon/compiler"
|
|
136
|
+
|
|
137
|
+
const d = diagnoseError("props.when is not a function")
|
|
138
|
+
if (d) console.log(d.cause, d.fix)`,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'detectPyreonPatterns',
|
|
142
|
+
kind: 'function',
|
|
143
|
+
signature:
|
|
144
|
+
"detectPyreonPatterns(code: string, filename?: string): PyreonDiagnostic[]",
|
|
145
|
+
summary:
|
|
146
|
+
'AST-based (TypeScript compiler API) detector for "using Pyreon wrong" mistakes — 14 codes today (`for-missing-by`, `for-with-key`, `props-destructured`, `props-destructured-body`, `process-dev-gate`, `empty-theme`, `raw-add-event-listener`, `raw-remove-event-listener`, `date-math-random-id`, `on-click-undefined`, `signal-write-as-call`, `static-return-null-conditional`, `as-unknown-as-vnodechild`, `island-never-with-registry-entry`). The detector arm behind the MCP `validate` tool and `pyreon doctor --check-pyreon-patterns`. Every diagnostic reports `fixable: false` (invariant — no `migrate_pyreon` codemod ships yet).',
|
|
147
|
+
example: `import { detectPyreonPatterns } from "@pyreon/compiler"
|
|
148
|
+
|
|
149
|
+
const diags = detectPyreonPatterns(
|
|
150
|
+
"const A = (props) => { const { x } = props; return <i>{x}</i> }",
|
|
151
|
+
"A.tsx",
|
|
152
|
+
)
|
|
153
|
+
console.log(diags[0]?.code) // "props-destructured-body"`,
|
|
154
|
+
mistakes: [
|
|
155
|
+
'Reading `fixable` as sometimes-true — it is an enforced `false` invariant for every Pyreon code; wiring auto-fix UX off it applies nothing',
|
|
156
|
+
'Expecting it to flag `const { x } = props.nested` or an `onMount`-scoped destructure — `props-destructured-body` is deliberately scoped to the canonical `= props` body-scope shape for zero false positives',
|
|
157
|
+
],
|
|
158
|
+
seeAlso: ['hasPyreonPatterns', 'detectReactPatterns', 'analyzeReactivity'],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'hasPyreonPatterns',
|
|
162
|
+
kind: 'function',
|
|
163
|
+
signature: 'hasPyreonPatterns(code: string): boolean',
|
|
164
|
+
summary:
|
|
165
|
+
'Fast regex pre-filter for `detectPyreonPatterns` — deliberately loose (the AST walker is the precise gate); only has to avoid skipping a file that might contain a pattern.',
|
|
166
|
+
example: `import { hasPyreonPatterns, detectPyreonPatterns } from "@pyreon/compiler"
|
|
167
|
+
|
|
168
|
+
if (hasPyreonPatterns(src)) report(detectPyreonPatterns(src, file))`,
|
|
169
|
+
seeAlso: ['detectPyreonPatterns'],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'auditTestEnvironment',
|
|
173
|
+
kind: 'function',
|
|
174
|
+
signature: 'auditTestEnvironment(startDir: string): TestAuditResult',
|
|
175
|
+
summary:
|
|
176
|
+
'Scans every `*.test.ts(x)` under `startDir` for the mock-vnode anti-pattern (constructing `{ type, props, children }` literals or a `vnode()` helper instead of going through real `h()`), the bug class behind PR #197’s silent metadata drop. Classifies each file HIGH / MEDIUM / LOW. Powers the MCP `audit_test_environment` tool and `pyreon doctor --audit-tests`.',
|
|
177
|
+
example: `import { auditTestEnvironment, formatTestAudit } from "@pyreon/compiler"
|
|
178
|
+
|
|
179
|
+
const r = auditTestEnvironment(process.cwd())
|
|
180
|
+
console.log(formatTestAudit(r, { minRisk: "high" }))`,
|
|
181
|
+
seeAlso: ['formatTestAudit', 'auditIslands', 'auditSsg'],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'formatTestAudit',
|
|
185
|
+
kind: 'function',
|
|
186
|
+
signature:
|
|
187
|
+
'formatTestAudit(result: TestAuditResult, options?: AuditFormatOptions): string',
|
|
188
|
+
summary:
|
|
189
|
+
'Human-readable renderer for an `auditTestEnvironment` result; `options.minRisk` filters the floor (`high` | `medium` | `low`). The CLI / MCP surfaces also have a JSON path — this is the text view.',
|
|
190
|
+
example: `import { auditTestEnvironment, formatTestAudit } from "@pyreon/compiler"
|
|
191
|
+
|
|
192
|
+
console.log(formatTestAudit(auditTestEnvironment("."), { minRisk: "medium" }))`,
|
|
193
|
+
seeAlso: ['auditTestEnvironment'],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'auditIslands',
|
|
197
|
+
kind: 'function',
|
|
198
|
+
signature: 'auditIslands(rootDir: string): IslandAuditResult',
|
|
199
|
+
summary:
|
|
200
|
+
'Project-wide syntactic island audit — five cross-file detectors (`duplicate-name`, `never-with-registry-entry`, `registry-mismatch`, `nested-island`, `dead-island`) that auto-registry and the per-file detector cannot reach. No type-check pass / module resolution; entirely TypeScript-compiler-API syntactic. Powers `pyreon doctor --check-islands` + the MCP `audit_islands` tool.',
|
|
201
|
+
example: `import { auditIslands, formatIslandAudit } from "@pyreon/compiler"
|
|
202
|
+
|
|
203
|
+
const r = auditIslands(process.cwd())
|
|
204
|
+
for (const f of r.findings) console.log(f.code, f.location.file)`,
|
|
205
|
+
seeAlso: ['formatIslandAudit', 'auditTestEnvironment', 'auditSsg'],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'formatIslandAudit',
|
|
209
|
+
kind: 'function',
|
|
210
|
+
signature:
|
|
211
|
+
'formatIslandAudit(result: IslandAuditResult, options?: IslandAuditFormatOptions): string',
|
|
212
|
+
summary:
|
|
213
|
+
'Text renderer for an `auditIslands` result — each finding with file path + line/column + an actionable fix suggestion. The `--json` CLI path bypasses this for CI gates.',
|
|
214
|
+
example: `import { auditIslands, formatIslandAudit } from "@pyreon/compiler"
|
|
215
|
+
|
|
216
|
+
console.log(formatIslandAudit(auditIslands(".")))`,
|
|
217
|
+
seeAlso: ['auditIslands'],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'auditSsg',
|
|
221
|
+
kind: 'function',
|
|
222
|
+
signature: 'auditSsg(rootDir: string): SsgAuditResult',
|
|
223
|
+
summary:
|
|
224
|
+
'Project-wide syntactic SSG audit — three detectors: `404-outside-layout-dir` (`_404.tsx` not co-located with `_layout.tsx` → no layout chrome), `dynamic-route-missing-get-static-paths` (`[id].tsx` without `getStaticPaths` → silently skipped by SSG auto-detect), `non-literal-revalidate-export` (`export const revalidate = TTL` → dropped from the build-time ISR manifest). API routes (`src/routes/api/` or no `export default`) are skipped. Powers `pyreon doctor --check-ssg`.',
|
|
225
|
+
example: `import { auditSsg, formatSsgAudit } from "@pyreon/compiler"
|
|
226
|
+
|
|
227
|
+
const r = auditSsg(process.cwd())
|
|
228
|
+
for (const f of r.findings) console.log(f.code, f.location.file)`,
|
|
229
|
+
seeAlso: ['formatSsgAudit', 'auditIslands'],
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'formatSsgAudit',
|
|
233
|
+
kind: 'function',
|
|
234
|
+
signature:
|
|
235
|
+
'formatSsgAudit(result: SsgAuditResult, options?: SsgAuditFormatOptions): string',
|
|
236
|
+
summary:
|
|
237
|
+
'Text renderer for an `auditSsg` result — file path + line/column + actionable fix per finding. CI gates use the JSON path instead.',
|
|
238
|
+
example: `import { auditSsg, formatSsgAudit } from "@pyreon/compiler"
|
|
239
|
+
|
|
240
|
+
console.log(formatSsgAudit(auditSsg(".")))`,
|
|
241
|
+
seeAlso: ['auditSsg'],
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'transformDeferInline',
|
|
245
|
+
kind: 'function',
|
|
246
|
+
signature:
|
|
247
|
+
'transformDeferInline(code: string, filename?: string): DeferInlineResult',
|
|
248
|
+
summary:
|
|
249
|
+
'Standalone pre-pass that inlines `<Defer>` namespace-import boundaries. Fast-paths out entirely when the source contains no `Defer` mention (no parse). Returns `{ code, changed, warnings }`; runs before the JSX transform in the Vite plugin chain.',
|
|
250
|
+
example: `import { transformDeferInline } from "@pyreon/compiler"
|
|
251
|
+
|
|
252
|
+
const { code, changed } = transformDeferInline(src, "page.tsx")`,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'generateContext',
|
|
256
|
+
kind: 'function',
|
|
257
|
+
signature: 'generateContext(cwd: string): ProjectContext',
|
|
258
|
+
summary:
|
|
259
|
+
'Project scanner — walks the source tree and produces a structured `ProjectContext` (routes, islands, components) that `@pyreon/vite-plugin` regenerates into `.pyreon/context.json` for AI agents. Syntactic only; no type-check / bundle.',
|
|
260
|
+
example: `import { generateContext } from "@pyreon/compiler"
|
|
261
|
+
|
|
262
|
+
const ctx = generateContext(process.cwd())
|
|
263
|
+
console.log(ctx.routes.length, ctx.islands.length)`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
gotchas: [
|
|
267
|
+
{
|
|
268
|
+
label: 'Dual backend',
|
|
269
|
+
note: 'Reverting `src/jsx.ts` (the JS path) is INVISIBLE to anything that goes through the native binary — the Rust path in `native/src/lib.rs` is a parallel implementation, kept byte-identical by the cross-backend equivalence tests. Edits to transform behavior must land in BOTH; the equivalence suite is the gate.',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
label: 'Reactivity-Lens is editor-only',
|
|
273
|
+
note: '`analyzeReactivity` / `formatReactivityLens` are authoring-time tools (LSP inlay hints via `@pyreon/lint --lsp`, CLI debug). They are NOT consumed at production bundle time and force the JS backend — they never affect emitted code (`reactivityLens` is an additive, byte-neutral sidecar).',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
label: 'Detectors are not codemods',
|
|
277
|
+
note: '`detectPyreonPatterns` always reports `fixable: false` (enforced invariant). `detectReactPatterns` is paired with the real `migrateReactCode` codemod; the Pyreon detector has no companion codemod yet, so consumers must not wire auto-fix UX off its `fixable` flag.',
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
})
|