@pyreon/compiler 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -54
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +414 -9
- package/lib/types/index.d.ts +94 -1
- package/package.json +12 -12
- package/src/index.ts +2 -0
- package/src/jsx.ts +425 -5
- package/src/lpih.ts +270 -0
- package/src/pyreon-intercept.ts +19 -8
- package/src/ssg-audit.ts +3 -3
- package/src/tests/collapse-bail-census.test.ts +101 -16
- package/src/tests/component-child-no-wrap.test.ts +204 -0
- package/src/tests/dynamic-collapse-detector.test.ts +164 -0
- package/src/tests/dynamic-collapse-emit.test.ts +192 -0
- package/src/tests/dynamic-collapse-scan.test.ts +111 -0
- package/src/tests/lpih.test.ts +404 -0
- package/src/tests/native-equivalence.test.ts +92 -0
package/src/lpih.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Program Inlay Hints (LPIH) — merge runtime fire data onto static
|
|
3
|
+
* Reactivity-Lens findings.
|
|
4
|
+
*
|
|
5
|
+
* This is a PURE function. The runtime side (`@pyreon/reactivity`)
|
|
6
|
+
* captures source locations at signal/computed/effect creation and emits
|
|
7
|
+
* fire counts via `getFireSummaries()`. The editor/LSP side calls
|
|
8
|
+
* `analyzeReactivity()` to get static findings. This module bridges them:
|
|
9
|
+
* given findings + fires, produces enriched findings whose `detail` field
|
|
10
|
+
* carries the live fire count.
|
|
11
|
+
*
|
|
12
|
+
* No I/O, no devtools dependency. The LSP transport is the consumer's
|
|
13
|
+
* responsibility (read fires from a cache file, an IPC bridge, or
|
|
14
|
+
* the editor's devtools panel — whatever the editor extension wants).
|
|
15
|
+
*
|
|
16
|
+
* ## The category
|
|
17
|
+
*
|
|
18
|
+
* Editors today show STATIC errors, types, and lint warnings at the
|
|
19
|
+
* cursor. They do NOT show LIVE program data — "this signal fires 240×
|
|
20
|
+
* per second", "this effect re-runs 3× per render", "this computed has
|
|
21
|
+
* 12 downstream subscribers". That data lives in a separate devtools
|
|
22
|
+
* panel that the developer has to context-switch to.
|
|
23
|
+
*
|
|
24
|
+
* LPIH closes that gap: live runtime data appears AT THE SOURCE LINE
|
|
25
|
+
* via LSP inlay hints, like a type annotation or a TypeScript error.
|
|
26
|
+
* No category like this exists for any reactive framework today.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* import { analyzeReactivity, mergeFireDataIntoFindings } from '@pyreon/compiler'
|
|
30
|
+
* import { getFireSummaries } from '@pyreon/reactivity'
|
|
31
|
+
*
|
|
32
|
+
* const code = `const count = signal(0)\nreturn <div>{count()}</div>`
|
|
33
|
+
* const { findings } = analyzeReactivity(code, 'app.tsx')
|
|
34
|
+
* const fires = getFireSummaries().map(s => ({
|
|
35
|
+
* file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
|
|
36
|
+
* }))
|
|
37
|
+
* const enriched = mergeFireDataIntoFindings(findings, fires, 'app.tsx')
|
|
38
|
+
* // enriched[0].detail might now be "live — signal fired 240×"
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { ReactivityFinding, ReactivityFindingKind } from './reactivity-lens'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Runtime fire data carried into the merge function. Shape mirrors
|
|
45
|
+
* `@pyreon/reactivity`'s `FireSummary` but is duplicated here to keep
|
|
46
|
+
* `@pyreon/compiler` free of a runtime-package import. The consumer
|
|
47
|
+
* adapts the shape at the call site.
|
|
48
|
+
*/
|
|
49
|
+
export interface LPIHFireDatum {
|
|
50
|
+
/** Source file path captured from `new Error().stack`. */
|
|
51
|
+
file: string
|
|
52
|
+
/** 1-based line number (V8 stack format). */
|
|
53
|
+
line: number
|
|
54
|
+
/** Total fires recorded at this location. */
|
|
55
|
+
count: number
|
|
56
|
+
/** `performance.now()` of most recent fire, or null. */
|
|
57
|
+
lastFire?: number | null | undefined
|
|
58
|
+
/** Node kind that fired (signal / derived / effect). */
|
|
59
|
+
kind?: 'signal' | 'derived' | 'effect' | undefined
|
|
60
|
+
/**
|
|
61
|
+
* Exponentially-decayed fire rate, fires/sec (1s time constant). 0
|
|
62
|
+
* when the node has been idle longer than several time constants.
|
|
63
|
+
* Used by the default formatter to add a "12/s" suffix when active.
|
|
64
|
+
* See `@pyreon/reactivity`'s `FireSummary.rate1s` for the math.
|
|
65
|
+
*/
|
|
66
|
+
rate1s?: number | undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Options for `mergeFireDataIntoFindings`. */
|
|
70
|
+
export interface LPIHMergeOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Optional file-path normalizer. Used for both the analyzed source
|
|
73
|
+
* file and each fire's `file` field. Useful when fires come from
|
|
74
|
+
* runtime stacks (absolute paths) but the source file is identified
|
|
75
|
+
* relative (e.g. workspace-rooted). Defaults to identity.
|
|
76
|
+
*/
|
|
77
|
+
normalizeFile?: (path: string) => string
|
|
78
|
+
/**
|
|
79
|
+
* Optional formatter for the enriched detail. Receives the original
|
|
80
|
+
* detail + the matched fire datum. Defaults to:
|
|
81
|
+
* `${detail} — ${kind ? kind + ' ' : ''}fired ${count}×`
|
|
82
|
+
*/
|
|
83
|
+
formatDetail?: (detail: string, fire: LPIHFireDatum) => string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Threshold below which the rate suffix is omitted. A long-dormant node
|
|
88
|
+
* decays toward 0; showing "0/s" or "0.001/s" is noise. The 0.5 cutoff
|
|
89
|
+
* means "less than once every 2 seconds at steady state" — at that
|
|
90
|
+
* rate, the cumulative count is the more useful signal.
|
|
91
|
+
*
|
|
92
|
+
* @internal — exported for tests + tunability.
|
|
93
|
+
*/
|
|
94
|
+
export const _LPIH_RATE_VISIBLE_THRESHOLD = 0.5
|
|
95
|
+
|
|
96
|
+
function _formatRate(rate1s: number): string {
|
|
97
|
+
if (rate1s < _LPIH_RATE_VISIBLE_THRESHOLD) return ''
|
|
98
|
+
// < 10/s: 1 decimal place. ≥ 10/s: rounded integer.
|
|
99
|
+
return rate1s < 10
|
|
100
|
+
? ` (${rate1s.toFixed(1)}/s)`
|
|
101
|
+
: ` (${Math.round(rate1s)}/s)`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DEFAULT_FORMAT = (detail: string, fire: LPIHFireDatum): string => {
|
|
105
|
+
const kindLabel = fire.kind ? `${fire.kind} ` : ''
|
|
106
|
+
const rate = typeof fire.rate1s === 'number' ? _formatRate(fire.rate1s) : ''
|
|
107
|
+
return `${detail} — ${kindLabel}fired ${fire.count}×${rate}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Merge runtime fire data onto static reactivity findings. Pure function,
|
|
112
|
+
* deterministic, input not mutated.
|
|
113
|
+
*
|
|
114
|
+
* Matching rules:
|
|
115
|
+
* - Only fires whose normalized `file` matches the analyzed source file
|
|
116
|
+
* are considered (cross-file fires are silently skipped).
|
|
117
|
+
* - Line-level matching only (column is ignored). V8 stack columns
|
|
118
|
+
* differ from compiler-emitted span columns by 1+ chars in practice,
|
|
119
|
+
* and the user-visible affordance is "this signal at this line is
|
|
120
|
+
* firing" — line precision is sufficient.
|
|
121
|
+
* - Multiple fires at the same `line` are summed; latest `lastFire`
|
|
122
|
+
* and corresponding `kind` win.
|
|
123
|
+
* - Findings of kind `footgun`, `hoisted-static`, or `static-text` are
|
|
124
|
+
* passed through unchanged — they're not runtime-active reactive
|
|
125
|
+
* reads, so a fire count at their location is unrelated to them.
|
|
126
|
+
*/
|
|
127
|
+
export function mergeFireDataIntoFindings(
|
|
128
|
+
findings: ReactivityFinding[],
|
|
129
|
+
fires: readonly LPIHFireDatum[],
|
|
130
|
+
sourceFile: string,
|
|
131
|
+
options: LPIHMergeOptions = {},
|
|
132
|
+
): ReactivityFinding[] {
|
|
133
|
+
if (fires.length === 0) return findings
|
|
134
|
+
const norm = options.normalizeFile ?? ((p) => p)
|
|
135
|
+
const format = options.formatDetail ?? DEFAULT_FORMAT
|
|
136
|
+
const targetFile = norm(sourceFile)
|
|
137
|
+
|
|
138
|
+
// Build line-keyed index. Sum counts at the same line; latest wins for
|
|
139
|
+
// lastFire + kind.
|
|
140
|
+
const byLine = new Map<number, LPIHFireDatum>()
|
|
141
|
+
for (const f of fires) {
|
|
142
|
+
if (norm(f.file) !== targetFile) continue
|
|
143
|
+
const existing = byLine.get(f.line)
|
|
144
|
+
if (existing) {
|
|
145
|
+
existing.count += f.count
|
|
146
|
+
if (typeof f.rate1s === 'number') {
|
|
147
|
+
existing.rate1s = (existing.rate1s ?? 0) + f.rate1s
|
|
148
|
+
}
|
|
149
|
+
const incomingLast = f.lastFire ?? -Infinity
|
|
150
|
+
const existingLast = existing.lastFire ?? -Infinity
|
|
151
|
+
if (incomingLast > existingLast) {
|
|
152
|
+
existing.lastFire = f.lastFire
|
|
153
|
+
existing.kind = f.kind ?? existing.kind
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
byLine.set(f.line, { ...f })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (byLine.size === 0) return findings
|
|
161
|
+
|
|
162
|
+
return findings.map((finding) => {
|
|
163
|
+
// Footguns + static spans are NOT enriched — fire data at those lines
|
|
164
|
+
// belongs to a SEPARATE reactive expression on the same line, and
|
|
165
|
+
// attributing it to the footgun would be misleading.
|
|
166
|
+
if (
|
|
167
|
+
finding.kind === 'footgun' ||
|
|
168
|
+
finding.kind === 'hoisted-static' ||
|
|
169
|
+
finding.kind === 'static-text'
|
|
170
|
+
) {
|
|
171
|
+
return finding
|
|
172
|
+
}
|
|
173
|
+
const fire = byLine.get(finding.line)
|
|
174
|
+
if (!fire) return finding
|
|
175
|
+
return {
|
|
176
|
+
...finding,
|
|
177
|
+
detail: format(finding.detail, fire),
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Synthesize "creation-site" inlay-hint findings directly from fire data.
|
|
184
|
+
*
|
|
185
|
+
* `analyzeReactivity()` produces findings at REACTIVE READ sites (JSX
|
|
186
|
+
* expressions). But the runtime captures fires at CREATION sites
|
|
187
|
+
* (`signal(0)`, `computed(...)`, `effect(...)`). These are usually
|
|
188
|
+
* different source lines — so the merge function above only helps when
|
|
189
|
+
* they happen to coincide.
|
|
190
|
+
*
|
|
191
|
+
* The simpler, more useful editor surface is: show fire counts AT THE
|
|
192
|
+
* CREATION LINE. The user writes `const count = signal(0)` and sees
|
|
193
|
+
* `(signal fired 129×)` as ghost text on that line, the same way
|
|
194
|
+
* TypeScript shows the inferred type.
|
|
195
|
+
*
|
|
196
|
+
* This function turns each fire datum into a synthetic finding the LSP
|
|
197
|
+
* can serve as an inlay hint. No static analysis required — pure runtime
|
|
198
|
+
* data → editor hint.
|
|
199
|
+
*
|
|
200
|
+
* Returns findings sorted by (line, column). Files that don't match
|
|
201
|
+
* `sourceFile` (after normalization) are skipped.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* import { firesToCreationSiteFindings } from '@pyreon/compiler'
|
|
205
|
+
* import { getFireSummaries } from '@pyreon/reactivity'
|
|
206
|
+
*
|
|
207
|
+
* const fires = getFireSummaries().map(s => ({
|
|
208
|
+
* file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
|
|
209
|
+
* }))
|
|
210
|
+
* const findings = firesToCreationSiteFindings(fires, 'app.tsx')
|
|
211
|
+
* // [{ kind: 'live-fire', line: 5, detail: 'signal fired 129×', ... }]
|
|
212
|
+
*/
|
|
213
|
+
export function firesToCreationSiteFindings(
|
|
214
|
+
fires: readonly LPIHFireDatum[],
|
|
215
|
+
sourceFile: string,
|
|
216
|
+
options: LPIHMergeOptions = {},
|
|
217
|
+
): ReactivityFinding[] {
|
|
218
|
+
if (fires.length === 0) return []
|
|
219
|
+
const norm = options.normalizeFile ?? ((p) => p)
|
|
220
|
+
const targetFile = norm(sourceFile)
|
|
221
|
+
|
|
222
|
+
// Per-line aggregation (multiple nodes on the same line — rare but
|
|
223
|
+
// possible: `const [a, b] = [signal(0), signal(0)]`).
|
|
224
|
+
const byLine = new Map<number, LPIHFireDatum>()
|
|
225
|
+
for (const f of fires) {
|
|
226
|
+
if (norm(f.file) !== targetFile) continue
|
|
227
|
+
const existing = byLine.get(f.line)
|
|
228
|
+
if (existing) {
|
|
229
|
+
existing.count += f.count
|
|
230
|
+
// Sum rates at the same line (e.g. destructured signal pair).
|
|
231
|
+
if (typeof f.rate1s === 'number') {
|
|
232
|
+
existing.rate1s = (existing.rate1s ?? 0) + f.rate1s
|
|
233
|
+
}
|
|
234
|
+
const incomingLast = f.lastFire ?? -Infinity
|
|
235
|
+
const existingLast = existing.lastFire ?? -Infinity
|
|
236
|
+
if (incomingLast > existingLast) {
|
|
237
|
+
existing.lastFire = f.lastFire
|
|
238
|
+
existing.kind = f.kind ?? existing.kind
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
byLine.set(f.line, { ...f })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const format = options.formatDetail ?? ((_: string, fire: LPIHFireDatum) => {
|
|
246
|
+
const kindLabel = fire.kind ?? 'node'
|
|
247
|
+
const rate = typeof fire.rate1s === 'number' ? _formatRate(fire.rate1s) : ''
|
|
248
|
+
return `${kindLabel} fired ${fire.count}×${rate}`
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// 'live-fire' is a new finding kind — synthetic, not produced by
|
|
252
|
+
// `analyzeReactivity()`. The LSP renders it as an inlay hint the same
|
|
253
|
+
// way as the structural kinds (reactive/static-text/etc).
|
|
254
|
+
const LIVE_KIND = 'live-fire' as ReactivityFindingKind
|
|
255
|
+
|
|
256
|
+
const out: ReactivityFinding[] = []
|
|
257
|
+
for (const [line, fire] of byLine) {
|
|
258
|
+
out.push({
|
|
259
|
+
kind: LIVE_KIND,
|
|
260
|
+
line,
|
|
261
|
+
column: 0,
|
|
262
|
+
endLine: line,
|
|
263
|
+
// 9999 = "end of line" sentinel; the LSP can clamp to actual line length.
|
|
264
|
+
endColumn: 9999,
|
|
265
|
+
detail: format('', fire),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
out.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
269
|
+
return out
|
|
270
|
+
}
|
package/src/pyreon-intercept.ts
CHANGED
|
@@ -993,16 +993,27 @@ export function hasPyreonPatterns(code: string): boolean {
|
|
|
993
993
|
/\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) ||
|
|
994
994
|
/\b(?:add|remove)EventListener\s*\(/.test(code) ||
|
|
995
995
|
(/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
|
|
996
|
+
// Bounded `\w{0,60}` cap on the handler identifier — real `on*`
|
|
997
|
+
// names are at most ~25 chars (`onPointerLeaveCapture`); 60 leaves
|
|
998
|
+
// headroom. The unbounded `\w*` form was flagged by CodeQL
|
|
999
|
+
// `js/polynomial-redos` (alert #65) as polynomial-time on inputs
|
|
1000
|
+
// like `onAAAA…` (long runs of `[A-Z]`): per starting position
|
|
1001
|
+
// the greedy `\w*` consumes O(N) chars before the trailing `=`
|
|
1002
|
+
// fails to match, giving O(N²) overall on N starting positions.
|
|
1003
|
+
// The cap keeps the regex linear regardless of input shape.
|
|
1004
|
+
/on[A-Z]\w{0,60}\s*=\s*\{\s*undefined\s*\}/.test(code) ||
|
|
1005
|
+
// Bounded `{0,500}` / `{1,500}` quantifiers — this is a pre-filter
|
|
1006
|
+
// scan before the precise AST walker, so losing detector recall on
|
|
1007
|
+
// a pathologically long single-line input is acceptable.
|
|
1008
|
+
/=\s*\(\s*\{[^}]{1,500}\}\s*[:)]/.test(code) ||
|
|
1009
|
+
// props-destructured-body: `const { … } = <ident>` anywhere.
|
|
1010
|
+
/\b(?:const|let|var)\s+\{[^}]{0,500}\}\s*=\s*[A-Za-z_$]/.test(code) ||
|
|
1002
1011
|
// signal-write-as-call: `const X = signal(` declaration anywhere
|
|
1003
1012
|
/\b(?:signal|computed)\s*[<(]/.test(code) ||
|
|
1004
|
-
// static-return-null-conditional: `if (...) return null` anywhere
|
|
1005
|
-
|
|
1013
|
+
// static-return-null-conditional: `if (...) return null` anywhere.
|
|
1014
|
+
// `[\s{]*` (single class) instead of `\s*\{?\s*` (overlapping
|
|
1015
|
+
// quantifiers) — the latter is polynomial on long whitespace runs.
|
|
1016
|
+
/\bif\s*\([^)]{1,500}\)[\s{]{0,20}return\s+null\b/.test(code) ||
|
|
1006
1017
|
// as-unknown-as-vnodechild
|
|
1007
1018
|
/\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
|
|
1008
1019
|
// query-options-as-function: a query hook called with an object literal
|
package/src/ssg-audit.ts
CHANGED
|
@@ -278,8 +278,8 @@ function detectDynamicRouteMissingGetStaticPaths(
|
|
|
278
278
|
const findings: SsgFinding[] = []
|
|
279
279
|
for (const file of routeFiles) {
|
|
280
280
|
const base = file.split('/').pop() ?? ''
|
|
281
|
-
//
|
|
282
|
-
if (!/\[
|
|
281
|
+
// `[^\]]+` instead of `.+` — bounded, no backtrack potential.
|
|
282
|
+
if (!/\[[^\]]{1,200}\]/.test(base)) continue
|
|
283
283
|
// Skip layouts / errors / 404s — only PAGE files take getStaticPaths.
|
|
284
284
|
if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue
|
|
285
285
|
// Skip API routes under `routes/api/` (path-based convention).
|
|
@@ -425,7 +425,7 @@ export function auditSsg(rootDir: string): SsgAuditResult {
|
|
|
425
425
|
let revalidateExports = 0
|
|
426
426
|
for (const file of routeFiles) {
|
|
427
427
|
const base = file.split('/').pop() ?? ''
|
|
428
|
-
if (/\[
|
|
428
|
+
if (/\[[^\]]{1,200}\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) {
|
|
429
429
|
dynamicRoutes++
|
|
430
430
|
}
|
|
431
431
|
const source = parseSourceFile(file)
|
|
@@ -84,6 +84,13 @@ interface SiteClass {
|
|
|
84
84
|
* attrs are string literals AND children are static text (partial-collapse
|
|
85
85
|
* addressable). */
|
|
86
86
|
partialAddressable: boolean
|
|
87
|
+
/** dynamic-prop only: true iff EXACTLY ONE dynamic attr is a ternary of
|
|
88
|
+
* two string literals AND every OTHER non-literal attr is an `on*`
|
|
89
|
+
* handler (which compose orthogonally via the handler-combined
|
|
90
|
+
* emit), AND children are static text. Counts the subset addressable
|
|
91
|
+
* by the dynamic-prop collapse PR sequence (PRs #765-#767 plus the
|
|
92
|
+
* handler-combined follow-up). */
|
|
93
|
+
dynamicTernaryAddressable: boolean
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
const isPascal = (t: string): boolean =>
|
|
@@ -113,17 +120,47 @@ function classifySite(node: any): SiteClass {
|
|
|
113
120
|
const attrs: any[] = opening.attributes ?? []
|
|
114
121
|
let sawDynamic = false
|
|
115
122
|
let everyDynamicIsHandler = true
|
|
123
|
+
// Dynamic-prop addressable tracking: count ternaries + check shape.
|
|
124
|
+
// Exactly one ternary-of-two-literals + every other non-literal attr
|
|
125
|
+
// is either a ternary or an `on*` handler (handlers compose via the
|
|
126
|
+
// combined `_rsCollapseDynH` emit) → addressable. Note no
|
|
127
|
+
// `sawHandler` tracking: the original PR 3 no-handler restriction
|
|
128
|
+
// was lifted by the handler-combined follow-up; handlers no longer
|
|
129
|
+
// disqualify a site from `dynamicTernaryAddressable`.
|
|
130
|
+
let ternaryCount = 0
|
|
131
|
+
let everyDynamicIsTernary = true
|
|
116
132
|
for (const a of attrs) {
|
|
117
|
-
if (a.type === 'JSXSpreadAttribute')
|
|
133
|
+
if (a.type === 'JSXSpreadAttribute')
|
|
134
|
+
return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
|
|
118
135
|
const nm = a.name?.type === 'JSXIdentifier' ? a.name.name : null
|
|
119
|
-
if (!nm)
|
|
136
|
+
if (!nm)
|
|
137
|
+
return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
|
|
120
138
|
const v = a.value
|
|
121
|
-
if (!v)
|
|
139
|
+
if (!v)
|
|
140
|
+
return {
|
|
141
|
+
bucket: 'boolean-attr',
|
|
142
|
+
partialAddressable: false,
|
|
143
|
+
dynamicTernaryAddressable: false,
|
|
144
|
+
}
|
|
122
145
|
const isStr =
|
|
123
146
|
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
124
147
|
if (!isStr) {
|
|
125
148
|
sawDynamic = true
|
|
126
|
-
|
|
149
|
+
const isHandler = /^on[A-Z]/.test(nm)
|
|
150
|
+
if (!isHandler) everyDynamicIsHandler = false
|
|
151
|
+
// Probe for the ternary-of-two-literals shape (PR 2 detector's
|
|
152
|
+
// structural shape).
|
|
153
|
+
const expr = v.type === 'JSXExpressionContainer' ? v.expression : null
|
|
154
|
+
const isLitStr = (n: any): boolean =>
|
|
155
|
+
n &&
|
|
156
|
+
(n.type === 'StringLiteral' || (n.type === 'Literal' && typeof n.value === 'string'))
|
|
157
|
+
const isTernaryOfLits =
|
|
158
|
+
expr &&
|
|
159
|
+
expr.type === 'ConditionalExpression' &&
|
|
160
|
+
isLitStr(expr.consequent) &&
|
|
161
|
+
isLitStr(expr.alternate)
|
|
162
|
+
if (isTernaryOfLits) ternaryCount++
|
|
163
|
+
else if (!isHandler) everyDynamicIsTernary = false
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
// children
|
|
@@ -135,22 +172,41 @@ function classifySite(node: any): SiteClass {
|
|
|
135
172
|
else staticChildrenOnly = false // JSXExpressionContainer etc.
|
|
136
173
|
}
|
|
137
174
|
if (sawDynamic) {
|
|
138
|
-
// Every NON-dynamic attr is a string literal by construction: the loop
|
|
139
|
-
// above early-returns on spread / missing-name / boolean attrs, so any
|
|
140
|
-
// attr that didn't set `sawDynamic` is necessarily `isStr`. Hence the
|
|
141
|
-
// partial-addressable condition is just "every dynamic attr is on*" AND
|
|
142
|
-
// "children are static text" — no separate literal check needed.
|
|
143
175
|
const partialAddressable = everyDynamicIsHandler && staticChildrenOnly
|
|
144
|
-
|
|
176
|
+
// Dynamic-collapse claims: EXACTLY 1 ternary, every OTHER dynamic
|
|
177
|
+
// attr is either a ternary or an `on*` handler (no plain dynamic
|
|
178
|
+
// shapes like `state={getValue()}`), static children. The
|
|
179
|
+
// handler-combined follow-up (this PR) lifted the no-handler
|
|
180
|
+
// restriction by routing handler-bearing dynamic sites to the
|
|
181
|
+
// `_rsCollapseDynH` runtime helper instead of bailing — closes
|
|
182
|
+
// the bulk of the 15.4% dynamic-prop bucket (previously the
|
|
183
|
+
// strict no-handler scope only addressed 0.2% of sites).
|
|
184
|
+
//
|
|
185
|
+
// The `everyDynamicIsTernary` flag here is computed in the loop
|
|
186
|
+
// above as "every non-handler dynamic attr is a ternary"; combined
|
|
187
|
+
// with `ternaryCount === 1` + `staticChildrenOnly` it precisely
|
|
188
|
+
// matches what `detectDynamicCollapsibleShape` + `tryDynamicCollapse`
|
|
189
|
+
// claim. Handlers are NO LONGER excluded — they compose orthogonally.
|
|
190
|
+
const dynamicTernaryAddressable =
|
|
191
|
+
ternaryCount === 1 && everyDynamicIsTernary && staticChildrenOnly
|
|
192
|
+
return { bucket: 'dynamic-prop', partialAddressable, dynamicTernaryAddressable }
|
|
145
193
|
}
|
|
146
194
|
// No spread / boolean / dynamic attr. Bail can now only come from children.
|
|
147
195
|
for (const c of kids) {
|
|
148
196
|
if (c.type === 'JSXText') continue
|
|
149
197
|
if (c.type === 'JSXElement' || c.type === 'JSXFragment')
|
|
150
|
-
return {
|
|
151
|
-
|
|
198
|
+
return {
|
|
199
|
+
bucket: 'element-child',
|
|
200
|
+
partialAddressable: false,
|
|
201
|
+
dynamicTernaryAddressable: false,
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
bucket: 'expression-child',
|
|
205
|
+
partialAddressable: false,
|
|
206
|
+
dynamicTernaryAddressable: false,
|
|
207
|
+
}
|
|
152
208
|
}
|
|
153
|
-
return { bucket: 'collapsible', partialAddressable: false }
|
|
209
|
+
return { bucket: 'collapsible', partialAddressable: false, dynamicTernaryAddressable: false }
|
|
154
210
|
}
|
|
155
211
|
|
|
156
212
|
describe('proposal #1 — collapse-tail bail-reason census (measurement, not a build)', () => {
|
|
@@ -168,6 +224,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
168
224
|
}
|
|
169
225
|
let candidates = 0
|
|
170
226
|
let partialAddressable = 0
|
|
227
|
+
let dynamicTernaryAddressable = 0
|
|
171
228
|
let myCollapsible = 0
|
|
172
229
|
let scannerCollapsible = 0
|
|
173
230
|
|
|
@@ -191,6 +248,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
191
248
|
tally[c.bucket]++
|
|
192
249
|
if (c.bucket === 'collapsible') myCollapsible++
|
|
193
250
|
if (c.partialAddressable) partialAddressable++
|
|
251
|
+
if (c.dynamicTernaryAddressable) dynamicTernaryAddressable++
|
|
194
252
|
}
|
|
195
253
|
}
|
|
196
254
|
for (const k in node) {
|
|
@@ -220,14 +278,27 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
220
278
|
` bail:expression-child : ${tally['expression-child']} (${pct(tally['expression-child'])})`,
|
|
221
279
|
` ── partial-collapse ADDRESSABLE : ${partialAddressable} (${pct(partialAddressable)} of all sites)`,
|
|
222
280
|
` (dynamic-prop bails where every dynamic attr is on*, all else literal, static children)`,
|
|
281
|
+
` ── dynamic-collapse ADDRESSABLE : ${dynamicTernaryAddressable} (${pct(dynamicTernaryAddressable)} of all sites)`,
|
|
282
|
+
` (dynamic-prop bails where EXACTLY ONE attr is a ternary-of-two-string-literals,`,
|
|
283
|
+
` every other non-literal attr is on* (handlers compose via _rsCollapseDynH),`,
|
|
284
|
+
` static children — dynamic-prop sequence #765-#767 + handler-combined follow-up)`,
|
|
223
285
|
'',
|
|
224
286
|
].join('\n'),
|
|
225
287
|
)
|
|
226
288
|
|
|
227
289
|
// ── Trustworthiness gate (bisect-equivalent) ────────────────────────────
|
|
228
|
-
// This independent walk's "collapsible" count MUST equal the
|
|
229
|
-
// scanner's truth-set. If they diverge the census is
|
|
230
|
-
|
|
290
|
+
// This independent walk's "collapsible-equivalent" count MUST equal the
|
|
291
|
+
// production scanner's truth-set. If they diverge the census is
|
|
292
|
+
// measuring fiction.
|
|
293
|
+
//
|
|
294
|
+
// Per PR 3 (#767) the scanner emits TWO `CollapsibleSite` entries per
|
|
295
|
+
// dynamic-prop site (one per literal value — the resolver pre-renders
|
|
296
|
+
// both); the compiler emit still produces ONE collapsed call site. So
|
|
297
|
+
// the per-site classifier count + 2× the dynamic-addressable count
|
|
298
|
+
// equals the scanner's per-resolution count. If they diverge, either
|
|
299
|
+
// the classifier and scanner disagree on which dynamic sites are
|
|
300
|
+
// addressable, OR the scanner's expansion drifted from this formula.
|
|
301
|
+
expect(myCollapsible + 2 * dynamicTernaryAddressable).toBe(scannerCollapsible)
|
|
231
302
|
|
|
232
303
|
// ── Lock the headline finding (ratchet record) ──────────────────────────
|
|
233
304
|
// The corpus is real and large; these are the measured facts as of this
|
|
@@ -241,5 +312,19 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
|
|
|
241
312
|
// the classifier ran over a non-trivial dynamic-prop population so the
|
|
242
313
|
// ratio is meaningful, not noise.
|
|
243
314
|
expect(tally['dynamic-prop'] + tally.collapsible).toBeGreaterThan(0)
|
|
315
|
+
// PR 4 of the dynamic-prop partial-collapse build: lock that the
|
|
316
|
+
// dynamic-collapse classifier ran over a meaningful population
|
|
317
|
+
// (dynamic-prop bucket non-zero). The addressable count is in the
|
|
318
|
+
// log; we DON'T assert it >0 here because the strict no-handler
|
|
319
|
+
// PR 3 scope is honestly small in real-world corpora (real Buttons
|
|
320
|
+
// with `state={cond ? ... : ...}` almost always also carry
|
|
321
|
+
// `onClick` → BAIL until the handler-combined follow-up). The
|
|
322
|
+
// dynamic-prop bucket itself is the size of the future surface;
|
|
323
|
+
// PR 3's no-handler subset is the first measurable step.
|
|
324
|
+
expect(tally['dynamic-prop']).toBeGreaterThan(0)
|
|
325
|
+
// The dynamic-addressable count can be 0 in a clean run (no
|
|
326
|
+
// matching sites in the corpus); just lock that the counter is
|
|
327
|
+
// wired and consistent with the bucket.
|
|
328
|
+
expect(dynamicTernaryAddressable).toBeLessThanOrEqual(tally['dynamic-prop'])
|
|
244
329
|
})
|
|
245
330
|
})
|