verify-package-dependencies.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886
  1. /** Verify and repair npm dependency sections from published Client and Host faces. */
  2. import { spawnSync } from 'node:child_process'
  3. import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
  4. import { isBuiltin } from 'node:module'
  5. import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
  6. import ts from 'typescript'
  7. import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts'
  8. import { writeModuleGraph } from './gen-module-graph.ts'
  9. import {
  10. hasClientDeclaration,
  11. PACKAGE_DEPENDENCY_POLICY,
  12. type PackageDependencyPolicy,
  13. } from './package-dependency-policy.ts'
  14. import {
  15. collectRuntimeLocalSourceSpecifiers,
  16. collectRuntimeSourcePackageUses,
  17. collectSourcePackageUses,
  18. } from './verify-client-packages.ts'
  19. const GATE = 'verify-package-dependencies'
  20. const CORDIS = '@deepseek-ai/cordis'
  21. const WORKSPACE_RANGE = 'workspace:^'
  22. const RELEASE_MANIFEST_GLOB = 'packages/!(experimental)/*/package.json'
  23. const WORKSPACE_MANIFEST_GLOBS = [
  24. 'apps/*/package.json',
  25. 'packages/*/*/package.json',
  26. 'vendor/*/package.json',
  27. ]
  28. type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
  29. export type PackageDependencyRole = 'client-only' | 'client-host' | 'configured-host'
  30. /** Manifest fields read and repaired by the package dependency policy. */
  31. export interface PackageDependencyManifest {
  32. name?: string
  33. version?: string
  34. exports?: unknown
  35. dependencies?: Record<string, string>
  36. devDependencies?: Record<string, string>
  37. optionalDependencies?: Record<string, string>
  38. peerDependencies?: Record<string, string>
  39. peerDependenciesMeta?: Record<string, unknown>
  40. dsh?: { client?: { inject?: string[] } }
  41. }
  42. /** One workspace package and its source location. */
  43. export interface WorkspacePackageManifest {
  44. readonly dir: string
  45. readonly manifestPath: string
  46. readonly manifest: PackageDependencyManifest
  47. readonly name: string
  48. }
  49. /** Source and manifest facts for one package covered by the policy. */
  50. export interface PackageDependencyFacts {
  51. readonly manifestPath: string
  52. readonly role: PackageDependencyRole
  53. readonly manifest: PackageDependencyManifest
  54. readonly workspaceNames: ReadonlySet<string>
  55. readonly allSourceUses: ReadonlyMap<string, readonly string[]>
  56. readonly hostRuntimeSourceUses: ReadonlyMap<string, readonly string[]>
  57. readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[]
  58. readonly peerRequiredHostDependencies: ReadonlySet<string>
  59. readonly configurationOnlyDevDependencies: ReadonlySet<string>
  60. readonly clientInject: ReadonlySet<string>
  61. }
  62. /** One runtime export used by an authored or generated Host module. */
  63. export interface HostRuntimeExportUse {
  64. readonly packageName: string
  65. readonly specifier: string
  66. readonly exportName: string
  67. readonly sourcePath: string
  68. readonly line: number
  69. readonly column: number
  70. readonly sourceLine: string
  71. }
  72. /** Complete policy input read from the repository. */
  73. export interface PackageDependencyState {
  74. readonly facts: readonly PackageDependencyFacts[]
  75. readonly packages: readonly WorkspacePackageManifest[]
  76. readonly policyViolations: readonly string[]
  77. readonly workspaceNames: ReadonlySet<string>
  78. }
  79. export interface ExpectedPackageDependency {
  80. readonly section: 'dependencies' | 'devDependencies' | 'peer-dev'
  81. readonly origins: readonly string[]
  82. }
  83. function normalizePath(path: string): string {
  84. return path.split(sep).join('/')
  85. }
  86. function packageNameOf(specifier: string): string | undefined {
  87. if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
  88. || specifier.includes(':') || specifier.includes('*')) {
  89. return undefined
  90. }
  91. const parts = specifier.split('/')
  92. return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
  93. }
  94. /** Read package manifests used for scope discovery and workspace-name checks. */
  95. export function readWorkspacePackageManifests(root: string): {
  96. all: WorkspacePackageManifest[]
  97. release: WorkspacePackageManifest[]
  98. } {
  99. const read = (manifestPath: string): WorkspacePackageManifest => {
  100. const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as PackageDependencyManifest
  101. if (typeof manifest.name !== 'string') throw new Error(`${manifestPath}: missing package name`)
  102. return {
  103. dir: dirname(manifestPath),
  104. manifestPath,
  105. manifest,
  106. name: manifest.name,
  107. }
  108. }
  109. const all = globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).map(normalizePath).sort().map(read)
  110. const releasePaths = new Set(globSync(RELEASE_MANIFEST_GLOB, { cwd: root }).map(normalizePath))
  111. return { all, release: all.filter(pkg => releasePaths.has(pkg.manifestPath)) }
  112. }
  113. function duplicates(values: readonly string[]): string[] {
  114. const seen = new Set<string>()
  115. const duplicated = new Set<string>()
  116. for (const value of values) {
  117. if (seen.has(value)) duplicated.add(value)
  118. seen.add(value)
  119. }
  120. return [...duplicated].sort()
  121. }
  122. /** Discover Client faces and configured Host packages, validating explicit overrides. */
  123. export function discoverPackageDependencyScope(
  124. packages: readonly WorkspacePackageManifest[],
  125. policy: PackageDependencyPolicy,
  126. ): { selected: Array<WorkspacePackageManifest & { role: PackageDependencyRole }>; violations: string[] } {
  127. const violations: string[] = []
  128. const byName = new Map(packages.map(pkg => [pkg.name, pkg]))
  129. const include = new Set(policy.clientFaceInclude)
  130. const exclude = new Set(policy.clientFaceExclude)
  131. const host = new Set(policy.hostPackages)
  132. for (const [field, values] of [
  133. ['clientFaceInclude', policy.clientFaceInclude],
  134. ['clientFaceExclude', policy.clientFaceExclude],
  135. ['hostPackages', policy.hostPackages],
  136. ] as const) {
  137. for (const name of duplicates(values)) violations.push(`${field} lists ${name} more than once`)
  138. for (const name of values) {
  139. if (!byName.has(name)) violations.push(`${field} names unknown release package ${name}`)
  140. }
  141. }
  142. for (const name of include) {
  143. if (exclude.has(name)) violations.push(`${name} appears in both clientFaceInclude and clientFaceExclude`)
  144. const pkg = byName.get(name)
  145. if (pkg !== undefined
  146. && (pkg.manifestPath.startsWith('packages/client/') || hasClientDeclaration(pkg.manifest.dsh))) {
  147. violations.push(`clientFaceInclude redundantly names automatically discovered package ${name}`)
  148. }
  149. }
  150. for (const name of exclude) {
  151. const pkg = byName.get(name)
  152. if (pkg !== undefined && pkg.manifestPath.startsWith('packages/client/')) {
  153. violations.push(`clientFaceExclude cannot exempt packages/client package ${name}`)
  154. } else if (pkg !== undefined && !hasClientDeclaration(pkg.manifest.dsh)) {
  155. violations.push(`clientFaceExclude names ${name}, which declares no dsh.client entry`)
  156. }
  157. }
  158. const selected: Array<WorkspacePackageManifest & { role: PackageDependencyRole }> = []
  159. for (const pkg of packages) {
  160. const clientDirectory = pkg.manifestPath.startsWith('packages/client/')
  161. const clientHost = (hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name)
  162. const clientOnly = clientDirectory && !clientHost
  163. const configuredHost = host.has(pkg.name)
  164. if (configuredHost && (clientHost || clientOnly)) {
  165. violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`)
  166. }
  167. const role = clientHost ? 'client-host' : clientOnly ? 'client-only' : configuredHost ? 'configured-host' : undefined
  168. if (role !== undefined) selected.push({ ...pkg, role })
  169. }
  170. return {
  171. selected: selected.sort((left, right) => left.manifestPath.localeCompare(right.manifestPath)),
  172. violations: [...new Set(violations)].sort(),
  173. }
  174. }
  175. function addUse(target: Map<string, string[]>, name: string, path: string): void {
  176. const paths = target.get(name) ?? []
  177. if (!paths.includes(path)) paths.push(path)
  178. target.set(name, paths)
  179. }
  180. const NAMESPACE_RUNTIME_EXPORT = '*'
  181. const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)'
  182. interface RuntimeSourceExportUse {
  183. readonly specifier: string
  184. readonly exportName: string
  185. readonly line: number
  186. readonly column: number
  187. readonly sourceLine: string
  188. }
  189. /** Collect exact runtime exports imported or re-exported by one source file. */
  190. export function collectRuntimeSourceExportUses(path: string, source: string): RuntimeSourceExportUse[] {
  191. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  192. const uses = new Map<string, RuntimeSourceExportUse>()
  193. const sourceLines = source.split(/\r?\n/u)
  194. const lazyRequireBindings = new Set<string>()
  195. const lazyRequireNamespaces = new Set<string>()
  196. for (const statement of sourceFile.statements) {
  197. if (!ts.isImportDeclaration(statement)
  198. || !ts.isStringLiteralLike(statement.moduleSpecifier)
  199. || statement.moduleSpecifier.text !== '@deepseek-ai/dsh-lazy-require') continue
  200. const bindings = statement.importClause?.namedBindings
  201. if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
  202. lazyRequireNamespaces.add(bindings.name.text)
  203. } else if (bindings !== undefined) {
  204. for (const element of bindings.elements) {
  205. if ((element.propertyName ?? element.name).text === 'createLazyRequire') {
  206. lazyRequireBindings.add(element.name.text)
  207. }
  208. }
  209. }
  210. }
  211. const record = (specifier: string, exportName: string, locationNode: ts.Node): void => {
  212. const key = `${specifier}\0${exportName}`
  213. if (uses.has(key)) return
  214. const position = sourceFile.getLineAndCharacterOfPosition(locationNode.getStart(sourceFile))
  215. uses.set(key, {
  216. specifier,
  217. exportName,
  218. line: position.line + 1,
  219. column: position.character + 1,
  220. sourceLine: sourceLines[position.line]?.trim() ?? '',
  221. })
  222. }
  223. const add = (
  224. specifierNode: ts.Expression | undefined,
  225. exportName: string,
  226. locationNode: ts.Node = specifierNode ?? sourceFile,
  227. ): void => {
  228. if (specifierNode === undefined || !ts.isStringLiteralLike(specifierNode)) return
  229. if (packageNameOf(specifierNode.text) === undefined) return
  230. record(specifierNode.text, exportName, locationNode)
  231. }
  232. const visit = (node: ts.Node): void => {
  233. if (ts.isImportDeclaration(node)) {
  234. const clause = node.importClause
  235. if (clause === undefined) {
  236. add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
  237. } else if (clause.phaseModifier !== ts.SyntaxKind.TypeKeyword) {
  238. if (clause.name !== undefined) add(node.moduleSpecifier, 'default', clause.name)
  239. const bindings = clause.namedBindings
  240. if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
  241. add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT, bindings.name)
  242. } else if (bindings !== undefined && bindings.elements.length === 0) {
  243. add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
  244. } else if (bindings !== undefined) {
  245. for (const element of bindings.elements) {
  246. const imported = element.propertyName ?? element.name
  247. if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
  248. }
  249. }
  250. }
  251. } else if (ts.isExportDeclaration(node) && !node.isTypeOnly) {
  252. const clause = node.exportClause
  253. if (clause === undefined || ts.isNamespaceExport(clause)) {
  254. add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT)
  255. } else if (clause.elements.length === 0) {
  256. add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
  257. } else {
  258. for (const element of clause.elements) {
  259. const imported = element.propertyName ?? element.name
  260. if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
  261. }
  262. }
  263. } else if (ts.isImportEqualsDeclaration(node)
  264. && !node.isTypeOnly
  265. && ts.isExternalModuleReference(node.moduleReference)) {
  266. add(node.moduleReference.expression, NAMESPACE_RUNTIME_EXPORT, node.name)
  267. } else if (ts.isCallExpression(node)) {
  268. const lazyRequire = ts.isIdentifier(node.expression)
  269. ? lazyRequireBindings.has(node.expression.text)
  270. : ts.isPropertyAccessExpression(node.expression)
  271. && ts.isIdentifier(node.expression.expression)
  272. && lazyRequireNamespaces.has(node.expression.expression.text)
  273. && node.expression.name.text === 'createLazyRequire'
  274. if (node.expression.kind === ts.SyntaxKind.ImportKeyword
  275. || ts.isIdentifier(node.expression) && node.expression.text === 'require'
  276. || lazyRequire) add(node.arguments[0], NAMESPACE_RUNTIME_EXPORT)
  277. } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
  278. record('react/jsx-runtime', NAMESPACE_RUNTIME_EXPORT, node)
  279. }
  280. ts.forEachChild(node, visit)
  281. }
  282. visit(sourceFile)
  283. return [...uses.values()].sort((left, right) =>
  284. left.specifier.localeCompare(right.specifier)
  285. || left.exportName.localeCompare(right.exportName)
  286. || left.line - right.line
  287. || left.column - right.column)
  288. }
  289. function resolveLocal(importer: string, specifier: string): string | undefined {
  290. const raw = resolve(dirname(importer), specifier)
  291. const candidates = extname(raw) === ''
  292. ? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')]
  293. : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx?$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')]
  294. return candidates.find(candidate => existsSync(candidate))
  295. }
  296. function nodeExportTargets(value: unknown): string[] {
  297. if (typeof value === 'string') return /\.[cm]?js$/.test(value) ? [value] : []
  298. if (Array.isArray(value)) return value.flatMap(nodeExportTargets)
  299. if (value === null || typeof value !== 'object') return []
  300. return Object.entries(value)
  301. .filter(([condition]) => ['node', 'import', 'require', 'default'].includes(condition))
  302. .flatMap(([, target]) => nodeExportTargets(target))
  303. }
  304. function hasGeneratedHostExport(pkg: WorkspacePackageManifest): boolean {
  305. const exports = pkg.manifest.exports
  306. return exports !== null && typeof exports === 'object'
  307. && nodeExportTargets((exports as Record<string, unknown>)['./typert']).includes('./lib/typert.host.js')
  308. }
  309. /** Generate Host modules in memory, without the build plugin's artifact writes. */
  310. function generatedHostSources(root: string, packages: readonly WorkspacePackageManifest[]): ReadonlyMap<string, string> {
  311. const selected = packages.filter(hasGeneratedHostExport)
  312. if (selected.length === 0) return new Map()
  313. const artifacts = new WorkspaceTypertGenerator(root).generate(selected.map(pkg => pkg.name), ['host'])
  314. const sources = new Map(artifacts.map(artifact => [artifact.package, artifact.js]))
  315. for (const pkg of selected) {
  316. if (!sources.has(pkg.name)) throw new Error(`${pkg.manifestPath}: declared Host Typert export has no generated module`)
  317. }
  318. return sources
  319. }
  320. /** Source-backed Node exports; generated Typert artifacts have no standalone source entry. */
  321. function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[] {
  322. const entry = resolve(root, pkg.dir, 'src/index.ts')
  323. if (!existsSync(entry)) {
  324. throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`)
  325. }
  326. const entries = new Set([entry])
  327. const exports = pkg.manifest.exports
  328. if (exports === null || typeof exports !== 'object') return [...entries]
  329. for (const [subpath, declaration] of Object.entries(exports as Record<string, unknown>)) {
  330. if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue
  331. const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration
  332. ? declaration.types
  333. : undefined
  334. for (const runtime of nodeExportTargets(declaration)) {
  335. const generatedFace = subpath === './typert' ? 'host' : subpath === './remote' ? 'remote-client' : undefined
  336. if (generatedFace !== undefined
  337. && runtime === `./lib/typert.${generatedFace}.js`
  338. && types === `./lib/typert.${generatedFace}.d.ts`) continue
  339. const target = typeof types === 'string' && types.startsWith('./lib/types/') ? types : runtime
  340. if (!target.startsWith('./lib/')) {
  341. throw new Error(`${pkg.manifestPath}: Host export ${subpath} cannot map ${target} to a source entry`)
  342. }
  343. const source = target.replace(/^\.\/lib\/(?:types\/)?/, './src/').replace(/\.d\.([cm]?)ts$/, '.$1js')
  344. const matched = source.includes('*')
  345. ? globSync(source.replace(/\.[cm]?js$/, '.{ts,tsx,mts,cts}'), { cwd: resolve(root, pkg.dir) })
  346. .map(path => resolve(root, pkg.dir, path))
  347. : [resolveLocal(resolve(root, pkg.manifestPath), source)].filter(path => path !== undefined)
  348. if (matched.length === 0) {
  349. throw new Error(`${pkg.manifestPath}: Host export ${subpath} has no source entry for ${target}`)
  350. }
  351. for (const path of matched) entries.add(path)
  352. }
  353. }
  354. return [...entries].sort()
  355. }
  356. function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest, generatedHostSource?: string): {
  357. packageUses: Map<string, string[]>
  358. exportUses: HostRuntimeExportUse[]
  359. } {
  360. const packageUses = new Map<string, string[]>()
  361. const exportUses = new Map<string, HostRuntimeExportUse>()
  362. const seen = new Set<string>()
  363. const collect = (displayPath: string, source: string): void => {
  364. for (const use of collectRuntimeSourceExportUses(displayPath, source)) {
  365. const name = packageNameOf(use.specifier)
  366. if (name === undefined) continue
  367. addUse(packageUses, name, displayPath)
  368. const fact = { packageName: name, ...use, sourcePath: displayPath }
  369. exportUses.set(`${use.specifier}\0${use.exportName}\0${displayPath}\0${String(use.line)}\0${String(use.column)}`, fact)
  370. }
  371. }
  372. const visit = (path: string): void => {
  373. const normalized = normalize(path)
  374. if (seen.has(normalized)) return
  375. seen.add(normalized)
  376. const source = readFileSync(normalized, 'utf8')
  377. const displayPath = normalizePath(relative(root, normalized))
  378. collect(displayPath, source)
  379. for (const specifier of collectRuntimeLocalSourceSpecifiers(normalized, source)) {
  380. const target = resolveLocal(normalized, specifier)
  381. if (target !== undefined) visit(target)
  382. }
  383. }
  384. for (const entry of hostSourceEntries(root, pkg)) visit(entry)
  385. const generated = generatedHostSource ?? generatedHostSources(root, [pkg]).get(pkg.name)
  386. if (generated !== undefined) collect(`${normalizePath(pkg.dir)}/lib/typert.host.js`, generated)
  387. return {
  388. packageUses,
  389. exportUses: [...exportUses.values()].sort((left, right) =>
  390. left.packageName.localeCompare(right.packageName)
  391. || left.specifier.localeCompare(right.specifier)
  392. || left.exportName.localeCompare(right.exportName)
  393. || left.sourcePath.localeCompare(right.sourcePath)
  394. || left.line - right.line
  395. || left.column - right.column),
  396. }
  397. }
  398. function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
  399. const uses = new Map<string, string[]>()
  400. for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
  401. const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
  402. const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
  403. let runtimeUses: Set<string> | undefined
  404. for (const specifier of collectSourcePackageUses(sourcePath, source)) {
  405. const name = packageNameOf(specifier)
  406. if (name === undefined) continue
  407. const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
  408. if (declaredSections(pkg.manifest, name).length === 0 && declaredSections(pkg.manifest, typesName).length > 0) {
  409. runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
  410. if (!runtimeUses.has(name)) {
  411. addUse(uses, typesName, displayPath)
  412. continue
  413. }
  414. }
  415. addUse(uses, name, displayPath)
  416. }
  417. }
  418. return uses
  419. }
  420. /**
  421. * Read authored and generated source usage for one already-classified package.
  422. * @param root - Repository containing source files and face tsconfigs.
  423. * @param pkg - Package manifest and directory.
  424. * @param role - Selected dependency policy role.
  425. * @param workspaceNames - Workspace package identities.
  426. * @param policy - Reviewed Host export and configuration-only classifications.
  427. * @param generatedHostSource - Host module already emitted in memory by a batched Typert pass.
  428. * @returns Source-derived dependency facts without writing build artifacts.
  429. */
  430. export function readPackageDependencyFacts(
  431. root: string,
  432. pkg: WorkspacePackageManifest,
  433. role: PackageDependencyRole,
  434. workspaceNames: ReadonlySet<string>,
  435. policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
  436. generatedHostSource?: string,
  437. ): PackageDependencyFacts {
  438. const inject = pkg.manifest.dsh?.client?.inject ?? []
  439. const hostRuntime = role === 'client-only'
  440. ? { packageUses: new Map<string, string[]>(), exportUses: [] }
  441. : readHostRuntimeUses(root, pkg, generatedHostSource)
  442. return {
  443. manifestPath: pkg.manifestPath,
  444. role,
  445. manifest: pkg.manifest,
  446. workspaceNames,
  447. allSourceUses: readAllSourceUses(root, pkg),
  448. hostRuntimeSourceUses: hostRuntime.packageUses,
  449. hostRuntimeExportUses: hostRuntime.exportUses,
  450. peerRequiredHostDependencies: new Set(hostRuntime.exportUses
  451. .filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
  452. .map(use => use.packageName)),
  453. configurationOnlyDevDependencies: new Set(
  454. policy.configurationOnlyDevDependencies[pkg.manifest.name ?? ''] ?? [],
  455. ),
  456. clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
  457. }
  458. }
  459. /** Validate reviewed Host export classifications against current source facts. */
  460. export function collectHostDependencyExportPolicyViolations(
  461. facts: readonly PackageDependencyFacts[],
  462. workspaceNames: ReadonlySet<string>,
  463. policy: Pick<PackageDependencyPolicy, 'duplicateSafePackages' | 'peerRequiredHostExports' | 'safeHostDependencyExports'>,
  464. ): string[] {
  465. const violations: string[] = []
  466. const allRuntimeUses = facts.flatMap(fact => fact.hostRuntimeExportUses)
  467. const duplicateSafePackages = new Set(policy.duplicateSafePackages ?? [])
  468. for (const packageName of duplicates(policy.duplicateSafePackages ?? [])) {
  469. violations.push(`duplicateSafePackages lists ${packageName} more than once`)
  470. }
  471. for (const packageName of duplicateSafePackages) {
  472. if (!workspaceNames.has(packageName)) {
  473. violations.push(`duplicateSafePackages names unknown workspace package ${packageName}`)
  474. }
  475. }
  476. const classifications = [
  477. ['safeHostDependencyExports', policy.safeHostDependencyExports],
  478. ['peerRequiredHostExports', policy.peerRequiredHostExports],
  479. ] as const
  480. for (const [field, entries] of classifications) {
  481. for (const [specifier, exportNames] of Object.entries(entries)) {
  482. const provider = packageNameOf(specifier)
  483. if (provider === undefined || !workspaceNames.has(provider)) {
  484. violations.push(`${field} specifier ${specifier} is not a workspace package`)
  485. } else if (duplicateSafePackages.has(provider)) {
  486. violations.push(`${field} redundantly classifies duplicate-install-safe package ${specifier}`)
  487. }
  488. if (exportNames.length === 0) {
  489. violations.push(`${field} lists no exports for ${specifier}`)
  490. }
  491. for (const exportName of duplicates(exportNames)) {
  492. violations.push(`${field} lists ${specifier} export ${exportName} more than once`)
  493. }
  494. for (const exportName of exportNames) {
  495. if (exportName === '' || exportName === NAMESPACE_RUNTIME_EXPORT || exportName === SIDE_EFFECT_RUNTIME_EXPORT) {
  496. violations.push(`${field} cannot classify unbounded ${specifier} export ${exportName}`)
  497. continue
  498. }
  499. if (!allRuntimeUses.some(use => use.specifier === specifier && use.exportName === exportName)) {
  500. violations.push(`${field} lists unused ${specifier} export ${exportName}`)
  501. }
  502. if (field === 'safeHostDependencyExports'
  503. && policy.peerRequiredHostExports[specifier]?.includes(exportName) === true) {
  504. violations.push(`${specifier} export ${exportName} appears in both Host export classifications`)
  505. }
  506. }
  507. }
  508. }
  509. for (const fact of facts) {
  510. for (const use of fact.hostRuntimeExportUses) {
  511. if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue
  512. if (!workspaceNames.has(use.packageName)) continue
  513. if (duplicateSafePackages.has(use.packageName)) continue
  514. if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue
  515. if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue
  516. violations.push(
  517. `${use.sourcePath}:${String(use.line)}:${String(use.column)}: `
  518. + `${use.specifier}#${use.exportName} is not classified as safe or peer-required — ${use.sourceLine}`,
  519. )
  520. }
  521. }
  522. return violations.sort()
  523. }
  524. /** Read every package covered by the current dependency policy. */
  525. export function readPackageDependencyState(
  526. root: string,
  527. policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
  528. ): PackageDependencyState {
  529. const packages = readWorkspacePackageManifests(root)
  530. const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
  531. const discovered = discoverPackageDependencyScope(packages.release, policy)
  532. const generated = generatedHostSources(root, discovered.selected.filter(pkg => pkg.role !== 'client-only'))
  533. const facts = discovered.selected.map(pkg =>
  534. readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy, generated.get(pkg.name)))
  535. const selectedNames = new Set(facts.map(fact => fact.manifest.name))
  536. return {
  537. facts,
  538. packages: packages.release,
  539. policyViolations: [
  540. ...discovered.violations,
  541. ...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
  542. ...Object.keys(policy.configurationOnlyDevDependencies)
  543. .filter(name => !selectedNames.has(name))
  544. .map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`),
  545. ].sort(),
  546. workspaceNames,
  547. }
  548. }
  549. /** Derive the required npm section for each relationship owned by the policy. */
  550. export function expectedPackageDependencies(
  551. facts: PackageDependencyFacts,
  552. ): ReadonlyMap<string, ExpectedPackageDependency> {
  553. const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
  554. const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
  555. if (name === facts.manifest.name || name === CORDIS) return
  556. const current = expected.get(name)
  557. const section = current?.section === 'peer-dev' || sectionName === 'peer-dev'
  558. ? 'peer-dev'
  559. : current?.section === 'dependencies' || sectionName === 'dependencies'
  560. ? 'dependencies'
  561. : 'devDependencies'
  562. expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) })
  563. }
  564. expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) })
  565. for (const [name, paths] of facts.allSourceUses) {
  566. for (const path of paths) add(name, 'devDependencies', path)
  567. }
  568. if (facts.role !== 'configured-host') {
  569. for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
  570. for (const name of Object.keys(facts.manifest[sectionName] ?? {})) {
  571. if (!facts.workspaceNames.has(name)) add(name, 'devDependencies', 'declared browser build input')
  572. }
  573. }
  574. }
  575. for (const name of facts.clientInject) {
  576. if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
  577. }
  578. for (const name of facts.configurationOnlyDevDependencies) {
  579. if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
  580. }
  581. for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) {
  582. if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer')
  583. }
  584. for (const [name, paths] of facts.hostRuntimeSourceUses) {
  585. const expectedSection = facts.workspaceNames.has(name) && facts.peerRequiredHostDependencies.has(name)
  586. ? 'peer-dev'
  587. : 'dependencies'
  588. for (const path of paths) add(name, expectedSection, path)
  589. }
  590. return new Map([...expected].map(([name, rule]) => [name, {
  591. section: rule.section,
  592. origins: [...rule.origins].sort(),
  593. }]))
  594. }
  595. interface ManagedRuntimeEdge {
  596. readonly consumer: string
  597. readonly dependency: string
  598. readonly exports: readonly string[]
  599. }
  600. function managedRuntimeEdges(
  601. state: PackageDependencyState,
  602. expectedSection: 'dependencies' | 'peer-dev',
  603. ): ManagedRuntimeEdge[] {
  604. return state.facts.flatMap(facts => [...expectedPackageDependencies(facts)]
  605. .filter(([name, rule]) => name !== CORDIS && rule.section === expectedSection)
  606. .map(([dependency]) => ({
  607. consumer: facts.manifest.name ?? facts.manifestPath,
  608. dependency,
  609. exports: [...new Set(facts.hostRuntimeExportUses
  610. .filter(use => use.packageName === dependency)
  611. .map(use => `${use.specifier}#${use.exportName}`))].sort(),
  612. })))
  613. .sort((left, right) =>
  614. left.consumer.localeCompare(right.consumer) || left.dependency.localeCompare(right.dependency))
  615. }
  616. /** Format Host runtime edges whose reviewed exports permit ordinary dependencies. */
  617. export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] {
  618. const rows = managedRuntimeEdges(state, 'dependencies')
  619. const packages = new Set(rows.map(row => row.consumer)).size
  620. return [
  621. `${GATE}: ${String(rows.length)} managed Host runtime edge(s) remain in dependencies across ${String(packages)} package(s):`,
  622. ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
  623. ]
  624. }
  625. /** Format Host runtime edges retained as peers by their imported export classification. */
  626. export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] {
  627. const rows = managedRuntimeEdges(state, 'peer-dev')
  628. const packages = new Set(rows.map(row => row.consumer)).size
  629. return [
  630. `${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`,
  631. ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
  632. ]
  633. }
  634. function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
  635. return manifest[name] ?? {}
  636. }
  637. function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
  638. manifest[name] ??= {}
  639. return manifest[name]
  640. }
  641. function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
  642. return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
  643. .filter(sectionName => section(manifest, sectionName)[name] !== undefined)
  644. }
  645. function describeSections(sections: readonly DependencySection[]): string {
  646. return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
  647. }
  648. /** Return all manifest and policy violations in stable order. */
  649. export function collectPackageDependencyViolations(state: PackageDependencyState): string[] {
  650. const violations = [...state.policyViolations]
  651. if (violations.length > 0) return [...new Set(violations)].sort()
  652. for (const facts of state.facts) {
  653. for (const [name, rule] of expectedPackageDependencies(facts)) {
  654. const actual = declaredSections(facts.manifest, name)
  655. if (rule.section === 'peer-dev') {
  656. if (actual.length === 2
  657. && actual.includes('peerDependencies')
  658. && actual.includes('devDependencies')
  659. && section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE
  660. && section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE
  661. && facts.manifest.peerDependenciesMeta?.[name] === undefined) continue
  662. violations.push(
  663. `${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`,
  664. )
  665. continue
  666. }
  667. const expectedSection = rule.section
  668. const range = section(facts.manifest, expectedSection)[name]
  669. if (actual.length === 1
  670. && actual[0] === expectedSection
  671. && (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue
  672. violations.push(
  673. `${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only`
  674. + (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '')
  675. + `; found ${describeSections(actual)}`,
  676. )
  677. }
  678. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  679. for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) {
  680. if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue
  681. violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`)
  682. }
  683. }
  684. for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
  685. if (facts.manifest.peerDependencies?.[name] === undefined) {
  686. violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`)
  687. }
  688. }
  689. }
  690. return [...new Set(violations)].sort()
  691. }
  692. function deleteDependency(
  693. manifest: PackageDependencyManifest,
  694. sectionName: DependencySection,
  695. name: string,
  696. ): void {
  697. const dependencies = manifest[sectionName]
  698. if (dependencies?.[name] === undefined) return
  699. const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
  700. if (Object.keys(retained).length > 0) {
  701. manifest[sectionName] = retained
  702. return
  703. }
  704. switch (sectionName) {
  705. case 'dependencies': delete manifest.dependencies; break
  706. case 'devDependencies': delete manifest.devDependencies; break
  707. case 'optionalDependencies': delete manifest.optionalDependencies; break
  708. case 'peerDependencies': delete manifest.peerDependencies; break
  709. }
  710. }
  711. function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void {
  712. if (manifest.peerDependenciesMeta?.[name] === undefined) return
  713. const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta)
  714. .filter(([key]) => key !== name))
  715. if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained
  716. else delete manifest.peerDependenciesMeta
  717. }
  718. function preferredRange(
  719. facts: PackageDependencyFacts,
  720. name: string,
  721. target: ExpectedPackageDependency['section'],
  722. ): string {
  723. if (name === CORDIS || facts.workspaceNames.has(name)) return WORKSPACE_RANGE
  724. const order: readonly DependencySection[] = target === 'dependencies'
  725. ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
  726. : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies']
  727. const range = order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
  728. if (range === undefined) {
  729. throw new Error(`${facts.manifestPath}: cannot repair undeclared third-party dependency ${name}; declare its version range first`)
  730. }
  731. return range
  732. }
  733. /**
  734. * Apply the dependency policy to one in-memory manifest.
  735. * @param facts - Source uses and manifest to repair.
  736. * @throws When a third-party dependency has no declared range; the manifest remains unchanged.
  737. */
  738. export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void {
  739. const repairs = [...expectedPackageDependencies(facts)].map(([name, rule]) => ({
  740. name, rule, range: preferredRange(facts, name, rule.section),
  741. }))
  742. for (const { name, rule, range } of repairs) {
  743. if (rule.section === 'peer-dev') {
  744. for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
  745. deleteDependency(facts.manifest, sectionName, name)
  746. }
  747. mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE
  748. mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE
  749. deletePeerMeta(facts.manifest, name)
  750. continue
  751. }
  752. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  753. if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name)
  754. }
  755. mutableSection(facts.manifest, rule.section)[name] = range
  756. deletePeerMeta(facts.manifest, name)
  757. }
  758. for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
  759. if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name)
  760. }
  761. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  762. for (const name of Object.keys(section(facts.manifest, sectionName))) {
  763. if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE
  764. }
  765. }
  766. }
  767. /**
  768. * Repair every covered manifest after validating all dependency ranges.
  769. * @param root - Repository containing the manifests.
  770. * @param state - Classified packages and policy violations that block repair.
  771. * @returns Repository-relative changed paths, or no paths when policy violations block repair.
  772. * @throws When any third-party dependency is undeclared, before modifying any manifest.
  773. */
  774. export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] {
  775. if (state.policyViolations.length > 0) return []
  776. for (const facts of state.facts) {
  777. for (const [name, rule] of expectedPackageDependencies(facts)) preferredRange(facts, name, rule.section)
  778. }
  779. const changed: string[] = []
  780. for (const facts of state.facts) {
  781. const before = `${JSON.stringify(facts.manifest, null, 2)}\n`
  782. repairPackageDependencyManifest(facts)
  783. const after = `${JSON.stringify(facts.manifest, null, 2)}\n`
  784. if (after === before) continue
  785. writeFileSync(resolve(root, facts.manifestPath), after)
  786. changed.push(facts.manifestPath)
  787. }
  788. return changed.sort()
  789. }
  790. function refreshPnpmLockfile(root: string): void {
  791. const result = spawnSync(
  792. 'pnpm',
  793. ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'],
  794. { cwd: root, shell: process.platform === 'win32', stdio: 'inherit' },
  795. )
  796. if (result.error !== undefined) throw new Error(`could not refresh pnpm-lock.yaml: ${result.error.message}`)
  797. if (result.status !== 0) throw new Error(`pnpm lockfile refresh exited with status ${String(result.status)}`)
  798. }
  799. function main(): void {
  800. const root = resolve(import.meta.dirname, '..')
  801. let state = readPackageDependencyState(root)
  802. const fix = process.argv.includes('--fix')
  803. if (fix) {
  804. if (state.policyViolations.length > 0) {
  805. console.error(`${GATE}: --fix skipped because dependency policy review failed.`)
  806. } else {
  807. const changed = fixPackageDependencies(root, state)
  808. console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
  809. refreshPnpmLockfile(root)
  810. const graphChanges = writeModuleGraph(root)
  811. console.log(
  812. `${GATE}: refreshed pnpm-lock.yaml and wrote ${String(graphChanges.length)} module-graph artifact(s).`,
  813. )
  814. state = readPackageDependencyState(root)
  815. }
  816. }
  817. const violations = collectPackageDependencyViolations(state)
  818. if (violations.length > 0) {
  819. console.error(`${GATE}: ${String(violations.length)} violation(s):`)
  820. for (const violation of violations) console.error(` ${violation}`)
  821. process.exitCode = 1
  822. return
  823. }
  824. const roles = Object.groupBy(state.facts, fact => fact.role)
  825. console.log(
  826. `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy`
  827. + ` (${String(roles['client-only']?.length ?? 0)} Client-only,`
  828. + ` ${String(roles['client-host']?.length ?? 0)} Client/Host,`
  829. + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`,
  830. )
  831. if (fix) {
  832. for (const line of formatManagedRuntimeDependencies(state)) console.log(line)
  833. for (const line of formatPeerRequiredRuntimeDependencies(state)) console.log(line)
  834. }
  835. }
  836. if (import.meta.main) main()