@tanstack/start-plugin-core 1.163.2 → 1.163.4

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.
Files changed (47) hide show
  1. package/dist/esm/constants.d.ts +1 -0
  2. package/dist/esm/constants.js +2 -0
  3. package/dist/esm/constants.js.map +1 -1
  4. package/dist/esm/import-protection-plugin/ast.d.ts +3 -0
  5. package/dist/esm/import-protection-plugin/ast.js +8 -0
  6. package/dist/esm/import-protection-plugin/ast.js.map +1 -0
  7. package/dist/esm/import-protection-plugin/constants.d.ts +6 -0
  8. package/dist/esm/import-protection-plugin/constants.js +24 -0
  9. package/dist/esm/import-protection-plugin/constants.js.map +1 -0
  10. package/dist/esm/import-protection-plugin/extensionlessAbsoluteIdResolver.d.ts +22 -0
  11. package/dist/esm/import-protection-plugin/extensionlessAbsoluteIdResolver.js +95 -0
  12. package/dist/esm/import-protection-plugin/extensionlessAbsoluteIdResolver.js.map +1 -0
  13. package/dist/esm/import-protection-plugin/plugin.d.ts +2 -13
  14. package/dist/esm/import-protection-plugin/plugin.js +684 -299
  15. package/dist/esm/import-protection-plugin/plugin.js.map +1 -1
  16. package/dist/esm/import-protection-plugin/postCompileUsage.js +4 -2
  17. package/dist/esm/import-protection-plugin/postCompileUsage.js.map +1 -1
  18. package/dist/esm/import-protection-plugin/rewriteDeniedImports.d.ts +4 -5
  19. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js +225 -3
  20. package/dist/esm/import-protection-plugin/rewriteDeniedImports.js.map +1 -1
  21. package/dist/esm/import-protection-plugin/sourceLocation.d.ts +4 -7
  22. package/dist/esm/import-protection-plugin/sourceLocation.js +18 -73
  23. package/dist/esm/import-protection-plugin/sourceLocation.js.map +1 -1
  24. package/dist/esm/import-protection-plugin/types.d.ts +94 -0
  25. package/dist/esm/import-protection-plugin/utils.d.ts +33 -1
  26. package/dist/esm/import-protection-plugin/utils.js +69 -3
  27. package/dist/esm/import-protection-plugin/utils.js.map +1 -1
  28. package/dist/esm/import-protection-plugin/virtualModules.d.ts +30 -2
  29. package/dist/esm/import-protection-plugin/virtualModules.js +66 -23
  30. package/dist/esm/import-protection-plugin/virtualModules.js.map +1 -1
  31. package/dist/esm/start-compiler-plugin/plugin.d.ts +2 -1
  32. package/dist/esm/start-compiler-plugin/plugin.js +1 -2
  33. package/dist/esm/start-compiler-plugin/plugin.js.map +1 -1
  34. package/package.json +6 -6
  35. package/src/constants.ts +2 -0
  36. package/src/import-protection-plugin/INTERNALS.md +462 -60
  37. package/src/import-protection-plugin/ast.ts +7 -0
  38. package/src/import-protection-plugin/constants.ts +25 -0
  39. package/src/import-protection-plugin/extensionlessAbsoluteIdResolver.ts +121 -0
  40. package/src/import-protection-plugin/plugin.ts +1080 -597
  41. package/src/import-protection-plugin/postCompileUsage.ts +8 -2
  42. package/src/import-protection-plugin/rewriteDeniedImports.ts +141 -9
  43. package/src/import-protection-plugin/sourceLocation.ts +19 -89
  44. package/src/import-protection-plugin/types.ts +103 -0
  45. package/src/import-protection-plugin/utils.ts +123 -4
  46. package/src/import-protection-plugin/virtualModules.ts +117 -31
  47. package/src/start-compiler-plugin/plugin.ts +7 -2
@@ -2,26 +2,37 @@ import { normalizePath } from 'vite'
2
2
 
3
3
  import { resolveViteId } from '../utils'
4
4
  import { VITE_ENVIRONMENT_NAMES } from '../constants'
5
- import { SERVER_FN_LOOKUP } from '../start-compiler-plugin/plugin'
6
5
  import { ImportGraph, buildTrace, formatViolation } from './trace'
7
6
  import {
8
7
  getDefaultImportProtectionRules,
9
8
  getMarkerSpecifiers,
10
9
  } from './defaults'
11
- import { findPostCompileUsagePos } from './postCompileUsage'
12
10
  import { compileMatchers, matchesAny } from './matchers'
13
11
  import {
12
+ buildResolutionCandidates,
13
+ buildSourceCandidates,
14
+ canonicalizeResolvedId,
14
15
  clearNormalizeFilePathCache,
16
+ debugLog,
15
17
  dedupePatterns,
16
18
  escapeRegExp,
17
19
  extractImportSources,
18
20
  getOrCreate,
21
+ isInsideDirectory,
22
+ matchesDebugFilter,
19
23
  normalizeFilePath,
20
24
  relativizePath,
25
+ shouldDeferViolation,
21
26
  } from './utils'
22
- import { collectMockExportNamesBySource } from './rewriteDeniedImports'
27
+ import {
28
+ collectMockExportNamesBySource,
29
+ collectNamedExports,
30
+ rewriteDeniedImports,
31
+ } from './rewriteDeniedImports'
23
32
  import {
24
33
  MOCK_BUILD_PREFIX,
34
+ generateDevSelfDenialModule,
35
+ generateSelfContainedMockModule,
25
36
  getResolvedVirtualModuleMatchers,
26
37
  loadResolvedVirtualModule,
27
38
  makeMockEdgeModuleId,
@@ -29,12 +40,17 @@ import {
29
40
  resolveInternalVirtualModuleId,
30
41
  resolvedMarkerVirtualModuleId,
31
42
  } from './virtualModules'
43
+ import { ExtensionlessAbsoluteIdResolver } from './extensionlessAbsoluteIdResolver'
44
+ import {
45
+ IMPORT_PROTECTION_DEBUG,
46
+ SERVER_FN_LOOKUP_QUERY,
47
+ VITE_BROWSER_VIRTUAL_PREFIX,
48
+ } from './constants'
32
49
  import {
33
50
  ImportLocCache,
34
51
  addTraceImportLocations,
35
52
  buildCodeSnippet,
36
53
  buildLineIndex,
37
- clearImportPatternCache,
38
54
  findImportStatementLocationFromTransformed,
39
55
  findPostCompileUsageLocation,
40
56
  pickOriginalCodeFromSourcesContent,
@@ -47,201 +63,52 @@ import type {
47
63
  TransformResult,
48
64
  TransformResultProvider,
49
65
  } from './sourceLocation'
66
+ import type { ImportProtectionOptions } from '../schema'
50
67
  import type {
51
- ImportProtectionBehavior,
52
- ImportProtectionOptions,
53
- } from '../schema'
54
- import type { CompileStartFrameworkOptions, GetConfigFn } from '../types'
55
-
56
- const SERVER_FN_LOOKUP_QUERY = '?' + SERVER_FN_LOOKUP
57
- const IMPORT_PROTECTION_DEBUG =
58
- process.env.TSR_IMPORT_PROTECTION_DEBUG === '1' ||
59
- process.env.TSR_IMPORT_PROTECTION_DEBUG === 'true'
60
- const IMPORT_PROTECTION_DEBUG_FILTER =
61
- process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER
62
-
63
- function debugLog(...args: Array<unknown>) {
64
- if (!IMPORT_PROTECTION_DEBUG) return
65
- console.warn('[import-protection:debug]', ...args)
66
- }
67
-
68
- /** Check if a value matches the debug filter (when set). */
69
- function matchesDebugFilter(...values: Array<string>): boolean {
70
- if (!IMPORT_PROTECTION_DEBUG_FILTER) return true
71
- return values.some((v) => v.includes(IMPORT_PROTECTION_DEBUG_FILTER))
72
- }
73
-
74
- export { rewriteDeniedImports } from './rewriteDeniedImports'
75
- export { dedupePatterns, extractImportSources } from './utils'
76
- export type { Pattern } from './utils'
77
-
78
- /**
79
- * Immutable plugin configuration — set once in `configResolved`, never mutated
80
- * per-env or per-request afterward.
81
- */
82
- interface PluginConfig {
83
- enabled: boolean
84
- root: string
85
- command: 'build' | 'serve'
86
- srcDirectory: string
87
- framework: CompileStartFrameworkOptions
88
-
89
- /** Absolute, query-free entry file ids used for trace roots. */
90
- entryFiles: Array<string>
91
-
92
- effectiveBehavior: ImportProtectionBehavior
93
- mockAccess: 'error' | 'warn' | 'off'
94
- logMode: 'once' | 'always'
95
- maxTraceDepth: number
96
-
97
- compiledRules: {
98
- client: {
99
- specifiers: Array<CompiledMatcher>
100
- files: Array<CompiledMatcher>
101
- excludeFiles: Array<CompiledMatcher>
102
- }
103
- server: {
104
- specifiers: Array<CompiledMatcher>
105
- files: Array<CompiledMatcher>
106
- excludeFiles: Array<CompiledMatcher>
107
- }
108
- }
109
- includeMatchers: Array<CompiledMatcher>
110
- excludeMatchers: Array<CompiledMatcher>
111
- ignoreImporterMatchers: Array<CompiledMatcher>
112
-
113
- markerSpecifiers: { serverOnly: Set<string>; clientOnly: Set<string> }
114
- envTypeMap: Map<string, 'client' | 'server'>
115
-
116
- onViolation?: (
117
- info: ViolationInfo,
118
- ) => boolean | void | Promise<boolean | void>
119
- }
120
-
121
- /**
122
- * Per-Vite-environment mutable state. One instance per environment name,
123
- * stored in `envStates: Map<string, EnvState>`.
124
- *
125
- * All caches that previously lived on `PluginState` with `${envName}:` key
126
- * prefixes now live here without any prefix.
127
- */
128
- interface EnvState {
129
- graph: ImportGraph
130
- /** Specifiers that resolved to the mock module (for transform-time rewriting). */
131
- deniedSources: Set<string>
132
- /** Per-importer denied edges (for dev ESM mock modules). */
133
- deniedEdges: Map<string, Set<string>>
134
- /**
135
- * During `vite dev` in mock mode, we generate a per-importer mock module that
136
- * exports the names the importer expects.
137
- * Populated in the transform hook (no disk reads).
138
- */
139
- mockExportsByImporter: Map<string, Map<string, Array<string>>>
140
-
141
- /** Resolve cache. Key: `${normalizedImporter}:${source}` (no env prefix). */
142
- resolveCache: Map<string, string | null>
143
- /** Reverse index: file path → Set of resolveCache keys involving that file. */
144
- resolveCacheByFile: Map<string, Set<string>>
145
-
146
- /** Import location cache. Key: `${importerFile}::${source}`. */
147
- importLocCache: ImportLocCache
148
-
149
- /** Deduplication of logged violations (no env prefix in key). */
150
- seenViolations: Set<string>
151
-
152
- /**
153
- * Modules transitively loaded during a `fetchModule(?SERVER_FN_LOOKUP)` call.
154
- * In dev mode the compiler calls `fetchModule(id + '?' + SERVER_FN_LOOKUP)` to
155
- * analyse a module's exports. The direct target carries the query parameter so
156
- * `isPreTransformResolve` is `true`. But Vite also resolves the target's own
157
- * imports (and their imports, etc.) with the plain file path as the importer —
158
- * those would otherwise fire false-positive violations.
159
- *
160
- * We record every module resolved while walking a SERVER_FN_LOOKUP chain so
161
- * that their child imports are also treated as pre-transform resolves.
162
- */
163
- serverFnLookupModules: Set<string>
164
-
165
- /** Transform result cache (code + composed sourcemap + original source). */
166
- transformResultCache: Map<string, TransformResult>
167
- /** Reverse index: physical file path → Set of transformResultCache keys. */
168
- transformResultKeysByFile: Map<string, Set<string>>
169
-
170
- /** Cached provider that reads from {@link transformResultCache}. */
171
- transformResultProvider: TransformResultProvider
172
-
173
- /**
174
- * Post-transform resolved imports. Populated by the transform-cache hook
175
- * after resolving every import source found in the transformed code.
176
- * Key: transform cache key (normalised module ID incl. query params).
177
- * Value: set of resolved child file paths.
178
- */
179
- postTransformImports: Map<string, Set<string>>
180
-
181
- /**
182
- * Violations deferred in dev mock mode. Keyed by the violating importer's
183
- * normalized file path. Violations are confirmed or discarded by the
184
- * transform-cache hook once enough post-transform data is available to
185
- * determine whether the importer is still reachable from an entry point.
186
- */
187
- pendingViolations: Map<string, Array<PendingViolation>>
188
-
189
- /**
190
- * Violations deferred in build mode (both mock and error). Each gets a
191
- * unique mock module ID so we can check which ones survived tree-shaking
192
- * in `generateBundle`.
193
- */
194
- deferredBuildViolations: Array<DeferredBuildViolation>
195
- }
196
-
197
- interface PendingViolation {
198
- info: ViolationInfo
199
- /** The mock module ID that resolveId already returned for this violation. */
200
- mockReturnValue: string
201
- }
202
-
203
- interface DeferredBuildViolation {
204
- info: ViolationInfo
205
- /** Unique mock module ID assigned to this violation. */
206
- mockModuleId: string
207
-
208
- /**
209
- * Module ID to check for tree-shaking survival in `generateBundle`.
210
- *
211
- * For most violations we check the unique mock module ID.
212
- * For `marker` violations the import is a bare side-effect import that often
213
- * gets optimized away regardless of whether the importer survives, so we
214
- * instead check whether the importer module itself survived.
215
- */
216
- checkModuleId?: string
217
- }
218
-
219
- /**
220
- * Intentionally cross-env shared mutable state.
221
- *
222
- * A file's `'use server'`/`'use client'` directive is inherent to the file
223
- * content, not the environment that happens to discover it first.
224
- */
225
- interface SharedState {
226
- fileMarkerKind: Map<string, 'server' | 'client'>
227
- }
228
-
229
- export interface ImportProtectionPluginOptions {
230
- getConfig: GetConfigFn
231
- framework: CompileStartFrameworkOptions
232
- environments: Array<{ name: string; type: 'client' | 'server' }>
233
- providerEnvName: string
234
- }
68
+ EnvRules,
69
+ EnvState,
70
+ HandleViolationResult,
71
+ ImportProtectionPluginOptions,
72
+ ModuleGraphNode,
73
+ PendingViolation,
74
+ PluginConfig,
75
+ SharedState,
76
+ ViolationReporter,
77
+ } from './types'
78
+
79
+ export type { ImportProtectionPluginOptions } from './types'
235
80
 
236
81
  export function importProtectionPlugin(
237
82
  opts: ImportProtectionPluginOptions,
238
83
  ): PluginOption {
239
84
  let devServer: ViteDevServer | null = null
85
+ const extensionlessIdResolver = new ExtensionlessAbsoluteIdResolver()
86
+ const resolveExtensionlessAbsoluteId = (id: string) =>
87
+ extensionlessIdResolver.resolve(id)
88
+
89
+ const importPatternCache = new Map<string, Array<RegExp>>()
90
+
91
+ function findFirstImportSpecifierIndex(code: string, source: string): number {
92
+ let patterns = importPatternCache.get(source)
93
+ if (!patterns) {
94
+ const escaped = escapeRegExp(source)
95
+ patterns = [
96
+ new RegExp(`\\bimport\\s+(['"])${escaped}\\1`),
97
+ new RegExp(`\\bfrom\\s+(['"])${escaped}\\1`),
98
+ new RegExp(`\\bimport\\s*\\(\\s*(['"])${escaped}\\1\\s*\\)`),
99
+ ]
100
+ importPatternCache.set(source, patterns)
101
+ }
240
102
 
241
- type ModuleGraphNode = {
242
- id?: string | null
243
- url?: string
244
- importers: Set<ModuleGraphNode>
103
+ let best = -1
104
+ for (const re of patterns) {
105
+ const m = re.exec(code)
106
+ if (!m) continue
107
+ const idx = m.index + m[0].indexOf(source)
108
+ if (idx === -1) continue
109
+ if (best === -1 || idx < best) best = idx
110
+ }
111
+ return best
245
112
  }
246
113
 
247
114
  /**
@@ -344,7 +211,6 @@ export function importProtectionPlugin(
344
211
  command: 'build',
345
212
  srcDirectory: '',
346
213
  framework: opts.framework,
347
- entryFiles: [],
348
214
  effectiveBehavior: 'error',
349
215
  mockAccess: 'error',
350
216
  logMode: 'once',
@@ -364,18 +230,6 @@ export function importProtectionPlugin(
364
230
  const envStates = new Map<string, EnvState>()
365
231
  const shared: SharedState = { fileMarkerKind: new Map() }
366
232
 
367
- function getMarkerKindForFile(
368
- fileId: string,
369
- ): 'server' | 'client' | undefined {
370
- const file = normalizeFilePath(fileId)
371
- return shared.fileMarkerKind.get(file)
372
- }
373
-
374
- type ViolationReporter = {
375
- warn: (msg: string) => void
376
- error: (msg: string) => never
377
- }
378
-
379
233
  /**
380
234
  * Build the best available trace for a module and enrich each step with
381
235
  * line/column locations. Tries the plugin's own ImportGraph first, then
@@ -407,7 +261,12 @@ export function importProtectionPlugin(
407
261
  trace = mgTrace
408
262
  }
409
263
  }
410
- await addTraceImportLocations(provider, trace, env.importLocCache)
264
+ await addTraceImportLocations(
265
+ provider,
266
+ trace,
267
+ env.importLocCache,
268
+ findFirstImportSpecifierIndex,
269
+ )
411
270
 
412
271
  if (trace.length > 0) {
413
272
  const last = trace[trace.length - 1]!
@@ -448,19 +307,20 @@ export function importProtectionPlugin(
448
307
  >,
449
308
  traceOverride?: Array<TraceStep>,
450
309
  ): Promise<ViolationInfo> {
451
- const loc =
452
- (await findPostCompileUsageLocation(
453
- provider,
454
- importer,
455
- source,
456
- findPostCompileUsagePos,
457
- )) ||
458
- (await findImportStatementLocationFromTransformed(
459
- provider,
460
- importer,
461
- source,
462
- env.importLocCache,
463
- ))
310
+ const sourceCandidates = buildSourceCandidates(
311
+ source,
312
+ 'resolved' in overrides && typeof overrides.resolved === 'string'
313
+ ? overrides.resolved
314
+ : undefined,
315
+ config.root,
316
+ )
317
+
318
+ const loc = await resolveImporterLocation(
319
+ provider,
320
+ env,
321
+ importer,
322
+ sourceCandidates,
323
+ )
464
324
 
465
325
  const trace = await rebuildAndAnnotateTrace(
466
326
  provider,
@@ -487,6 +347,27 @@ export function importProtectionPlugin(
487
347
  }
488
348
  }
489
349
 
350
+ async function resolveImporterLocation(
351
+ provider: TransformResultProvider,
352
+ env: EnvState,
353
+ importer: string,
354
+ sourceCandidates: Iterable<string>,
355
+ ): Promise<Loc | undefined> {
356
+ for (const candidate of sourceCandidates) {
357
+ const loc =
358
+ (await findPostCompileUsageLocation(provider, importer, candidate)) ||
359
+ (await findImportStatementLocationFromTransformed(
360
+ provider,
361
+ importer,
362
+ candidate,
363
+ env.importLocCache,
364
+ findFirstImportSpecifierIndex,
365
+ ))
366
+ if (loc) return loc
367
+ }
368
+ return undefined
369
+ }
370
+
490
371
  /**
491
372
  * Check if a resolved import violates marker restrictions (e.g. importing
492
373
  * a server-only module in the client env). If so, build and return the
@@ -505,7 +386,8 @@ export function importProtectionPlugin(
505
386
  relativePath: string,
506
387
  traceOverride?: Array<TraceStep>,
507
388
  ): Promise<ViolationInfo | undefined> {
508
- const markerKind = getMarkerKindForFile(resolvedId)
389
+ const normalizedResolvedId = normalizeFilePath(resolvedId)
390
+ const markerKind = shared.fileMarkerKind.get(normalizedResolvedId)
509
391
  const violates =
510
392
  (envType === 'client' && markerKind === 'server') ||
511
393
  (envType === 'server' && markerKind === 'client')
@@ -523,11 +405,49 @@ export function importProtectionPlugin(
523
405
  source,
524
406
  {
525
407
  type: 'marker',
526
- resolved: normalizeFilePath(resolvedId),
527
- message:
528
- markerKind === 'server'
529
- ? `Module "${relativePath}" is marked server-only but is imported in the client environment`
530
- : `Module "${relativePath}" is marked client-only but is imported in the server environment`,
408
+ resolved: normalizedResolvedId,
409
+ message: buildMarkerViolationMessage(relativePath, markerKind),
410
+ },
411
+ traceOverride,
412
+ )
413
+ }
414
+
415
+ function buildMarkerViolationMessage(
416
+ relativePath: string,
417
+ markerKind: 'server' | 'client' | undefined,
418
+ ): string {
419
+ return markerKind === 'server'
420
+ ? `Module "${relativePath}" is marked server-only but is imported in the client environment`
421
+ : `Module "${relativePath}" is marked client-only but is imported in the server environment`
422
+ }
423
+
424
+ async function buildFileViolationInfo(
425
+ provider: TransformResultProvider,
426
+ env: EnvState,
427
+ envName: string,
428
+ envType: 'client' | 'server',
429
+ importer: string,
430
+ normalizedImporter: string,
431
+ source: string,
432
+ resolvedPath: string,
433
+ pattern: string | RegExp,
434
+ traceOverride?: Array<TraceStep>,
435
+ ): Promise<ViolationInfo> {
436
+ const relativePath = getRelativePath(resolvedPath)
437
+
438
+ return buildViolationInfo(
439
+ provider,
440
+ env,
441
+ envName,
442
+ envType,
443
+ importer,
444
+ normalizedImporter,
445
+ source,
446
+ {
447
+ type: 'file',
448
+ pattern,
449
+ resolved: resolvedPath,
450
+ message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`,
531
451
  },
532
452
  traceOverride,
533
453
  )
@@ -537,17 +457,36 @@ export function importProtectionPlugin(
537
457
  return config.envTypeMap.get(envName) ?? 'server'
538
458
  }
539
459
 
540
- function getRulesForEnvironment(envName: string): {
541
- specifiers: Array<CompiledMatcher>
542
- files: Array<CompiledMatcher>
543
- excludeFiles: Array<CompiledMatcher>
544
- } {
460
+ function getRulesForEnvironment(envName: string): EnvRules {
545
461
  const type = getEnvType(envName)
546
462
  return type === 'client'
547
463
  ? config.compiledRules.client
548
464
  : config.compiledRules.server
549
465
  }
550
466
 
467
+ /**
468
+ * Check if a relative path matches any denied file pattern for the given
469
+ * environment, respecting `excludeFiles`. Returns the matching pattern
470
+ * or `undefined` if the file is not denied.
471
+ */
472
+ function checkFileDenial(
473
+ relativePath: string,
474
+ matchers: {
475
+ files: Array<CompiledMatcher>
476
+ excludeFiles: Array<CompiledMatcher>
477
+ },
478
+ ): CompiledMatcher | undefined {
479
+ if (
480
+ matchers.excludeFiles.length > 0 &&
481
+ matchesAny(relativePath, matchers.excludeFiles)
482
+ ) {
483
+ return undefined
484
+ }
485
+ return matchers.files.length > 0
486
+ ? matchesAny(relativePath, matchers.files)
487
+ : undefined
488
+ }
489
+
551
490
  const environmentNames = new Set<string>([
552
491
  VITE_ENVIRONMENT_NAMES.client,
553
492
  VITE_ENVIRONMENT_NAMES.server,
@@ -563,8 +502,6 @@ export function importProtectionPlugin(
563
502
  const transformResultCache = new Map<string, TransformResult>()
564
503
  envState = {
565
504
  graph: new ImportGraph(),
566
- deniedSources: new Set(),
567
- deniedEdges: new Map(),
568
505
  mockExportsByImporter: new Map(),
569
506
  resolveCache: new Map(),
570
507
  resolveCacheByFile: new Map(),
@@ -593,6 +530,186 @@ export function importProtectionPlugin(
593
530
  return envState
594
531
  }
595
532
 
533
+ /**
534
+ * Search a parsed export-names map for an entry matching any of the
535
+ * specifier candidates. Returns matching names or empty array.
536
+ */
537
+ function findExportsInMap(
538
+ exportMap: Map<string, Array<string>>,
539
+ candidates: Array<string>,
540
+ ): Array<string> {
541
+ for (const candidate of candidates) {
542
+ const hit = exportMap.get(candidate)
543
+ if (hit && hit.length > 0) return hit
544
+ }
545
+ return []
546
+ }
547
+
548
+ /**
549
+ * Build deduped resolution candidates for a module ID, including the
550
+ * extensionless absolute path when the ID looks like a file path.
551
+ */
552
+ function buildIdCandidates(id: string, extra?: string): Array<string> {
553
+ const set = new Set(buildResolutionCandidates(id))
554
+ if (extra) {
555
+ for (const c of buildResolutionCandidates(extra)) set.add(c)
556
+ set.add(resolveExtensionlessAbsoluteId(extra))
557
+ }
558
+ return Array.from(set)
559
+ }
560
+
561
+ /**
562
+ * Resolve which named exports the importer needs from a denied specifier,
563
+ * so mock-edge modules can provide explicit ESM named exports.
564
+ *
565
+ * Tries multiple strategies: cached export maps, AST parsing, and
566
+ * resolver-based comparison.
567
+ */
568
+ async function resolveExportsForDeniedSpecifier(
569
+ env: EnvState,
570
+ ctx: ViolationReporter,
571
+ info: ViolationInfo,
572
+ importerIdHint?: string,
573
+ ): Promise<Array<string>> {
574
+ const importerFile = normalizeFilePath(info.importer)
575
+ const specifierCandidates = buildIdCandidates(info.specifier, info.resolved)
576
+
577
+ // Only parse AST when a violation occurs (this function is only called
578
+ // while handling a violation). Cache per-importer to avoid repeated parses
579
+ // across multiple violations.
580
+ let parsedBySource = env.mockExportsByImporter.get(importerFile)
581
+ if (!parsedBySource) {
582
+ // Try transform-cache result first, then moduleInfo fallback.
583
+ const importerCode =
584
+ env.transformResultProvider.getTransformResult(importerFile)?.code ??
585
+ (importerIdHint && ctx.getModuleInfo
586
+ ? (ctx.getModuleInfo(importerIdHint)?.code ?? undefined)
587
+ : undefined)
588
+ if (typeof importerCode !== 'string' || importerCode.length === 0)
589
+ return []
590
+
591
+ try {
592
+ parsedBySource = collectMockExportNamesBySource(importerCode)
593
+
594
+ // Also index by resolved physical IDs so later lookups match.
595
+ await recordMockExportsForImporter(
596
+ env,
597
+ importerFile,
598
+ parsedBySource,
599
+ async (src) => {
600
+ const cacheKey = `${importerFile}:${src}`
601
+ if (env.resolveCache.has(cacheKey)) {
602
+ return env.resolveCache.get(cacheKey) ?? undefined
603
+ }
604
+ if (!ctx.resolve) return undefined
605
+ const resolved = await ctx.resolve(src, info.importer, {
606
+ skipSelf: true,
607
+ })
608
+ if (!resolved || resolved.external) return undefined
609
+ return resolved.id
610
+ },
611
+ )
612
+
613
+ // Keep the parsed-by-source map for direct lookups.
614
+ parsedBySource =
615
+ env.mockExportsByImporter.get(importerFile) ?? parsedBySource
616
+ } catch {
617
+ return []
618
+ }
619
+ }
620
+
621
+ // 1. Direct candidate match
622
+ const direct = findExportsInMap(parsedBySource, specifierCandidates)
623
+ if (direct.length > 0) return direct
624
+
625
+ // 2. Resolve each source key and compare candidates.
626
+ const candidateSet = new Set(specifierCandidates)
627
+ for (const [sourceKey, names] of parsedBySource) {
628
+ if (!names.length) continue
629
+
630
+ const resolvedId = await resolveSourceKey(
631
+ env,
632
+ ctx,
633
+ importerFile,
634
+ sourceKey,
635
+ info.importer,
636
+ )
637
+ if (!resolvedId) continue
638
+
639
+ const resolvedCandidates = buildIdCandidates(resolvedId)
640
+ resolvedCandidates.push(resolveExtensionlessAbsoluteId(resolvedId))
641
+ if (resolvedCandidates.some((v) => candidateSet.has(v))) {
642
+ return names
643
+ }
644
+ }
645
+
646
+ return []
647
+ }
648
+
649
+ /** Best-effort resolve a source key using the cache or ctx.resolve. */
650
+ async function resolveSourceKey(
651
+ env: EnvState,
652
+ ctx: ViolationReporter,
653
+ importerFile: string,
654
+ sourceKey: string,
655
+ importerId: string,
656
+ ): Promise<string | undefined> {
657
+ const cacheKey = `${importerFile}:${sourceKey}`
658
+ if (env.resolveCache.has(cacheKey)) {
659
+ return env.resolveCache.get(cacheKey) ?? undefined
660
+ }
661
+ if (!ctx.resolve) return undefined
662
+ try {
663
+ const resolved = await ctx.resolve(sourceKey, importerId, {
664
+ skipSelf: true,
665
+ })
666
+ if (!resolved || resolved.external) return undefined
667
+ return resolved.id
668
+ } catch {
669
+ return undefined
670
+ }
671
+ }
672
+
673
+ async function recordMockExportsForImporter(
674
+ env: EnvState,
675
+ importerId: string,
676
+ namesBySource: Map<string, Array<string>>,
677
+ resolveSource: (source: string) => Promise<string | undefined>,
678
+ ): Promise<void> {
679
+ const importerFile = normalizeFilePath(importerId)
680
+
681
+ if (namesBySource.size === 0) return
682
+
683
+ for (const [source, names] of namesBySource) {
684
+ try {
685
+ const resolvedId = await resolveSource(source)
686
+ if (!resolvedId) continue
687
+
688
+ namesBySource.set(normalizeFilePath(resolvedId), names)
689
+ namesBySource.set(resolveExtensionlessAbsoluteId(resolvedId), names)
690
+ } catch {
691
+ // Best-effort only
692
+ }
693
+ }
694
+
695
+ const existing = env.mockExportsByImporter.get(importerFile)
696
+ if (!existing) {
697
+ env.mockExportsByImporter.set(importerFile, namesBySource)
698
+ return
699
+ }
700
+
701
+ for (const [source, names] of namesBySource) {
702
+ const prev = existing.get(source)
703
+ if (!prev) {
704
+ existing.set(source, names)
705
+ continue
706
+ }
707
+
708
+ const union = new Set([...prev, ...names])
709
+ existing.set(source, Array.from(union).sort())
710
+ }
711
+ }
712
+
596
713
  const shouldCheckImporterCache = new Map<string, boolean>()
597
714
  function shouldCheckImporter(importer: string): boolean {
598
715
  let result = shouldCheckImporterCache.get(importer)
@@ -600,20 +717,19 @@ export function importProtectionPlugin(
600
717
 
601
718
  const relativePath = relativizePath(importer, config.root)
602
719
 
603
- if (
604
- config.excludeMatchers.length > 0 &&
605
- matchesAny(relativePath, config.excludeMatchers)
606
- ) {
607
- result = false
608
- } else if (
609
- config.ignoreImporterMatchers.length > 0 &&
610
- matchesAny(relativePath, config.ignoreImporterMatchers)
611
- ) {
720
+ // Excluded or ignored importers are never checked.
721
+ const excluded =
722
+ (config.excludeMatchers.length > 0 &&
723
+ matchesAny(relativePath, config.excludeMatchers)) ||
724
+ (config.ignoreImporterMatchers.length > 0 &&
725
+ matchesAny(relativePath, config.ignoreImporterMatchers))
726
+
727
+ if (excluded) {
612
728
  result = false
613
729
  } else if (config.includeMatchers.length > 0) {
614
730
  result = !!matchesAny(relativePath, config.includeMatchers)
615
731
  } else if (config.srcDirectory) {
616
- result = importer.startsWith(config.srcDirectory)
732
+ result = isInsideDirectory(importer, config.srcDirectory)
617
733
  } else {
618
734
  result = true
619
735
  }
@@ -622,13 +738,8 @@ export function importProtectionPlugin(
622
738
  return result
623
739
  }
624
740
 
625
- function dedupeKey(
626
- type: string,
627
- importer: string,
628
- specifier: string,
629
- resolved?: string,
630
- ): string {
631
- return `${type}:${importer}:${specifier}:${resolved ?? ''}`
741
+ function dedupeKey(info: ViolationInfo): string {
742
+ return `${info.type}:${info.importer}:${info.specifier}:${info.resolved ?? ''}`
632
743
  }
633
744
 
634
745
  function hasSeen(env: EnvState, key: string): boolean {
@@ -642,6 +753,72 @@ export function importProtectionPlugin(
642
753
  return relativizePath(normalizePath(absolutePath), config.root)
643
754
  }
644
755
 
756
+ /** Reset all caches on an EnvState (called from buildStart). */
757
+ function clearEnvState(envState: EnvState): void {
758
+ envState.resolveCache.clear()
759
+ envState.resolveCacheByFile.clear()
760
+ envState.importLocCache.clear()
761
+ envState.seenViolations.clear()
762
+ envState.transformResultCache.clear()
763
+ envState.transformResultKeysByFile.clear()
764
+ envState.postTransformImports.clear()
765
+ envState.serverFnLookupModules.clear()
766
+ envState.pendingViolations.clear()
767
+ envState.deferredBuildViolations.length = 0
768
+ envState.graph.clear()
769
+ envState.mockExportsByImporter.clear()
770
+ }
771
+
772
+ /** Invalidate all env-level caches that reference a specific file. */
773
+ function invalidateFileFromEnv(envState: EnvState, file: string): void {
774
+ envState.importLocCache.deleteByFile(file)
775
+
776
+ // Resolve cache (keyed "importer:source")
777
+ const resolveKeys = envState.resolveCacheByFile.get(file)
778
+ if (resolveKeys) {
779
+ for (const key of resolveKeys) envState.resolveCache.delete(key)
780
+ envState.resolveCacheByFile.delete(file)
781
+ }
782
+
783
+ envState.graph.invalidate(file)
784
+ envState.mockExportsByImporter.delete(file)
785
+ envState.serverFnLookupModules.delete(file)
786
+ envState.pendingViolations.delete(file)
787
+
788
+ // Transform result cache + post-transform imports
789
+ const transformKeys = envState.transformResultKeysByFile.get(file)
790
+ if (transformKeys) {
791
+ for (const key of transformKeys) {
792
+ envState.transformResultCache.delete(key)
793
+ envState.postTransformImports.delete(key)
794
+ }
795
+ envState.transformResultKeysByFile.delete(file)
796
+ } else {
797
+ envState.transformResultCache.delete(file)
798
+ envState.postTransformImports.delete(file)
799
+ }
800
+ }
801
+
802
+ /** Store a transform result under both the cacheKey and physical file path. */
803
+ function cacheTransformResult(
804
+ envState: EnvState,
805
+ file: string,
806
+ cacheKey: string,
807
+ result: TransformResult,
808
+ ): void {
809
+ envState.transformResultCache.set(cacheKey, result)
810
+ const keySet = getOrCreate(
811
+ envState.transformResultKeysByFile,
812
+ file,
813
+ () => new Set<string>(),
814
+ )
815
+ keySet.add(cacheKey)
816
+ if (cacheKey !== file) {
817
+ envState.transformResultCache.set(file, result)
818
+ keySet.add(file)
819
+ }
820
+ }
821
+
645
822
  /** Register known Start entrypoints as trace roots for all environments. */
646
823
  function registerEntries(): void {
647
824
  const { resolvedStartConfig } = opts.getConfig()
@@ -660,6 +837,82 @@ export function importProtectionPlugin(
660
837
  }
661
838
  }
662
839
 
840
+ /**
841
+ * Get the merged set of post-transform imports for a file, checking all
842
+ * code-split variants. Returns `null` if no post-transform data exists
843
+ * yet (transform hasn't run).
844
+ *
845
+ * Skips `SERVER_FN_LOOKUP` variants because they contain untransformed
846
+ * code — the Start compiler excludes them.
847
+ */
848
+ function getPostTransformImports(
849
+ env: EnvState,
850
+ file: string,
851
+ ): Set<string> | null {
852
+ const keySet = env.transformResultKeysByFile.get(file)
853
+ let merged: Set<string> | null = null
854
+
855
+ if (keySet) {
856
+ for (const k of keySet) {
857
+ if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue
858
+ const imports = env.postTransformImports.get(k)
859
+ if (imports) {
860
+ if (!merged) merged = new Set(imports)
861
+ else for (const v of imports) merged.add(v)
862
+ }
863
+ }
864
+ }
865
+
866
+ // Fallback: direct file-path key
867
+ if (!merged) {
868
+ const imports = env.postTransformImports.get(file)
869
+ if (imports) merged = new Set(imports)
870
+ }
871
+
872
+ return merged
873
+ }
874
+
875
+ /**
876
+ * Check whether an import edge from `parent` to `target` survived
877
+ * post-transform compilation.
878
+ *
879
+ * Returns:
880
+ * - `'live'` — target appears in a non-lookup variant's post-transform imports
881
+ * - `'dead'` — post-transform data exists but target is absent (compiler stripped it)
882
+ * - `'pending'` — transform ran but import data not yet posted
883
+ * - `'no-data'` — transform never ran (warm-start cached module)
884
+ */
885
+ function checkEdgeLiveness(
886
+ env: EnvState,
887
+ parent: string,
888
+ target: string,
889
+ ): 'live' | 'dead' | 'pending' | 'no-data' {
890
+ const keySet = env.transformResultKeysByFile.get(parent)
891
+ let anyVariantCached = false
892
+
893
+ if (keySet) {
894
+ for (const k of keySet) {
895
+ if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue
896
+ const imports = env.postTransformImports.get(k)
897
+ if (imports) {
898
+ anyVariantCached = true
899
+ if (imports.has(target)) return 'live'
900
+ }
901
+ }
902
+ }
903
+
904
+ if (!anyVariantCached) {
905
+ const imports = env.postTransformImports.get(parent)
906
+ if (imports) return imports.has(target) ? 'live' : 'dead'
907
+ const hasTransformResult =
908
+ env.transformResultCache.has(parent) ||
909
+ (keySet ? keySet.size > 0 : false)
910
+ return hasTransformResult ? 'pending' : 'no-data'
911
+ }
912
+
913
+ return 'dead'
914
+ }
915
+
663
916
  function checkPostTransformReachability(
664
917
  env: EnvState,
665
918
  file: string,
@@ -678,73 +931,60 @@ export function importProtectionPlugin(
678
931
  return 'reachable'
679
932
  }
680
933
 
681
- // Walk reverse edges
682
934
  const importers = env.graph.reverseEdges.get(current)
683
935
  if (!importers) continue
684
936
 
685
937
  for (const [parent] of importers) {
686
938
  if (visited.has(parent)) continue
687
-
688
- // Check all code-split variants for this parent. The edge is
689
- // live if ANY variant's resolved imports include `current`.
690
- // Skip SERVER_FN_LOOKUP variants — they contain untransformed
691
- // code (the compiler excludes them), so their import lists
692
- // include imports that the compiler would normally strip.
693
- const keySet = env.transformResultKeysByFile.get(parent)
694
- let anyVariantCached = false
695
- let edgeLive = false
696
-
697
- if (keySet) {
698
- for (const k of keySet) {
699
- if (k.includes(SERVER_FN_LOOKUP_QUERY)) continue
700
- const resolvedImports = env.postTransformImports.get(k)
701
- if (resolvedImports) {
702
- anyVariantCached = true
703
- if (resolvedImports.has(current)) {
704
- edgeLive = true
705
- break
706
- }
707
- }
708
- }
709
- }
710
-
711
- // Fallback: direct file-path key
712
- if (!anyVariantCached) {
713
- const resolvedImports = env.postTransformImports.get(parent)
714
- if (resolvedImports) {
715
- anyVariantCached = true
716
- if (resolvedImports.has(current)) {
717
- edgeLive = true
718
- }
719
- }
720
- }
721
-
722
- if (!anyVariantCached) {
723
- const hasTransformResult =
724
- env.transformResultCache.has(parent) ||
725
- (keySet ? keySet.size > 0 : false)
726
-
727
- if (hasTransformResult) {
728
- // Transform ran but postTransformImports not yet populated
729
- hasUnknownEdge = true
730
- continue
731
- }
732
-
733
- // Transform never ran — Vite served from cache (warm start).
734
- // Conservatively treat edge as live.
735
- queue.push(parent)
736
- continue
737
- }
738
-
739
- if (edgeLive) {
939
+ const liveness = checkEdgeLiveness(env, parent, current)
940
+ if (liveness === 'live' || liveness === 'no-data') {
941
+ // Live edge or warm-start (no transform data) — follow it
740
942
  queue.push(parent)
943
+ } else if (liveness === 'pending') {
944
+ hasUnknownEdge = true
741
945
  }
946
+ // 'dead' — edge was stripped by compiler, skip
742
947
  }
743
948
  }
744
949
 
745
950
  return hasUnknownEdge ? 'unknown' : 'unreachable'
746
951
  }
747
952
 
953
+ /**
954
+ * Filter pending violations using edge-survival data. Returns the subset
955
+ * of violations whose resolved import survived the Start compiler (or all
956
+ * violations when no post-transform data is available yet).
957
+ *
958
+ * Returns `undefined` when all violations were stripped or when we must wait
959
+ * for post-transform data before proceeding.
960
+ */
961
+ function filterEdgeSurvival(
962
+ env: EnvState,
963
+ file: string,
964
+ violations: Array<PendingViolation>,
965
+ ):
966
+ | { active: Array<PendingViolation>; edgeSurvivalApplied: boolean }
967
+ | 'all-stripped'
968
+ | 'await-transform' {
969
+ const postTransform = getPostTransformImports(env, file)
970
+
971
+ if (postTransform) {
972
+ const surviving = violations.filter(
973
+ (pv) => !pv.info.resolved || postTransform.has(pv.info.resolved),
974
+ )
975
+ if (surviving.length === 0) return 'all-stripped'
976
+ env.pendingViolations.set(file, surviving)
977
+ return { active: surviving, edgeSurvivalApplied: true }
978
+ }
979
+
980
+ // Pre-transform violations need edge-survival verification first.
981
+ if (violations.some((pv) => pv.fromPreTransformResolve)) {
982
+ return 'await-transform'
983
+ }
984
+
985
+ return { active: violations, edgeSurvivalApplied: false }
986
+ }
987
+
748
988
  /**
749
989
  * Process pending violations for the given environment. Called from the
750
990
  * transform-cache hook after each module transform is cached, because new
@@ -761,6 +1001,16 @@ export function importProtectionPlugin(
761
1001
  const toDelete: Array<string> = []
762
1002
 
763
1003
  for (const [file, violations] of env.pendingViolations) {
1004
+ const filtered = filterEdgeSurvival(env, file, violations)
1005
+
1006
+ if (filtered === 'all-stripped') {
1007
+ toDelete.push(file)
1008
+ continue
1009
+ }
1010
+ if (filtered === 'await-transform') continue
1011
+
1012
+ const { active, edgeSurvivalApplied } = filtered
1013
+
764
1014
  // Wait for entries before running reachability. registerEntries()
765
1015
  // populates entries at buildStart; resolveId(!importer) may add more.
766
1016
  const status =
@@ -769,36 +1019,35 @@ export function importProtectionPlugin(
769
1019
  : 'unknown'
770
1020
 
771
1021
  if (status === 'reachable') {
772
- for (const pv of violations) {
773
- const key = dedupeKey(
774
- pv.info.type,
775
- pv.info.importer,
776
- pv.info.specifier,
777
- pv.info.resolved,
778
- )
779
- if (!hasSeen(env, key)) {
780
- const freshTrace = await rebuildAndAnnotateTrace(
781
- env.transformResultProvider,
782
- env,
783
- pv.info.env,
784
- pv.info.importer,
785
- pv.info.specifier,
786
- pv.info.importerLoc,
787
- )
788
- if (freshTrace.length > pv.info.trace.length) {
789
- pv.info.trace = freshTrace
790
- }
791
-
792
- if (config.onViolation) {
793
- const result = await config.onViolation(pv.info)
794
- if (result === false) continue
795
- }
796
- warnFn(formatViolation(pv.info, config.root))
797
- }
1022
+ for (const pv of active) {
1023
+ await emitPendingViolation(env, warnFn, pv)
798
1024
  }
799
1025
  toDelete.push(file)
800
1026
  } else if (status === 'unreachable') {
801
1027
  toDelete.push(file)
1028
+ } else if (config.command === 'serve') {
1029
+ // 'unknown' reachability — some graph edges lack transform data.
1030
+ // When edge-survival was applied, surviving violations are confirmed
1031
+ // real. Without it (warm start), emit conservatively.
1032
+ let emittedAny = false
1033
+ for (const pv of active) {
1034
+ if (pv.fromPreTransformResolve) continue
1035
+
1036
+ const shouldEmit =
1037
+ edgeSurvivalApplied ||
1038
+ (pv.info.type === 'file' &&
1039
+ !!pv.info.resolved &&
1040
+ isInsideDirectory(pv.info.resolved, config.srcDirectory))
1041
+
1042
+ if (shouldEmit) {
1043
+ emittedAny =
1044
+ (await emitPendingViolation(env, warnFn, pv)) || emittedAny
1045
+ }
1046
+ }
1047
+
1048
+ if (emittedAny) {
1049
+ toDelete.push(file)
1050
+ }
802
1051
  }
803
1052
  // 'unknown' — keep pending for next transform-cache invocation.
804
1053
  }
@@ -808,6 +1057,59 @@ export function importProtectionPlugin(
808
1057
  }
809
1058
  }
810
1059
 
1060
+ async function emitPendingViolation(
1061
+ env: EnvState,
1062
+ warnFn: (msg: string) => void,
1063
+ pv: PendingViolation,
1064
+ ): Promise<boolean> {
1065
+ if (!pv.info.importerLoc) {
1066
+ const sourceCandidates = buildSourceCandidates(
1067
+ pv.info.specifier,
1068
+ pv.info.resolved,
1069
+ config.root,
1070
+ )
1071
+ const loc = await resolveImporterLocation(
1072
+ env.transformResultProvider,
1073
+ env,
1074
+ pv.info.importer,
1075
+ sourceCandidates,
1076
+ )
1077
+
1078
+ if (loc) {
1079
+ pv.info.importerLoc = loc
1080
+ pv.info.snippet = buildCodeSnippet(
1081
+ env.transformResultProvider,
1082
+ pv.info.importer,
1083
+ loc,
1084
+ )
1085
+ }
1086
+ }
1087
+
1088
+ if (hasSeen(env, dedupeKey(pv.info))) {
1089
+ return false
1090
+ }
1091
+
1092
+ const freshTrace = await rebuildAndAnnotateTrace(
1093
+ env.transformResultProvider,
1094
+ env,
1095
+ pv.info.env,
1096
+ pv.info.importer,
1097
+ pv.info.specifier,
1098
+ pv.info.importerLoc,
1099
+ )
1100
+ if (freshTrace.length > pv.info.trace.length) {
1101
+ pv.info.trace = freshTrace
1102
+ }
1103
+
1104
+ if (config.onViolation) {
1105
+ const result = await config.onViolation(pv.info)
1106
+ if (result === false) return false
1107
+ }
1108
+
1109
+ warnFn(formatViolation(pv.info, config.root))
1110
+ return true
1111
+ }
1112
+
811
1113
  /**
812
1114
  * Record a violation as pending for later confirmation via graph
813
1115
  * reachability. Called from `resolveId` when `shouldDefer` is true.
@@ -816,38 +1118,28 @@ export function importProtectionPlugin(
816
1118
  env: EnvState,
817
1119
  importerFile: string,
818
1120
  info: ViolationInfo,
819
- mockReturnValue: string | undefined,
1121
+ isPreTransformResolve?: boolean,
820
1122
  ): void {
821
1123
  getOrCreate(env.pendingViolations, importerFile, () => []).push({
822
1124
  info,
823
- mockReturnValue: mockReturnValue ?? '',
1125
+ fromPreTransformResolve: isPreTransformResolve,
824
1126
  })
825
1127
  }
826
1128
 
827
1129
  /** Counter for generating unique per-violation mock module IDs in build mode. */
828
1130
  let buildViolationCounter = 0
829
1131
 
830
- type HandleViolationResult = string | undefined
831
-
832
1132
  async function handleViolation(
833
1133
  ctx: ViolationReporter,
834
1134
  env: EnvState,
835
1135
  info: ViolationInfo,
1136
+ importerIdHint?: string,
836
1137
  violationOpts?: { silent?: boolean },
837
1138
  ): Promise<HandleViolationResult> {
838
- const key = dedupeKey(
839
- info.type,
840
- info.importer,
841
- info.specifier,
842
- info.resolved,
843
- )
844
-
845
1139
  if (!violationOpts?.silent) {
846
1140
  if (config.onViolation) {
847
1141
  const result = await config.onViolation(info)
848
- if (result === false) {
849
- return undefined
850
- }
1142
+ if (result === false) return undefined
851
1143
  }
852
1144
 
853
1145
  if (config.effectiveBehavior === 'error') {
@@ -862,71 +1154,56 @@ export function importProtectionPlugin(
862
1154
  //
863
1155
  // Build mode never reaches here — all build violations are
864
1156
  // deferred via shouldDefer and handled silently.
865
-
866
1157
  return ctx.error(formatViolation(info, config.root))
867
1158
  }
868
1159
 
869
- const seen = hasSeen(env, key)
870
-
871
- if (!seen) {
1160
+ if (!hasSeen(env, dedupeKey(info))) {
872
1161
  ctx.warn(formatViolation(info, config.root))
873
1162
  }
874
- } else {
875
- if (config.effectiveBehavior === 'error' && config.command !== 'build') {
876
- return undefined
877
- }
1163
+ } else if (
1164
+ config.effectiveBehavior === 'error' &&
1165
+ config.command !== 'build'
1166
+ ) {
1167
+ return undefined
878
1168
  }
879
1169
 
880
- env.deniedSources.add(info.specifier)
881
- getOrCreate(env.deniedEdges, info.importer, () => new Set<string>()).add(
882
- info.specifier,
883
- )
884
-
885
- if (config.command === 'serve') {
886
- const runtimeId = mockRuntimeModuleIdFromViolation(
887
- info,
888
- config.mockAccess,
889
- config.root,
890
- )
891
- const importerFile = normalizeFilePath(info.importer)
892
- const exports =
893
- env.mockExportsByImporter.get(importerFile)?.get(info.specifier) ?? []
894
- return resolveViteId(
895
- makeMockEdgeModuleId(exports, info.specifier, runtimeId),
896
- )
897
- }
1170
+ // File violations: return resolved path — the self-denial transform
1171
+ // will replace the file's content with a mock module. This avoids
1172
+ // virtual module IDs that could leak across environments via
1173
+ // third-party resolver caches.
1174
+ if (info.type === 'file') return info.resolved
898
1175
 
899
- // Build: unique per-violation mock IDs so generateBundle can check
900
- // which violations survived tree-shaking (both mock and error mode).
901
- // We wrap the base mock in a mock-edge module that provides explicit
902
- // named exports Rolldown doesn't support Rollup's
903
- // syntheticNamedExports, so without this named imports like
904
- // `import { Foo } from "denied-pkg"` would fail with MISSING_EXPORT.
905
- //
906
- // Use the unresolved MOCK_BUILD_PREFIX (without \0) as the runtimeId
907
- // so the mock-edge module's `import mock from "..."` goes through
908
- // resolveId, which adds the \0 prefix. Using the resolved ID directly
909
- // would cause Rollup/Vite to skip resolveId and fail to find the module.
910
- const baseMockId = `${MOCK_BUILD_PREFIX}${buildViolationCounter++}`
911
- const importerFile = normalizeFilePath(info.importer)
912
- const exports =
913
- env.mockExportsByImporter.get(importerFile)?.get(info.specifier) ?? []
914
- return resolveViteId(
915
- makeMockEdgeModuleId(exports, info.specifier, baseMockId),
1176
+ // Non-file violations (specifier/marker): create mock-edge module.
1177
+ // Dev mode uses a runtime diagnostics ID; build mode uses a unique
1178
+ // per-violation ID so generateBundle can check tree-shaking survival.
1179
+ const exports = await resolveExportsForDeniedSpecifier(
1180
+ env,
1181
+ ctx,
1182
+ info,
1183
+ importerIdHint,
916
1184
  )
1185
+ const baseMockId =
1186
+ config.command === 'serve'
1187
+ ? mockRuntimeModuleIdFromViolation(info, config.mockAccess, config.root)
1188
+ : `${MOCK_BUILD_PREFIX}${buildViolationCounter++}`
1189
+ return resolveViteId(makeMockEdgeModuleId(exports, baseMockId))
917
1190
  }
918
1191
 
919
1192
  /**
920
1193
  * Unified violation dispatch: either defers or reports immediately.
921
1194
  *
922
- * When `shouldDefer` is true, calls `handleViolation` silently to obtain
923
- * the mock module ID, then stores the violation for later verification:
924
- * - Dev mock mode: defers to `pendingViolations` for graph-reachability
925
- * checking via `processPendingViolations`.
1195
+ * When `shouldDefer` is true (dev mock + build modes), calls
1196
+ * `handleViolation` silently to obtain the mock module ID, then stores
1197
+ * the violation for later verification:
1198
+ * - Dev mock mode: all violations are deferred to `pendingViolations`
1199
+ * for edge-survival and graph-reachability checking via
1200
+ * `processPendingViolations`.
926
1201
  * - Build mode (mock + error): defers to `deferredBuildViolations` for
927
1202
  * tree-shaking verification in `generateBundle`.
928
1203
  *
929
- * Otherwise reports (or silences for pre-transform resolves) immediately.
1204
+ * Otherwise reports immediately (dev error mode). Pre-transform
1205
+ * resolves are silenced in error mode because they fire before the
1206
+ * compiler runs and there is no deferred verification path.
930
1207
  *
931
1208
  * Returns the mock module ID / resolve result from `handleViolation`.
932
1209
  */
@@ -934,12 +1211,15 @@ export function importProtectionPlugin(
934
1211
  ctx: ViolationReporter,
935
1212
  env: EnvState,
936
1213
  importerFile: string,
1214
+ importerIdHint: string | undefined,
937
1215
  info: ViolationInfo,
938
1216
  shouldDefer: boolean,
939
1217
  isPreTransformResolve: boolean,
940
1218
  ): Promise<HandleViolationResult> {
941
1219
  if (shouldDefer) {
942
- const result = await handleViolation(ctx, env, info, { silent: true })
1220
+ const result = await handleViolation(ctx, env, info, importerIdHint, {
1221
+ silent: true,
1222
+ })
943
1223
 
944
1224
  if (config.command === 'build') {
945
1225
  // Build mode: store for generateBundle tree-shaking check.
@@ -953,13 +1233,18 @@ export function importProtectionPlugin(
953
1233
  })
954
1234
  } else {
955
1235
  // Dev mock: store for graph-reachability check.
956
- deferViolation(env, importerFile, info, result)
1236
+ deferViolation(env, importerFile, info, isPreTransformResolve)
957
1237
  await processPendingViolations(env, ctx.warn.bind(ctx))
958
1238
  }
959
1239
 
960
1240
  return result
961
1241
  }
962
- return await handleViolation(ctx, env, info, {
1242
+
1243
+ // Non-deferred path: dev error mode only.
1244
+ // Pre-transform resolves are silenced because they fire before the
1245
+ // compiler runs — imports inside `.server()` callbacks haven't been
1246
+ // stripped yet and error mode has no deferred verification.
1247
+ return handleViolation(ctx, env, info, importerIdHint, {
963
1248
  silent: isPreTransformResolve,
964
1249
  })
965
1250
  }
@@ -983,11 +1268,6 @@ export function importProtectionPlugin(
983
1268
  const { startConfig, resolvedStartConfig } = opts.getConfig()
984
1269
  config.srcDirectory = resolvedStartConfig.srcDirectory
985
1270
 
986
- config.entryFiles = [
987
- resolvedStartConfig.routerFilePath,
988
- resolvedStartConfig.startFilePath,
989
- ].filter((f): f is string => Boolean(f))
990
-
991
1271
  const userOpts: ImportProtectionOptions | undefined =
992
1272
  startConfig.importProtection
993
1273
 
@@ -998,18 +1278,14 @@ export function importProtectionPlugin(
998
1278
 
999
1279
  config.enabled = true
1000
1280
 
1001
- if (userOpts?.behavior) {
1002
- if (typeof userOpts.behavior === 'string') {
1003
- config.effectiveBehavior = userOpts.behavior
1004
- } else {
1005
- config.effectiveBehavior =
1006
- viteConfig.command === 'serve'
1007
- ? (userOpts.behavior.dev ?? 'mock')
1008
- : (userOpts.behavior.build ?? 'error')
1009
- }
1281
+ const behavior = userOpts?.behavior
1282
+ if (typeof behavior === 'string') {
1283
+ config.effectiveBehavior = behavior
1010
1284
  } else {
1011
1285
  config.effectiveBehavior =
1012
- viteConfig.command === 'serve' ? 'mock' : 'error'
1286
+ viteConfig.command === 'serve'
1287
+ ? (behavior?.dev ?? 'mock')
1288
+ : (behavior?.build ?? 'error')
1013
1289
  }
1014
1290
 
1015
1291
  config.logMode = userOpts?.log ?? 'once'
@@ -1021,6 +1297,9 @@ export function importProtectionPlugin(
1021
1297
  }
1022
1298
 
1023
1299
  const defaults = getDefaultImportProtectionRules()
1300
+ // Use user-provided patterns when available, otherwise defaults.
1301
+ const pick = <T>(user: Array<T> | undefined, fallback: Array<T>) =>
1302
+ user ? [...user] : [...fallback]
1024
1303
 
1025
1304
  // Client specifier denies always include framework defaults even
1026
1305
  // when the user provides a custom list.
@@ -1029,45 +1308,34 @@ export function importProtectionPlugin(
1029
1308
  ...(userOpts?.client?.specifiers ?? []),
1030
1309
  ])
1031
1310
 
1032
- const clientFiles = userOpts?.client?.files
1033
- ? [...userOpts.client.files]
1034
- : [...defaults.client.files]
1035
- const clientExcludeFiles = userOpts?.client?.excludeFiles
1036
- ? [...userOpts.client.excludeFiles]
1037
- : [...defaults.client.excludeFiles]
1038
- const serverSpecifiers = userOpts?.server?.specifiers
1039
- ? dedupePatterns([...userOpts.server.specifiers])
1040
- : dedupePatterns([...defaults.server.specifiers])
1041
- const serverFiles = userOpts?.server?.files
1042
- ? [...userOpts.server.files]
1043
- : [...defaults.server.files]
1044
- const serverExcludeFiles = userOpts?.server?.excludeFiles
1045
- ? [...userOpts.server.excludeFiles]
1046
- : [...defaults.server.excludeFiles]
1047
-
1048
1311
  config.compiledRules.client = {
1049
1312
  specifiers: compileMatchers(clientSpecifiers),
1050
- files: compileMatchers(clientFiles),
1051
- excludeFiles: compileMatchers(clientExcludeFiles),
1313
+ files: compileMatchers(
1314
+ pick(userOpts?.client?.files, defaults.client.files),
1315
+ ),
1316
+ excludeFiles: compileMatchers(
1317
+ pick(userOpts?.client?.excludeFiles, defaults.client.excludeFiles),
1318
+ ),
1052
1319
  }
1053
1320
  config.compiledRules.server = {
1054
- specifiers: compileMatchers(serverSpecifiers),
1055
- files: compileMatchers(serverFiles),
1056
- excludeFiles: compileMatchers(serverExcludeFiles),
1321
+ specifiers: compileMatchers(
1322
+ dedupePatterns(
1323
+ pick(userOpts?.server?.specifiers, defaults.server.specifiers),
1324
+ ),
1325
+ ),
1326
+ files: compileMatchers(
1327
+ pick(userOpts?.server?.files, defaults.server.files),
1328
+ ),
1329
+ excludeFiles: compileMatchers(
1330
+ pick(userOpts?.server?.excludeFiles, defaults.server.excludeFiles),
1331
+ ),
1057
1332
  }
1058
1333
 
1059
- // Include/exclude
1060
- if (userOpts?.include) {
1061
- config.includeMatchers = compileMatchers(userOpts.include)
1062
- }
1063
- if (userOpts?.exclude) {
1064
- config.excludeMatchers = compileMatchers(userOpts.exclude)
1065
- }
1066
- if (userOpts?.ignoreImporters) {
1067
- config.ignoreImporterMatchers = compileMatchers(
1068
- userOpts.ignoreImporters,
1069
- )
1070
- }
1334
+ config.includeMatchers = compileMatchers(userOpts?.include ?? [])
1335
+ config.excludeMatchers = compileMatchers(userOpts?.exclude ?? [])
1336
+ config.ignoreImporterMatchers = compileMatchers(
1337
+ userOpts?.ignoreImporters ?? [],
1338
+ )
1071
1339
 
1072
1340
  // Marker specifiers
1073
1341
  const markers = getMarkerSpecifiers()
@@ -1085,25 +1353,13 @@ export function importProtectionPlugin(
1085
1353
  if (!config.enabled) return
1086
1354
  // Clear memoization caches that grow unboundedly across builds
1087
1355
  clearNormalizeFilePathCache()
1088
- clearImportPatternCache()
1356
+ extensionlessIdResolver.clear()
1357
+ importPatternCache.clear()
1089
1358
  shouldCheckImporterCache.clear()
1090
1359
 
1091
1360
  // Clear per-env caches
1092
1361
  for (const envState of envStates.values()) {
1093
- envState.resolveCache.clear()
1094
- envState.resolveCacheByFile.clear()
1095
- envState.importLocCache.clear()
1096
- envState.seenViolations.clear()
1097
- envState.transformResultCache.clear()
1098
- envState.transformResultKeysByFile.clear()
1099
- envState.postTransformImports.clear()
1100
- envState.serverFnLookupModules.clear()
1101
- envState.pendingViolations.clear()
1102
- envState.deferredBuildViolations.length = 0
1103
- envState.graph.clear()
1104
- envState.deniedSources.clear()
1105
- envState.deniedEdges.clear()
1106
- envState.mockExportsByImporter.clear()
1362
+ clearEnvState(envState)
1107
1363
  }
1108
1364
 
1109
1365
  // Clear shared state
@@ -1119,42 +1375,14 @@ export function importProtectionPlugin(
1119
1375
  if (mod.id) {
1120
1376
  const id = mod.id
1121
1377
  const importerFile = normalizeFilePath(id)
1378
+
1379
+ // Invalidate extensionless-resolution cache entries affected by this file.
1380
+ extensionlessIdResolver.invalidateByFile(importerFile)
1122
1381
  shared.fileMarkerKind.delete(importerFile)
1123
1382
 
1124
1383
  // Invalidate per-env caches
1125
1384
  for (const envState of envStates.values()) {
1126
- envState.importLocCache.deleteByFile(importerFile)
1127
-
1128
- // Invalidate resolve cache using reverse index
1129
- const resolveKeys = envState.resolveCacheByFile.get(importerFile)
1130
- if (resolveKeys) {
1131
- for (const key of resolveKeys) {
1132
- envState.resolveCache.delete(key)
1133
- }
1134
- envState.resolveCacheByFile.delete(importerFile)
1135
- }
1136
-
1137
- // Invalidate graph edges
1138
- envState.graph.invalidate(importerFile)
1139
- envState.deniedEdges.delete(importerFile)
1140
- envState.mockExportsByImporter.delete(importerFile)
1141
- envState.serverFnLookupModules.delete(importerFile)
1142
- envState.pendingViolations.delete(importerFile)
1143
-
1144
- // Invalidate transform result cache for this file.
1145
- const transformKeys =
1146
- envState.transformResultKeysByFile.get(importerFile)
1147
- if (transformKeys) {
1148
- for (const key of transformKeys) {
1149
- envState.transformResultCache.delete(key)
1150
- envState.postTransformImports.delete(key)
1151
- }
1152
- envState.transformResultKeysByFile.delete(importerFile)
1153
- } else {
1154
- // Fallback: at least clear the physical-file entry.
1155
- envState.transformResultCache.delete(importerFile)
1156
- envState.postTransformImports.delete(importerFile)
1157
- }
1385
+ invalidateFileFromEnv(envState, importerFile)
1158
1386
  }
1159
1387
  }
1160
1388
  }
@@ -1173,7 +1401,7 @@ export function importProtectionPlugin(
1173
1401
  : '(entry)'
1174
1402
  const isEntryResolve = !importer
1175
1403
  const filtered =
1176
- IMPORT_PROTECTION_DEBUG_FILTER === 'entry'
1404
+ process.env.TSR_IMPORT_PROTECTION_DEBUG_FILTER === 'entry'
1177
1405
  ? isEntryResolve
1178
1406
  : matchesDebugFilter(source, importerPath)
1179
1407
  if (filtered) {
@@ -1184,12 +1412,11 @@ export function importProtectionPlugin(
1184
1412
  importer: importerPath,
1185
1413
  isEntryResolve,
1186
1414
  command: config.command,
1187
- behavior: config.effectiveBehavior,
1188
1415
  })
1189
1416
  }
1190
1417
  }
1191
1418
 
1192
- // Internal virtual modules
1419
+ // Internal virtual modules (mock:build:N, mock-edge, mock-runtime, marker)
1193
1420
  const internalVirtualId = resolveInternalVirtualModuleId(source)
1194
1421
  if (internalVirtualId) return internalVirtualId
1195
1422
 
@@ -1217,15 +1444,30 @@ export function importProtectionPlugin(
1217
1444
  env.serverFnLookupModules.has(normalizedImporter) ||
1218
1445
  isScanResolve
1219
1446
 
1220
- // Dev mock mode: defer violations until post-transform data is
1221
- // available, then confirm/discard via graph reachability.
1447
+ // Dev mock mode: defer all violations (including pre-transform
1448
+ // resolves) until post-transform data is available, then
1449
+ // confirm/discard via graph reachability.
1222
1450
  // Build mode (both mock and error): defer violations until
1223
1451
  // generateBundle so tree-shaking can eliminate false positives.
1224
1452
  const isDevMock =
1225
1453
  config.command === 'serve' && config.effectiveBehavior === 'mock'
1226
1454
  const isBuild = config.command === 'build'
1455
+ const shouldDefer = shouldDeferViolation({ isBuild, isDevMock })
1456
+
1457
+ const resolveAgainstImporter = async (): Promise<string | null> => {
1458
+ const primary = await this.resolve(source, importer, {
1459
+ skipSelf: true,
1460
+ })
1461
+ if (primary) {
1462
+ return canonicalizeResolvedId(
1463
+ primary.id,
1464
+ config.root,
1465
+ resolveExtensionlessAbsoluteId,
1466
+ )
1467
+ }
1227
1468
 
1228
- const shouldDefer = (isDevMock && !isPreTransformResolve) || isBuild
1469
+ return null
1470
+ }
1229
1471
 
1230
1472
  // Check if this is a marker import
1231
1473
  const markerKind = config.markerSpecifiers.serverOnly.has(source)
@@ -1258,16 +1500,17 @@ export function importProtectionPlugin(
1258
1500
  source,
1259
1501
  {
1260
1502
  type: 'marker',
1261
- message:
1262
- markerKind === 'server'
1263
- ? `Module "${getRelativePath(normalizedImporter)}" is marked server-only but is imported in the client environment`
1264
- : `Module "${getRelativePath(normalizedImporter)}" is marked client-only but is imported in the server environment`,
1503
+ message: buildMarkerViolationMessage(
1504
+ getRelativePath(normalizedImporter),
1505
+ markerKind,
1506
+ ),
1265
1507
  },
1266
1508
  )
1267
1509
  const markerResult = await reportOrDeferViolation(
1268
1510
  this,
1269
1511
  env,
1270
1512
  normalizedImporter,
1513
+ importer,
1271
1514
  info,
1272
1515
  shouldDefer,
1273
1516
  isPreTransformResolve,
@@ -1284,6 +1527,60 @@ export function importProtectionPlugin(
1284
1527
  }
1285
1528
  }
1286
1529
 
1530
+ // Retroactive marker violation detection: on cold starts, module
1531
+ // A may import module B before B's marker is set (because B hasn't
1532
+ // been processed yet). When B's marker is set (here),
1533
+ // retroactively check all known importers of B in the graph and
1534
+ // create deferred marker violations for them. Without this,
1535
+ // cold-start ordering can miss marker violations that warm starts
1536
+ // detect (warm starts see markers early from cached transforms).
1537
+ //
1538
+ // Uses lightweight `deferViolation` to avoid heavy side effects
1539
+ // (mock module creation, export resolution). Immediately calls
1540
+ // `processPendingViolations` to flush the deferred violations,
1541
+ // because the marker resolveId fires during Vite's import
1542
+ // analysis (after our transform hook) — there may be no
1543
+ // subsequent transform invocation to flush them.
1544
+ //
1545
+ // Guarded by `violatesEnv` (per-environment) plus a per-env
1546
+ // seen-set. The marker is shared across environments but each
1547
+ // env's graph has its own edges; this ensures the check runs
1548
+ // at most once per (env, module) pair.
1549
+ const envRetroKey = `retro-marker:${normalizedImporter}`
1550
+ if (violatesEnv && !env.seenViolations.has(envRetroKey)) {
1551
+ env.seenViolations.add(envRetroKey)
1552
+ let retroDeferred = false
1553
+ const importersMap = env.graph.reverseEdges.get(normalizedImporter)
1554
+ if (importersMap && importersMap.size > 0) {
1555
+ for (const [importerFile, specifier] of importersMap) {
1556
+ if (!specifier) continue
1557
+ if (!shouldCheckImporter(importerFile)) continue
1558
+ const markerInfo = await buildMarkerViolationFromResolvedImport(
1559
+ provider,
1560
+ env,
1561
+ envName,
1562
+ envType,
1563
+ importerFile,
1564
+ specifier,
1565
+ normalizedImporter,
1566
+ getRelativePath(normalizedImporter),
1567
+ )
1568
+ if (markerInfo) {
1569
+ deferViolation(
1570
+ env,
1571
+ importerFile,
1572
+ markerInfo,
1573
+ isPreTransformResolve,
1574
+ )
1575
+ retroDeferred = true
1576
+ }
1577
+ }
1578
+ }
1579
+ if (retroDeferred) {
1580
+ await processPendingViolations(env, this.warn.bind(this))
1581
+ }
1582
+ }
1583
+
1287
1584
  return markerKind === 'server'
1288
1585
  ? resolvedMarkerVirtualModuleId('server')
1289
1586
  : resolvedMarkerVirtualModuleId('client')
@@ -1299,7 +1596,9 @@ export function importProtectionPlugin(
1299
1596
  // 1. Specifier-based denial
1300
1597
  const specifierMatch = matchesAny(source, matchers.specifiers)
1301
1598
  if (specifierMatch) {
1302
- env.graph.addEdge(source, normalizedImporter, source)
1599
+ if (!isPreTransformResolve) {
1600
+ env.graph.addEdge(source, normalizedImporter, source)
1601
+ }
1303
1602
  const info = await buildViolationInfo(
1304
1603
  provider,
1305
1604
  env,
@@ -1314,10 +1613,24 @@ export function importProtectionPlugin(
1314
1613
  message: `Import "${source}" is denied in the ${envType} environment`,
1315
1614
  },
1316
1615
  )
1616
+
1617
+ // Resolve the specifier so edge-survival can verify whether
1618
+ // the import survives the Start compiler transform (e.g.
1619
+ // factory-safe pattern strips imports inside .server() callbacks).
1620
+ if (shouldDefer && !info.resolved) {
1621
+ try {
1622
+ const resolvedForInfo = await resolveAgainstImporter()
1623
+ if (resolvedForInfo) info.resolved = resolvedForInfo
1624
+ } catch {
1625
+ // Non-fatal: edge-survival will skip unresolved specifiers
1626
+ }
1627
+ }
1628
+
1317
1629
  return reportOrDeferViolation(
1318
1630
  this,
1319
1631
  env,
1320
1632
  normalizedImporter,
1633
+ importer,
1321
1634
  info,
1322
1635
  shouldDefer,
1323
1636
  isPreTransformResolve,
@@ -1331,16 +1644,19 @@ export function importProtectionPlugin(
1331
1644
  if (env.resolveCache.has(cacheKey)) {
1332
1645
  resolved = env.resolveCache.get(cacheKey) ?? null
1333
1646
  } else {
1334
- const result = await this.resolve(source, importer, {
1335
- skipSelf: true,
1336
- })
1337
- resolved = result ? normalizeFilePath(result.id) : null
1338
- env.resolveCache.set(cacheKey, resolved)
1339
- getOrCreate(
1340
- env.resolveCacheByFile,
1341
- normalizedImporter,
1342
- () => new Set(),
1343
- ).add(cacheKey)
1647
+ resolved = await resolveAgainstImporter()
1648
+
1649
+ // Only cache successful resolves. Null resolves can be
1650
+ // order-dependent across importer variants (e.g. code-split
1651
+ // `?tsr-split=...` ids) and may poison later lookups.
1652
+ if (resolved !== null) {
1653
+ env.resolveCache.set(cacheKey, resolved)
1654
+ getOrCreate(
1655
+ env.resolveCacheByFile,
1656
+ normalizedImporter,
1657
+ () => new Set(),
1658
+ ).add(cacheKey)
1659
+ }
1344
1660
  }
1345
1661
 
1346
1662
  if (resolved) {
@@ -1351,7 +1667,9 @@ export function importProtectionPlugin(
1351
1667
  env.serverFnLookupModules.add(resolved)
1352
1668
  }
1353
1669
 
1354
- env.graph.addEdge(resolved, normalizedImporter, source)
1670
+ if (!isPreTransformResolve) {
1671
+ env.graph.addEdge(resolved, normalizedImporter, source)
1672
+ }
1355
1673
 
1356
1674
  // Skip file-based and marker-based denial for resolved paths that
1357
1675
  // match the per-environment `excludeFiles` patterns. By default
@@ -1371,7 +1689,7 @@ export function importProtectionPlugin(
1371
1689
  : undefined
1372
1690
 
1373
1691
  if (fileMatch) {
1374
- const info = await buildViolationInfo(
1692
+ const info = await buildFileViolationInfo(
1375
1693
  provider,
1376
1694
  env,
1377
1695
  envName,
@@ -1379,17 +1697,14 @@ export function importProtectionPlugin(
1379
1697
  importer,
1380
1698
  normalizedImporter,
1381
1699
  source,
1382
- {
1383
- type: 'file',
1384
- pattern: fileMatch.pattern,
1385
- resolved,
1386
- message: `Import "${source}" (resolved to "${relativePath}") is denied in the ${envType} environment`,
1387
- },
1700
+ resolved,
1701
+ fileMatch.pattern,
1388
1702
  )
1389
1703
  return reportOrDeferViolation(
1390
1704
  this,
1391
1705
  env,
1392
1706
  normalizedImporter,
1707
+ importer,
1393
1708
  info,
1394
1709
  shouldDefer,
1395
1710
  isPreTransformResolve,
@@ -1411,6 +1726,7 @@ export function importProtectionPlugin(
1411
1726
  this,
1412
1727
  env,
1413
1728
  normalizedImporter,
1729
+ importer,
1414
1730
  markerInfo,
1415
1731
  shouldDefer,
1416
1732
  isPreTransformResolve,
@@ -1447,16 +1763,51 @@ export function importProtectionPlugin(
1447
1763
  const env = envStates.get(envName)
1448
1764
  if (!env || env.deferredBuildViolations.length === 0) return
1449
1765
 
1766
+ const candidateCache = new Map<string, Array<string>>()
1767
+ const toModuleIdCandidates = (id: string): Array<string> => {
1768
+ let cached = candidateCache.get(id)
1769
+ if (cached) return cached
1770
+
1771
+ const out = new Set<string>()
1772
+ const normalized = normalizeFilePath(id)
1773
+ out.add(id)
1774
+ out.add(normalized)
1775
+ out.add(relativizePath(normalized, config.root))
1776
+
1777
+ if (normalized.startsWith(VITE_BROWSER_VIRTUAL_PREFIX)) {
1778
+ const internal = `\0${normalized.slice(VITE_BROWSER_VIRTUAL_PREFIX.length)}`
1779
+ out.add(internal)
1780
+ out.add(relativizePath(normalizeFilePath(internal), config.root))
1781
+ }
1782
+
1783
+ if (normalized.startsWith('\0')) {
1784
+ const browser = `${VITE_BROWSER_VIRTUAL_PREFIX}${normalized.slice(1)}`
1785
+ out.add(browser)
1786
+ out.add(relativizePath(normalizeFilePath(browser), config.root))
1787
+ }
1788
+
1789
+ cached = Array.from(out)
1790
+ candidateCache.set(id, cached)
1791
+ return cached
1792
+ }
1793
+
1450
1794
  // Collect all module IDs that survived tree-shaking in this bundle.
1451
1795
  const survivingModules = new Set<string>()
1452
1796
  for (const chunk of Object.values(bundle)) {
1453
1797
  if (chunk.type === 'chunk') {
1454
1798
  for (const moduleId of Object.keys(chunk.modules)) {
1455
- survivingModules.add(moduleId)
1799
+ for (const candidate of toModuleIdCandidates(moduleId)) {
1800
+ survivingModules.add(candidate)
1801
+ }
1456
1802
  }
1457
1803
  }
1458
1804
  }
1459
1805
 
1806
+ const didModuleSurvive = (moduleId: string): boolean =>
1807
+ toModuleIdCandidates(moduleId).some((candidate) =>
1808
+ survivingModules.has(candidate),
1809
+ )
1810
+
1460
1811
  // Check each deferred violation: if its check module survived
1461
1812
  // in the bundle, the import was NOT tree-shaken — real leak.
1462
1813
  const realViolations: Array<ViolationInfo> = []
@@ -1465,8 +1816,34 @@ export function importProtectionPlugin(
1465
1816
  mockModuleId,
1466
1817
  checkModuleId,
1467
1818
  } of env.deferredBuildViolations) {
1468
- const checkId = checkModuleId ?? mockModuleId
1469
- if (!survivingModules.has(checkId)) continue
1819
+ let survived: boolean
1820
+ if (checkModuleId != null) {
1821
+ // Marker violation: check if the importer survived
1822
+ // (marker is about the file's directive, not a binding).
1823
+ // Include transform-result keys (e.g. code-split variants)
1824
+ // to cover all bundle representations of the importer.
1825
+ const importerVariantIds = new Set<string>([info.importer])
1826
+ const importerKeys = env.transformResultKeysByFile.get(
1827
+ normalizeFilePath(info.importer),
1828
+ )
1829
+ if (importerKeys) {
1830
+ for (const key of importerKeys) {
1831
+ importerVariantIds.add(key)
1832
+ }
1833
+ }
1834
+ survived = false
1835
+ for (const importerId of importerVariantIds) {
1836
+ if (didModuleSurvive(importerId)) {
1837
+ survived = true
1838
+ break
1839
+ }
1840
+ }
1841
+ } else {
1842
+ // File/specifier violation: check if the mock module survived.
1843
+ survived = didModuleSurvive(mockModuleId)
1844
+ }
1845
+
1846
+ if (!survived) continue
1470
1847
 
1471
1848
  if (config.onViolation) {
1472
1849
  const result = await config.onViolation(info)
@@ -1485,12 +1862,7 @@ export function importProtectionPlugin(
1485
1862
  // Mock mode: warn for each surviving violation.
1486
1863
  const seen = new Set<string>()
1487
1864
  for (const info of realViolations) {
1488
- const key = dedupeKey(
1489
- info.type,
1490
- info.importer,
1491
- info.specifier,
1492
- info.resolved,
1493
- )
1865
+ const key = dedupeKey(info)
1494
1866
  if (!seen.has(key)) {
1495
1867
  seen.add(key)
1496
1868
  this.warn(formatViolation(info, config.root))
@@ -1519,6 +1891,9 @@ export function importProtectionPlugin(
1519
1891
  async handler(code, id) {
1520
1892
  const envName = this.environment.name
1521
1893
  const file = normalizeFilePath(id)
1894
+ const envType = getEnvType(envName)
1895
+ const matchers = getRulesForEnvironment(envName)
1896
+ const isBuild = config.command === 'build'
1522
1897
 
1523
1898
  if (IMPORT_PROTECTION_DEBUG) {
1524
1899
  if (matchesDebugFilter(file)) {
@@ -1534,6 +1909,58 @@ export function importProtectionPlugin(
1534
1909
  return undefined
1535
1910
  }
1536
1911
 
1912
+ // Self-denial: if this file is denied in the current environment
1913
+ // (e.g. a `.server` file transformed in the client environment),
1914
+ // replace its entire content with a mock module.
1915
+ //
1916
+ // This is the core mechanism for preventing cross-environment
1917
+ // cache contamination: resolveId never returns virtual module
1918
+ // IDs for file-based violations, so there is nothing for
1919
+ // third-party resolver caches (e.g. vite-tsconfig-paths) to
1920
+ // leak across environments. Each environment's transform
1921
+ // independently decides whether the file is denied.
1922
+ //
1923
+ // In dev mode, this also solves the cold-start problem where
1924
+ // the importer's AST is unavailable for export resolution:
1925
+ // the denied file's own source code is always available here,
1926
+ // so we parse its exports directly.
1927
+ const selfFileMatch = checkFileDenial(getRelativePath(file), matchers)
1928
+ if (selfFileMatch) {
1929
+ // Parse exports once — shared by build and dev paths.
1930
+ // Falls back to empty list on non-standard syntax.
1931
+ let exportNames: Array<string> = []
1932
+ try {
1933
+ exportNames = collectNamedExports(code)
1934
+ } catch {
1935
+ // Parsing may fail on non-standard syntax
1936
+ }
1937
+
1938
+ if (isBuild) {
1939
+ return generateSelfContainedMockModule(exportNames)
1940
+ }
1941
+
1942
+ // Dev mode: generate a mock that imports mock-runtime for
1943
+ // runtime diagnostics (error/warn on property access).
1944
+ const runtimeId = mockRuntimeModuleIdFromViolation(
1945
+ {
1946
+ type: 'file',
1947
+ env: envType,
1948
+ envType,
1949
+ behavior:
1950
+ config.effectiveBehavior === 'error' ? 'error' : 'mock',
1951
+ importer: file,
1952
+ specifier: relativizePath(file, config.root),
1953
+ resolved: file,
1954
+ pattern: selfFileMatch.pattern,
1955
+ message: `File "${relativizePath(file, config.root)}" is denied in the ${envType} environment`,
1956
+ trace: [],
1957
+ },
1958
+ config.mockAccess,
1959
+ config.root,
1960
+ )
1961
+ return generateDevSelfDenialModule(exportNames, runtimeId)
1962
+ }
1963
+
1537
1964
  // getCombinedSourcemap() returns the composed sourcemap
1538
1965
  let map: SourceMapLike | undefined
1539
1966
  try {
@@ -1555,110 +1982,166 @@ export function importProtectionPlugin(
1555
1982
  const cacheKey = normalizePath(id)
1556
1983
 
1557
1984
  const envState = getEnv(envName)
1985
+ const isServerFnLookup = id.includes(SERVER_FN_LOOKUP_QUERY)
1558
1986
 
1559
1987
  // Propagate SERVER_FN_LOOKUP status before import-analysis
1560
- if (id.includes(SERVER_FN_LOOKUP_QUERY)) {
1988
+ if (isServerFnLookup) {
1561
1989
  envState.serverFnLookupModules.add(file)
1562
1990
  }
1563
1991
 
1564
- envState.transformResultCache.set(cacheKey, {
1992
+ const result: TransformResult = {
1565
1993
  code,
1566
1994
  map,
1567
1995
  originalCode,
1568
1996
  lineIndex,
1569
- })
1570
-
1571
- const keySet = getOrCreate(
1572
- envState.transformResultKeysByFile,
1573
- file,
1574
- () => new Set<string>(),
1575
- )
1576
- keySet.add(cacheKey)
1577
-
1578
- // Also store stripped-path entry for physical-file lookups.
1579
- if (cacheKey !== file) {
1580
- envState.transformResultCache.set(file, {
1581
- code,
1582
- map,
1583
- originalCode,
1584
- lineIndex,
1585
- })
1586
- keySet.add(file)
1587
1997
  }
1588
-
1589
- // Resolve import sources to canonical paths for reachability checks.
1998
+ cacheTransformResult(envState, file, cacheKey, result)
1999
+
2000
+ // Build mode: only self-denial (above) and transform caching are
2001
+ // needed. All violations are detected and deferred in resolveId;
2002
+ // self-denial replaces denied file content; generateBundle checks
2003
+ // tree-shaking survival. The import resolution loop below is
2004
+ // dev-mode only — it resolves imports for graph reachability,
2005
+ // catches violations missed on warm starts (where Vite caches
2006
+ // resolveId), and rewrites denied imports to mock modules.
2007
+ if (isBuild) return undefined
2008
+
2009
+ // Dev mode: resolve imports, populate graph, detect violations,
2010
+ // and rewrite denied imports.
2011
+ const isDevMock = config.effectiveBehavior === 'mock'
1590
2012
  const importSources = extractImportSources(code)
1591
2013
  const resolvedChildren = new Set<string>()
2014
+ const deniedSourceReplacements = new Map<string, string>()
1592
2015
  for (const src of importSources) {
1593
2016
  try {
1594
2017
  const resolved = await this.resolve(src, id, { skipSelf: true })
1595
2018
  if (resolved && !resolved.external) {
1596
- const resolvedPath = normalizeFilePath(resolved.id)
2019
+ const resolvedPath = canonicalizeResolvedId(
2020
+ resolved.id,
2021
+ config.root,
2022
+ resolveExtensionlessAbsoluteId,
2023
+ )
2024
+
1597
2025
  resolvedChildren.add(resolvedPath)
2026
+
2027
+ // When the resolved ID is a mock-module (from our
2028
+ // resolveId returning a mock-edge ID), postTransformImports
2029
+ // would only contain the mock ID. Edge-survival needs the
2030
+ // real physical path so pending violations can be matched.
2031
+ //
2032
+ // For relative specifiers we can compute the physical path
2033
+ // directly. For bare/alias specifiers, look up the real
2034
+ // resolved path from the pending violations that were
2035
+ // already stored by resolveId before this transform ran.
2036
+ if (resolved.id.includes('tanstack-start-import-protection:')) {
2037
+ let physicalPath: string | undefined
2038
+ // Look up real resolved path from pending violations
2039
+ const pending = envState.pendingViolations.get(file)
2040
+ if (pending) {
2041
+ const match = pending.find(
2042
+ (pv) => pv.info.specifier === src && pv.info.resolved,
2043
+ )
2044
+ if (match) physicalPath = match.info.resolved
2045
+ }
2046
+ if (physicalPath && physicalPath !== resolvedPath) {
2047
+ resolvedChildren.add(physicalPath)
2048
+ envState.graph.addEdge(physicalPath, file, src)
2049
+ }
2050
+ }
2051
+
1598
2052
  // Populate import graph edges for warm-start trace accuracy
1599
2053
  envState.graph.addEdge(resolvedPath, file, src)
2054
+
2055
+ if (isDevMock) {
2056
+ const relativePath = getRelativePath(resolvedPath)
2057
+ const fileMatch = checkFileDenial(relativePath, matchers)
2058
+
2059
+ if (fileMatch) {
2060
+ const info = await buildFileViolationInfo(
2061
+ envState.transformResultProvider,
2062
+ envState,
2063
+ envName,
2064
+ envType,
2065
+ id,
2066
+ file,
2067
+ src,
2068
+ resolvedPath,
2069
+ fileMatch.pattern,
2070
+ )
2071
+
2072
+ const replacement = await reportOrDeferViolation(
2073
+ this,
2074
+ envState,
2075
+ file,
2076
+ id,
2077
+ info,
2078
+ isDevMock,
2079
+ isServerFnLookup,
2080
+ )
2081
+
2082
+ if (replacement) {
2083
+ deniedSourceReplacements.set(
2084
+ src,
2085
+ replacement.startsWith('\0')
2086
+ ? VITE_BROWSER_VIRTUAL_PREFIX + replacement.slice(1)
2087
+ : replacement,
2088
+ )
2089
+ }
2090
+ }
2091
+ }
1600
2092
  }
1601
2093
  } catch {
1602
2094
  // Non-fatal
1603
2095
  }
1604
2096
  }
1605
2097
  envState.postTransformImports.set(cacheKey, resolvedChildren)
1606
- if (cacheKey !== file) {
2098
+ if (cacheKey !== file && !isServerFnLookup) {
1607
2099
  envState.postTransformImports.set(file, resolvedChildren)
1608
2100
  }
1609
2101
 
1610
2102
  await processPendingViolations(envState, this.warn.bind(this))
1611
2103
 
1612
- return undefined
1613
- },
1614
- },
1615
- },
1616
- {
1617
- // Separate plugin so the transform can be enabled/disabled per-environment.
1618
- name: 'tanstack-start-core:import-protection-mock-rewrite',
1619
- enforce: 'pre',
1620
-
1621
- applyToEnvironment(env) {
1622
- if (!config.enabled) return false
1623
- // We record expected named exports per importer so mock-edge modules
1624
- // can provide explicit ESM named exports. This is needed in both dev
1625
- // and build: native ESM (dev) requires real named exports, and
1626
- // Rolldown (used in Vite 6+) doesn't support Rollup's
1627
- // syntheticNamedExports flag which was previously relied upon in build.
1628
- //
1629
- // In build+error mode we still emit mock modules for deferred
1630
- // violations (checked at generateBundle time), so we always need the
1631
- // export name data when import protection is active.
1632
- return environmentNames.has(env.name)
1633
- },
2104
+ if (deniedSourceReplacements.size > 0) {
2105
+ try {
2106
+ const rewritten = rewriteDeniedImports(
2107
+ code,
2108
+ id,
2109
+ new Set(deniedSourceReplacements.keys()),
2110
+ (source: string) =>
2111
+ deniedSourceReplacements.get(source) ?? source,
2112
+ )
1634
2113
 
1635
- transform: {
1636
- filter: {
1637
- id: {
1638
- include: [/\.[cm]?[tj]sx?($|\?)/],
1639
- },
1640
- },
1641
- handler(code, id) {
1642
- const envName = this.environment.name
1643
- const envState = envStates.get(envName)
1644
- if (!envState) return undefined
2114
+ if (!rewritten) {
2115
+ return undefined
2116
+ }
1645
2117
 
1646
- // Record export names per source for this importer so we can generate
1647
- // dev mock-edge modules without any disk reads.
1648
- try {
1649
- const importerFile = normalizeFilePath(id)
1650
- envState.mockExportsByImporter.set(
1651
- importerFile,
1652
- collectMockExportNamesBySource(code),
1653
- )
1654
- } catch {
1655
- // Best-effort only
2118
+ const normalizedMap = rewritten.map
2119
+ ? {
2120
+ ...rewritten.map,
2121
+ version: Number(rewritten.map.version),
2122
+ sourcesContent:
2123
+ rewritten.map.sourcesContent?.map(
2124
+ (s: string | null) => s ?? '',
2125
+ ) ?? [],
2126
+ }
2127
+ : {
2128
+ version: 3,
2129
+ file: id,
2130
+ names: [],
2131
+ sources: [id],
2132
+ sourcesContent: [code],
2133
+ mappings: '',
2134
+ }
2135
+
2136
+ return {
2137
+ code: rewritten.code,
2138
+ map: normalizedMap,
2139
+ }
2140
+ } catch {
2141
+ // Non-fatal: keep original code when rewrite fails.
2142
+ }
1656
2143
  }
1657
2144
 
1658
- // Note: we no longer rewrite imports here.
1659
- // Dev uses per-importer mock-edge modules in resolveId so native ESM
1660
- // has explicit named exports, and runtime diagnostics are handled by
1661
- // the mock runtime proxy when those mocks are actually invoked.
1662
2145
  return undefined
1663
2146
  },
1664
2147
  },