verify-optional-dependency-imports.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /**
  2. * Reject a static value import of an optional dependency.
  3. *
  4. * A dependency declared in `optionalDependencies`, or as a peer carrying
  5. * `peerDependenciesMeta.<name>.optional`, may be absent from an installed tree —
  6. * that absence is what "optional" promises a consumer. A static import is
  7. * evaluated when the importing module loads, so one absent package turns
  8. * "this capability is unavailable" into a load failure for everything that
  9. * reaches the importing module.
  10. *
  11. * The way out, in order: import it as a type, which emits nothing and is all
  12. * that declaration merging needs; or restructure so nothing at module scope
  13. * needs the package. A dynamic `import()` only moves the failure to first use,
  14. * so it belongs to a caller that genuinely requires the package and handles its
  15. * absence — it is a last resort, not the default answer, and reaching for it is
  16. * a sign the dependency is not optional.
  17. *
  18. * Value-vs-type is decided against a bound Program rather than the import
  19. * syntax, because `verbatimModuleSyntax` is off: a named import used only in
  20. * type positions is elided and does not load anything. The decision is
  21. * deliberately conservative in one direction — a value binding the compiler
  22. * would elide because nothing references it in a value position is still
  23. * reported, and the fix it asks for (`import type`, or dropping the binding) is
  24. * what the published package wants regardless. Both compiler faces are scanned,
  25. * and only files that ship — a published package's `src` — are subject.
  26. */
  27. import { existsSync, readFileSync } from 'node:fs'
  28. import { resolve } from 'node:path'
  29. import ts from 'typescript'
  30. import { TypeScriptProject, type CompilerFace } from './ts-project.ts'
  31. const root = resolve(import.meta.dirname, '..')
  32. /** Directories whose `src` ships as a published package. */
  33. const PUBLISHED_SOURCE = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+)\/src\//
  34. /** How a manifest marked a dependency optional, for the violation message. */
  35. type OptionalKind = 'optionalDependencies' | 'peerDependenciesMeta'
  36. /**
  37. * The package name a module specifier resolves to.
  38. * @param specifier - an import specifier, possibly a subpath.
  39. * @returns The bare package name, keeping a leading scope.
  40. */
  41. function packageOf(specifier: string): string {
  42. const parts = specifier.split('/')
  43. return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier
  44. }
  45. /**
  46. * Read a manifest field as a record.
  47. * @param manifest - parsed manifest.
  48. * @param field - field name.
  49. * @returns The field value, or an empty record.
  50. */
  51. function record(manifest: Record<string, unknown>, field: string): Record<string, unknown> {
  52. const value = manifest[field]
  53. if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
  54. return value as Record<string, unknown>
  55. }
  56. /**
  57. * The dependencies one manifest allows to be absent.
  58. * @param manifest - parsed manifest.
  59. * @returns Each optional package name and how it was marked.
  60. */
  61. function optionalDependencies(manifest: Record<string, unknown>): Map<string, OptionalKind> {
  62. const optional = new Map<string, OptionalKind>()
  63. for (const name of Object.keys(record(manifest, 'optionalDependencies'))) {
  64. optional.set(name, 'optionalDependencies')
  65. }
  66. const peers = record(manifest, 'peerDependencies')
  67. for (const [name, meta] of Object.entries(record(manifest, 'peerDependenciesMeta'))) {
  68. if (meta === null || typeof meta !== 'object') continue
  69. if ((meta as Record<string, unknown>).optional !== true) continue
  70. // A meta entry for an undeclared peer is check-workspace-constraints' business.
  71. if (!(name in peers)) continue
  72. optional.set(name, 'peerDependenciesMeta')
  73. }
  74. return optional
  75. }
  76. /** One package directory's optional dependencies, resolved once per directory. */
  77. const optionalByDirectory = new Map<string, Map<string, OptionalKind>>()
  78. /**
  79. * The optional dependencies of the package owning a source file.
  80. * @param projectRoot - root the relative path is resolved against.
  81. * @param relativePath - repository-relative path of a source file.
  82. * @returns That package's optional dependencies, empty when it declares none.
  83. */
  84. function optionalFor(projectRoot: string, relativePath: string): Map<string, OptionalKind> {
  85. const directory = resolve(projectRoot, relativePath.slice(0, relativePath.indexOf('/src/')))
  86. const cached = optionalByDirectory.get(directory)
  87. if (cached !== undefined) return cached
  88. const manifestPath = resolve(directory, 'package.json')
  89. const parsed: unknown = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {}
  90. const manifest = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
  91. ? parsed as Record<string, unknown>
  92. : {}
  93. const optional = optionalDependencies(manifest)
  94. optionalByDirectory.set(directory, optional)
  95. return optional
  96. }
  97. /**
  98. * Whether one binding of an import or re-export names a value.
  99. * @param name - the local binding name node.
  100. * @param checker - the program's checker.
  101. * @returns True when the binding carries value meaning, and on an unresolved
  102. * symbol, so an unresolvable binding fails closed.
  103. */
  104. function bindsValue(name: ts.Identifier | ts.StringLiteral, checker: ts.TypeChecker): boolean {
  105. const symbol = checker.getSymbolAtLocation(name)
  106. if (symbol === undefined) return true
  107. const target = (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol)
  108. return (target.flags & ts.SymbolFlags.Value) !== 0
  109. }
  110. /**
  111. * Whether an import declaration loads its module at run time.
  112. * @param declaration - the import declaration.
  113. * @param checker - the program's checker.
  114. * @returns True when the emitted module keeps the import.
  115. */
  116. function importLoadsModule(declaration: ts.ImportDeclaration, checker: ts.TypeChecker): boolean {
  117. const clause = declaration.importClause
  118. // A bare `import 'x'` is kept for its side effects.
  119. if (clause === undefined) return true
  120. // Only the type phase erases the import. `import defer` still resolves and
  121. // links the module, deferring evaluation alone, so an absent package fails
  122. // exactly as it would without the modifier.
  123. if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
  124. if (clause.name !== undefined) return true
  125. const bindings = clause.namedBindings
  126. if (bindings === undefined || ts.isNamespaceImport(bindings)) return true
  127. return bindings.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker))
  128. }
  129. /**
  130. * Whether a re-export loads its module at run time.
  131. * @param declaration - the export declaration, which carries a module specifier.
  132. * @param checker - the program's checker.
  133. * @returns True when the emitted module keeps the re-export.
  134. */
  135. function exportLoadsModule(declaration: ts.ExportDeclaration, checker: ts.TypeChecker): boolean {
  136. if (declaration.isTypeOnly) return false
  137. const clause = declaration.exportClause
  138. // `export * from 'x'` re-exports whatever values the module has.
  139. if (clause === undefined || ts.isNamespaceExport(clause)) return true
  140. return clause.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker))
  141. }
  142. /**
  143. * Collect every static value import of an optional dependency in one face.
  144. * @param project - a bound repository project.
  145. * @returns One message per violation, sorted by location.
  146. */
  147. export function collectOptionalImportViolations(project: TypeScriptProject): string[] {
  148. const checker = project.checker
  149. const violations: string[] = []
  150. for (const sourceFile of project.sourceFiles()) {
  151. if (sourceFile.isDeclarationFile) continue
  152. const relativePath = project.relativePath(sourceFile)
  153. if (!PUBLISHED_SOURCE.test(relativePath)) continue
  154. const optional = optionalFor(project.projectRoot, relativePath)
  155. if (optional.size === 0) continue
  156. for (const statement of sourceFile.statements) {
  157. const isImport = ts.isImportDeclaration(statement)
  158. if (!isImport && !ts.isExportDeclaration(statement)) continue
  159. const specifierNode = statement.moduleSpecifier
  160. if (specifierNode === undefined || !ts.isStringLiteral(specifierNode)) continue
  161. const kind = optional.get(packageOf(specifierNode.text))
  162. if (kind === undefined) continue
  163. const loads = isImport
  164. ? importLoadsModule(statement, checker)
  165. : exportLoadsModule(statement, checker)
  166. if (!loads) continue
  167. const { line } = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile))
  168. violations.push(
  169. `${relativePath}:${String(line + 1)} loads ${specifierNode.text} at module scope,`
  170. + ` declared optional in ${kind}; import it as a type, or restructure so module scope does not need it`,
  171. )
  172. }
  173. }
  174. return violations.sort((left, right) => left.localeCompare(right))
  175. }
  176. /** CLI entry: list every violation and exit 1, or confirm the invariant holds. */
  177. function main(): void {
  178. const faces: readonly CompilerFace[] = ['host', 'client']
  179. const violations = new Set<string>()
  180. for (const face of faces) {
  181. for (const violation of collectOptionalImportViolations(new TypeScriptProject(root, face))) {
  182. violations.add(violation)
  183. }
  184. }
  185. if (violations.size === 0) {
  186. console.log('verify-optional-dependency-imports: no optional dependency is loaded at module scope.')
  187. return
  188. }
  189. console.error(`verify-optional-dependency-imports: ${String(violations.size)} optional dependency load(s) at module scope:`)
  190. for (const violation of [...violations].sort((left, right) => left.localeCompare(right))) {
  191. console.error(` ${violation}`)
  192. }
  193. process.exit(1)
  194. }
  195. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  196. main()
  197. }