phantom-build 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,760 @@
1
+ import { walkNode } from './index.js';
2
+ import { isBrowserGlobal, isAmbiguousGlobal } from './browser-globals.js';
3
+ import { CLIENT_ONLY_HOOKS } from './react-patterns.js';
4
+ // ── Public API ────────────────────────────────────────────────────────
5
+ /**
6
+ * Classify all components in a module for SSR safety.
7
+ *
8
+ * Uses intermediate results from the classification pipeline to avoid
9
+ * redundant AST walks. The classification context provides taint results,
10
+ * hook contexts, and event handler contexts already computed.
11
+ */
12
+ export function classifyModuleSSR(analyzed, sourceCode, context) {
13
+ // Step 1: Check for top-level browser access
14
+ const topLevelResult = detectTopLevelBrowserAccess(analyzed);
15
+ // Step 2: Find component functions
16
+ const components = identifyComponents(analyzed);
17
+ // Step 3: Detect typeof window guards across the module
18
+ const guardedSpans = detectTypeofWindowGuards(analyzed.ast);
19
+ // Step 4: Classify each component
20
+ const results = [];
21
+ for (const comp of components) {
22
+ const renderPath = analyzeRenderPath(comp, analyzed, context, guardedSpans, sourceCode);
23
+ const result = classifyComponent(comp.name, renderPath, topLevelResult.hasTopLevelBrowserAccess);
24
+ results.push(result);
25
+ }
26
+ return {
27
+ components: results,
28
+ hasTopLevelBrowserAccess: topLevelResult.hasTopLevelBrowserAccess,
29
+ topLevelBrowserAPIs: topLevelResult.topLevelBrowserAPIs,
30
+ };
31
+ }
32
+ // ── Component Identification ──────────────────────────────────────────
33
+ /**
34
+ * Identify React component functions in the module.
35
+ *
36
+ * A function is considered a component if:
37
+ * 1. It has a PascalCase name (starts with uppercase)
38
+ * 2. Its body contains JSX
39
+ * 3. It's exported (or could be — we're generous here)
40
+ */
41
+ function identifyComponents(analyzed) {
42
+ const components = [];
43
+ const functionNodes = buildFunctionNodeMap(analyzed);
44
+ for (const fn of analyzed.functions) {
45
+ // Must have a PascalCase name
46
+ if (fn.name === '<anonymous>' || !/^[A-Z]/.test(fn.name))
47
+ continue;
48
+ // Must contain JSX in its body
49
+ const node = functionNodes.get(`${fn.span.start}:${fn.span.end}`);
50
+ if (!node || !containsJSX(node))
51
+ continue;
52
+ components.push(fn);
53
+ }
54
+ return components;
55
+ }
56
+ // ── Render Path Analysis ──────────────────────────────────────────────
57
+ /**
58
+ * Analyze the "render path" of a component — the code that runs during
59
+ * renderToString() on the server.
60
+ *
61
+ * The render path EXCLUDES:
62
+ * - useEffect/useLayoutEffect callback bodies (never run during SSR)
63
+ * - Event handler functions (never run during SSR)
64
+ * - Code inside `typeof window !== 'undefined'` guards
65
+ *
66
+ * The render path INCLUDES:
67
+ * - Top-level component body statements
68
+ * - useMemo/useCallback callback bodies (these DO run during SSR)
69
+ * - Conditional logic affecting JSX
70
+ */
71
+ function analyzeRenderPath(component, analyzed, context, guardedSpans, _sourceCode) {
72
+ const { taintResults, hookContexts, eventHandlerContexts } = context;
73
+ const renderGlobals = [];
74
+ const browserAPIs = [];
75
+ const ambiguousAPIs = [];
76
+ const hooks = [];
77
+ let hasWindowGuards = false;
78
+ let hasEventHandlers = false;
79
+ // Collect hooks used by this component by checking child functions
80
+ for (const fn of analyzed.functions) {
81
+ // Only consider functions nested inside this component
82
+ if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
83
+ continue;
84
+ }
85
+ const hookName = hookContexts.get(fn);
86
+ if (hookName && !hooks.includes(hookName)) {
87
+ hooks.push(hookName);
88
+ }
89
+ if (eventHandlerContexts.has(fn)) {
90
+ hasEventHandlers = true;
91
+ }
92
+ }
93
+ // Also detect hooks from direct calls in the component body (useState, useRef, etc.)
94
+ const componentNode = findFunctionNode(analyzed.ast, component.span);
95
+ if (componentNode) {
96
+ collectDirectHookCalls(componentNode, hooks);
97
+ }
98
+ // Now analyze globals in the render path.
99
+ //
100
+ // IMPORTANT: We do NOT use component.globals as a starting point because
101
+ // eslint-scope's "through" references include globals from ALL nested scopes
102
+ // (useEffect callbacks, event handlers, etc.). Instead, we build the render
103
+ // path globals bottom-up: only include globals from nested functions that
104
+ // ARE in the render path.
105
+ // Step A: Identify which nested functions are excluded from the render path
106
+ const excludedSpans = new Set();
107
+ for (const fn of analyzed.functions) {
108
+ if (fn === component)
109
+ continue;
110
+ if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
111
+ continue;
112
+ }
113
+ const hookName = hookContexts.get(fn);
114
+ if (hookName && CLIENT_ONLY_HOOKS.has(hookName)) {
115
+ excludedSpans.add(`${fn.span.start}:${fn.span.end}`);
116
+ continue;
117
+ }
118
+ if (eventHandlerContexts.has(fn)) {
119
+ excludedSpans.add(`${fn.span.start}:${fn.span.end}`);
120
+ continue;
121
+ }
122
+ const spanKey = `${fn.span.start}:${fn.span.end}`;
123
+ if (guardedSpans.has(spanKey)) {
124
+ hasWindowGuards = true;
125
+ excludedSpans.add(spanKey);
126
+ continue;
127
+ }
128
+ }
129
+ // Step B: Collect render path globals — only from non-excluded functions
130
+ const renderPathGlobals = new Set();
131
+ for (const fn of analyzed.functions) {
132
+ if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
133
+ continue;
134
+ }
135
+ const spanKey = `${fn.span.start}:${fn.span.end}`;
136
+ // Skip excluded functions
137
+ if (fn !== component && excludedSpans.has(spanKey))
138
+ continue;
139
+ // Skip functions nested inside an excluded function
140
+ if (fn !== component && isInsideExcludedSpan(fn.span.start, fn.span.end, excludedSpans))
141
+ continue;
142
+ // For the component itself, we can't use its globals directly because
143
+ // they include through-references from nested scopes. Instead, only
144
+ // include the component's globals if they DON'T come from excluded children.
145
+ if (fn === component) {
146
+ // The component's "own" globals (not from nested functions) are those
147
+ // that appear in its globals list but aren't exclusively from excluded children.
148
+ // We compute this by: component.globals MINUS globals that ONLY appear in excluded children.
149
+ const excludedOnlyGlobals = collectGlobalsOnlyInExcluded(component, analyzed.functions, excludedSpans);
150
+ for (const g of component.globals) {
151
+ if (!excludedOnlyGlobals.has(g)) {
152
+ renderPathGlobals.add(g);
153
+ }
154
+ }
155
+ }
156
+ else {
157
+ for (const g of fn.globals) {
158
+ renderPathGlobals.add(g);
159
+ }
160
+ }
161
+ }
162
+ // Step C: Remove guarded globals and typeof-checked globals
163
+ if (componentNode && guardedSpans.size > 0) {
164
+ const guardedGlobals = collectGuardedGlobals(componentNode, guardedSpans);
165
+ if (guardedGlobals.size > 0) {
166
+ hasWindowGuards = true;
167
+ for (const g of guardedGlobals) {
168
+ renderPathGlobals.delete(g);
169
+ }
170
+ }
171
+ }
172
+ // Step C2: Handle `typeof <global>` — the identifier in `typeof window !== 'undefined'`
173
+ // is safe (typeof never throws) and should not count as browser API usage.
174
+ // If a global is only referenced via typeof checks and inside guards, remove it.
175
+ if (componentNode) {
176
+ for (const guardGlobal of TYPEOF_GUARD_GLOBALS) {
177
+ if (renderPathGlobals.has(guardGlobal)) {
178
+ const allUsages = countGlobalUsages(componentNode, guardGlobal);
179
+ const typeofUsages = countTypeofUsages(componentNode, guardGlobal);
180
+ if (allUsages > 0 && allUsages === typeofUsages) {
181
+ // All references are typeof checks — safe to remove
182
+ renderPathGlobals.delete(guardGlobal);
183
+ hasWindowGuards = true;
184
+ }
185
+ }
186
+ }
187
+ }
188
+ // Step D: Classify the render path globals
189
+ for (const g of renderPathGlobals) {
190
+ renderGlobals.push(g);
191
+ if (isBrowserGlobal(g)) {
192
+ browserAPIs.push(g);
193
+ }
194
+ else if (isAmbiguousGlobal(g)) {
195
+ ambiguousAPIs.push(g);
196
+ }
197
+ }
198
+ return {
199
+ renderGlobals,
200
+ browserAPIs,
201
+ ambiguousAPIs,
202
+ hooks,
203
+ hasWindowGuards,
204
+ hasEventHandlers,
205
+ };
206
+ }
207
+ // ── typeof window Guard Detection ─────────────────────────────────────
208
+ /**
209
+ * Detect `typeof window` guard patterns in the AST.
210
+ *
211
+ * Supported patterns (for window, document, navigator, self):
212
+ * - `if (typeof window !== 'undefined') { ... }`
213
+ * - `if (typeof document === 'undefined') { ... } else { ... }`
214
+ * - `typeof navigator !== 'undefined' && expr` (short-circuit)
215
+ * - `const isClient = typeof window !== 'undefined'`
216
+ * - Early return: `if (typeof window === 'undefined') return ...` (code after is browser-only)
217
+ * - Early return: `if (typeof document !== 'undefined') return ...` (code after is SSR-safe)
218
+ *
219
+ * Returns a Set of span keys ("start:end") for AST nodes that are
220
+ * inside a browser-only guard (the consequent of the check).
221
+ */
222
+ function detectTypeofWindowGuards(ast) {
223
+ const guardedSpans = new Set();
224
+ walkNode(ast, (node) => {
225
+ // Pattern 1: if (typeof window !== 'undefined') { ... }
226
+ if (node.type === 'IfStatement') {
227
+ const ifNode = node;
228
+ if (isTypeofWindowCheck(ifNode.test, '!==')) {
229
+ // The consequent is browser-only code
230
+ addSpansFromNode(ifNode.consequent, guardedSpans);
231
+ }
232
+ else if (isTypeofWindowCheck(ifNode.test, '===')) {
233
+ // Inverted: typeof window === 'undefined' → else branch is browser code
234
+ if (ifNode.alternate) {
235
+ addSpansFromNode(ifNode.alternate, guardedSpans);
236
+ }
237
+ }
238
+ }
239
+ // Pattern 2: typeof window !== 'undefined' && expr (short-circuit)
240
+ if (node.type === 'LogicalExpression') {
241
+ const logical = node;
242
+ if (logical.operator === '&&' && isTypeofWindowCheck(logical.left, '!==')) {
243
+ addSpansFromNode(logical.right, guardedSpans);
244
+ }
245
+ }
246
+ });
247
+ // Pattern 3 (Mini-CFG): Early return guards
248
+ // Detect `if (typeof window === 'undefined') { return ... }` WITHOUT an else,
249
+ // which makes all sibling statements after the if unreachable on the client.
250
+ // And the inverse: `if (typeof window !== 'undefined') { return ... }` WITHOUT else,
251
+ // which makes sibling statements after the if unreachable on the server (browser-only).
252
+ detectEarlyReturnGuards(ast, guardedSpans);
253
+ return guardedSpans;
254
+ }
255
+ // ── Mini-CFG: Early Return Guard Detection ────────────────────────────
256
+ /**
257
+ * Detect early-return guard patterns in function bodies.
258
+ *
259
+ * This is a targeted mini-CFG analysis that handles the common pattern:
260
+ *
261
+ * function Component() {
262
+ * if (typeof window === 'undefined') {
263
+ * return <div>Server fallback</div>;
264
+ * }
265
+ * // Everything here is browser-only (unreachable during SSR)
266
+ * const width = window.innerWidth;
267
+ * return <div>{width}</div>;
268
+ * }
269
+ *
270
+ * And the inverse:
271
+ *
272
+ * function Component() {
273
+ * if (typeof window !== 'undefined') {
274
+ * return <div>{window.innerWidth}</div>;
275
+ * }
276
+ * // Everything here is SSR-only (unreachable on client)
277
+ * return <div>Server content</div>;
278
+ * }
279
+ *
280
+ * For each function body (BlockStatement), walks statements in order.
281
+ * When an if-statement with a typeof window check has a return in its
282
+ * consequent and no else branch, all subsequent sibling statements are
283
+ * marked as guarded (browser-only or SSR-only depending on the check direction).
284
+ */
285
+ function detectEarlyReturnGuards(ast, guardedSpans) {
286
+ // Find all function bodies (BlockStatements that are function bodies)
287
+ walkNode(ast, (node) => {
288
+ if (node.type === 'FunctionDeclaration' ||
289
+ node.type === 'FunctionExpression' ||
290
+ node.type === 'ArrowFunctionExpression') {
291
+ const fn = node;
292
+ if (fn.body.type === 'BlockStatement') {
293
+ detectEarlyReturnInBlock(fn.body, guardedSpans);
294
+ }
295
+ }
296
+ });
297
+ }
298
+ /**
299
+ * Walk a block statement's direct children looking for early return guards.
300
+ */
301
+ function detectEarlyReturnInBlock(block, guardedSpans) {
302
+ const body = block.body;
303
+ if (!body || !Array.isArray(body))
304
+ return;
305
+ for (let i = 0; i < body.length; i++) {
306
+ const stmt = body[i];
307
+ if (stmt.type !== 'IfStatement')
308
+ continue;
309
+ const ifStmt = stmt;
310
+ // Only handle if-without-else (early return pattern)
311
+ if (ifStmt.alternate)
312
+ continue;
313
+ // Check if the consequent contains a return statement at the top level
314
+ if (!blockContainsReturn(ifStmt.consequent))
315
+ continue;
316
+ // Determine what kind of guard this is
317
+ const isServerCheck = isTypeofWindowCheck(ifStmt.test, '===');
318
+ const isClientCheck = isTypeofWindowCheck(ifStmt.test, '!==');
319
+ if (!isServerCheck && !isClientCheck)
320
+ continue;
321
+ // All subsequent sibling statements are unreachable for one side:
322
+ // - typeof window === 'undefined' + return → code after is browser-only (guarded)
323
+ // - typeof window !== 'undefined' + return → code after is SSR-only (safe, not guarded)
324
+ //
325
+ // For SSR boundary detection, we only care about marking browser-only code as guarded.
326
+ // When the check is `=== 'undefined'` (server check), code after return is client-only.
327
+ if (isServerCheck) {
328
+ for (let j = i + 1; j < body.length; j++) {
329
+ addSpansFromNode(body[j], guardedSpans);
330
+ }
331
+ }
332
+ // When the check is `!== 'undefined'` (client check) with a return,
333
+ // code after is server-only (safe). We don't need to guard it, but we DO
334
+ // need to mark the consequent (the return branch itself) as guarded since
335
+ // it's browser-only code.
336
+ // Note: The consequent is already guarded by the Pattern 1 detection above.
337
+ // Early return found — no need to check further statements in this block
338
+ break;
339
+ }
340
+ }
341
+ /**
342
+ * Check if a statement or block contains a return statement at its top level.
343
+ * Handles both bare return and block with return.
344
+ */
345
+ function blockContainsReturn(node) {
346
+ if (node.type === 'ReturnStatement')
347
+ return true;
348
+ if (node.type === 'BlockStatement') {
349
+ const body = node.body;
350
+ if (!body)
351
+ return false;
352
+ return body.some((stmt) => stmt.type === 'ReturnStatement');
353
+ }
354
+ return false;
355
+ }
356
+ /**
357
+ * Check if a node is a `typeof window !== 'undefined'` or `typeof window === 'undefined'` test.
358
+ */
359
+ function isTypeofWindowCheck(node, operator) {
360
+ if (node.type !== 'BinaryExpression')
361
+ return false;
362
+ const binary = node;
363
+ if (binary.operator !== operator && binary.operator !== operator.slice(0, 2)) {
364
+ return false;
365
+ }
366
+ // Check for typeof window on left
367
+ const left = binary.left;
368
+ const right = binary.right;
369
+ return ((isTypeofBrowserGlobal(left) && isUndefinedLiteral(right)) ||
370
+ (isUndefinedLiteral(left) && isTypeofBrowserGlobal(right)));
371
+ }
372
+ /** Browser globals commonly used in typeof SSR guards */
373
+ const TYPEOF_GUARD_GLOBALS = new Set(['window', 'document', 'navigator', 'self']);
374
+ function isTypeofBrowserGlobal(node) {
375
+ if (node.type !== 'UnaryExpression')
376
+ return false;
377
+ const unary = node;
378
+ if (unary.operator !== 'typeof')
379
+ return false;
380
+ return (unary.argument.type === 'Identifier' &&
381
+ TYPEOF_GUARD_GLOBALS.has(unary.argument.name));
382
+ }
383
+ function isUndefinedLiteral(node) {
384
+ if (node.type === 'Literal') {
385
+ return node.value === 'undefined';
386
+ }
387
+ return false;
388
+ }
389
+ /**
390
+ * Add all function-level spans within a node to the guarded set.
391
+ */
392
+ function addSpansFromNode(node, spans) {
393
+ const start = node.start;
394
+ const end = node.end;
395
+ if (start != null && end != null) {
396
+ spans.add(`${start}:${end}`);
397
+ }
398
+ // Also add spans for any nested functions
399
+ walkNode(node, (child) => {
400
+ if (child.type === 'FunctionDeclaration' ||
401
+ child.type === 'FunctionExpression' ||
402
+ child.type === 'ArrowFunctionExpression') {
403
+ const cStart = child.start;
404
+ const cEnd = child.end;
405
+ if (cStart != null && cEnd != null) {
406
+ spans.add(`${cStart}:${cEnd}`);
407
+ }
408
+ }
409
+ });
410
+ }
411
+ // ── Top-Level Browser Access Detection ────────────────────────────────
412
+ /**
413
+ * Detect browser API references at module scope (outside any function).
414
+ *
415
+ * If found, the entire module is ClientOnly regardless of component analysis.
416
+ * Example: `const width = window.innerWidth;` at top level.
417
+ */
418
+ function detectTopLevelBrowserAccess(analyzed) {
419
+ const topLevelBrowserAPIs = [];
420
+ // Module-level globals are variables referenced outside any function scope.
421
+ // We check for globals that aren't captured by any function — they're at module scope.
422
+ // The AST body statements outside function declarations are module-scope.
423
+ // Walk top-level statements of the module
424
+ const body = analyzed.ast.body;
425
+ const functionSpans = new Set();
426
+ for (const fn of analyzed.functions) {
427
+ functionSpans.add(`${fn.span.start}:${fn.span.end}`);
428
+ }
429
+ // Collect identifiers at module scope (not inside any function).
430
+ // Skip identifiers that are the argument of a `typeof` expression,
431
+ // since `typeof window` is safe and doesn't actually access window.
432
+ const typeofArgPositions = new Set();
433
+ walkNode(body, (node) => {
434
+ if (node.type === 'UnaryExpression') {
435
+ const unary = node;
436
+ if (unary.operator === 'typeof' && unary.argument.type === 'Identifier') {
437
+ const argPos = unary.argument.start;
438
+ if (argPos != null)
439
+ typeofArgPositions.add(argPos);
440
+ }
441
+ }
442
+ });
443
+ walkNode(body, (node) => {
444
+ if (node.type === 'Identifier') {
445
+ const name = node.name;
446
+ if (isBrowserGlobal(name)) {
447
+ const pos = node.start;
448
+ if (pos != null && !isInsideAnyFunction(pos, analyzed.functions)) {
449
+ // Skip identifiers inside typeof expressions (typeof window is safe)
450
+ if (typeofArgPositions.has(pos))
451
+ return;
452
+ if (!topLevelBrowserAPIs.includes(name)) {
453
+ topLevelBrowserAPIs.push(name);
454
+ }
455
+ }
456
+ }
457
+ }
458
+ });
459
+ return {
460
+ hasTopLevelBrowserAccess: topLevelBrowserAPIs.length > 0,
461
+ topLevelBrowserAPIs,
462
+ };
463
+ }
464
+ function isInsideAnyFunction(pos, functions) {
465
+ return functions.some(fn => pos >= fn.span.start && pos <= fn.span.end);
466
+ }
467
+ // ── Component Classification ──────────────────────────────────────────
468
+ /**
469
+ * Classify a single component based on its render path analysis.
470
+ */
471
+ function classifyComponent(name, renderPath, hasTopLevelBrowserAccess) {
472
+ let classification;
473
+ let confidence;
474
+ const reasons = [];
475
+ // Rule 1: Module has top-level browser access → entire module is ClientOnly
476
+ if (hasTopLevelBrowserAccess) {
477
+ classification = 'ClientOnly';
478
+ confidence = 1.0;
479
+ reasons.push('Module has top-level browser API access');
480
+ }
481
+ // Rule 2: Render path references browser-only APIs → ClientOnly
482
+ else if (renderPath.browserAPIs.length > 0) {
483
+ classification = 'ClientOnly';
484
+ confidence = 0.95;
485
+ reasons.push(`Render path references browser APIs: ${renderPath.browserAPIs.join(', ')}`);
486
+ }
487
+ // Rule 3: No hooks, no handlers, no guards, pure props→JSX → FullyStatic
488
+ else if (renderPath.hooks.length === 0 &&
489
+ !renderPath.hasEventHandlers &&
490
+ !renderPath.hasWindowGuards &&
491
+ renderPath.ambiguousAPIs.length === 0) {
492
+ classification = 'FullyStatic';
493
+ confidence = 0.9;
494
+ reasons.push('Pure props-to-JSX component with no hooks or handlers');
495
+ }
496
+ // Rule 4: Has hooks/state but render path is clean → SSRSafe
497
+ else if (renderPath.browserAPIs.length === 0) {
498
+ classification = 'SSRSafe';
499
+ confidence = renderPath.ambiguousAPIs.length > 0 ? 0.7 : 0.85;
500
+ reasons.push('Render path is free of browser APIs');
501
+ if (renderPath.hooks.length > 0) {
502
+ reasons.push(`Uses hooks: ${renderPath.hooks.join(', ')}`);
503
+ }
504
+ if (renderPath.hasEventHandlers) {
505
+ reasons.push('Has event handlers (excluded from render path)');
506
+ }
507
+ if (renderPath.hasWindowGuards) {
508
+ reasons.push('Browser access is guarded by typeof window checks');
509
+ }
510
+ if (renderPath.ambiguousAPIs.length > 0) {
511
+ reasons.push(`Cross-environment APIs in render: ${renderPath.ambiguousAPIs.join(', ')}`);
512
+ }
513
+ }
514
+ // Fallback (shouldn't reach here given the rules above)
515
+ else {
516
+ classification = 'ClientOnly';
517
+ confidence = 0.5;
518
+ reasons.push('Could not determine SSR safety');
519
+ }
520
+ return {
521
+ name,
522
+ classification,
523
+ confidence,
524
+ reasons,
525
+ renderPathBrowserAPIs: renderPath.browserAPIs,
526
+ hooks: renderPath.hooks,
527
+ hasWindowGuards: renderPath.hasWindowGuards,
528
+ };
529
+ }
530
+ // ── Render Path Helpers ───────────────────────────────────────────────
531
+ /**
532
+ * Check if a span is nested inside any excluded span.
533
+ */
534
+ function isInsideExcludedSpan(start, end, excludedSpans) {
535
+ for (const spanKey of excludedSpans) {
536
+ const [exStartStr, exEndStr] = spanKey.split(':');
537
+ const exStart = parseInt(exStartStr, 10);
538
+ const exEnd = parseInt(exEndStr, 10);
539
+ if (start >= exStart && end <= exEnd) {
540
+ return true;
541
+ }
542
+ }
543
+ return false;
544
+ }
545
+ /**
546
+ * Collect globals that ONLY appear in excluded child functions.
547
+ *
548
+ * A global is "excluded-only" if:
549
+ * - It appears in at least one excluded child function's globals
550
+ * - It does NOT appear in any non-excluded child function's globals
551
+ *
552
+ * These globals come from nested scopes (useEffect, event handlers) and
553
+ * should not count as render-path globals even though eslint-scope's
554
+ * through-references include them in the parent component's globals.
555
+ */
556
+ function collectGlobalsOnlyInExcluded(component, functions, excludedSpans) {
557
+ const globalsInExcluded = new Set();
558
+ const globalsInRenderPath = new Set();
559
+ for (const fn of functions) {
560
+ if (fn === component)
561
+ continue;
562
+ if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
563
+ continue;
564
+ }
565
+ const spanKey = `${fn.span.start}:${fn.span.end}`;
566
+ const isExcluded = excludedSpans.has(spanKey) ||
567
+ isInsideExcludedSpan(fn.span.start, fn.span.end, excludedSpans);
568
+ for (const g of fn.globals) {
569
+ if (isExcluded) {
570
+ globalsInExcluded.add(g);
571
+ }
572
+ else {
573
+ globalsInRenderPath.add(g);
574
+ }
575
+ }
576
+ }
577
+ // Return globals that are in excluded but NOT in render path
578
+ const excludedOnly = new Set();
579
+ for (const g of globalsInExcluded) {
580
+ if (!globalsInRenderPath.has(g)) {
581
+ excludedOnly.add(g);
582
+ }
583
+ }
584
+ return excludedOnly;
585
+ }
586
+ // ── Helpers ───────────────────────────────────────────────────────────
587
+ /**
588
+ * Build a map from span key to AST node for function nodes.
589
+ */
590
+ function buildFunctionNodeMap(analyzed) {
591
+ const nodeMap = new Map();
592
+ walkNode(analyzed.ast, (node) => {
593
+ if (node.type === 'FunctionDeclaration' ||
594
+ node.type === 'FunctionExpression' ||
595
+ node.type === 'ArrowFunctionExpression') {
596
+ const start = node.start;
597
+ const end = node.end;
598
+ if (start != null && end != null) {
599
+ nodeMap.set(`${start}:${end}`, node);
600
+ }
601
+ }
602
+ });
603
+ return nodeMap;
604
+ }
605
+ /**
606
+ * Check if an AST node contains any JSX elements or fragments.
607
+ */
608
+ function containsJSX(node) {
609
+ let found = false;
610
+ walkNode(node, (n) => {
611
+ if (found)
612
+ return;
613
+ if (n.type.startsWith('JSX'))
614
+ found = true;
615
+ });
616
+ return found;
617
+ }
618
+ /**
619
+ * Find the AST node for a function by its span.
620
+ */
621
+ function findFunctionNode(ast, span) {
622
+ let result = null;
623
+ walkNode(ast, (node) => {
624
+ if (result)
625
+ return;
626
+ if (node.type === 'FunctionDeclaration' ||
627
+ node.type === 'FunctionExpression' ||
628
+ node.type === 'ArrowFunctionExpression') {
629
+ const start = node.start;
630
+ const end = node.end;
631
+ if (start === span.start && end === span.end) {
632
+ result = node;
633
+ }
634
+ }
635
+ });
636
+ return result;
637
+ }
638
+ /**
639
+ * Collect hook calls made directly in a component body (not inside nested functions).
640
+ * Detects patterns like: useState(), useRef(), useContext(), etc.
641
+ *
642
+ * Uses a shallow walk that stops at nested function boundaries to avoid
643
+ * attributing hooks from useEffect callbacks or helper functions to the
644
+ * component's direct hooks list.
645
+ */
646
+ function collectDirectHookCalls(componentNode, hooks) {
647
+ walkNodeShallow(componentNode, (node) => {
648
+ if (node.type !== 'CallExpression')
649
+ return;
650
+ const call = node;
651
+ let hookName = null;
652
+ if (call.callee.type === 'Identifier') {
653
+ const name = call.callee.name;
654
+ if (name.startsWith('use'))
655
+ hookName = name;
656
+ }
657
+ else if (call.callee.type === 'MemberExpression') {
658
+ const prop = call.callee.property;
659
+ if (prop.type === 'Identifier') {
660
+ const name = prop.name;
661
+ if (name.startsWith('use'))
662
+ hookName = name;
663
+ }
664
+ }
665
+ if (hookName && !hooks.includes(hookName)) {
666
+ hooks.push(hookName);
667
+ }
668
+ });
669
+ }
670
+ /**
671
+ * Walk an AST node recursively but stop at nested function boundaries.
672
+ * Does not recurse into FunctionDeclaration, FunctionExpression, or
673
+ * ArrowFunctionExpression children (other than the root node itself).
674
+ */
675
+ function walkNodeShallow(node, callback, isRoot = true) {
676
+ if (!node || typeof node !== 'object')
677
+ return;
678
+ if (Array.isArray(node)) {
679
+ for (const child of node) {
680
+ walkNodeShallow(child, callback, false);
681
+ }
682
+ return;
683
+ }
684
+ const obj = node;
685
+ if (typeof obj.type !== 'string')
686
+ return;
687
+ // Stop at nested function boundaries (but visit the root node itself)
688
+ if (!isRoot) {
689
+ if (obj.type === 'FunctionDeclaration' ||
690
+ obj.type === 'FunctionExpression' ||
691
+ obj.type === 'ArrowFunctionExpression') {
692
+ return;
693
+ }
694
+ }
695
+ callback(obj);
696
+ for (const key of Object.keys(obj)) {
697
+ if (key === 'type')
698
+ continue;
699
+ walkNodeShallow(obj[key], callback, false);
700
+ }
701
+ }
702
+ /**
703
+ * Count total references to a specific identifier in a node.
704
+ */
705
+ function countGlobalUsages(node, name) {
706
+ let count = 0;
707
+ walkNode(node, (n) => {
708
+ if (n.type === 'Identifier' && n.name === name) {
709
+ count++;
710
+ }
711
+ });
712
+ return count;
713
+ }
714
+ /**
715
+ * Count how many times an identifier appears inside a `typeof` expression.
716
+ * `typeof window` is safe (never throws) and shouldn't count as API access.
717
+ */
718
+ function countTypeofUsages(node, name) {
719
+ let count = 0;
720
+ walkNode(node, (n) => {
721
+ if (n.type === 'UnaryExpression') {
722
+ const unary = n;
723
+ if (unary.operator === 'typeof' &&
724
+ unary.argument.type === 'Identifier' &&
725
+ unary.argument.name === name) {
726
+ count++;
727
+ }
728
+ }
729
+ });
730
+ return count;
731
+ }
732
+ /**
733
+ * Collect global identifiers that are inside guarded blocks.
734
+ * Used to filter out browser APIs that are safely guarded by typeof window checks.
735
+ */
736
+ function collectGuardedGlobals(componentNode, guardedSpans) {
737
+ const guardedGlobals = new Set();
738
+ walkNode(componentNode, (node) => {
739
+ if (node.type !== 'Identifier')
740
+ return;
741
+ const name = node.name;
742
+ if (!isBrowserGlobal(name) && !isAmbiguousGlobal(name))
743
+ return;
744
+ const pos = node.start;
745
+ if (pos == null)
746
+ return;
747
+ // Check if this identifier is inside any guarded span
748
+ for (const spanKey of guardedSpans) {
749
+ const [startStr, endStr] = spanKey.split(':');
750
+ const start = parseInt(startStr, 10);
751
+ const end = parseInt(endStr, 10);
752
+ if (pos >= start && pos <= end) {
753
+ guardedGlobals.add(name);
754
+ break;
755
+ }
756
+ }
757
+ });
758
+ return guardedGlobals;
759
+ }
760
+ //# sourceMappingURL=ssr-boundary.js.map