Lints import maps: HTML-spec-accurate resolution, unresolvable specifiers, unversioned or unaudited third-party CDN entries, missing integrity metadata, dead entries, and the structural errors the spec defines.
[error] duplicate-key: Duplicate key "moment" at imports.moment (occurs 2 times: 4:5, 5:5). JSON.parse keeps only the last one silently. (imports.moment)
[error] empty-specifier-key: Specifier key at imports is the empty string; the HTML spec requires it to be dropped. (imports)
[error] trailing-slash-mismatch: Specifier key "utils/" ends with "/", so its address must too; got "https://importmap-lint.invalid/no-trailing-slash". (imports.utils/)
[warn] unversioned-remote-specifier: "moment" points at an unversioned remote URL (https://cdn.example.com/moment/moment.js) — no version segment, "@version", or "?v=" query found. This is the import-map equivalent of a floating tag: the code it loads can change without this file changing. (imports.moment)
[warn] missing-integrity: "moment" (https://cdn.example.com/moment/moment.js) has no matching entry in the import map's "integrity" object, so a tampered or replaced response at that URL would load without complaint. (imports.moment)
[warn] missing-integrity: "lodash" (https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js) has no matching entry in the import map's "integrity" object, so a tampered or replaced response at that URL would load without complaint. (imports.lodash)
[warn] unversioned-remote-specifier: "left-pad" points at an unversioned remote URL (https://cdn.example.com/left-pad/index.js) — no version segment, "@version", or "?v=" query found. This is the import-map equivalent of a floating tag: the code it loads can change without this file changing. (imports.left-pad)
[warn] missing-integrity: "left-pad" (https://cdn.example.com/left-pad/index.js) has no matching entry in the import map's "integrity" object, so a tampered or replaced response at that URL would load without complaint. (imports.left-pad)
[warn] unparseable-import: Dynamic import() at /tmp/demo/main.ts:4:29 does not have a plain string literal argument; it can't be checked statically.
[warn] dead-entry: "utils/" in imports is not imported anywhere in the scanned source. (imports.utils/)
[warn] dead-entry: "unused-vendor-thing" in imports is not imported anywhere in the scanned source. (imports.unused-vendor-thing)
[info] remote-specifier: "moment" resolves to a third-party URL: https://cdn.example.com/moment/moment.js (imports.moment)
[info] remote-specifier: "lodash" resolves to a third-party URL: https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js (imports.lodash)
[info] remote-specifier: "left-pad" resolves to a third-party URL: https://cdn.example.com/left-pad/index.js (imports.left-pad)
Scanned 1 source file(s). 3 error(s), 8 warning(s), 3 info finding(s).
That's the real, unedited output of node examples/demo.ts (the one file path is a
Node temp directory, shortened here to /tmp/demo/main.ts for readability — the
counts, rules, and messages are exactly what it printed) against a small import map
seeded with one instance of every issue this tool looks for, plus one source file
that imports from it.
Import maps are now a standard browser feature, and the mechanism Deno,
buildless setups, and micro-frontend "scope"-based module federation use to pin
what a bare specifier like "lodash" actually resolves to. That JSON is also
an unguarded supply-chain surface with no equivalent of npm audit,
npm ls, or a lockfile:
- An entry can point at any URL, including an unversioned third-party CDN
URL —
"lodash": "https://cdn.example.com/lodash/lodash.js"will load whatever that server serves at request time, forever. It is the import-map equivalent ofFROM node:latestor an unpinned:latesttag. - A typo'd or removed specifier in the codebase that isn't in the map
fails only when a browser actually executes that code path —
tsc, ESLint, and your bundler (if you have one) don't see it, because none of them resolve through the import map. - Scopes have precedence rules people get wrong: the most-specific
matching scope wins, but only if it actually defines the specifier —
otherwise resolution falls through to a less-specific scope, and finally to
the top-level
imports. It is easy to add a scope override, watch it appear to do nothing, and not know why. - The JSON has structural rules of its own — an empty-string key, a
trailing-slash mismatch, a duplicate key — that a browser's
JSON.parsewill not warn you about; a duplicate key is silently resolved to "last one wins" with zero indication a duplicate ever existed.
Import maps are small enough that reading one over feels sufficient, and that is exactly the failure mode: scope precedence is not textual (you cannot tell which scope wins by reading top to bottom — it depends on the importing module's URL, which isn't in the file at all), and "is this specifier used anywhere" is a codebase-wide question, not a file-local one. Both require actually running the resolution algorithm and cross-referencing source, which is what this tool does instead of eyeballing it.
This is a small space, and worth being straightforward about what's already in it — checked on npm before writing this:
importmap-check— a real, maintained package, but a different tool for a different job: it checks whether the packages named in an import map have newer versions available (annpm outdatedfor import maps), assuming the map is otherwise fine. It doesn't validate structure, flag unversioned/unaudited entries, or check integrity. Its name was already taken on npm, which is why this package is published asimportmap-lintinstead.@import-maps/resolve(open-wc/modern-web) — a real implementation of the resolution algorithm, used at runtime/dev-server time to actually resolve specifiers. It has no opinion on supply chain, dead entries, or CI; it's a resolver, not a linter.- Nothing found combines spec-accurate structural validation, supply-chain signal detection, and cross-referencing against real source into one zero-dependency CLI with CI exit codes. That's the gap this fills.
npm install --save-dev importmap-lintNode >= 20.6. No runtime dependencies.
npx importmap-lint import-map.json
npx importmap-lint index.html --src src/ # extract from <script type="importmap">,
# and cross-check against real imports
npx importmap-lint import-map.json --json # machine-readable output
npx importmap-lint import-map.json --fail-on-warn # CI: fail the build on warnings tooProgrammatically:
import { lintImportMap } from 'importmap-lint';
const report = lintImportMap({
input: { text: await fs.readFile('import-map.json', 'utf8') },
sourceDir: 'src', // optional: enables unresolvable-specifier / dead-entry checks
baseURL: 'https://app.example/',
});
for (const finding of report.findings) console.log(finding.severity, finding.rule, finding.message);| Option | What it does |
|---|---|
--src <dir> |
Scans JS/TS under <dir> for import specifiers; enables the unresolvable-specifier and dead-entry checks. |
--base-url <url> |
Resolves relative addresses/scopes, and decides what counts as "third-party" for supply-chain checks. Default: https://importmap-lint.invalid/ (so any http(s) entry reads as remote). |
--script-index <n> |
Which <script type="importmap"> to use (0-based) when the HTML has more than one. Required when there's more than one — see below. |
--json |
Machine-readable findings instead of text. |
--fail-on-warn |
Exit non-zero on warnings too, not just errors. |
Exit codes: 0 clean, 1 findings at/above the failure threshold, 2 usage error
(bad arguments, missing file, --src matching zero source files).
Structural checks follow the HTML Standard's import map processing model —
"parse an import map string",
"sort and normalize a module specifier map",
"sort and normalize scopes" —
and resolution follows
"resolve a module specifier"
and
"resolve an imports match"
exactly, including the most-specific-scope-first precedence and the
backtracking guard that stops a prefix mapping ("pkg/": "/vendor/pkg/") from
being escaped with ../. src/resolve.ts and src/parse-import-map.ts cite
the specific spec step for every check.
| Rule | Severity | What it means |
|---|---|---|
invalid-json / not-an-object |
error | Not parseable as an import map at all. |
empty-specifier-key |
error | A specifier key is "". |
duplicate-key |
error | The same key appears twice in one JSON object — JSON.parse would silently keep the last one. |
trailing-slash-mismatch |
error | A key ends in / but its address doesn't. |
non-string-value / invalid-address |
error | An address isn't a string, or isn't an absolute URL / /-./-../-prefixed path. |
scope-value-not-an-object / invalid-scope-prefix |
error | A scope's value isn't an object, or its prefix isn't a parseable URL. |
invalid-integrity-key / non-string-integrity-value |
error | A malformed "integrity" entry. |
remote-specifier |
info | An entry resolves to a third-party http(s) URL. |
unversioned-remote-specifier |
warning | ...and that URL has no version segment, @version, or ?v= query. |
missing-integrity |
warning | ...and it has no matching entry in the map's "integrity" object. |
unresolvable-specifier |
error | (needs --src) Something in your code imports a bare specifier with no matching entry in imports or any scopes map — this fails only in the browser. |
dead-entry |
warning | (needs --src) A map entry nothing in the scanned source imports. |
unparseable-import |
warning | (needs --src) A dynamic import() whose argument isn't a plain string (or substitution-free template) literal — coverage gap, not a pass. |
Tests are TypeScript run directly by Node's test runner — no build, no install:
node --test "test/*.test.ts" # full suite, needs node 24+ for type stripping
node examples/demo.ts
npm run build && npm run test:dist # what CI runs against node 20 and 22test/fixtures/ includes two real import maps, fetched rather than invented:
Deno's own import_map.json
(~470 flat entries, no scopes or remote URLs — confirms large real-world flat
maps parse cleanly) and a benchmark page from
es-module-shims
(a genuine <script type="importmap"> with a prefix mapping, extracted from
real HTML, whose inline module also exercises the "dynamic import with a
non-literal specifier" case for real).
- Doesn't implement the multi-import-map merge algorithm. A document can
legally carry more than one
<script type="importmap">, merged by rules this tool doesn't reproduce; given more than one, it refuses and asks for--script-indexrather than silently picking (or merging) wrong. - The source scanner is heuristic, not a parser. It tracks strings,
comments, regex-literal-vs-division, and template-literal nesting well
enough to find
import/export ... from/import()reliably in ordinary code, but it can be fooled — e.g. a variable literally namedfromimmediately assigned a string (const from = "x") can be misread as an import specifier if nothing between them resets the heuristic.require()is intentionally ignored (import maps don't apply to CommonJS)..d.tsfiles are skipped. --srccross-checks don't know each file's real browser URL. Whichscopesentry applies to a given module depends on where a browser would load it from, which a static source tree doesn't encode. The unresolvable-specifier and dead-entry checks look at the union of top-levelimportsand every scope, not which scope would actually apply to a specific importing file.- The version heuristic is pattern-based, not a semver parser: it
recognizes
@1.2.3, a bare1.2.3/v18path segment, and a?v=/?version=query. An unconventional versioning scheme can produce a false "unversioned" flag; conversely, a path segment that merely looks like a version isn't proof the URL is actually pinned to immutable content. - No network access. Nothing is fetched to confirm a CDN URL is reachable, that its integrity hash matches the live bytes, or that a "versioned" URL hasn't been republished in place. Zero dependencies means offline by construction, not just by default.
- Doesn't check whether an address a browser would actually load exists (404s, wrong MIME type) — this is a static analysis of the map and the source, not an integration test.
MIT