Skip to content

Commit 03113e4

Browse files
committed
fix: improve snippet lookup with fallback for aliased/pre-resolved specifiers
1 parent 13cc7aa commit 03113e4

File tree

2 files changed

+122
-8
lines changed

2 files changed

+122
-8
lines changed

src/index.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,15 +290,29 @@ function enrichAndReport(
290290
// Build trace
291291
const trace = buildTrace(importer, moduleGraph, resolvedImports, entries, maxTraceDepth, cwd)
292292

293-
// Build snippet — try multiple key forms for the importer since resolveId
294-
// and transform may use different path formats (relative vs absolute, with/without query)
293+
// Build snippet from the module graph (entries are stored under normalized key forms in transform)
295294
let snippet: ImpoundSnippet | undefined
296-
const importerBare = importer.split('?')[0]!
297-
const importerAbs = cwd && !isAbsolute(importer) ? join(cwd, importer) : importer
298-
const importerAbsBare = importerAbs.split('?')[0]!
299-
const importerEntry = moduleGraph.get(importer) || moduleGraph.get(importerBare) || moduleGraph.get(importerAbs) || moduleGraph.get(importerAbsBare)
295+
/* v8 ignore start -- always defined: enrichAndReport is only called when the importer is in the module graph */
296+
const importerEntry = moduleGraph.get(importer)
300297
if (importerEntry) {
301-
const loc = importerEntry.imports.get(rawId)
298+
/* v8 ignore stop */
299+
// Try exact rawId first, then fall back to searching for a matching specifier.
300+
// rawId may differ from the source specifier when bundlers pre-resolve imports.
301+
let loc = importerEntry.imports.get(rawId)
302+
if (!loc) {
303+
const importerBase = importer.split('?')[0]!
304+
for (const [specifier, specLoc] of importerEntry.imports) {
305+
const resolved = RELATIVE_IMPORT_RE.test(specifier) ? join(importerBase, '..', specifier) : specifier
306+
let normalizedResolved = resolved
307+
if (cwd && isAbsolute(resolved)) {
308+
normalizedResolved = relative(cwd, resolved)
309+
}
310+
if (normalizedResolved === id || resolved === rawId || specifier.endsWith(id)) {
311+
loc = specLoc
312+
break
313+
}
314+
}
315+
}
302316
if (loc) {
303317
const text = generateSnippet(importerEntry.code, loc.line, loc.column)
304318
snippet = { text, line: loc.line, column: loc.column }
@@ -492,7 +506,22 @@ export const ImpoundPlugin = createUnplugin((globalOptions: ImpoundOptions) => {
492506
})
493507
}
494508
}
495-
moduleGraph.set(id, { code, imports: importMap })
509+
const graphEntry = { code, imports: importMap }
510+
moduleGraph.set(id, graphEntry)
511+
// Also store under normalized key forms so enrichAndReport can find it
512+
// when the importer path format differs (e.g. with/without query string)
513+
/* v8 ignore start -- defensive normalization for framework-specific virtual module IDs */
514+
const bareId = id.split('?')[0]!
515+
if (bareId !== id)
516+
moduleGraph.set(bareId, graphEntry)
517+
if (isAbsolute(id) && globalOptions.cwd) {
518+
const relId = relative(globalOptions.cwd, id)
519+
moduleGraph.set(relId, graphEntry)
520+
const relBareId = relId.split('?')[0]!
521+
if (relBareId !== relId)
522+
moduleGraph.set(relBareId, graphEntry)
523+
}
524+
/* v8 ignore stop */
496525
}
497526
catch {
498527
// If parsing fails (e.g. non-JS asset), just skip

test/index.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,91 @@ describe('trace mode (deferred violations)', () => {
506506
expect(result.message).toContain('entry.js')
507507
})
508508

509+
it('finds snippet via fallback when rawId differs from source specifier', async () => {
510+
// Simulates frameworks like Nuxt where alias resolution rewrites import specifiers
511+
// before resolveId sees them (e.g. ../server/api/test → ~~/server/api/test)
512+
const plugins = ImpoundPlugin.rollup({ trace: true, cwd: '/root', patterns: [[/server\/api/, 'Not allowed']] })
513+
const pluginArray = Array.isArray(plugins) ? plugins : [plugins]
514+
const impoundPlugin = pluginArray.find(p => p.name === 'impound')!
515+
const tracePlugin = pluginArray.find(p => p.name === 'impound:trace')!
516+
517+
const errors: string[] = []
518+
const context = { error: (msg: string) => errors.push(msg) }
519+
520+
// Transform with source code using a relative specifier
521+
await (tracePlugin as any).transform('import api from "../server/api/test";\nconsole.log(api)', '/root/app/app.vue')
522+
523+
// But resolveId receives an absolute pre-resolved path (as if a bundler alias resolved it)
524+
// rawId will be the absolute path, not the relative specifier from source
525+
await (impoundPlugin as any).resolveId.call(context, '/root/server/api/test', '/root/app/app.vue')
526+
527+
expect(errors).toHaveLength(1)
528+
expect(errors[0]).toContain('Not allowed')
529+
// Fallback search should find the snippet by resolving '../server/api/test' relative to importer
530+
expect(errors[0]).toContain('Code:')
531+
expect(errors[0]).toContain('import api from "../server/api/test"')
532+
expect(errors[0]).toContain('^')
533+
})
534+
535+
it('handles fallback when no specifier matches the resolved id', async () => {
536+
// The fallback loop runs but no specifier matches — snippet remains undefined
537+
const plugins = ImpoundPlugin.rollup({ trace: true, patterns: [[/secret/, 'Not allowed']] })
538+
const pluginArray = Array.isArray(plugins) ? plugins : [plugins]
539+
const impoundPlugin = pluginArray.find(p => p.name === 'impound')!
540+
const tracePlugin = pluginArray.find(p => p.name === 'impound:trace')!
541+
542+
const errors: string[] = []
543+
const context = { error: (msg: string) => errors.push(msg) }
544+
545+
// Transform with an import that doesn't match the resolved id at all
546+
await (tracePlugin as any).transform('import foo from "unrelated-module";\nexport default foo', 'middle.js')
547+
// resolveId with a completely different id
548+
await (impoundPlugin as any).resolveId.call(context, 'secret', 'middle.js')
549+
550+
expect(errors).toHaveLength(1)
551+
expect(errors[0]).toContain('Not allowed')
552+
// No Code: since no specifier in the fallback matched
553+
expect(errors[0]).not.toContain('Code:')
554+
})
555+
556+
it('finds snippet via fallback without cwd', async () => {
557+
// Exercises the fallback path where cwd is undefined
558+
const plugins = ImpoundPlugin.rollup({ trace: true, patterns: [[/server\/api/, 'Not allowed']] })
559+
const pluginArray = Array.isArray(plugins) ? plugins : [plugins]
560+
const impoundPlugin = pluginArray.find(p => p.name === 'impound')!
561+
const tracePlugin = pluginArray.find(p => p.name === 'impound:trace')!
562+
563+
const errors: string[] = []
564+
const context = { error: (msg: string) => errors.push(msg) }
565+
566+
await (tracePlugin as any).transform('import api from "~~/server/api/test";\nconsole.log(api)', 'app.vue')
567+
await (impoundPlugin as any).resolveId.call(context, 'server/api/test', 'app.vue')
568+
569+
expect(errors).toHaveLength(1)
570+
expect(errors[0]).toContain('Code:')
571+
})
572+
573+
it('finds snippet via suffix match when specifier uses aliases', async () => {
574+
// When the source has an alias like ~~/server/api/test and the resolved id is server/api/test
575+
const plugins = ImpoundPlugin.rollup({ trace: true, patterns: [[/server\/api/, 'Not allowed']] })
576+
const pluginArray = Array.isArray(plugins) ? plugins : [plugins]
577+
const impoundPlugin = pluginArray.find(p => p.name === 'impound')!
578+
const tracePlugin = pluginArray.find(p => p.name === 'impound:trace')!
579+
580+
const errors: string[] = []
581+
const context = { error: (msg: string) => errors.push(msg) }
582+
583+
// Transform with aliased import
584+
await (tracePlugin as any).transform('import api from "~~/server/api/test";\nconsole.log(api)', 'app.vue')
585+
586+
// resolveId receives the resolved form (without alias)
587+
await (impoundPlugin as any).resolveId.call(context, 'server/api/test', 'app.vue')
588+
589+
expect(errors).toHaveLength(1)
590+
expect(errors[0]).toContain('Code:')
591+
expect(errors[0]).toContain('~~/server/api/test')
592+
})
593+
509594
it('handles dynamic imports with non-literal specifiers in transform', async () => {
510595
const plugins = ImpoundPlugin.rollup({ trace: true, patterns: [['secret']] })
511596
const pluginArray = Array.isArray(plugins) ? plugins : [plugins]

0 commit comments

Comments
 (0)