verify-package-dependencies.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  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 record = (specifier: string, exportName: string, locationNode: ts.Node): void => {
  195. const key = `${specifier}\0${exportName}`
  196. if (uses.has(key)) return
  197. const position = sourceFile.getLineAndCharacterOfPosition(locationNode.getStart(sourceFile))
  198. uses.set(key, {
  199. specifier,
  200. exportName,
  201. line: position.line + 1,
  202. column: position.character + 1,
  203. sourceLine: sourceLines[position.line]?.trim() ?? '',
  204. })
  205. }
  206. const add = (
  207. specifierNode: ts.Expression | undefined,
  208. exportName: string,
  209. locationNode: ts.Node = specifierNode ?? sourceFile,
  210. ): void => {
  211. if (specifierNode === undefined || !ts.isStringLiteralLike(specifierNode)) return
  212. if (packageNameOf(specifierNode.text) === undefined) return
  213. record(specifierNode.text, exportName, locationNode)
  214. }
  215. const visit = (node: ts.Node): void => {
  216. if (ts.isImportDeclaration(node)) {
  217. const clause = node.importClause
  218. if (clause === undefined) {
  219. add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
  220. } else if (clause.phaseModifier !== ts.SyntaxKind.TypeKeyword) {
  221. if (clause.name !== undefined) add(node.moduleSpecifier, 'default', clause.name)
  222. const bindings = clause.namedBindings
  223. if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
  224. add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT, bindings.name)
  225. } else if (bindings !== undefined && bindings.elements.length === 0) {
  226. add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
  227. } else if (bindings !== undefined) {
  228. for (const element of bindings.elements) {
  229. const imported = element.propertyName ?? element.name
  230. if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
  231. }
  232. }
  233. }
  234. } else if (ts.isExportDeclaration(node) && !node.isTypeOnly) {
  235. const clause = node.exportClause
  236. if (clause === undefined || ts.isNamespaceExport(clause)) {
  237. add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT)
  238. } else if (clause.elements.length === 0) {
  239. add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
  240. } else {
  241. for (const element of clause.elements) {
  242. const imported = element.propertyName ?? element.name
  243. if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
  244. }
  245. }
  246. } else if (ts.isImportEqualsDeclaration(node)
  247. && !node.isTypeOnly
  248. && ts.isExternalModuleReference(node.moduleReference)) {
  249. add(node.moduleReference.expression, NAMESPACE_RUNTIME_EXPORT, node.name)
  250. } else if (ts.isCallExpression(node)
  251. && (node.expression.kind === ts.SyntaxKind.ImportKeyword
  252. || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
  253. add(node.arguments[0], NAMESPACE_RUNTIME_EXPORT)
  254. } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
  255. record('react/jsx-runtime', NAMESPACE_RUNTIME_EXPORT, node)
  256. }
  257. ts.forEachChild(node, visit)
  258. }
  259. visit(sourceFile)
  260. return [...uses.values()].sort((left, right) =>
  261. left.specifier.localeCompare(right.specifier)
  262. || left.exportName.localeCompare(right.exportName)
  263. || left.line - right.line
  264. || left.column - right.column)
  265. }
  266. function resolveLocal(importer: string, specifier: string): string | undefined {
  267. const raw = resolve(dirname(importer), specifier)
  268. const candidates = extname(raw) === ''
  269. ? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')]
  270. : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx?$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')]
  271. return candidates.find(candidate => existsSync(candidate))
  272. }
  273. function nodeExportTargets(value: unknown): string[] {
  274. if (typeof value === 'string') return /\.[cm]?js$/.test(value) ? [value] : []
  275. if (Array.isArray(value)) return value.flatMap(nodeExportTargets)
  276. if (value === null || typeof value !== 'object') return []
  277. return Object.entries(value)
  278. .filter(([condition]) => ['node', 'import', 'require', 'default'].includes(condition))
  279. .flatMap(([, target]) => nodeExportTargets(target))
  280. }
  281. function hasGeneratedHostExport(pkg: WorkspacePackageManifest): boolean {
  282. const exports = pkg.manifest.exports
  283. return exports !== null && typeof exports === 'object'
  284. && nodeExportTargets((exports as Record<string, unknown>)['./typert']).includes('./lib/typert.host.js')
  285. }
  286. /** Generate Host modules in memory, without the build plugin's artifact writes. */
  287. function generatedHostSources(root: string, packages: readonly WorkspacePackageManifest[]): ReadonlyMap<string, string> {
  288. const selected = packages.filter(hasGeneratedHostExport)
  289. if (selected.length === 0) return new Map()
  290. const artifacts = new WorkspaceTypertGenerator(root).generate(selected.map(pkg => pkg.name), ['host'])
  291. const sources = new Map(artifacts.map(artifact => [artifact.package, artifact.js]))
  292. for (const pkg of selected) {
  293. if (!sources.has(pkg.name)) throw new Error(`${pkg.manifestPath}: declared Host Typert export has no generated module`)
  294. }
  295. return sources
  296. }
  297. /** Source-backed Node exports; generated Typert artifacts have no standalone source entry. */
  298. function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[] {
  299. const entry = resolve(root, pkg.dir, 'src/index.ts')
  300. if (!existsSync(entry)) {
  301. throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`)
  302. }
  303. const entries = new Set([entry])
  304. const exports = pkg.manifest.exports
  305. if (exports === null || typeof exports !== 'object') return [...entries]
  306. for (const [subpath, declaration] of Object.entries(exports as Record<string, unknown>)) {
  307. if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue
  308. const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration
  309. ? declaration.types
  310. : undefined
  311. for (const runtime of nodeExportTargets(declaration)) {
  312. const generatedFace = subpath === './typert' ? 'host' : subpath === './remote' ? 'remote-client' : undefined
  313. if (generatedFace !== undefined
  314. && runtime === `./lib/typert.${generatedFace}.js`
  315. && types === `./lib/typert.${generatedFace}.d.ts`) continue
  316. const target = typeof types === 'string' && types.startsWith('./lib/types/') ? types : runtime
  317. if (!target.startsWith('./lib/')) {
  318. throw new Error(`${pkg.manifestPath}: Host export ${subpath} cannot map ${target} to a source entry`)
  319. }
  320. const source = target.replace(/^\.\/lib\/(?:types\/)?/, './src/').replace(/\.d\.([cm]?)ts$/, '.$1js')
  321. const matched = source.includes('*')
  322. ? globSync(source.replace(/\.[cm]?js$/, '.{ts,tsx,mts,cts}'), { cwd: resolve(root, pkg.dir) })
  323. .map(path => resolve(root, pkg.dir, path))
  324. : [resolveLocal(resolve(root, pkg.manifestPath), source)].filter(path => path !== undefined)
  325. if (matched.length === 0) {
  326. throw new Error(`${pkg.manifestPath}: Host export ${subpath} has no source entry for ${target}`)
  327. }
  328. for (const path of matched) entries.add(path)
  329. }
  330. }
  331. return [...entries].sort()
  332. }
  333. function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest, generatedHostSource?: string): {
  334. packageUses: Map<string, string[]>
  335. exportUses: HostRuntimeExportUse[]
  336. } {
  337. const packageUses = new Map<string, string[]>()
  338. const exportUses = new Map<string, HostRuntimeExportUse>()
  339. const seen = new Set<string>()
  340. const collect = (displayPath: string, source: string): void => {
  341. for (const use of collectRuntimeSourceExportUses(displayPath, source)) {
  342. const name = packageNameOf(use.specifier)
  343. if (name === undefined) continue
  344. addUse(packageUses, name, displayPath)
  345. const fact = { packageName: name, ...use, sourcePath: displayPath }
  346. exportUses.set(`${use.specifier}\0${use.exportName}\0${displayPath}\0${String(use.line)}\0${String(use.column)}`, fact)
  347. }
  348. }
  349. const visit = (path: string): void => {
  350. const normalized = normalize(path)
  351. if (seen.has(normalized)) return
  352. seen.add(normalized)
  353. const source = readFileSync(normalized, 'utf8')
  354. const displayPath = normalizePath(relative(root, normalized))
  355. collect(displayPath, source)
  356. for (const specifier of collectRuntimeLocalSourceSpecifiers(normalized, source)) {
  357. const target = resolveLocal(normalized, specifier)
  358. if (target !== undefined) visit(target)
  359. }
  360. }
  361. for (const entry of hostSourceEntries(root, pkg)) visit(entry)
  362. const generated = generatedHostSource ?? generatedHostSources(root, [pkg]).get(pkg.name)
  363. if (generated !== undefined) collect(`${normalizePath(pkg.dir)}/lib/typert.host.js`, generated)
  364. return {
  365. packageUses,
  366. exportUses: [...exportUses.values()].sort((left, right) =>
  367. left.packageName.localeCompare(right.packageName)
  368. || left.specifier.localeCompare(right.specifier)
  369. || left.exportName.localeCompare(right.exportName)
  370. || left.sourcePath.localeCompare(right.sourcePath)
  371. || left.line - right.line
  372. || left.column - right.column),
  373. }
  374. }
  375. function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
  376. const uses = new Map<string, string[]>()
  377. for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
  378. const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
  379. const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
  380. let runtimeUses: Set<string> | undefined
  381. for (const specifier of collectSourcePackageUses(sourcePath, source)) {
  382. const name = packageNameOf(specifier)
  383. if (name === undefined) continue
  384. const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
  385. if (declaredSections(pkg.manifest, name).length === 0 && declaredSections(pkg.manifest, typesName).length > 0) {
  386. runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
  387. if (!runtimeUses.has(name)) {
  388. addUse(uses, typesName, displayPath)
  389. continue
  390. }
  391. }
  392. addUse(uses, name, displayPath)
  393. }
  394. }
  395. return uses
  396. }
  397. /**
  398. * Read authored and generated source usage for one already-classified package.
  399. * @param root - Repository containing source files and face tsconfigs.
  400. * @param pkg - Package manifest and directory.
  401. * @param role - Selected dependency policy role.
  402. * @param workspaceNames - Workspace package identities.
  403. * @param policy - Reviewed Host export and configuration-only classifications.
  404. * @param generatedHostSource - Host module already emitted in memory by a batched Typert pass.
  405. * @returns Source-derived dependency facts without writing build artifacts.
  406. */
  407. export function readPackageDependencyFacts(
  408. root: string,
  409. pkg: WorkspacePackageManifest,
  410. role: PackageDependencyRole,
  411. workspaceNames: ReadonlySet<string>,
  412. policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
  413. generatedHostSource?: string,
  414. ): PackageDependencyFacts {
  415. const inject = pkg.manifest.dsh?.client?.inject ?? []
  416. const hostRuntime = role === 'client-only'
  417. ? { packageUses: new Map<string, string[]>(), exportUses: [] }
  418. : readHostRuntimeUses(root, pkg, generatedHostSource)
  419. return {
  420. manifestPath: pkg.manifestPath,
  421. role,
  422. manifest: pkg.manifest,
  423. workspaceNames,
  424. allSourceUses: readAllSourceUses(root, pkg),
  425. hostRuntimeSourceUses: hostRuntime.packageUses,
  426. hostRuntimeExportUses: hostRuntime.exportUses,
  427. peerRequiredHostDependencies: new Set(hostRuntime.exportUses
  428. .filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
  429. .map(use => use.packageName)),
  430. configurationOnlyDevDependencies: new Set(
  431. policy.configurationOnlyDevDependencies[pkg.manifest.name ?? ''] ?? [],
  432. ),
  433. clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
  434. }
  435. }
  436. /** Validate reviewed Host export classifications against current source facts. */
  437. export function collectHostDependencyExportPolicyViolations(
  438. facts: readonly PackageDependencyFacts[],
  439. workspaceNames: ReadonlySet<string>,
  440. policy: Pick<PackageDependencyPolicy, 'duplicateSafePackages' | 'peerRequiredHostExports' | 'safeHostDependencyExports'>,
  441. ): string[] {
  442. const violations: string[] = []
  443. const allRuntimeUses = facts.flatMap(fact => fact.hostRuntimeExportUses)
  444. const duplicateSafePackages = new Set(policy.duplicateSafePackages ?? [])
  445. for (const packageName of duplicates(policy.duplicateSafePackages ?? [])) {
  446. violations.push(`duplicateSafePackages lists ${packageName} more than once`)
  447. }
  448. for (const packageName of duplicateSafePackages) {
  449. if (!workspaceNames.has(packageName)) {
  450. violations.push(`duplicateSafePackages names unknown workspace package ${packageName}`)
  451. }
  452. }
  453. const classifications = [
  454. ['safeHostDependencyExports', policy.safeHostDependencyExports],
  455. ['peerRequiredHostExports', policy.peerRequiredHostExports],
  456. ] as const
  457. for (const [field, entries] of classifications) {
  458. for (const [specifier, exportNames] of Object.entries(entries)) {
  459. const provider = packageNameOf(specifier)
  460. if (provider === undefined || !workspaceNames.has(provider)) {
  461. violations.push(`${field} specifier ${specifier} is not a workspace package`)
  462. } else if (duplicateSafePackages.has(provider)) {
  463. violations.push(`${field} redundantly classifies duplicate-install-safe package ${specifier}`)
  464. }
  465. if (exportNames.length === 0) {
  466. violations.push(`${field} lists no exports for ${specifier}`)
  467. }
  468. for (const exportName of duplicates(exportNames)) {
  469. violations.push(`${field} lists ${specifier} export ${exportName} more than once`)
  470. }
  471. for (const exportName of exportNames) {
  472. if (exportName === '' || exportName === NAMESPACE_RUNTIME_EXPORT || exportName === SIDE_EFFECT_RUNTIME_EXPORT) {
  473. violations.push(`${field} cannot classify unbounded ${specifier} export ${exportName}`)
  474. continue
  475. }
  476. if (!allRuntimeUses.some(use => use.specifier === specifier && use.exportName === exportName)) {
  477. violations.push(`${field} lists unused ${specifier} export ${exportName}`)
  478. }
  479. if (field === 'safeHostDependencyExports'
  480. && policy.peerRequiredHostExports[specifier]?.includes(exportName) === true) {
  481. violations.push(`${specifier} export ${exportName} appears in both Host export classifications`)
  482. }
  483. }
  484. }
  485. }
  486. for (const fact of facts) {
  487. for (const use of fact.hostRuntimeExportUses) {
  488. if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue
  489. if (!workspaceNames.has(use.packageName)) continue
  490. if (duplicateSafePackages.has(use.packageName)) continue
  491. if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue
  492. if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue
  493. violations.push(
  494. `${use.sourcePath}:${String(use.line)}:${String(use.column)}: `
  495. + `${use.specifier}#${use.exportName} is not classified as safe or peer-required — ${use.sourceLine}`,
  496. )
  497. }
  498. }
  499. return violations.sort()
  500. }
  501. /** Read every package covered by the current dependency policy. */
  502. export function readPackageDependencyState(
  503. root: string,
  504. policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
  505. ): PackageDependencyState {
  506. const packages = readWorkspacePackageManifests(root)
  507. const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
  508. const discovered = discoverPackageDependencyScope(packages.release, policy)
  509. const generated = generatedHostSources(root, discovered.selected.filter(pkg => pkg.role !== 'client-only'))
  510. const facts = discovered.selected.map(pkg =>
  511. readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy, generated.get(pkg.name)))
  512. const selectedNames = new Set(facts.map(fact => fact.manifest.name))
  513. return {
  514. facts,
  515. packages: packages.release,
  516. policyViolations: [
  517. ...discovered.violations,
  518. ...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
  519. ...Object.keys(policy.configurationOnlyDevDependencies)
  520. .filter(name => !selectedNames.has(name))
  521. .map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`),
  522. ].sort(),
  523. workspaceNames,
  524. }
  525. }
  526. /** Derive the required npm section for each relationship owned by the policy. */
  527. export function expectedPackageDependencies(
  528. facts: PackageDependencyFacts,
  529. ): ReadonlyMap<string, ExpectedPackageDependency> {
  530. const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
  531. const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
  532. if (name === facts.manifest.name || name === CORDIS) return
  533. const current = expected.get(name)
  534. const section = current?.section === 'peer-dev' || sectionName === 'peer-dev'
  535. ? 'peer-dev'
  536. : current?.section === 'dependencies' || sectionName === 'dependencies'
  537. ? 'dependencies'
  538. : 'devDependencies'
  539. expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) })
  540. }
  541. expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) })
  542. for (const [name, paths] of facts.allSourceUses) {
  543. for (const path of paths) add(name, 'devDependencies', path)
  544. }
  545. if (facts.role !== 'configured-host') {
  546. for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
  547. for (const name of Object.keys(facts.manifest[sectionName] ?? {})) {
  548. if (!facts.workspaceNames.has(name)) add(name, 'devDependencies', 'declared browser build input')
  549. }
  550. }
  551. }
  552. for (const name of facts.clientInject) {
  553. if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
  554. }
  555. for (const name of facts.configurationOnlyDevDependencies) {
  556. if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
  557. }
  558. for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) {
  559. if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer')
  560. }
  561. for (const [name, paths] of facts.hostRuntimeSourceUses) {
  562. const expectedSection = facts.workspaceNames.has(name) && facts.peerRequiredHostDependencies.has(name)
  563. ? 'peer-dev'
  564. : 'dependencies'
  565. for (const path of paths) add(name, expectedSection, path)
  566. }
  567. return new Map([...expected].map(([name, rule]) => [name, {
  568. section: rule.section,
  569. origins: [...rule.origins].sort(),
  570. }]))
  571. }
  572. interface ManagedRuntimeEdge {
  573. readonly consumer: string
  574. readonly dependency: string
  575. readonly exports: readonly string[]
  576. }
  577. function managedRuntimeEdges(
  578. state: PackageDependencyState,
  579. expectedSection: 'dependencies' | 'peer-dev',
  580. ): ManagedRuntimeEdge[] {
  581. return state.facts.flatMap(facts => [...expectedPackageDependencies(facts)]
  582. .filter(([name, rule]) => name !== CORDIS && rule.section === expectedSection)
  583. .map(([dependency]) => ({
  584. consumer: facts.manifest.name ?? facts.manifestPath,
  585. dependency,
  586. exports: [...new Set(facts.hostRuntimeExportUses
  587. .filter(use => use.packageName === dependency)
  588. .map(use => `${use.specifier}#${use.exportName}`))].sort(),
  589. })))
  590. .sort((left, right) =>
  591. left.consumer.localeCompare(right.consumer) || left.dependency.localeCompare(right.dependency))
  592. }
  593. /** Format Host runtime edges whose reviewed exports permit ordinary dependencies. */
  594. export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] {
  595. const rows = managedRuntimeEdges(state, 'dependencies')
  596. const packages = new Set(rows.map(row => row.consumer)).size
  597. return [
  598. `${GATE}: ${String(rows.length)} managed Host runtime edge(s) remain in dependencies across ${String(packages)} package(s):`,
  599. ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
  600. ]
  601. }
  602. /** Format Host runtime edges retained as peers by their imported export classification. */
  603. export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] {
  604. const rows = managedRuntimeEdges(state, 'peer-dev')
  605. const packages = new Set(rows.map(row => row.consumer)).size
  606. return [
  607. `${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`,
  608. ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
  609. ]
  610. }
  611. function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
  612. return manifest[name] ?? {}
  613. }
  614. function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
  615. manifest[name] ??= {}
  616. return manifest[name]
  617. }
  618. function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
  619. return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
  620. .filter(sectionName => section(manifest, sectionName)[name] !== undefined)
  621. }
  622. function describeSections(sections: readonly DependencySection[]): string {
  623. return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
  624. }
  625. /** Return all manifest and policy violations in stable order. */
  626. export function collectPackageDependencyViolations(state: PackageDependencyState): string[] {
  627. const violations = [...state.policyViolations]
  628. if (violations.length > 0) return [...new Set(violations)].sort()
  629. for (const facts of state.facts) {
  630. for (const [name, rule] of expectedPackageDependencies(facts)) {
  631. const actual = declaredSections(facts.manifest, name)
  632. if (rule.section === 'peer-dev') {
  633. if (actual.length === 2
  634. && actual.includes('peerDependencies')
  635. && actual.includes('devDependencies')
  636. && section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE
  637. && section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE
  638. && facts.manifest.peerDependenciesMeta?.[name] === undefined) continue
  639. violations.push(
  640. `${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`,
  641. )
  642. continue
  643. }
  644. const expectedSection = rule.section
  645. const range = section(facts.manifest, expectedSection)[name]
  646. if (actual.length === 1
  647. && actual[0] === expectedSection
  648. && (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue
  649. violations.push(
  650. `${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only`
  651. + (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '')
  652. + `; found ${describeSections(actual)}`,
  653. )
  654. }
  655. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  656. for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) {
  657. if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue
  658. violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`)
  659. }
  660. }
  661. for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
  662. if (facts.manifest.peerDependencies?.[name] === undefined) {
  663. violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`)
  664. }
  665. }
  666. }
  667. return [...new Set(violations)].sort()
  668. }
  669. function deleteDependency(
  670. manifest: PackageDependencyManifest,
  671. sectionName: DependencySection,
  672. name: string,
  673. ): void {
  674. const dependencies = manifest[sectionName]
  675. if (dependencies?.[name] === undefined) return
  676. const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
  677. if (Object.keys(retained).length > 0) {
  678. manifest[sectionName] = retained
  679. return
  680. }
  681. switch (sectionName) {
  682. case 'dependencies': delete manifest.dependencies; break
  683. case 'devDependencies': delete manifest.devDependencies; break
  684. case 'optionalDependencies': delete manifest.optionalDependencies; break
  685. case 'peerDependencies': delete manifest.peerDependencies; break
  686. }
  687. }
  688. function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void {
  689. if (manifest.peerDependenciesMeta?.[name] === undefined) return
  690. const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta)
  691. .filter(([key]) => key !== name))
  692. if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained
  693. else delete manifest.peerDependenciesMeta
  694. }
  695. function preferredRange(
  696. facts: PackageDependencyFacts,
  697. name: string,
  698. target: ExpectedPackageDependency['section'],
  699. ): string {
  700. if (name === CORDIS || facts.workspaceNames.has(name)) return WORKSPACE_RANGE
  701. const order: readonly DependencySection[] = target === 'dependencies'
  702. ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
  703. : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies']
  704. const range = order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
  705. if (range === undefined) {
  706. throw new Error(`${facts.manifestPath}: cannot repair undeclared third-party dependency ${name}; declare its version range first`)
  707. }
  708. return range
  709. }
  710. /**
  711. * Apply the dependency policy to one in-memory manifest.
  712. * @param facts - Source uses and manifest to repair.
  713. * @throws When a third-party dependency has no declared range; the manifest remains unchanged.
  714. */
  715. export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void {
  716. const repairs = [...expectedPackageDependencies(facts)].map(([name, rule]) => ({
  717. name, rule, range: preferredRange(facts, name, rule.section),
  718. }))
  719. for (const { name, rule, range } of repairs) {
  720. if (rule.section === 'peer-dev') {
  721. for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
  722. deleteDependency(facts.manifest, sectionName, name)
  723. }
  724. mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE
  725. mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE
  726. deletePeerMeta(facts.manifest, name)
  727. continue
  728. }
  729. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  730. if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name)
  731. }
  732. mutableSection(facts.manifest, rule.section)[name] = range
  733. deletePeerMeta(facts.manifest, name)
  734. }
  735. for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
  736. if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name)
  737. }
  738. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  739. for (const name of Object.keys(section(facts.manifest, sectionName))) {
  740. if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE
  741. }
  742. }
  743. }
  744. /**
  745. * Repair every covered manifest after validating all dependency ranges.
  746. * @param root - Repository containing the manifests.
  747. * @param state - Classified packages and policy violations that block repair.
  748. * @returns Repository-relative changed paths, or no paths when policy violations block repair.
  749. * @throws When any third-party dependency is undeclared, before modifying any manifest.
  750. */
  751. export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] {
  752. if (state.policyViolations.length > 0) return []
  753. for (const facts of state.facts) {
  754. for (const [name, rule] of expectedPackageDependencies(facts)) preferredRange(facts, name, rule.section)
  755. }
  756. const changed: string[] = []
  757. for (const facts of state.facts) {
  758. const before = `${JSON.stringify(facts.manifest, null, 2)}\n`
  759. repairPackageDependencyManifest(facts)
  760. const after = `${JSON.stringify(facts.manifest, null, 2)}\n`
  761. if (after === before) continue
  762. writeFileSync(resolve(root, facts.manifestPath), after)
  763. changed.push(facts.manifestPath)
  764. }
  765. return changed.sort()
  766. }
  767. function refreshPnpmLockfile(root: string): void {
  768. const result = spawnSync(
  769. 'pnpm',
  770. ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'],
  771. { cwd: root, shell: process.platform === 'win32', stdio: 'inherit' },
  772. )
  773. if (result.error !== undefined) throw new Error(`could not refresh pnpm-lock.yaml: ${result.error.message}`)
  774. if (result.status !== 0) throw new Error(`pnpm lockfile refresh exited with status ${String(result.status)}`)
  775. }
  776. function main(): void {
  777. const root = resolve(import.meta.dirname, '..')
  778. let state = readPackageDependencyState(root)
  779. const fix = process.argv.includes('--fix')
  780. if (fix) {
  781. if (state.policyViolations.length > 0) {
  782. console.error(`${GATE}: --fix skipped because dependency policy review failed.`)
  783. } else {
  784. const changed = fixPackageDependencies(root, state)
  785. console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
  786. refreshPnpmLockfile(root)
  787. const graphChanges = writeModuleGraph(root)
  788. console.log(
  789. `${GATE}: refreshed pnpm-lock.yaml and wrote ${String(graphChanges.length)} module-graph artifact(s).`,
  790. )
  791. state = readPackageDependencyState(root)
  792. }
  793. }
  794. const violations = collectPackageDependencyViolations(state)
  795. if (violations.length > 0) {
  796. console.error(`${GATE}: ${String(violations.length)} violation(s):`)
  797. for (const violation of violations) console.error(` ${violation}`)
  798. process.exitCode = 1
  799. return
  800. }
  801. const roles = Object.groupBy(state.facts, fact => fact.role)
  802. console.log(
  803. `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy`
  804. + ` (${String(roles['client-only']?.length ?? 0)} Client-only,`
  805. + ` ${String(roles['client-host']?.length ?? 0)} Client/Host,`
  806. + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`,
  807. )
  808. if (fix) {
  809. for (const line of formatManagedRuntimeDependencies(state)) console.log(line)
  810. for (const line of formatPeerRequiredRuntimeDependencies(state)) console.log(line)
  811. }
  812. }
  813. if (import.meta.main) main()