Skip to content

About

Validate import maps against the HTML spec resolution algorithm, and flag unversioned CDN entries.

Topics

Resources

Stars

20 stars

Watchers

0 watching

Forks

Repository files navigation

importmap-lint

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.

The problem

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 of FROM node:latest or an unpinned :latest tag.
  • 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.parse will not warn you about; a duplicate key is silently resolved to "last one wins" with zero indication a duplicate ever existed.

Why "just read the JSON" doesn't work

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.

What's already out there

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 (an npm outdated for 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 as importmap-lint instead.
  • @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.

Install

npm install --save-dev importmap-lint

Node >= 20.6. No runtime dependencies.

Use

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 too

Programmatically:

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);

Options

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).

What it checks

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.

Develop

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 22

test/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).

What it does not do

  • 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-index rather 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 named from immediately 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.ts files are skipped.
  • --src cross-checks don't know each file's real browser URL. Which scopes entry 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-level imports and 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 bare 1.2.3/v18 path 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.

License

MIT

About

Validate import maps against the HTML spec resolution algorithm, and flag unversioned CDN entries.

Topics

Resources

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages