Thanks to visit codestin.com
Credit goes to github.com

Skip to content

unplugin's transform emits a line-only sourcemap (generateMap() with no options), silently collapsing code coverage #562

Description

@zizzfizzix

Environment

  • unimport 6.4.0 (the same code is on main)
  • Any bundler consuming unimport/unplugin

Describe the bug

The unplugin transform builds its sourcemap with a bare MagicString#generateMap():

https://github.com/unjs/unimport/blob/main/src/unplugin.ts#L62-L65

return {
  code: s.toString(),
  map: s.generateMap(),
}

With no options, generateMap() defaults to hires: false and leaves source undefined, so the emitted map has exactly one mapping per generated line — all at column 0 — and sources: [""].

Auto-imports are injected at the top of the file, so line numbers still line up and most things look fine. But every column collapses to 0, which breaks anything that consumes columns.

The concrete failure we hit is V8 code coverage (@vitest/coverage-v8): a module that references even one auto-imported global drops out of the coverage report entirely and is scored 100% (0/0 statements). Because the number goes up, this fails silently — coverage thresholds keep passing while real coverage rots.

Reproduction

import MagicString from 'magic-string'
import { createUnimport } from 'unimport'

const ctx = createUnimport({ presets: [{ from: 'my-lib', imports: ['helper'] }] })
await ctx.init()

const code = [
  'export function classify(n) {',
  '  const h = helper();',
  '  if (n > 10) return h.big;',
  '  if (n > 0) return h.small;',
  '  return h.zero;',
  '}',
  '',
].join('\n')

const run = async (opts) => {
  const s = new MagicString(code)
  await ctx.injectImports(s, '/src/a.ts')
  return s.generateMap(opts)
}

const segments = (m) => m.mappings.split(';').map((l) => (l ? l.split(',').length : 0))

const plain = await run(undefined)                                    // what unplugin emits today
const hires = await run({ hires: 'boundary', source: '/src/a.ts' })

console.log(plain.sources, segments(plain))
console.log(hires.sources, segments(hires))

Output:

current (s.generateMap()):
  sources          : [""]
  segments per line: [0,1,1,1,1,1,1,0]
  mappings         : ";AAAA;AACA;AACA;AACA;AACA;AACA;"

with hires: 'boundary' + source:
  sources          : ["/src/a.ts"]
  segments per line: [0,10,12,18,18,8,1,0]
  mappings         : ";AAAA,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;AAC5B,..."

End-to-end effect, measured on a four-branch module using one auto-imported global with a test covering a single branch:

Statements Branches
s.generateMap() (today) 100% (0/0) — file absent from the report 100% (0/0)
{ hires: 'boundary', source: id } 37.5% (3/8) 16.66% (1/6)

Expected behaviour

The transform emits a sourcemap with column-level mappings and a populated source, so downstream consumers can map positions back accurately.

Suggested fix

       return {
         code: s.toString(),
-        map: s.generateMap(),
+        map: s.generateMap({ hires: 'boundary', source: id }),
       }

hires: 'boundary' measured identical to hires: true in our testing while being cheaper to compute and producing a smaller map.

If you'd rather not change the default, exposing the generateMap options through UnimportPluginOptions would let consumers opt in — though a correct map seems like the better default.

Happy to open a PR for whichever you prefer.

Downstream context

wxt-dev/wxt#2604 — WXT is currently working around this by owning the transform so it can pass the options itself.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions