feat(release): reject a module-scope load of an optional dependency

A dependency in optionalDependencies, or a peer carrying
peerDependenciesMeta.<name>.optional, may be absent from an installed
tree — that absence is the whole promise of "optional". A static import
is evaluated when the importing module loads, so one absent package
stops being "this capability is unavailable" and becomes a load failure
for everything that reaches the importing module.

Nothing checked it, and nothing here could: the failure needs an
installed tree missing that package, and a workspace install always has
every package, so the unit tests, the snapshots, and the packed-install
probe all pass while the published package is broken for the consumer
who declined the optional peer.

verify-optional-dependency-imports reads each package's own manifest for
what it allows to be absent, then scans the files that ship across both
compiler faces. Value-versus-type is decided against a bound Program
rather than the import syntax, because verbatimModuleSyntax is off: the
compiler already erases an import whose bindings resolve to types, so a
syntactic rule would report four forms that emit nothing. Only the type
phase erases an import — `import defer` still resolves and links its
module, deferring evaluation alone — which is what phaseModifier
expresses and the deprecated isTypeOnly cannot.

A violation names the package, the declaration that made it optional,
and the way out in order: import it as a type, or restructure so module
scope does not need it. A dynamic import() only moves the failure to
first use, so the gate does not offer it as the remedy.

The gate runs in ci-static and ci-primary through ciSharedStaticGates
and locally in hygiene; it needs no build. TypeScriptProject gained a
face parameter so a repository-wide gate can seed the client aggregate,
which was previously unreachable; the constraint it was built with is
unchanged, a face config and never the root solution.

The tree has no violation today, so this guards the rule rather than
fixing a defect. The spec pins all seven import forms against what tsc
emits, including the four a syntactic rule would misreport.
This commit is contained in:
imccyu
2026-08-14 15:08:01 +08:00
parent 9fa0575ccc
commit 7b973e27c8
8 changed files with 385 additions and 8 deletions

View File

@@ -249,6 +249,9 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
]
}
@@ -565,6 +568,9 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
label: 'node-next types',
...artifactOptions,
}),
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
]
}