import { TraceMap, originalPositionFor, sourceContentFor } from "@jridgewell/trace-mapping"; import { init, parse } from "es-module-lexer"; import { isAbsolute, join, relative } from "pathe"; import { createUnplugin } from "unplugin"; import { createFilter } from "unplugin-utils"; //#region src/index.ts const PROXY_ID = "\0impound:proxy"; const PROXY_ID_RE = /^\0impound:proxy$/; const PROXY_CODE = ` function createMock(name, overrides = {}) { const proxyFn = function () {}; proxyFn.prototype.name = name; const props = {}; const proxy = new Proxy(proxyFn, { get(_target, prop) { if (prop === "caller") return null; if (prop === "__createMock__") return createMock; if (prop === "__mock__") return true; if (prop in overrides) return overrides[prop]; if (prop === "then") return (fn) => Promise.resolve(fn()); if (prop === "catch") return (_fn) => Promise.resolve(); if (prop === "finally") return (fn) => Promise.resolve(fn()); return (props[prop] = props[prop] || createMock(\`\${name}.\${prop.toString()}\`)); }, apply(_target, _this, _args) { return createMock(\`\${name}()\`); }, construct(_target, _args, _newT) { return createMock(\`[\${name}]\`); }, }); return proxy; } export default createMock("mock"); `.trim(); const RELATIVE_IMPORT_RE = /^\.\.?\//; const BINARY_ASSET_RE = /\.(?:png|jpe?g|gif|webp|avif|bmp|ico|woff2?|[ot]tf|eot|mp[34]|webm|ogg|wav|flac|pdf|zip|gz|wasm)(?:\?.*)?$/i; function stripQuery(id) { const queryIndex = id.indexOf("?"); return queryIndex === -1 ? id : id.slice(0, queryIndex); } /** Map imports to 1-indexed lines and 0-indexed UTF-16 columns. */ function getImportLocations(code, imports) { const locations = /* @__PURE__ */ new Map(); let line = 1; let lastNewline = -1; let offset = 0; for (const imp of imports) { if (!imp.specifier || imp.type !== "dynamic" && imp.typeOnly) continue; /* v8 ignore start -- es-module-lexer emits source-ordered imports; a reset only if that changes */ if (imp.start < offset) { line = 1; lastNewline = -1; offset = 0; } /* v8 ignore stop */ while (offset < imp.start && offset < code.length) { if (code[offset] === "\n") { line++; lastNewline = offset; } offset++; } locations.set(imp.specifier, { line, column: imp.start - lastNewline - 1, statementStart: imp.importStart, statementEnd: imp.importEnd }); } return locations; } /** Generate a code snippet with context lines, a `>` marker, and a `^` caret. */ function generateSnippet(code, line, column, context = 2) { const lines = code.split("\n"); const start = Math.max(0, line - 1 - context); const end = Math.min(lines.length, line + context); const gutterWidth = String(end).length; const result = []; for (let i = start; i < end; i++) { const lineNum = i + 1; const gutter = String(lineNum).padStart(gutterWidth); const marker = lineNum === line ? ">" : " "; result.push(`${marker} ${gutter} | ${lines[i]}`); if (lineNum === line) result.push(` ${" ".repeat(gutterWidth)} | ${" ".repeat(column)}^`); } return result.join("\n"); } /** Locate a denied specifier's import statement, by raw specifier then by resolved target. */ function findImportLocation(imports, rawId, id, importer, cwd) { const direct = imports.get(rawId); if (direct) return direct; const importerBase = stripQuery(importer); for (const [specifier, specLoc] of imports) { const resolved = RELATIVE_IMPORT_RE.test(specifier) ? join(importerBase, "..", specifier) : specifier; let normalizedResolved = resolved; if (cwd && isAbsolute(resolved)) normalizedResolved = relative(cwd, resolved); if (normalizedResolved === id || resolved === rawId || specifier === id || specifier.endsWith(`/${id}`)) return specLoc; } } /** Build an import trace from entry to the importer via BFS backwards through the graph. */ function buildTrace(graph, importer, maxDepth) { const cameFrom = /* @__PURE__ */ new Map([[importer, void 0]]); const queue = [importer]; const depths = [1]; let found; for (let cursor = 0; cursor < queue.length && found === void 0; cursor++) { const current = queue[cursor]; const depth = depths[cursor]; if (depth > maxDepth) continue; if (graph.isEntry(current)) { found = current; break; } for (const parent of graph.parents(current)) { if (cameFrom.has(parent)) continue; cameFrom.set(parent, current); if (graph.isEntry(parent)) { found = parent; break; } queue.push(parent); depths.push(depth + 1); } } if (found === void 0) return [{ file: importer }]; const chain = []; for (let node = found; node !== void 0; node = cameFrom.get(node)) chain.push(node); return chain.map((file, i) => { const next = chain[i + 1]; const edge = next === void 0 ? void 0 : graph.importOf(file, next); if (!edge) return { file }; const step = { file, import: edge.specifier }; if (edge.line != null) { step.line = edge.line; step.column = edge.column; } return step; }); } /** Read the graph collected during transform, for `trace: true`. */ function eagerGraph(moduleImports, resolvedImports, entries, cwd) { const normalize = (p) => isAbsolute(p) && cwd ? relative(cwd, p) : p; const importersOf = /* @__PURE__ */ new Map(); for (const [moduleId, imports] of resolvedImports) { if (!moduleImports.has(moduleId)) continue; for (const resolvedId of imports.values()) { const existing = importersOf.get(resolvedId); if (existing) existing.push(moduleId); else importersOf.set(resolvedId, [moduleId]); } } return { parents: (id) => importersOf.get(id) || importersOf.get(normalize(id)) || [], isEntry: (id) => entries.has(id) || entries.has(normalize(id)), importOf(file, next) { /* v8 ignore next -- the walk only reaches files that have resolved imports */ for (const [specifier, resolvedId] of resolvedImports.get(file) || []) if (resolvedId === next) { const loc = moduleImports.get(file)?.get(specifier); return { specifier, line: loc?.line, column: loc?.column }; } } }; } function formatTrace(trace, cwd) { return trace.map((step, i) => { const file = cwd && isAbsolute(step.file) ? relative(cwd, step.file) : step.file; const loc = step.line != null ? `:${step.line}:${step.column}` : ""; const entry = i === 0 ? " (entry)" : ""; const imp = step.import ? ` (import "${step.import}")` : ""; return ` ${i + 1}. ${file}${loc}${entry}${imp}`; }).join("\n"); } function enrichAndReport(violation, moduleImports, moduleSources, graph, maxTraceDepth, cwd, warnedMessages) { const { id, rawId, importer, errorFn } = violation; const trace = buildTrace(graph, importer, maxTraceDepth); let snippet; const importerImports = moduleImports.get(importer); const importerSource = moduleSources.get(importer); /* v8 ignore start -- always defined: the importer was transformed and matched a matcher, so its source is retained */ if (importerImports && importerSource) { /* v8 ignore stop */ const loc = findImportLocation(importerImports, rawId, id, importer, cwd); if (loc) { let snippetCode = importerSource.code; let snippetLine = loc.line; let snippetColumn = loc.column; if (importerSource.sourceMap) try { const tracer = new TraceMap(importerSource.sourceMap); const original = originalPositionFor(tracer, { line: loc.line, column: loc.column }); if (original.line != null) { snippetLine = original.line; /* v8 ignore start -- originalPositionFor always returns column and source when line is non-null */ snippetColumn = original.column ?? 0; const originalSource = original.source != null ? sourceContentFor(tracer, original.source) : null; /* v8 ignore stop */ if (originalSource != null) snippetCode = originalSource; else if (importerSource.originalCode) snippetCode = importerSource.originalCode; } } catch {} snippet = { text: generateSnippet(snippetCode, snippetLine, snippetColumn), line: snippetLine, column: snippetColumn }; } } reportViolation(violation, trace, snippet, cwd, errorFn, warnedMessages); } /** Assemble the final message, run the `onViolation` hook, de-duplicate, and report. */ function reportViolation(violation, trace, snippet, cwd, errorFn, warnedMessages) { const { id, relativeImporter, options, suggestions } = violation; let message = violation.message; if (trace.length > 1) message += `\n\nTrace:\n${formatTrace(trace, cwd)}`; if (snippet) message += `\n\nCode:\n${snippet.text}`; if (suggestions?.length) message += `\n\nSuggestions:\n${suggestions.map((s) => ` - ${s}`).join("\n")}`; const violationInfo = { id, importer: relativeImporter, message, trace: trace.length > 1 ? trace : void 0, snippet }; if (options.onViolation?.(violationInfo) === false) return; if (!warnedMessages || !warnedMessages.has(message)) { warnedMessages?.add(message); errorFn(message); } } let lexerReady = false; /** Resolve once, then stay synchronous: a per-module `await` costs a microtask each. */ function whenLexerReady() { if (lexerReady) return; return init().then(() => { lexerReady = true; }); } /** Lex a module's imports once per reporting pass. Only modules on a violation's chain are read. */ function lexImports(cache, id, code) { const cached = cache.get(id); if (cached) return cached; let locations = /* @__PURE__ */ new Map(); try { const [imports] = parse(code, id); locations = getImportLocations(code, imports); } catch {} cache.set(id, locations); return locations; } /** Read the bundler's own graph, for `trace: 'lazy'`. Only modules on a chain are lexed. */ function lazyGraph(ctx, cwd, cache) { return { parents(id) { const info = ctx.getModuleInfo(id); return [...info?.importers || [], ...info?.dynamicImporters || []]; }, isEntry: (id) => ctx.getModuleInfo(id)?.isEntry === true, importOf(file, next) { const code = ctx.getModuleInfo(file)?.code; if (!code) return; const nextRelative = isAbsolute(next) && cwd ? relative(cwd, next) : next; for (const [specifier, loc] of lexImports(cache, file, code)) { const resolved = RELATIVE_IMPORT_RE.test(specifier) ? join(stripQuery(file), "..", specifier) : specifier; if (resolved === next || resolved === nextRelative || specifier === nextRelative || specifier.endsWith(`/${nextRelative}`)) return { specifier, line: loc.line, column: loc.column }; } } }; } /** * Adapt webpack's and rspack's `moduleGraph` to the same shape rollup's `getModuleInfo` * gives, so the lazy walk works there too. `originalSource()` is the pre-transform * source, so these snippets point at original code rather than transformed. */ function nativeGraphContext(native, cwd) { const compilation = native?.compilation; const moduleGraph = compilation?.moduleGraph; if (!moduleGraph || !compilation?.modules) return; const byId = /* @__PURE__ */ new Map(); for (const module of compilation.modules) { const resource = module.resource; if (!resource) continue; byId.set(resource, module); if (cwd && isAbsolute(resource)) byId.set(relative(cwd, resource), module); } const errors = compilation.errors; return { addError: errors && ((message) => { errors.push(new Error(message)); }), graph: { getModuleInfo(id) { const module = byId.get(id) || byId.get(stripQuery(id)); if (!module) return null; const importers = []; let isEntry = false; for (const connection of moduleGraph.getIncomingConnections(module)) { if (!connection.originModule) { isEntry = true; continue; } if (connection.originModule.resource) importers.push(connection.originModule.resource); } let code; try { code = module.originalSource?.()?.source()?.toString(); } catch {} return { code, importers, isEntry }; } } }; } /** Enrich a held violation once the bundler's graph is complete. Nothing was collected earlier. */ async function enrichAndReportLazy(ctx, violation, maxTraceDepth, cwd, errorFn, cache) { await whenLexerReady(); const trace = buildTrace(lazyGraph(ctx, cwd, cache), violation.importer, maxTraceDepth); let snippet; const code = ctx.getModuleInfo(violation.importer)?.code; if (code) { const loc = findImportLocation(lexImports(cache, violation.importer, code), violation.rawId, violation.id, violation.importer, cwd); if (loc) snippet = { text: generateSnippet(code, loc.line, loc.column), line: loc.line, column: loc.column }; } reportViolation(violation, trace, snippet, cwd, errorFn, violation.warnedMessages); } const ImpoundPlugin = createUnplugin((globalOptions) => { const matchers = "matchers" in globalOptions ? globalOptions.matchers : [globalOptions]; const traceMode = globalOptions.trace === "lazy" ? "lazy" : globalOptions.trace === true ? "eager" : "off"; const traceEnabled = traceMode !== "off"; const maxTraceDepth = globalOptions.maxTraceDepth ?? 20; const moduleImports = /* @__PURE__ */ new Map(); const moduleSources = /* @__PURE__ */ new Map(); const resolvedImports = /* @__PURE__ */ new Map(); const entries = /* @__PURE__ */ new Set(); const pendingViolations = /* @__PURE__ */ new Map(); const heldMessages = /* @__PURE__ */ new Set(); const cwd = globalOptions.cwd; function hold(importer, violation) { if (violation.warnedMessages) { const key = `${importer}\0${violation.message}`; if (heldMessages.has(key)) return; heldMessages.add(key); } let pending = pendingViolations.get(importer); if (!pending) { pending = []; pendingViolations.set(importer, pending); } pending.push(violation); } const matcherStates = matchers.map((options) => ({ options, filter: createFilter(options.include, options.exclude, { resolve: cwd }), filterCache: /* @__PURE__ */ new Map(), excludeFilter: options.excludeFiles?.length ? createFilter(options.excludeFiles, void 0, { resolve: cwd }) : void 0, warnedMessages: options.error === false && options.warn !== "always" ? /* @__PURE__ */ new Set() : void 0 })); function includes(matcher, id) { let included = matcher.filterCache.get(id); if (included === void 0) { let filterId = id; if (id.startsWith("\0")) filterId = id.slice(1); else if (id.startsWith("/\0")) filterId = `/${id.slice(2)}`; included = matcher.filter(filterId); matcher.filterCache.set(id, included); } return included; } function includedByAny(id) { for (const matcher of matcherStates) if (includes(matcher, id)) return true; return false; } const relativeImporterCache = /* @__PURE__ */ new Map(); let cachedEagerGraph; function getEagerGraph() { return cachedEagerGraph ??= eagerGraph(moduleImports, resolvedImports, entries, cwd); } const plugins = [{ name: "impound", enforce: "pre", ...traceEnabled ? { buildEnd: reportHeldViolations } : {}, load: { filter: { id: PROXY_ID_RE }, handler(id) { if (id === PROXY_ID) return { code: PROXY_CODE, syntheticNamedExports: "default" }; } }, resolveId(id, importer, resolveOptions) { if (id === PROXY_ID) return id; if (!importer) { if (traceMode === "eager" && resolveOptions?.isEntry) { entries.add(id); cachedEagerGraph = void 0; } return; } const rawId = id; let resolvedId; let relativeId; let relativeImporter; if (traceMode === "eager") { resolvedId = RELATIVE_IMPORT_RE.test(rawId) ? join(stripQuery(importer), "..", rawId) : rawId; relativeId = isAbsolute(resolvedId) && cwd ? relative(cwd, resolvedId) : resolvedId; let importerResolved = resolvedImports.get(importer); if (!importerResolved) { importerResolved = /* @__PURE__ */ new Map(); resolvedImports.set(importer, importerResolved); } if (importerResolved.get(rawId) !== relativeId) { importerResolved.set(rawId, relativeId); cachedEagerGraph = void 0; } } for (const matcher of matcherStates) { if (!includes(matcher, importer)) continue; resolvedId ??= RELATIVE_IMPORT_RE.test(rawId) ? join(stripQuery(importer), "..", rawId) : rawId; if (matcher.excludeFilter?.(resolvedId)) continue; relativeId ??= isAbsolute(resolvedId) && cwd ? relative(cwd, resolvedId) : resolvedId; const id = relativeId; if (relativeImporter === void 0) { relativeImporter = relativeImporterCache.get(importer); if (relativeImporter === void 0) { relativeImporter = isAbsolute(importer) && cwd ? relative(cwd, importer) : importer; relativeImporterCache.set(importer, relativeImporter); } } const { options, warnedMessages } = matcher; let matched = false; let formattedImporter; for (const [pattern, warning, suggestions] of options.patterns) { const usesImport = pattern instanceof RegExp ? pattern.test(id) : typeof pattern === "string" ? pattern === id : pattern(id, relativeImporter); if (usesImport) { formattedImporter ??= stripQuery(relativeImporter); const baseMessage = `${typeof usesImport === "string" ? usesImport : warning || "Invalid import"} [importing \`${id}\` from \`${formattedImporter}\`]`; if (traceEnabled) { const useConsoleError = options.error === false; const violation = { id, rawId, importer, relativeImporter, message: baseMessage, suggestions, options, errorFn: traceMode === "lazy" ? void 0 : useConsoleError ? console.error : this.error.bind(this), useConsoleError, warnedMessages }; if (traceMode === "eager" && moduleImports.has(importer)) enrichAndReport(violation, moduleImports, moduleSources, getEagerGraph(), maxTraceDepth, cwd, warnedMessages); else hold(importer, violation); } else { let message = baseMessage; if (suggestions?.length) message += `\n\nSuggestions:\n${suggestions.map((s) => ` - ${s}`).join("\n")}`; if (options.onViolation?.({ id, importer: relativeImporter, message }) === false) continue; if (!warnedMessages || !warnedMessages.has(message)) { warnedMessages?.add(message); (options.error === false ? console.error : this.error.bind(this))(message); } } matched = true; } } if (matched) return PROXY_ID; } } }]; if (traceMode === "eager") { function registerModule(code, id, getCombinedSourcemap) { const tracked = includedByAny(id); let importMap = /* @__PURE__ */ new Map(); let originalCode; let sourceMap; try { const [imports] = parse(code, id); importMap = getImportLocations(code, imports); if (tracked && getCombinedSourcemap) try { const map = getCombinedSourcemap(); if (map?.mappings) { sourceMap = map; const sourcesContent = map.sourcesContent; if (sourcesContent?.length && sourcesContent[0]) originalCode = sourcesContent[0]; } } catch {} } catch { importMap = /* @__PURE__ */ new Map(); } const source = tracked ? { code, originalCode, sourceMap } : void 0; const register = (key) => { moduleImports.set(key, importMap); if (source) moduleSources.set(key, source); }; register(id); /* v8 ignore start -- defensive normalization for framework-specific virtual module IDs */ const bareId = stripQuery(id); if (bareId !== id) register(bareId); if (isAbsolute(id) && cwd) { const relId = relative(cwd, id); register(relId); const relBareId = stripQuery(relId); if (relBareId !== relId) register(relBareId); } /* v8 ignore stop */ cachedEagerGraph = void 0; if (pendingViolations.size === 0) return; const relativeId = isAbsolute(id) && cwd ? relative(cwd, id) : id; const candidateKeys = /* @__PURE__ */ new Set([ id, relativeId, bareId, stripQuery(relativeId) ]); for (const key of candidateKeys) { const pending = pendingViolations.get(key); if (pending) { pendingViolations.delete(key); for (const violation of pending) enrichAndReport(violation, moduleImports, moduleSources, getEagerGraph(), maxTraceDepth, cwd, violation.warnedMessages); } } } function traceTransform(code, id, getCombinedSourcemap) { if (BINARY_ASSET_RE.test(id)) return; const pending = whenLexerReady(); if (pending) return pending.then(() => registerModule(code, id, getCombinedSourcemap)); registerModule(code, id, getCombinedSourcemap); } const transformWithSourceMap = { transform(code, id) { return traceTransform(code, id, this.getCombinedSourcemap?.bind(this)); } }; const filteredTransformWithSourceMap = { transform: { filter: { id: { exclude: BINARY_ASSET_RE } }, handler: transformWithSourceMap.transform } }; const tracePlugin = { name: "impound:trace", transform: { filter: { id: { exclude: BINARY_ASSET_RE } }, handler: traceTransform }, rollup: transformWithSourceMap, vite: filteredTransformWithSourceMap, rolldown: filteredTransformWithSourceMap }; plugins.push(tracePlugin); } async function reportHeldViolations(buildError) { if (buildError || pendingViolations.size === 0) { pendingViolations.clear(); return; } const held = []; for (const violations of pendingViolations.values()) for (const violation of violations) held.push(violation); pendingViolations.clear(); const ctx = this; const native = typeof ctx.getModuleInfo === "function" ? void 0 : nativeGraphContext(ctx.getNativeBuildContext?.(), cwd); const graph = typeof ctx.getModuleInfo === "function" ? ctx : native?.graph; const cache = /* @__PURE__ */ new Map(); for (const violation of held) { const errorFn = violation.useConsoleError ? console.error : typeof ctx.error === "function" ? ctx.error.bind(ctx) : native?.addError || ((msg) => { throw new Error(msg); }); if (graph) await enrichAndReportLazy(graph, violation, maxTraceDepth, cwd, errorFn, cache); else reportViolation(violation, [{ file: violation.relativeImporter }], void 0, cwd, errorFn, violation.warnedMessages); } } return plugins; }); //#endregion export { ImpoundPlugin };