mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.<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. 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.
|
||||
|
||||
@@ -80,6 +80,14 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
|
||||
|
||||
`scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。
|
||||
|
||||
### optional 依赖绝不在模块作用域被加载
|
||||
|
||||
`optionalDependencies` 里的依赖,或带 `peerDependenciesMeta.<name>.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,不改核心。
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Reject a static value import of an optional dependency.
|
||||
*
|
||||
* A dependency declared in `optionalDependencies`, or as a peer carrying
|
||||
* `peerDependenciesMeta.<name>.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<string, unknown>, field: string): Record<string, unknown> {
|
||||
const value = manifest[field]
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>): Map<string, OptionalKind> {
|
||||
const optional = new Map<string, OptionalKind>()
|
||||
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<string, unknown>).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<string, Map<string, OptionalKind>>()
|
||||
|
||||
/**
|
||||
* 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<string, OptionalKind> {
|
||||
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<string, unknown>
|
||||
: {}
|
||||
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<string>()
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user