@pyreon/compiler 0.18.0 → 0.20.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 +2081 -1262
- package/lib/types/index.d.ts +310 -125
- package/package.json +14 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +14 -2
- package/src/jsx.ts +784 -19
- 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/backend-parity-r7-r9.test.ts +91 -0
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
- package/src/tests/collapse-bail-census.test.ts +245 -0
- package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
- package/src/tests/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/element-valued-const-child.test.ts +61 -0
- package/src/tests/falsy-child-characterization.test.ts +48 -0
- package/src/tests/malformed-input-resilience.test.ts +50 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/partial-collapse-detector.test.ts +121 -0
- package/src/tests/partial-collapse-emit.test.ts +104 -0
- package/src/tests/partial-collapse-robustness.test.ts +53 -0
- package/src/tests/prop-derived-shadow.test.ts +96 -0
- package/src/tests/pure-call-reactive-args.test.ts +50 -0
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
- package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
- package/src/tests/r15-elemconst-propderived.test.ts +47 -0
- package/src/tests/r19-defer-inline-robust.test.ts +54 -0
- package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
- package/src/tests/rocketstyle-collapse.test.ts +208 -0
- package/src/tests/signal-autocall-shadow.test.ts +86 -0
- package/src/tests/sourcemap-fidelity.test.ts +77 -0
- package/src/tests/static-text-baking.test.ts +64 -0
- package/src/tests/transform-state-isolation.test.ts +49 -0
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
|
+
})
|
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)
|