Skip to content

Resolution

Every pattern in a manifest is matched against a resolved path. This is the property the rest of the package rests on: a rule about @/modules/user/domain is a rule about the file that specifier actually names, not about the eleven characters someone typed.

resolve:
scopes:
- files: "^packages/(web|components)/"
language: typescript
options: { tsconfig: tsconfig.resolve-web.json }
- files: "^packages/acceptance/"
language: typescript
options: { tsconfig: packages/acceptance/tsconfig.json }
- files: ""
language: typescript
options: { tsconfig: tsconfig.resolve.json }
unresolved: error
ignoreUnresolved: []

A scope says which language resolves and parses the files it covers, and hands that language its options. A monorepo rarely has one tsconfig whose paths are correct for every file, so it has several TypeScript scopes; a second language is one more scope. The first matching scope wins, so the catch-all goes lastfiles: "" matches everything.

files here is a regular-expression source string, not a glob. It is the one place in a manifest that is not glob-shaped, because it is matched against the importing file’s path before any of the glob machinery is involved.

options are opaque to the manifest and belong to the language: the TypeScript pack validates them at load and refuses a key it does not know, so a misspelled option is an error rather than a resolver quietly built on defaults.

Resolution is unrs-resolver — oxc’s native resolver, run synchronously — so paths, workspace links, package exports and conditions all behave the way your bundler and tsc make them behave.

Option Meaning
tsconfig repo-relative path to the tsconfig whose paths and baseUrl apply. Required.
extensions extensions tried on an extensionless specifier. Default .ts .tsx .js .jsx .mjs .cjs .json.
conditionNames package exports conditions. Default import require node default.
mainFields package.json fields tried for a bare package. Default main types.

One ResolverFactory is built per scope, once per run, and results are memoised on (directory, specifier). Resolution is the expensive part of linting an architecture, and rebuilding a resolver per file is the difference between a fast run and an unusable one.

paths targets in the tsconfigs a scope names should be extensionless. A mapped target is a template, so a .js-suffixed mapping turns @/x/y.js into a lookup for y.js.js. The resolver handles the .js.ts rewrite on the specifier instead:

.js → .ts, .tsx, .js
.mjs → .mts, .mjs
.cjs → .cts, .cjs

Without that mapping every relative import in a NodeNext codebase resolves to nothing, and every path rule about it goes silently vacuous — which is the failure the whole package exists to make impossible.

Kind What it is Which field governs it
local a file inside the repository imports.allow, deny
external a third-party package, named by the resolver imports.external
builtin a runtime builtin — node:crypto neither

An external is judged by its package name — the resolver reports which package a resolved file belongs to (effect, @scope/name), and imports.external is matched against that. Where the package was found on disk is the resolver’s business: a pnpm store path, a hoisted node_modules/, or a vendored copy all read as the same package. deny and allow patterns still see the resolved path, so a prohibition against one subpath of a package is written against where that subpath resolves to.

Builtins are resolved and classified rather than treated as unresolved packages, so node:crypto does not need an entry in every tier’s external list to avoid a false positive.

unresolved defaults to "error", and the reason is worth stating plainly: an edge nobody can resolve is an edge no rule can police. If unresolved imports were skipped, one misconfigured paths entry would disarm every rule about a whole package at once — quietly, with a green build, and without changing a line of policy. That is precisely the indistinguishable-from-passing failure the package is built to refuse.

ignoreUnresolved takes regexes matched against the specifier, for the genuine cases — a virtual module a bundler synthesises, a generated file that does not exist at lint time. Every entry there deserves a comment saying why, because each one is a hole in the policy.

Setting unresolved: "off" is available for a first-day adoption spike. It should not survive the week.