verify-package-dependencies.ts 32 KB

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