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: []Scopes
Section titled “Scopes”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 last — files: "" 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.
TypeScript
Section titled “TypeScript”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.
Extensionless path targets
Section titled “Extensionless path targets”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, .cjsWithout 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.
Three kinds of target
Section titled “Three kinds of target”| 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.
An unresolved import is an error
Section titled “An unresolved import is an error”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.