@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
@@ -1,6 +1,7 @@
1
1
  import babel from '@babel/core'
2
2
  import * as t from '@babel/types'
3
- import { parseAst } from '@tanstack/router-utils'
3
+ import { parseImportProtectionAst } from './ast'
4
+ import type { ParsedAst } from './ast'
4
5
 
5
6
  type UsagePos = { line: number; column0: number }
6
7
 
@@ -15,8 +16,13 @@ export function findPostCompileUsagePos(
15
16
  code: string,
16
17
  source: string,
17
18
  ): UsagePos | undefined {
18
- const ast = parseAst({ code })
19
+ return findPostCompileUsagePosFromAst(parseImportProtectionAst(code), source)
20
+ }
19
21
 
22
+ function findPostCompileUsagePosFromAst(
23
+ ast: ParsedAst,
24
+ source: string,
25
+ ): UsagePos | undefined {
20
26
  // Collect local names bound from this specifier
21
27
  const imported = new Set<string>()
22
28
  for (const node of ast.program.body) {
@@ -1,8 +1,11 @@
1
1
  import * as t from '@babel/types'
2
- import { generateFromAst, parseAst } from '@tanstack/router-utils'
2
+ import { generateFromAst } from '@tanstack/router-utils'
3
3
 
4
4
  import { MOCK_MODULE_ID } from './virtualModules'
5
5
  import { getOrCreate } from './utils'
6
+ import { parseImportProtectionAst } from './ast'
7
+ import type { SourceMapLike } from './sourceLocation'
8
+ import type { ParsedAst } from './ast'
6
9
 
7
10
  export function isValidExportName(name: string): boolean {
8
11
  if (name === 'default' || name.length === 0) return false
@@ -34,16 +37,17 @@ export function isValidExportName(name: string): boolean {
34
37
  return true
35
38
  }
36
39
 
37
- /**
38
- * Best-effort static analysis of an importer's source to determine which
39
- * named exports are needed per specifier, to keep native ESM valid in dev.
40
- */
41
40
  export function collectMockExportNamesBySource(
42
41
  code: string,
43
42
  ): Map<string, Array<string>> {
44
- const ast = parseAst({ code })
43
+ return collectMockExportNamesBySourceFromAst(parseImportProtectionAst(code))
44
+ }
45
45
 
46
+ function collectMockExportNamesBySourceFromAst(
47
+ ast: ParsedAst,
48
+ ): Map<string, Array<string>> {
46
49
  const namesBySource = new Map<string, Set<string>>()
50
+ const memberBindingToSource = new Map<string, string>()
47
51
  const add = (source: string, name: string) => {
48
52
  if (name === 'default' || name.length === 0) return
49
53
  getOrCreate(namesBySource, source, () => new Set<string>()).add(name)
@@ -54,6 +58,14 @@ export function collectMockExportNamesBySource(
54
58
  if (node.importKind === 'type') continue
55
59
  const source = node.source.value
56
60
  for (const s of node.specifiers) {
61
+ if (t.isImportNamespaceSpecifier(s)) {
62
+ memberBindingToSource.set(s.local.name, source)
63
+ continue
64
+ }
65
+ if (t.isImportDefaultSpecifier(s)) {
66
+ memberBindingToSource.set(s.local.name, source)
67
+ continue
68
+ }
57
69
  if (!t.isImportSpecifier(s)) continue
58
70
  if (s.importKind === 'type') continue
59
71
  const importedName = t.isIdentifier(s.imported)
@@ -76,6 +88,45 @@ export function collectMockExportNamesBySource(
76
88
  }
77
89
  }
78
90
 
91
+ // For namespace/default imports, collect property names used as
92
+ // `binding.foo`/`binding?.foo` so mock-edge modules can expose explicit ESM
93
+ // named exports required by Rolldown/native ESM.
94
+ if (memberBindingToSource.size > 0) {
95
+ const visit = (node: t.Node): void => {
96
+ if (t.isMemberExpression(node)) {
97
+ const object = node.object
98
+ if (t.isIdentifier(object)) {
99
+ const source = memberBindingToSource.get(object.name)
100
+ if (source) {
101
+ const property = node.property
102
+ if (!node.computed && t.isIdentifier(property)) {
103
+ add(source, property.name)
104
+ } else if (node.computed && t.isStringLiteral(property)) {
105
+ add(source, property.value)
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ const keys = t.VISITOR_KEYS[node.type]
112
+ if (!keys) return
113
+ for (const key of keys) {
114
+ const child = (node as unknown as Record<string, unknown>)[key]
115
+ if (Array.isArray(child)) {
116
+ for (const item of child) {
117
+ if (item && typeof item === 'object' && 'type' in item) {
118
+ visit(item as t.Node)
119
+ }
120
+ }
121
+ } else if (child && typeof child === 'object' && 'type' in child) {
122
+ visit(child as t.Node)
123
+ }
124
+ }
125
+ }
126
+
127
+ visit(ast.program)
128
+ }
129
+
79
130
  const out = new Map<string, Array<string>>()
80
131
  for (const [source, set] of namesBySource) {
81
132
  out.set(source, Array.from(set).sort())
@@ -83,6 +134,71 @@ export function collectMockExportNamesBySource(
83
134
  return out
84
135
  }
85
136
 
137
+ /** Collect all valid named export identifiers from the given code. */
138
+ export function collectNamedExports(code: string): Array<string> {
139
+ return collectNamedExportsFromAst(parseImportProtectionAst(code))
140
+ }
141
+
142
+ function collectIdentifiersFromPattern(
143
+ pattern: t.LVal,
144
+ add: (name: string) => void,
145
+ ): void {
146
+ if (t.isIdentifier(pattern)) {
147
+ add(pattern.name)
148
+ } else if (t.isObjectPattern(pattern)) {
149
+ for (const prop of pattern.properties) {
150
+ if (t.isRestElement(prop)) {
151
+ collectIdentifiersFromPattern(prop.argument as t.LVal, add)
152
+ } else {
153
+ collectIdentifiersFromPattern(prop.value as t.LVal, add)
154
+ }
155
+ }
156
+ } else if (t.isArrayPattern(pattern)) {
157
+ for (const elem of pattern.elements) {
158
+ if (elem) collectIdentifiersFromPattern(elem as t.LVal, add)
159
+ }
160
+ } else if (t.isAssignmentPattern(pattern)) {
161
+ collectIdentifiersFromPattern(pattern.left, add)
162
+ } else if (t.isRestElement(pattern)) {
163
+ collectIdentifiersFromPattern(pattern.argument as t.LVal, add)
164
+ }
165
+ }
166
+
167
+ function collectNamedExportsFromAst(ast: ParsedAst): Array<string> {
168
+ const names = new Set<string>()
169
+ const add = (name: string) => {
170
+ if (isValidExportName(name)) names.add(name)
171
+ }
172
+
173
+ for (const node of ast.program.body) {
174
+ if (t.isExportNamedDeclaration(node)) {
175
+ if (node.exportKind === 'type') continue
176
+
177
+ if (node.declaration) {
178
+ const decl = node.declaration
179
+ if (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) {
180
+ if (decl.id?.name) add(decl.id.name)
181
+ } else if (t.isVariableDeclaration(decl)) {
182
+ for (const d of decl.declarations) {
183
+ collectIdentifiersFromPattern(d.id as t.LVal, add)
184
+ }
185
+ }
186
+ }
187
+
188
+ for (const s of node.specifiers) {
189
+ if (!t.isExportSpecifier(s)) continue
190
+ if (s.exportKind === 'type') continue
191
+ const exportedName = t.isIdentifier(s.exported)
192
+ ? s.exported.name
193
+ : s.exported.value
194
+ add(exportedName)
195
+ }
196
+ }
197
+ }
198
+
199
+ return Array.from(names).sort()
200
+ }
201
+
86
202
  /**
87
203
  * Rewrite static imports/re-exports from denied sources using Babel AST transforms.
88
204
  *
@@ -105,8 +221,21 @@ export function rewriteDeniedImports(
105
221
  id: string,
106
222
  deniedSources: Set<string>,
107
223
  getMockModuleId: (source: string) => string = () => MOCK_MODULE_ID,
108
- ): { code: string; map?: object | null } | undefined {
109
- const ast = parseAst({ code })
224
+ ): { code: string; map?: SourceMapLike } | undefined {
225
+ return rewriteDeniedImportsFromAst(
226
+ parseImportProtectionAst(code),
227
+ id,
228
+ deniedSources,
229
+ getMockModuleId,
230
+ )
231
+ }
232
+
233
+ function rewriteDeniedImportsFromAst(
234
+ ast: ParsedAst,
235
+ id: string,
236
+ deniedSources: Set<string>,
237
+ getMockModuleId: (source: string) => string = () => MOCK_MODULE_ID,
238
+ ): { code: string; map?: SourceMapLike } | undefined {
110
239
  let modified = false
111
240
  let mockCounter = 0
112
241
 
@@ -243,5 +372,8 @@ export function rewriteDeniedImports(
243
372
  filename: id,
244
373
  })
245
374
 
246
- return { code: result.code, map: result.map }
375
+ return {
376
+ code: result.code,
377
+ ...(result.map ? { map: result.map as SourceMapLike } : {}),
378
+ }
247
379
  }
@@ -1,7 +1,8 @@
1
1
  import { SourceMapConsumer } from 'source-map'
2
2
  import * as path from 'pathe'
3
3
 
4
- import { escapeRegExp, getOrCreate, normalizeFilePath } from './utils'
4
+ import { findPostCompileUsagePos } from './postCompileUsage'
5
+ import { getOrCreate, normalizeFilePath } from './utils'
5
6
  import type { Loc } from './trace'
6
7
  import type { RawSourceMap } from 'source-map'
7
8
 
@@ -270,36 +271,7 @@ export class ImportLocCache {
270
271
  }
271
272
  }
272
273
 
273
- // Import specifier search (regex-based)
274
-
275
- const importPatternCache = new Map<string, Array<RegExp>>()
276
-
277
- export function clearImportPatternCache(): void {
278
- importPatternCache.clear()
279
- }
280
-
281
- function findFirstImportSpecifierIndex(code: string, source: string): number {
282
- let patterns = importPatternCache.get(source)
283
- if (!patterns) {
284
- const escaped = escapeRegExp(source)
285
- patterns = [
286
- new RegExp(`\\bimport\\s+(['"])${escaped}\\1`),
287
- new RegExp(`\\bfrom\\s+(['"])${escaped}\\1`),
288
- new RegExp(`\\bimport\\s*\\(\\s*(['"])${escaped}\\1\\s*\\)`),
289
- ]
290
- importPatternCache.set(source, patterns)
291
- }
292
-
293
- let best = -1
294
- for (const re of patterns) {
295
- const m = re.exec(code)
296
- if (!m) continue
297
- const idx = m.index + m[0].indexOf(source)
298
- if (idx === -1) continue
299
- if (best === -1 || idx < best) best = idx
300
- }
301
- return best
302
- }
274
+ export type FindImportSpecifierIndex = (code: string, source: string) => number
303
275
 
304
276
  /**
305
277
  * Find the location of an import statement in a transformed module
@@ -311,6 +283,7 @@ export async function findImportStatementLocationFromTransformed(
311
283
  importerId: string,
312
284
  source: string,
313
285
  importLocCache: ImportLocCache,
286
+ findImportSpecifierIndex: FindImportSpecifierIndex,
314
287
  ): Promise<Loc | undefined> {
315
288
  const importerFile = normalizeFilePath(importerId)
316
289
  const cacheKey = `${importerFile}::${source}`
@@ -329,7 +302,7 @@ export async function findImportStatementLocationFromTransformed(
329
302
 
330
303
  const lineIndex = res.lineIndex ?? buildLineIndex(code)
331
304
 
332
- const idx = findFirstImportSpecifierIndex(code, source)
305
+ const idx = findImportSpecifierIndex(code, source)
333
306
  if (idx === -1) {
334
307
  importLocCache.set(cacheKey, null)
335
308
  return undefined
@@ -354,10 +327,6 @@ export async function findPostCompileUsageLocation(
354
327
  provider: TransformResultProvider,
355
328
  importerId: string,
356
329
  source: string,
357
- findPostCompileUsagePos: (
358
- code: string,
359
- source: string,
360
- ) => { line: number; column0: number } | undefined,
361
330
  ): Promise<Loc | undefined> {
362
331
  try {
363
332
  const importerFile = normalizeFilePath(importerId)
@@ -391,6 +360,7 @@ export async function addTraceImportLocations(
391
360
  column?: number
392
361
  }>,
393
362
  importLocCache: ImportLocCache,
363
+ findImportSpecifierIndex: FindImportSpecifierIndex,
394
364
  ): Promise<void> {
395
365
  for (const step of trace) {
396
366
  if (!step.specifier) continue
@@ -400,6 +370,7 @@ export async function addTraceImportLocations(
400
370
  step.file,
401
371
  step.specifier,
402
372
  importLocCache,
373
+ findImportSpecifierIndex,
403
374
  )
404
375
  if (!loc) continue
405
376
  step.line = loc.line
@@ -435,67 +406,26 @@ export function buildCodeSnippet(
435
406
  const res = provider.getTransformResult(moduleId)
436
407
  if (!res) return undefined
437
408
 
438
- const { code: transformedCode, originalCode } = res
439
-
440
- const sourceCode = originalCode ?? transformedCode
409
+ const sourceCode = res.originalCode ?? res.code
441
410
  const targetLine = loc.line // 1-indexed
442
411
  const targetCol = loc.column // 1-indexed
443
412
 
444
413
  if (targetLine < 1) return undefined
445
414
 
446
- const wantStart = Math.max(1, targetLine - contextLines)
447
- const wantEnd = targetLine + contextLines
448
-
449
- // Advance to wantStart
450
- let lineNum = 1
451
- let pos = 0
452
- while (lineNum < wantStart && pos < sourceCode.length) {
453
- const ch = sourceCode.charCodeAt(pos)
454
- if (ch === 10) {
455
- lineNum++
456
- } else if (ch === 13) {
457
- lineNum++
458
- if (
459
- pos + 1 < sourceCode.length &&
460
- sourceCode.charCodeAt(pos + 1) === 10
461
- )
462
- pos++
463
- }
464
- pos++
465
- }
466
- if (lineNum < wantStart) return undefined
467
-
468
- const lines: Array<string> = []
469
- let curLine = wantStart
470
- while (curLine <= wantEnd && pos <= sourceCode.length) {
471
- // Find end of current line
472
- let eol = pos
473
- while (eol < sourceCode.length) {
474
- const ch = sourceCode.charCodeAt(eol)
475
- if (ch === 10 || ch === 13) break
476
- eol++
477
- }
478
- lines.push(sourceCode.slice(pos, eol))
479
- curLine++
480
- if (eol < sourceCode.length) {
481
- if (
482
- sourceCode.charCodeAt(eol) === 13 &&
483
- eol + 1 < sourceCode.length &&
484
- sourceCode.charCodeAt(eol + 1) === 10
485
- ) {
486
- pos = eol + 2
487
- } else {
488
- pos = eol + 1
489
- }
490
- } else {
491
- pos = eol + 1
492
- }
415
+ const allLines = sourceCode.split('\n')
416
+ // Strip trailing \r from \r\n line endings
417
+ for (let i = 0; i < allLines.length; i++) {
418
+ const line = allLines[i]!
419
+ if (line.endsWith('\r')) allLines[i] = line.slice(0, -1)
493
420
  }
494
421
 
495
- if (targetLine > wantStart + lines.length - 1) return undefined
422
+ const wantStart = Math.max(1, targetLine - contextLines)
423
+ const wantEnd = Math.min(allLines.length, targetLine + contextLines)
424
+
425
+ if (targetLine > allLines.length) return undefined
496
426
 
497
- const actualEnd = wantStart + lines.length - 1
498
- const gutterWidth = String(actualEnd).length
427
+ const lines = allLines.slice(wantStart - 1, wantEnd)
428
+ const gutterWidth = String(wantEnd).length
499
429
 
500
430
  const sourceFile = loc.file ?? importerFile
501
431
  const snippetLines: Array<string> = []
@@ -0,0 +1,103 @@
1
+ import type { CompileStartFrameworkOptions, GetConfigFn } from '../types'
2
+ import type { ImportProtectionBehavior } from '../schema'
3
+ import type { CompiledMatcher } from './matchers'
4
+ import type { ImportGraph, ViolationInfo } from './trace'
5
+ import type {
6
+ ImportLocCache,
7
+ TransformResult,
8
+ TransformResultProvider,
9
+ } from './sourceLocation'
10
+
11
+ /** Compiled deny/exclude patterns for one environment (client or server). */
12
+ export interface EnvRules {
13
+ specifiers: Array<CompiledMatcher>
14
+ files: Array<CompiledMatcher>
15
+ excludeFiles: Array<CompiledMatcher>
16
+ }
17
+
18
+ export interface PluginConfig {
19
+ enabled: boolean
20
+ root: string
21
+ command: 'build' | 'serve'
22
+ srcDirectory: string
23
+ framework: CompileStartFrameworkOptions
24
+ effectiveBehavior: ImportProtectionBehavior
25
+ mockAccess: 'error' | 'warn' | 'off'
26
+ logMode: 'once' | 'always'
27
+ maxTraceDepth: number
28
+ compiledRules: {
29
+ client: EnvRules
30
+ server: EnvRules
31
+ }
32
+ includeMatchers: Array<CompiledMatcher>
33
+ excludeMatchers: Array<CompiledMatcher>
34
+ ignoreImporterMatchers: Array<CompiledMatcher>
35
+ markerSpecifiers: { serverOnly: Set<string>; clientOnly: Set<string> }
36
+ envTypeMap: Map<string, 'client' | 'server'>
37
+ onViolation?: (
38
+ info: ViolationInfo,
39
+ ) => boolean | void | Promise<boolean | void>
40
+ }
41
+
42
+ export interface EnvState {
43
+ graph: ImportGraph
44
+ mockExportsByImporter: Map<string, Map<string, Array<string>>>
45
+ resolveCache: Map<string, string | null>
46
+ resolveCacheByFile: Map<string, Set<string>>
47
+ importLocCache: ImportLocCache
48
+ seenViolations: Set<string>
49
+ serverFnLookupModules: Set<string>
50
+ transformResultCache: Map<string, TransformResult>
51
+ transformResultKeysByFile: Map<string, Set<string>>
52
+ transformResultProvider: TransformResultProvider
53
+ postTransformImports: Map<string, Set<string>>
54
+ pendingViolations: Map<string, Array<PendingViolation>>
55
+ deferredBuildViolations: Array<DeferredBuildViolation>
56
+ }
57
+
58
+ export interface PendingViolation {
59
+ info: ViolationInfo
60
+ /** True when the violation originates from a pre-transform resolveId call
61
+ * (e.g. server-fn lookup). These need edge-survival verification because
62
+ * the Start compiler may strip the import later. */
63
+ fromPreTransformResolve?: boolean
64
+ }
65
+
66
+ export interface DeferredBuildViolation {
67
+ info: ViolationInfo
68
+ mockModuleId: string
69
+ checkModuleId?: string
70
+ }
71
+
72
+ export interface SharedState {
73
+ fileMarkerKind: Map<string, 'server' | 'client'>
74
+ }
75
+
76
+ export interface ImportProtectionPluginOptions {
77
+ getConfig: GetConfigFn
78
+ framework: CompileStartFrameworkOptions
79
+ environments: Array<{ name: string; type: 'client' | 'server' }>
80
+ providerEnvName: string
81
+ }
82
+
83
+ export type ModuleGraphNode = {
84
+ id?: string | null
85
+ url?: string
86
+ importers: Set<ModuleGraphNode>
87
+ }
88
+
89
+ export type ViolationReporter = {
90
+ warn: (msg: string) => void
91
+ error: (msg: string) => never
92
+ resolve?: (
93
+ source: string,
94
+ importer?: string,
95
+ options?: {
96
+ skipSelf?: boolean
97
+ custom?: Record<string, unknown>
98
+ },
99
+ ) => Promise<{ id: string; external?: boolean | 'absolute' } | null>
100
+ getModuleInfo?: (id: string) => { code?: string | null } | null
101
+ }
102
+
103
+ export type HandleViolationResult = string | undefined
@@ -1,5 +1,17 @@
1
+ import {
2
+ extname,
3
+ isAbsolute,
4
+ relative,
5
+ resolve as resolvePath,
6
+ } from 'node:path'
1
7
  import { normalizePath } from 'vite'
2
8
 
9
+ import {
10
+ IMPORT_PROTECTION_DEBUG,
11
+ IMPORT_PROTECTION_DEBUG_FILTER,
12
+ KNOWN_SOURCE_EXTENSIONS,
13
+ } from './constants'
14
+
3
15
  export type Pattern = string | RegExp
4
16
 
5
17
  export function dedupePatterns(patterns: Array<Pattern>): Array<Pattern> {
@@ -14,7 +26,8 @@ export function dedupePatterns(patterns: Array<Pattern>): Array<Pattern> {
14
26
  return out
15
27
  }
16
28
 
17
- export function stripViteQuery(id: string): string {
29
+ /** Strip both `?query` and `#hash` from a module ID. */
30
+ export function stripQueryAndHash(id: string): string {
18
31
  const q = id.indexOf('?')
19
32
  const h = id.indexOf('#')
20
33
  if (q === -1 && h === -1) return id
@@ -24,8 +37,7 @@ export function stripViteQuery(id: string): string {
24
37
  }
25
38
 
26
39
  /**
27
- * Strip Vite query parameters and normalize the path in one step.
28
- * Replaces the repeated `normalizePath(stripViteQuery(id))` pattern.
40
+ * Strip Vite query/hash parameters and normalize the path in one step.
29
41
  *
30
42
  * Results are memoized because the same module IDs are processed many
31
43
  * times across resolveId, transform, and trace-building hooks.
@@ -34,7 +46,7 @@ const normalizeFilePathCache = new Map<string, string>()
34
46
  export function normalizeFilePath(id: string): string {
35
47
  let result = normalizeFilePathCache.get(id)
36
48
  if (result === undefined) {
37
- result = normalizePath(stripViteQuery(id))
49
+ result = normalizePath(stripQueryAndHash(id))
38
50
  normalizeFilePathCache.set(id, result)
39
51
  }
40
52
  return result
@@ -91,3 +103,110 @@ export function extractImportSources(code: string): Array<string> {
91
103
  }
92
104
  return sources
93
105
  }
106
+
107
+ /** Log import-protection debug output when debug mode is enabled. */
108
+ export function debugLog(...args: Array<unknown>): void {
109
+ if (!IMPORT_PROTECTION_DEBUG) return
110
+ console.warn('[import-protection:debug]', ...args)
111
+ }
112
+
113
+ /** Check if any value matches the configured debug filter (if present). */
114
+ export function matchesDebugFilter(...values: Array<string>): boolean {
115
+ const debugFilter = IMPORT_PROTECTION_DEBUG_FILTER
116
+ if (!debugFilter) return true
117
+ return values.some((v) => v.includes(debugFilter))
118
+ }
119
+
120
+ /** Strip `?query` (but not `#hash`) from a module ID. */
121
+ export function stripQuery(id: string): string {
122
+ const queryIndex = id.indexOf('?')
123
+ return queryIndex === -1 ? id : id.slice(0, queryIndex)
124
+ }
125
+
126
+ export function withoutKnownExtension(id: string): string {
127
+ const ext = extname(id)
128
+ return KNOWN_SOURCE_EXTENSIONS.has(ext) ? id.slice(0, -ext.length) : id
129
+ }
130
+
131
+ /**
132
+ * Check whether `filePath` is contained inside `directory` using a
133
+ * boundary-safe comparison. A naïve `filePath.startsWith(directory)`
134
+ * would incorrectly treat `/app/src2/foo.ts` as inside `/app/src`.
135
+ */
136
+ export function isInsideDirectory(
137
+ filePath: string,
138
+ directory: string,
139
+ ): boolean {
140
+ const rel = relative(resolvePath(directory), resolvePath(filePath))
141
+ return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel)
142
+ }
143
+
144
+ /**
145
+ * Decide whether a violation should be deferred for later verification
146
+ * rather than reported immediately.
147
+ *
148
+ * Build mode: always defer — generateBundle checks tree-shaking.
149
+ * Dev mock mode: always defer — edge-survival verifies whether the Start
150
+ * compiler strips the import (factory-safe pattern). All violation
151
+ * types and specifier formats are handled uniformly by the
152
+ * edge-survival mechanism in processPendingViolations.
153
+ * Dev error mode: never defer — throw immediately (no mock fallback).
154
+ */
155
+ export function shouldDeferViolation(opts: {
156
+ isBuild: boolean
157
+ isDevMock: boolean
158
+ }): boolean {
159
+ return opts.isBuild || opts.isDevMock
160
+ }
161
+
162
+ export function buildSourceCandidates(
163
+ source: string,
164
+ resolved: string | undefined,
165
+ root: string,
166
+ ): Set<string> {
167
+ const candidates = new Set<string>()
168
+ const push = (value: string | undefined) => {
169
+ if (!value) return
170
+ candidates.add(value)
171
+ candidates.add(stripQuery(value))
172
+ candidates.add(withoutKnownExtension(stripQuery(value)))
173
+ }
174
+
175
+ push(source)
176
+ if (resolved) {
177
+ push(resolved)
178
+ const relativeResolved = relativizePath(resolved, root)
179
+ push(relativeResolved)
180
+ push(`./${relativeResolved}`)
181
+ push(`/${relativeResolved}`)
182
+ }
183
+
184
+ return candidates
185
+ }
186
+
187
+ export function buildResolutionCandidates(id: string): Array<string> {
188
+ const normalized = normalizeFilePath(id)
189
+ const stripped = stripQuery(normalized)
190
+
191
+ return [...new Set([id, normalized, stripped])]
192
+ }
193
+
194
+ export function canonicalizeResolvedId(
195
+ id: string,
196
+ root: string,
197
+ resolveExtensionlessAbsoluteId: (value: string) => string,
198
+ ): string {
199
+ const stripped = stripQuery(id)
200
+ let normalized = normalizeFilePath(stripped)
201
+
202
+ if (
203
+ !isAbsolute(normalized) &&
204
+ !normalized.startsWith('.') &&
205
+ !normalized.startsWith('\0') &&
206
+ !/^[a-zA-Z]+:/.test(normalized)
207
+ ) {
208
+ normalized = normalizeFilePath(resolvePath(root, normalized))
209
+ }
210
+
211
+ return resolveExtensionlessAbsoluteId(normalized)
212
+ }