@pyreon/compiler 0.18.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 +1660 -1241
- package/lib/types/index.d.ts +221 -127
- package/package.json +13 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +12 -1
- package/src/jsx.ts +117 -4
- 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 +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- 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/pyreon-intercept.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* the component signature; reading is captured once
|
|
15
15
|
* and loses reactivity. Access `props.foo` instead
|
|
16
16
|
* or use `splitProps(props, [...])`.
|
|
17
|
+
* - `props-destructured-body` — `const { foo } = props` written
|
|
18
|
+
* SYNCHRONOUSLY in a component body — the body-scope
|
|
19
|
+
* companion to `props-destructured`. Same capture-
|
|
20
|
+
* once death; nested-function destructures (handler
|
|
21
|
+
* / effect / returned accessor) are NOT flagged
|
|
22
|
+
* (they re-read `props` per invocation).
|
|
17
23
|
* - `process-dev-gate` — `typeof process !== 'undefined' &&
|
|
18
24
|
* process.env.NODE_ENV !== 'production'` is dead
|
|
19
25
|
* code in real Vite browser bundles. Use
|
|
@@ -80,6 +86,7 @@ export type PyreonDiagnosticCode =
|
|
|
80
86
|
| 'for-missing-by'
|
|
81
87
|
| 'for-with-key'
|
|
82
88
|
| 'props-destructured'
|
|
89
|
+
| 'props-destructured-body'
|
|
83
90
|
| 'process-dev-gate'
|
|
84
91
|
| 'empty-theme'
|
|
85
92
|
| 'raw-add-event-listener'
|
|
@@ -90,6 +97,7 @@ export type PyreonDiagnosticCode =
|
|
|
90
97
|
| 'static-return-null-conditional'
|
|
91
98
|
| 'as-unknown-as-vnodechild'
|
|
92
99
|
| 'island-never-with-registry-entry'
|
|
100
|
+
| 'query-options-as-function'
|
|
93
101
|
|
|
94
102
|
export interface PyreonDiagnostic {
|
|
95
103
|
/** Machine-readable code for filtering + programmatic handling */
|
|
@@ -285,6 +293,110 @@ function detectPropsDestructured(
|
|
|
285
293
|
)
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
297
|
+
// Pattern: body-scope `const { x } = props` destructure
|
|
298
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Strip the wrappers that can sit between `=` and the props identifier
|
|
302
|
+
* (`const { x } = (props as Props)!`) so we can compare the base
|
|
303
|
+
* expression's identity to the component's first-parameter name.
|
|
304
|
+
*/
|
|
305
|
+
function unwrapInitializer(expr: ts.Expression): ts.Expression {
|
|
306
|
+
let cur = expr
|
|
307
|
+
let prev: ts.Expression | undefined
|
|
308
|
+
while (cur !== prev) {
|
|
309
|
+
prev = cur
|
|
310
|
+
if (ts.isParenthesizedExpression(cur)) cur = cur.expression
|
|
311
|
+
else if (ts.isAsExpression(cur)) cur = cur.expression
|
|
312
|
+
else if (ts.isSatisfiesExpression(cur)) cur = cur.expression
|
|
313
|
+
else if (ts.isNonNullExpression(cur)) cur = cur.expression
|
|
314
|
+
}
|
|
315
|
+
return cur
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Body-scope companion to {@link detectPropsDestructured}. Flags
|
|
320
|
+
* `const { x } = props` (also `let` / `var`, aliases, defaults, rest,
|
|
321
|
+
* nested patterns) written SYNCHRONOUSLY in a component's body.
|
|
322
|
+
*
|
|
323
|
+
* Why this is the footgun: the compiler emits `<C prop={sig()} />` as a
|
|
324
|
+
* getter-shaped reactive prop. `const { x } = props` fires that getter
|
|
325
|
+
* exactly ONCE at setup — `x` is a dead snapshot, never re-reads when
|
|
326
|
+
* the signal changes. `props.x` (live member access inside a tracking
|
|
327
|
+
* scope) or `splitProps(props, ['x'])` preserve the subscription.
|
|
328
|
+
*
|
|
329
|
+
* Precision (zero false positives is the priority — a missed body-scope
|
|
330
|
+
* destructure is acceptable, a wrong one is not):
|
|
331
|
+
* - Only PascalCase, JSX-rendering functions (`isComponentShapedFunction`
|
|
332
|
+
* + `containsJsx`) — a plain helper that happens to destructure an
|
|
333
|
+
* options bag named `props` is NOT a component and is left alone.
|
|
334
|
+
* - The initializer must be the bare first-parameter identifier
|
|
335
|
+
* (`= props`), unwrapped through paren / `as` / `satisfies` / `!`.
|
|
336
|
+
* `const { x } = props.nested` and `= someOtherObject` are NOT
|
|
337
|
+
* flagged (rarer shapes; out of the canonical scope).
|
|
338
|
+
* - The destructure must be at the component-body top scope. A nested
|
|
339
|
+
* function boundary (`onClick` handler, `effect(() => …)`, a returned
|
|
340
|
+
* reactive accessor) re-reads `props` on each invocation, so those
|
|
341
|
+
* destructures are reactivity-correct — the walk does NOT descend
|
|
342
|
+
* into nested functions.
|
|
343
|
+
* - The first parameter must itself be a plain identifier; the
|
|
344
|
+
* parameter-destructure shape (`({ x }) => …`) is the existing
|
|
345
|
+
* `detectPropsDestructured`'s job, not this one.
|
|
346
|
+
*/
|
|
347
|
+
function detectPropsDestructuredBody(
|
|
348
|
+
ctx: DetectContext,
|
|
349
|
+
node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
|
|
350
|
+
): void {
|
|
351
|
+
if (!isComponentShapedFunction(node)) return
|
|
352
|
+
if (!containsJsx(node)) return
|
|
353
|
+
if (!node.parameters.length) return
|
|
354
|
+
const first = node.parameters[0]
|
|
355
|
+
// First param must be a plain identifier — the destructured-param
|
|
356
|
+
// shape is detectPropsDestructured's domain.
|
|
357
|
+
if (!first || !ts.isIdentifier(first.name)) return
|
|
358
|
+
const paramName = first.name.text
|
|
359
|
+
const body = node.body
|
|
360
|
+
if (!body || !ts.isBlock(body)) return
|
|
361
|
+
|
|
362
|
+
function walk(n: ts.Node): void {
|
|
363
|
+
// Do NOT descend into nested functions: a `const { x } = props`
|
|
364
|
+
// inside a handler / effect / returned accessor re-reads on every
|
|
365
|
+
// invocation and is reactivity-correct.
|
|
366
|
+
if (
|
|
367
|
+
ts.isArrowFunction(n) ||
|
|
368
|
+
ts.isFunctionExpression(n) ||
|
|
369
|
+
ts.isFunctionDeclaration(n) ||
|
|
370
|
+
ts.isMethodDeclaration(n) ||
|
|
371
|
+
ts.isGetAccessorDeclaration(n) ||
|
|
372
|
+
ts.isSetAccessorDeclaration(n)
|
|
373
|
+
) {
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
if (
|
|
377
|
+
ts.isVariableDeclaration(n) &&
|
|
378
|
+
ts.isObjectBindingPattern(n.name) &&
|
|
379
|
+
n.name.elements.length > 0 &&
|
|
380
|
+
n.initializer
|
|
381
|
+
) {
|
|
382
|
+
const base = unwrapInitializer(n.initializer)
|
|
383
|
+
if (ts.isIdentifier(base) && base.text === paramName) {
|
|
384
|
+
pushDiag(
|
|
385
|
+
ctx,
|
|
386
|
+
n,
|
|
387
|
+
'props-destructured-body',
|
|
388
|
+
`Destructuring \`${paramName}\` in the component body captures the values ONCE during setup — the compiler emits signal-driven props as getters, so the destructured locals are dead snapshots that never update when the parent rewrites them. Read \`${paramName}.x\` directly inside the reactive scope (JSX / effect / computed), or use \`splitProps(${paramName}, ['x', ...])\` to carve out a group while preserving reactivity.`,
|
|
389
|
+
getNodeText(ctx, n),
|
|
390
|
+
`// read ${paramName}.x directly, or: const [local] = splitProps(${paramName}, ['x'])`,
|
|
391
|
+
false,
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
ts.forEachChild(n, walk)
|
|
396
|
+
}
|
|
397
|
+
for (const stmt of body.statements) walk(stmt)
|
|
398
|
+
}
|
|
399
|
+
|
|
288
400
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
289
401
|
// Pattern: typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
290
402
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -362,6 +474,48 @@ function detectEmptyTheme(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
362
474
|
)
|
|
363
475
|
}
|
|
364
476
|
|
|
477
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
478
|
+
// Pattern: @pyreon/query hook options passed as an object literal
|
|
479
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
480
|
+
|
|
481
|
+
// `useQuery` / `useInfiniteQuery` / `useQueries` / `useSuspenseQuery` take
|
|
482
|
+
// options as a FUNCTION so `queryKey` (etc.) can read Pyreon signals —
|
|
483
|
+
// changing a tracked signal re-runs the options and refetches. An object
|
|
484
|
+
// LITERAL is evaluated once at call time, so the query never reacts to
|
|
485
|
+
// signal changes. `useMutation` is deliberately NOT flagged: its options
|
|
486
|
+
// are a plain object (mutations are imperative, no tracking).
|
|
487
|
+
const QUERY_OPTS_HOOKS = new Set([
|
|
488
|
+
'useQuery',
|
|
489
|
+
'useInfiniteQuery',
|
|
490
|
+
'useQueries',
|
|
491
|
+
'useSuspenseQuery',
|
|
492
|
+
])
|
|
493
|
+
|
|
494
|
+
function detectQueryOptionsAsFunction(
|
|
495
|
+
ctx: DetectContext,
|
|
496
|
+
node: ts.CallExpression,
|
|
497
|
+
): void {
|
|
498
|
+
if (!ts.isIdentifier(node.expression)) return
|
|
499
|
+
const hook = node.expression.text
|
|
500
|
+
if (!QUERY_OPTS_HOOKS.has(hook)) return
|
|
501
|
+
const arg0 = node.arguments[0]
|
|
502
|
+
// Only the unambiguous object-literal-first-arg shape. An identifier /
|
|
503
|
+
// call / function arg can't be statically proven wrong — stay silent.
|
|
504
|
+
if (!arg0 || !ts.isObjectLiteralExpression(arg0)) return
|
|
505
|
+
|
|
506
|
+
const objText = getNodeText(ctx, arg0)
|
|
507
|
+
pushDiag(
|
|
508
|
+
ctx,
|
|
509
|
+
node,
|
|
510
|
+
'query-options-as-function',
|
|
511
|
+
`\`${hook}\` takes options as a FUNCTION so \`queryKey\` can read signals and refetch reactively — an object literal is captured once and never reacts. Wrap it: \`${hook}(() => (...))\`.`,
|
|
512
|
+
getNodeText(ctx, node),
|
|
513
|
+
`${hook}(() => (${objText}))`,
|
|
514
|
+
// No `migrate_pyreon` tool yet — claiming fixable would mislead.
|
|
515
|
+
false,
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
365
519
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
366
520
|
// Pattern: raw addEventListener / removeEventListener
|
|
367
521
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -780,6 +934,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
780
934
|
ts.isFunctionExpression(node)
|
|
781
935
|
) {
|
|
782
936
|
detectPropsDestructured(ctx, node)
|
|
937
|
+
detectPropsDestructuredBody(ctx, node)
|
|
783
938
|
detectStaticReturnNullConditional(ctx, node)
|
|
784
939
|
}
|
|
785
940
|
if (ts.isBinaryExpression(node)) {
|
|
@@ -794,6 +949,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
|
|
|
794
949
|
detectRawEventListener(ctx, node)
|
|
795
950
|
detectSignalWriteAsCall(ctx, node)
|
|
796
951
|
detectIslandNeverWithRegistry(ctx, node)
|
|
952
|
+
detectQueryOptionsAsFunction(ctx, node)
|
|
797
953
|
}
|
|
798
954
|
if (ts.isJsxAttribute(node)) {
|
|
799
955
|
detectOnClickUndefined(ctx, node)
|
|
@@ -839,12 +995,20 @@ export function hasPyreonPatterns(code: string): boolean {
|
|
|
839
995
|
(/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
|
|
840
996
|
/on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
|
|
841
997
|
/=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) ||
|
|
998
|
+
// props-destructured-body: `const { … } = <ident>` anywhere. Loose
|
|
999
|
+
// on purpose — the AST walker is the precise gate; this only has to
|
|
1000
|
+
// avoid skipping the full walk.
|
|
1001
|
+
/\b(?:const|let|var)\s+\{[^}]*\}\s*=\s*[A-Za-z_$]/.test(code) ||
|
|
842
1002
|
// signal-write-as-call: `const X = signal(` declaration anywhere
|
|
843
1003
|
/\b(?:signal|computed)\s*[<(]/.test(code) ||
|
|
844
1004
|
// static-return-null-conditional: `if (...) return null` anywhere
|
|
845
1005
|
/\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
|
|
846
1006
|
// as-unknown-as-vnodechild
|
|
847
1007
|
/\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
|
|
1008
|
+
// query-options-as-function: a query hook called with an object literal
|
|
1009
|
+
/\b(?:useQuery|useInfiniteQuery|useQueries|useSuspenseQuery)\s*\(\s*\{/.test(
|
|
1010
|
+
code,
|
|
1011
|
+
) ||
|
|
848
1012
|
// island-never-with-registry-entry: a never-strategy declaration AND a
|
|
849
1013
|
// hydrateIslands call must both appear in the same source for the bug
|
|
850
1014
|
// shape to trigger. Pre-filter on EITHER half — the AST walker fast-
|
package/src/react-intercept.ts
CHANGED
|
@@ -181,6 +181,55 @@ interface DetectContext {
|
|
|
181
181
|
code: string
|
|
182
182
|
diagnostics: ReactDiagnostic[]
|
|
183
183
|
reactImportedHooks: Set<string>
|
|
184
|
+
/**
|
|
185
|
+
* Identifiers bound to a signal factory (`const x = signal(...)` /
|
|
186
|
+
* `computed(...)` / `useSignal(...)` / `createSignal(...)`) anywhere in the
|
|
187
|
+
* file. Only `const` declarations are tracked — `let`/`var` may be
|
|
188
|
+
* reassigned to a non-signal value, so a `.value` write through them
|
|
189
|
+
* wouldn't be a reliable signal-write. The collection is scope-blind for
|
|
190
|
+
* the same reason `collectSignalBindings` in `pyreon-intercept.ts` is — the
|
|
191
|
+
* rare shadow-a-signal-name case is acceptable noise; the precision win is
|
|
192
|
+
* eliminating the `input.value = ''` / `cell.value = x` / `o.value = y`
|
|
193
|
+
* false-positive class entirely.
|
|
194
|
+
*/
|
|
195
|
+
signalBindings: Set<string>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Collects every identifier bound to a signal factory call. Mirrors
|
|
200
|
+
* `pyreon-intercept.ts:collectSignalBindings` but also recognises the
|
|
201
|
+
* `useSignal` / `createSignal` aliases (Solid / hook-style) so the React
|
|
202
|
+
* detector — which runs on cross-framework migration input — doesn't miss a
|
|
203
|
+
* genuine `mySignal.value = x` written by someone coming from Solid/Vue.
|
|
204
|
+
*/
|
|
205
|
+
function collectDetectSignalBindings(sf: ts.SourceFile): Set<string> {
|
|
206
|
+
const names = new Set<string>()
|
|
207
|
+
function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
|
|
208
|
+
if (!init || !ts.isCallExpression(init)) return false
|
|
209
|
+
const callee = init.expression
|
|
210
|
+
if (!ts.isIdentifier(callee)) return false
|
|
211
|
+
return (
|
|
212
|
+
callee.text === 'signal' ||
|
|
213
|
+
callee.text === 'computed' ||
|
|
214
|
+
callee.text === 'useSignal' ||
|
|
215
|
+
callee.text === 'createSignal'
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
function walk(node: ts.Node): void {
|
|
219
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
220
|
+
const list = node.parent
|
|
221
|
+
if (
|
|
222
|
+
ts.isVariableDeclarationList(list) &&
|
|
223
|
+
(list.flags & ts.NodeFlags.Const) !== 0 &&
|
|
224
|
+
isSignalFactoryCall(node.initializer)
|
|
225
|
+
) {
|
|
226
|
+
names.add(node.name.text)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
ts.forEachChild(node, walk)
|
|
230
|
+
}
|
|
231
|
+
walk(sf)
|
|
232
|
+
return names
|
|
184
233
|
}
|
|
185
234
|
|
|
186
235
|
function detectGetNodeText(ctx: DetectContext, node: ts.Node): string {
|
|
@@ -497,6 +546,15 @@ function detectJsxAttributes(ctx: DetectContext, node: ts.JsxAttribute): void {
|
|
|
497
546
|
|
|
498
547
|
function detectDotValueSignal(ctx: DetectContext, node: ts.PropertyAccessExpression): void {
|
|
499
548
|
const varName = (node.expression as ts.Identifier).text
|
|
549
|
+
// Precision gate: only flag `X.value = …` when X is actually a tracked
|
|
550
|
+
// signal binding. Without this, the detector false-positived on every
|
|
551
|
+
// DOM-element / data-object `.value` write — `input.value = ''`,
|
|
552
|
+
// `cell.value = x`, `o.value = y`, `ref.current.value = z` (the receiver
|
|
553
|
+
// there is the `.current` PropertyAccess, already excluded by
|
|
554
|
+
// `isDotValueAccess` requiring an Identifier receiver). Require positive
|
|
555
|
+
// evidence the receiver is a `const X = signal(...)` / `computed(...)` /
|
|
556
|
+
// `useSignal(...)` / `createSignal(...)` binding before emitting.
|
|
557
|
+
if (!ctx.signalBindings.has(varName)) return
|
|
500
558
|
const parent = node.parent
|
|
501
559
|
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
502
560
|
detectDiag(
|
|
@@ -598,6 +656,7 @@ export function detectReactPatterns(code: string, filename = 'input.tsx'): React
|
|
|
598
656
|
code,
|
|
599
657
|
diagnostics: [],
|
|
600
658
|
reactImportedHooks: new Set<string>(),
|
|
659
|
+
signalBindings: collectDetectSignalBindings(sf),
|
|
601
660
|
}
|
|
602
661
|
|
|
603
662
|
detectVisit(ctx, sf)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity Lens — surface the compiler's already-computed reactivity
|
|
3
|
+
* analysis back to the author at the source.
|
|
4
|
+
*
|
|
5
|
+
* Pyreon's #1 silent footgun class: whether code is reactive is invisible at
|
|
6
|
+
* the moment you write it. `const {x}=props` compiles fine, types fine,
|
|
7
|
+
* renders once, and is dead. `<div>{x}</div>` where `x` isn't a signal bakes
|
|
8
|
+
* once. The `@pyreon/compiler` ALREADY decides this per-expression (it has to,
|
|
9
|
+
* for codegen) and then throws the analysis away. This module pipes it back.
|
|
10
|
+
*
|
|
11
|
+
* `analyzeReactivity()` is the single entry point. It returns a sorted list of
|
|
12
|
+
* {@link ReactivityFinding}s built from TWO faithful sources, neither of which
|
|
13
|
+
* is a fresh approximation:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Compiler structural facts** — `TransformResult.reactivityLens`. Each
|
|
16
|
+
* span is a *record* of a codegen decision (`_bind`/`_bindText`/`_rp`/
|
|
17
|
+
* hoist/static-text). The positive "this is live" claim is the codegen
|
|
18
|
+
* branch itself, so it is correct by construction (drift-gated).
|
|
19
|
+
* 2. **Footgun negatives** — the existing `detectPyreonPatterns` AST
|
|
20
|
+
* detectors (`props-destructured`, `signal-write-as-call`, …). Already
|
|
21
|
+
* shipped, already AST-based; the lens just unifies them under one
|
|
22
|
+
* editor-facing taxonomy.
|
|
23
|
+
*
|
|
24
|
+
* Absence of a finding is "not asserted", NEVER an implicit static claim —
|
|
25
|
+
* see the asymmetric-precision commitment in `.claude/plans/reactivity-lens.md`.
|
|
26
|
+
*
|
|
27
|
+
* JS-backend only (Phase 1). The native Rust binary emits byte-identical
|
|
28
|
+
* codegen (527 cross-backend equivalence tests), so the JS path is a sound
|
|
29
|
+
* oracle for the analysis; Rust-path parity is Phase 3.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { transformJSX_JS } from './jsx'
|
|
35
|
+
import type { ReactivityKind, ReactivitySpan } from './jsx'
|
|
36
|
+
import { detectPyreonPatterns } from './pyreon-intercept'
|
|
37
|
+
import type { PyreonDiagnosticCode } from './pyreon-intercept'
|
|
38
|
+
|
|
39
|
+
export type { ReactivityKind, ReactivitySpan } from './jsx'
|
|
40
|
+
|
|
41
|
+
/** A footgun finding adds `'footgun'` to the structural codegen kinds. */
|
|
42
|
+
export type ReactivityFindingKind = ReactivityKind | 'footgun'
|
|
43
|
+
|
|
44
|
+
export interface ReactivityFinding {
|
|
45
|
+
/** Structural codegen decision, or `'footgun'` for a detected anti-pattern. */
|
|
46
|
+
kind: ReactivityFindingKind
|
|
47
|
+
/** 1-based line. */
|
|
48
|
+
line: number
|
|
49
|
+
/** 0-based column. */
|
|
50
|
+
column: number
|
|
51
|
+
/** 1-based end line. */
|
|
52
|
+
endLine: number
|
|
53
|
+
/** 0-based end column. */
|
|
54
|
+
endColumn: number
|
|
55
|
+
/** Editor-facing one-liner. For footguns, the detector's message. */
|
|
56
|
+
detail: string
|
|
57
|
+
/**
|
|
58
|
+
* For `'footgun'` findings: the static-detector code (e.g.
|
|
59
|
+
* `props-destructured`) so the editor surface can deep-link the
|
|
60
|
+
* anti-pattern catalogue. Absent for structural findings.
|
|
61
|
+
*/
|
|
62
|
+
code?: PyreonDiagnosticCode
|
|
63
|
+
/** For `'footgun'` findings: whether a mechanical auto-fix is safe. */
|
|
64
|
+
fixable?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AnalyzeReactivityResult {
|
|
68
|
+
/** Sorted (line, column) findings — structural facts + footguns merged. */
|
|
69
|
+
findings: ReactivityFinding[]
|
|
70
|
+
/**
|
|
71
|
+
* Raw compiler spans (pre-merge), kept so the drift gate can assert the
|
|
72
|
+
* lens kind faithfully records the codegen decision without re-deriving.
|
|
73
|
+
*/
|
|
74
|
+
spans: ReactivitySpan[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function spanToFinding(s: ReactivitySpan): ReactivityFinding {
|
|
78
|
+
return {
|
|
79
|
+
kind: s.kind,
|
|
80
|
+
line: s.line,
|
|
81
|
+
column: s.column,
|
|
82
|
+
endLine: s.endLine,
|
|
83
|
+
endColumn: s.endColumn,
|
|
84
|
+
detail: s.detail,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Analyze a source file's reactivity. Pure, side-effect-free, deterministic.
|
|
90
|
+
*
|
|
91
|
+
* @param code Source text (`.tsx` / `.jsx` / `.ts`).
|
|
92
|
+
* @param filename Used only for parse-mode (`tsx` vs `jsx`) detection.
|
|
93
|
+
* @param options `knownSignals` is forwarded to the compiler so
|
|
94
|
+
* cross-module imported signals are auto-call-aware.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* const { findings } = analyzeReactivity(
|
|
98
|
+
* `function C(){ const {x}=props; return <div>{count()}</div> }`,
|
|
99
|
+
* )
|
|
100
|
+
* // → footgun(props-destructured) on `{x}`, reactive on `count()`
|
|
101
|
+
*/
|
|
102
|
+
export function analyzeReactivity(
|
|
103
|
+
code: string,
|
|
104
|
+
filename = 'input.tsx',
|
|
105
|
+
options: { knownSignals?: string[] } = {},
|
|
106
|
+
): AnalyzeReactivityResult {
|
|
107
|
+
let spans: ReactivitySpan[] = []
|
|
108
|
+
try {
|
|
109
|
+
const r = transformJSX_JS(code, filename, {
|
|
110
|
+
reactivityLens: true,
|
|
111
|
+
...(options.knownSignals ? { knownSignals: options.knownSignals } : {}),
|
|
112
|
+
})
|
|
113
|
+
spans = r.reactivityLens ?? []
|
|
114
|
+
} catch {
|
|
115
|
+
// Parse failure → no structural facts. Footguns may still be derivable
|
|
116
|
+
// (detectPyreonPatterns uses the TS compiler API independently).
|
|
117
|
+
spans = []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const findings: ReactivityFinding[] = spans.map(spanToFinding)
|
|
121
|
+
|
|
122
|
+
let footguns: ReturnType<typeof detectPyreonPatterns> = []
|
|
123
|
+
try {
|
|
124
|
+
footguns = detectPyreonPatterns(code, filename)
|
|
125
|
+
} catch {
|
|
126
|
+
footguns = []
|
|
127
|
+
}
|
|
128
|
+
for (const d of footguns) {
|
|
129
|
+
// detectPyreonPatterns gives 1-based line / 0-based column + `current`
|
|
130
|
+
// (the offending source text). Approximate the end as same-line +
|
|
131
|
+
// current length; multi-line `current` is rare and the editor only
|
|
132
|
+
// needs a reasonable highlight range.
|
|
133
|
+
const firstLineLen = d.current.split('\n')[0]?.length ?? d.current.length
|
|
134
|
+
findings.push({
|
|
135
|
+
kind: 'footgun',
|
|
136
|
+
line: d.line,
|
|
137
|
+
column: d.column,
|
|
138
|
+
endLine: d.line,
|
|
139
|
+
endColumn: d.column + firstLineLen,
|
|
140
|
+
detail: d.message,
|
|
141
|
+
code: d.code,
|
|
142
|
+
fixable: d.fixable,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
findings.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
147
|
+
return { findings, spans }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const KIND_BADGE: Record<ReactivityFindingKind, string> = {
|
|
151
|
+
reactive: '◆ live',
|
|
152
|
+
'reactive-prop': '◆ live prop',
|
|
153
|
+
'reactive-attr': '◆ live attr',
|
|
154
|
+
'static-text': '○ baked once',
|
|
155
|
+
'hoisted-static': '○ hoisted static',
|
|
156
|
+
footgun: '⚠ footgun',
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Render an annotated source view for CLI / debugging — every analyzed line
|
|
161
|
+
* followed by its reactivity findings. Not the production surface (that's the
|
|
162
|
+
* LSP inlay hints); this is the spike's "can you see reactivity flow" probe
|
|
163
|
+
* and a stable diff target for tests.
|
|
164
|
+
*/
|
|
165
|
+
export function formatReactivityLens(
|
|
166
|
+
code: string,
|
|
167
|
+
result: AnalyzeReactivityResult,
|
|
168
|
+
): string {
|
|
169
|
+
const lines = code.split('\n')
|
|
170
|
+
const byLine = new Map<number, ReactivityFinding[]>()
|
|
171
|
+
for (const f of result.findings) {
|
|
172
|
+
const arr = byLine.get(f.line) ?? []
|
|
173
|
+
arr.push(f)
|
|
174
|
+
byLine.set(f.line, arr)
|
|
175
|
+
}
|
|
176
|
+
const out: string[] = []
|
|
177
|
+
for (let i = 0; i < lines.length; i++) {
|
|
178
|
+
const lineNo = i + 1
|
|
179
|
+
out.push(`${String(lineNo).padStart(4)} | ${lines[i]}`)
|
|
180
|
+
const fs = byLine.get(lineNo)
|
|
181
|
+
if (fs) {
|
|
182
|
+
for (const f of fs) {
|
|
183
|
+
const pad = ' '.repeat(7 + f.column)
|
|
184
|
+
const tag = f.code ? ` [${f.code}]` : ''
|
|
185
|
+
out.push(`${pad}^ ${KIND_BADGE[f.kind]}${tag} — ${f.detail}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out.join('\n')
|
|
190
|
+
}
|