From 7b973e27c807b4e4ece13329e74a5390d091d45e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:08:01 +0800 Subject: [PATCH] feat(release): reject a module-scope load of an optional dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta..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. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 8 + .../2026-08-10-npm-release-sequences.zh.md | 8 + package.json | 3 +- scripts/run-gates.ts | 6 + scripts/ts-project.ts | 20 +- ...verify-optional-dependency-imports.spec.ts | 130 +++++++++++ scripts/verify-optional-dependency-imports.ts | 214 ++++++++++++++++++ 8 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 scripts/verify-optional-dependency-imports.spec.ts create mode 100644 scripts/verify-optional-dependency-imports.ts diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 4b14d3b8aa..851f0e9d2d 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: 2c46fb9b3e3fb8ddd90131e3c3113166580608d4 -2026-08-10-npm-release-sequences.zh.md: edbb2a8884f658b6c87ceb5762c01551a81a249d +2026-08-10-npm-release-sequences.md: d8495f158482d5d6e06a1752a096d1e9200b6070 +2026-08-10-npm-release-sequences.zh.md: 24b466f6b7b10d31ac2e025da6e12ec3c91c7548 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index 2c46fb9b3e..d8495f1584 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -80,6 +80,14 @@ Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substit `scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason. +### An optional dependency is never loaded at module scope + +A dependency in `optionalDependencies`, or a peer carrying `peerDependenciesMeta..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. The failure appears only in an installed tree missing that package, and no test here constructs one: 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`](../../../../scripts/verify-optional-dependency-imports.ts) closes that hole. It reads each package's own manifest for what that package allows to be absent, then scans the files that ship — `packages/*/*/src/` and `apps/*/src/` — across both compiler faces. `vendor/` is out of scope, as pinned upstream source under the [vendoring policy](../../../../vendor/README.md). 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 `import type {}`, `import {}`, an inline `type` specifier, and a named binding that resolves to a type all emit nothing and are allowed, while a bare import, a value binding, and a star re-export are kept and rejected. Only the type phase erases an import: `import defer` still resolves and links its module, deferring evaluation alone, so the gate counts it as a load. + +A violation names the package, the declaration that made it optional, and the way out in order — import it as a type, which is all that declaration merging needs, or restructure so module scope does not need the package. A dynamic `import()` only moves the failure to first use, so it belongs to a caller that genuinely requires the package and handles its absence; reaching for it is a sign the dependency is not optional, and the gate does not offer it as the remedy. + ### Release family objects The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a subclass and a workflow lane, not changing the core. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index edbb2a8884..24b466f6b7 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -80,6 +80,14 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 `scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。 +### optional 依赖绝不在模块作用域被加载 + +`optionalDependencies` 里的依赖,或带 `peerDependenciesMeta..optional` 的 peer,在安装出来的树里可以不存在——这份「可以不存在」正是 optional 的全部承诺。而静态 import 在引入方模块加载时就求值,于是一个缺失的包不再表现为「这个能力不可用」,而是变成所有能走到该模块的代码的加载失败。这种失败只在「缺了该包的安装树」里出现,而本仓没有任何测试构造这种树:workspace 安装总是把每个包都装上,所以单测、快照、打包安装探针全都会过,而那个拒绝了这个 optional peer 的消费者拿到的却是坏的包。 + +[`verify-optional-dependency-imports`](../../../../scripts/verify-optional-dependency-imports.ts) 堵掉这个洞。它从每个包自己的 manifest 读取「这个包允许谁缺失」,再扫描会发布出去的文件——`packages/*/*/src/` 与 `apps/*/src/`——且两个编译门面各扫一遍。`vendor/` 不在范围内,那是[受 vendoring 政策管辖](../../../../vendor/README.md)的固定上游源码。值与类型的判定对着绑定好的 Program 做,而不是看 import 写法,因为 `verbatimModuleSyntax` 是关的:编译器本来就会消除绑定解析为类型的 import,所以 `import type {}`、`import {}`、内联 `type` 说明符、以及解析为类型的具名绑定都不产生产物、一律放行,而裸 import、值绑定、星号 re-export 会被保留、一律报错。只有 type 相位会消除 import:`import defer` 仍然解析并链接它的模块,只推迟求值,所以门禁把它算作一次加载。 + +报错会点名这个包、点名是哪条声明把它标成 optional 的,并按顺序给出出路——把它作为类型引入(声明合并需要的仅此而已),或者调整写法让模块作用域不再需要这个包。动态 `import()` 只是把失败推迟到首次使用,它属于那种确实需要这个包、并且自己处理缺失的调用方;会想到它,往往说明这个依赖并不 optional,所以门禁不把它作为解法给出。 + ### 发布族对象 这个领域里的实体是**发布族**:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。 diff --git a/package.json b/package.json index 1fd63bae9a..517d0c56d1 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", @@ -125,7 +126,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 27664fab5e..6b775c9bf1 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -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', + }), ] } diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 9a0400a39d..53b100ceb0 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -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() const visited = new Set() @@ -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() } diff --git a/scripts/verify-optional-dependency-imports.spec.ts b/scripts/verify-optional-dependency-imports.spec.ts new file mode 100644 index 0000000000..3bb857050f --- /dev/null +++ b/scripts/verify-optional-dependency-imports.spec.ts @@ -0,0 +1,130 @@ +/** + * Tests for the optional-dependency load gate: which import and re-export forms + * survive emit, and therefore load a package the installed tree may not carry. + * + * The expectations here match what `tsc` emits with `verbatimModuleSyntax` off: + * `import type`, `import {}`, an inline `type` specifier, and a named binding + * that resolves to a type all disappear; a bare import, a value binding, and a + * star re-export remain. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { TypeScriptProject } from './ts-project.ts' +import { collectOptionalImportViolations } from './verify-optional-dependency-imports.ts' + +const FIXTURE: Record = { + 'tsconfig.host.json': JSON.stringify({ + compilerOptions: { + target: 'es2022', + module: 'esnext', + moduleResolution: 'bundler', + noEmit: true, + skipLibCheck: true, + types: [], + paths: { + '@f/opt': ['./packages/f/opt/src/index.ts'], + '@f/hard': ['./packages/f/hard/src/index.ts'], + }, + }, + include: ['packages/**/*.ts'], + }), + + 'packages/f/opt/package.json': JSON.stringify({ name: '@f/opt', version: '0.0.1' }), + 'packages/f/opt/src/index.ts': [ + 'export interface Shape { a: number }', + 'export const runtimeValue = 1', + '', + ].join('\n'), + + 'packages/f/hard/package.json': JSON.stringify({ name: '@f/hard', version: '0.0.1' }), + 'packages/f/hard/src/index.ts': 'export const hardValue = 2\n', + + // The consumer allows @f/opt to be absent and requires @f/hard. + 'packages/f/consumer/package.json': JSON.stringify({ + name: '@f/consumer', + version: '0.0.1', + dependencies: { '@f/hard': '*' }, + peerDependencies: { '@f/opt': '*' }, + peerDependenciesMeta: { '@f/opt': { optional: true } }, + }), + + // Elided by the compiler, so each of these is allowed. + 'packages/f/consumer/src/allowed-type-only.ts': [ + "import type {} from '@f/opt'", + 'export const a = 1', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-empty.ts': [ + "import {} from '@f/opt'", + 'export const b = 1', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-inline-type.ts': [ + "import { type Shape } from '@f/opt'", + 'export const c: Shape = { a: 1 }', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-type-binding.ts': [ + "import { Shape } from '@f/opt'", + 'export const d: Shape = { a: 1 }', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-type-reexport.ts': [ + "export type { Shape } from '@f/opt'", + '', + ].join('\n'), + // A hard dependency may be loaded at module scope; only optional ones may not. + 'packages/f/consumer/src/allowed-hard-dependency.ts': [ + "import { hardValue } from '@f/hard'", + 'export const e = hardValue', + '', + ].join('\n'), + + // Kept by the compiler, so each of these loads a package that may be absent. + 'packages/f/consumer/src/rejected-bare.ts': [ + "import '@f/opt'", + 'export const f = 1', + '', + ].join('\n'), + 'packages/f/consumer/src/rejected-value.ts': [ + "import { runtimeValue } from '@f/opt'", + 'export const g = runtimeValue', + '', + ].join('\n'), + 'packages/f/consumer/src/rejected-star-reexport.ts': [ + "export * from '@f/opt'", + '', + ].join('\n'), +} + +const root = mkdtempSync(join(tmpdir(), 'optional-imports-')) +for (const [rel, content] of Object.entries(FIXTURE)) { + mkdirSync(dirname(join(root, rel)), { recursive: true }) + writeFileSync(join(root, rel), content) +} +const violations = collectOptionalImportViolations(new TypeScriptProject(root)) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('optional dependency loads', () => { + it('reports every form the compiler keeps, and nothing else', () => { + expect(violations.map(violation => violation.split(' loads ')[0])).toEqual([ + 'packages/f/consumer/src/rejected-bare.ts:1', + 'packages/f/consumer/src/rejected-star-reexport.ts:1', + 'packages/f/consumer/src/rejected-value.ts:1', + ]) + }) + + it('names the package, the declaration that made it optional, and the way out', () => { + expect(violations[0]).toBe( + 'packages/f/consumer/src/rejected-bare.ts:1 loads @f/opt at module scope,' + + ' declared optional in peerDependenciesMeta; import it as a type,' + + ' or restructure so module scope does not need it', + ) + }) +}) diff --git a/scripts/verify-optional-dependency-imports.ts b/scripts/verify-optional-dependency-imports.ts new file mode 100644 index 0000000000..e7e16d7aab --- /dev/null +++ b/scripts/verify-optional-dependency-imports.ts @@ -0,0 +1,214 @@ +/** + * Reject a static value import of an optional dependency. + * + * A dependency declared in `optionalDependencies`, or as a peer carrying + * `peerDependenciesMeta..optional`, may be absent from an installed tree — + * that absence is what "optional" promises a consumer. A static import is + * evaluated when the importing module loads, so one absent package turns + * "this capability is unavailable" into a load failure for everything that + * reaches the importing module. + * + * The way out, in order: import it as a type, which emits nothing and is all + * that declaration merging needs; or restructure so nothing at module scope + * needs the package. A dynamic `import()` only moves the failure to first use, + * so it belongs to a caller that genuinely requires the package and handles its + * absence — it is a last resort, not the default answer, and reaching for it is + * a sign the dependency is not optional. + * + * Value-vs-type is decided against a bound Program rather than the import + * syntax, because `verbatimModuleSyntax` is off: a named import used only in + * type positions is elided and does not load anything. The decision is + * deliberately conservative in one direction — a value binding the compiler + * would elide because nothing references it in a value position is still + * reported, and the fix it asks for (`import type`, or dropping the binding) is + * what the published package wants regardless. Both compiler faces are scanned, + * and only files that ship — a published package's `src` — are subject. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import { TypeScriptProject, type CompilerFace } from './ts-project.ts' + +const root = resolve(import.meta.dirname, '..') + +/** Directories whose `src` ships as a published package. */ +const PUBLISHED_SOURCE = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+)\/src\// + +/** How a manifest marked a dependency optional, for the violation message. */ +type OptionalKind = 'optionalDependencies' | 'peerDependenciesMeta' + +/** + * The package name a module specifier resolves to. + * @param specifier - an import specifier, possibly a subpath. + * @returns The bare package name, keeping a leading scope. + */ +function packageOf(specifier: string): string { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier +} + +/** + * Read a manifest field as a record. + * @param manifest - parsed manifest. + * @param field - field name. + * @returns The field value, or an empty record. + */ +function record(manifest: Record, field: string): Record { + const value = manifest[field] + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + +/** + * The dependencies one manifest allows to be absent. + * @param manifest - parsed manifest. + * @returns Each optional package name and how it was marked. + */ +function optionalDependencies(manifest: Record): Map { + const optional = new Map() + for (const name of Object.keys(record(manifest, 'optionalDependencies'))) { + optional.set(name, 'optionalDependencies') + } + const peers = record(manifest, 'peerDependencies') + for (const [name, meta] of Object.entries(record(manifest, 'peerDependenciesMeta'))) { + if (meta === null || typeof meta !== 'object') continue + if ((meta as Record).optional !== true) continue + // A meta entry for an undeclared peer is check-workspace-constraints' business. + if (!(name in peers)) continue + optional.set(name, 'peerDependenciesMeta') + } + return optional +} + +/** One package directory's optional dependencies, resolved once per directory. */ +const optionalByDirectory = new Map>() + +/** + * The optional dependencies of the package owning a source file. + * @param projectRoot - root the relative path is resolved against. + * @param relativePath - repository-relative path of a source file. + * @returns That package's optional dependencies, empty when it declares none. + */ +function optionalFor(projectRoot: string, relativePath: string): Map { + const directory = resolve(projectRoot, relativePath.slice(0, relativePath.indexOf('/src/'))) + const cached = optionalByDirectory.get(directory) + if (cached !== undefined) return cached + const manifestPath = resolve(directory, 'package.json') + const parsed: unknown = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {} + const manifest = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + const optional = optionalDependencies(manifest) + optionalByDirectory.set(directory, optional) + return optional +} + +/** + * Whether one binding of an import or re-export names a value. + * @param name - the local binding name node. + * @param checker - the program's checker. + * @returns True when the binding carries value meaning, and on an unresolved + * symbol, so an unresolvable binding fails closed. + */ +function bindsValue(name: ts.Identifier | ts.StringLiteral, checker: ts.TypeChecker): boolean { + const symbol = checker.getSymbolAtLocation(name) + if (symbol === undefined) return true + const target = (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol) + return (target.flags & ts.SymbolFlags.Value) !== 0 +} + +/** + * Whether an import declaration loads its module at run time. + * @param declaration - the import declaration. + * @param checker - the program's checker. + * @returns True when the emitted module keeps the import. + */ +function importLoadsModule(declaration: ts.ImportDeclaration, checker: ts.TypeChecker): boolean { + const clause = declaration.importClause + // A bare `import 'x'` is kept for its side effects. + if (clause === undefined) return true + // Only the type phase erases the import. `import defer` still resolves and + // links the module, deferring evaluation alone, so an absent package fails + // exactly as it would without the modifier. + if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false + if (clause.name !== undefined) return true + const bindings = clause.namedBindings + if (bindings === undefined || ts.isNamespaceImport(bindings)) return true + return bindings.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker)) +} + +/** + * Whether a re-export loads its module at run time. + * @param declaration - the export declaration, which carries a module specifier. + * @param checker - the program's checker. + * @returns True when the emitted module keeps the re-export. + */ +function exportLoadsModule(declaration: ts.ExportDeclaration, checker: ts.TypeChecker): boolean { + if (declaration.isTypeOnly) return false + const clause = declaration.exportClause + // `export * from 'x'` re-exports whatever values the module has. + if (clause === undefined || ts.isNamespaceExport(clause)) return true + return clause.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker)) +} + +/** + * Collect every static value import of an optional dependency in one face. + * @param project - a bound repository project. + * @returns One message per violation, sorted by location. + */ +export function collectOptionalImportViolations(project: TypeScriptProject): string[] { + const checker = project.checker + const violations: string[] = [] + for (const sourceFile of project.sourceFiles()) { + if (sourceFile.isDeclarationFile) continue + const relativePath = project.relativePath(sourceFile) + if (!PUBLISHED_SOURCE.test(relativePath)) continue + const optional = optionalFor(project.projectRoot, relativePath) + if (optional.size === 0) continue + + for (const statement of sourceFile.statements) { + const isImport = ts.isImportDeclaration(statement) + if (!isImport && !ts.isExportDeclaration(statement)) continue + const specifierNode = statement.moduleSpecifier + if (specifierNode === undefined || !ts.isStringLiteral(specifierNode)) continue + const kind = optional.get(packageOf(specifierNode.text)) + if (kind === undefined) continue + const loads = isImport + ? importLoadsModule(statement, checker) + : exportLoadsModule(statement, checker) + if (!loads) continue + const { line } = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)) + violations.push( + `${relativePath}:${String(line + 1)} loads ${specifierNode.text} at module scope,` + + ` declared optional in ${kind}; import it as a type, or restructure so module scope does not need it`, + ) + } + } + return violations.sort((left, right) => left.localeCompare(right)) +} + +/** CLI entry: list every violation and exit 1, or confirm the invariant holds. */ +function main(): void { + const faces: readonly CompilerFace[] = ['host', 'client'] + const violations = new Set() + for (const face of faces) { + for (const violation of collectOptionalImportViolations(new TypeScriptProject(root, face))) { + violations.add(violation) + } + } + if (violations.size === 0) { + console.log('verify-optional-dependency-imports: no optional dependency is loaded at module scope.') + return + } + console.error(`verify-optional-dependency-imports: ${String(violations.size)} optional dependency load(s) at module scope:`) + for (const violation of [...violations].sort((left, right) => left.localeCompare(right))) { + console.error(` ${violation}`) + } + process.exit(1) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +}