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

@@ -11,6 +11,12 @@ interface ProjectGraph {
options: ts.CompilerOptions
}
/**
* A compiler face: the two aggregates a repository-wide program may seed from.
* The root solution is never one of them.
*/
export type CompilerFace = 'host' | 'client'
/** TypeScript config host shared by repository scripts. */
export const repositoryConfigHost: ts.ParseConfigFileHost = {
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
@@ -24,12 +30,12 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = {
}
/**
* Parse the host aggregate tsconfig and flatten all referenced projects into one
* Parse one face aggregate tsconfig and flatten all referenced projects into one
* semantic graph. Never seed the root solution: flattening host+client into one
* program collides the cordis Context merges.
*/
function loadProjectGraph(projectRoot: string): ProjectGraph {
const rootConfigPath = resolve(projectRoot, 'tsconfig.host.json')
function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`)
const rootConfig = parseConfig(rootConfigPath)
const rootNames = new Set<string>()
const visited = new Set<string>()
@@ -81,8 +87,12 @@ export class TypeScriptProject {
/** The checker shared by every semantic query in this project. */
readonly checker: ts.TypeChecker
constructor(private readonly projectRoot: string) {
const graph = loadProjectGraph(projectRoot)
/**
* @param projectRoot - repository root the program is seeded and reported from.
* @param face - which compiler face aggregate to flatten.
*/
constructor(readonly projectRoot: string, face: CompilerFace = 'host') {
const graph = loadProjectGraph(projectRoot, face)
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
this.checker = this.program.getTypeChecker()
}