verify-package-dependencies.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  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, 'peerRequiredHostExports' | 'safeHostDependencyExports'>,
  353. ): string[] {
  354. const violations: string[] = []
  355. const allRuntimeUses = facts.flatMap(fact => fact.hostRuntimeExportUses)
  356. const classifications = [
  357. ['safeHostDependencyExports', policy.safeHostDependencyExports],
  358. ['peerRequiredHostExports', policy.peerRequiredHostExports],
  359. ] as const
  360. for (const [field, entries] of classifications) {
  361. for (const [specifier, exportNames] of Object.entries(entries)) {
  362. const provider = packageNameOf(specifier)
  363. if (provider === undefined || !workspaceNames.has(provider)) {
  364. violations.push(`${field} specifier ${specifier} is not a workspace package`)
  365. }
  366. if (exportNames.length === 0) {
  367. violations.push(`${field} lists no exports for ${specifier}`)
  368. }
  369. for (const exportName of duplicates(exportNames)) {
  370. violations.push(`${field} lists ${specifier} export ${exportName} more than once`)
  371. }
  372. for (const exportName of exportNames) {
  373. if (exportName === '' || exportName === NAMESPACE_RUNTIME_EXPORT || exportName === SIDE_EFFECT_RUNTIME_EXPORT) {
  374. violations.push(`${field} cannot classify unbounded ${specifier} export ${exportName}`)
  375. continue
  376. }
  377. if (!allRuntimeUses.some(use => use.specifier === specifier && use.exportName === exportName)) {
  378. violations.push(`${field} lists unused ${specifier} export ${exportName}`)
  379. }
  380. if (field === 'safeHostDependencyExports'
  381. && policy.peerRequiredHostExports[specifier]?.includes(exportName) === true) {
  382. violations.push(`${specifier} export ${exportName} appears in both Host export classifications`)
  383. }
  384. }
  385. }
  386. }
  387. for (const fact of facts) {
  388. for (const use of fact.hostRuntimeExportUses) {
  389. if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue
  390. if (!workspaceNames.has(use.packageName)) continue
  391. if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue
  392. if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue
  393. violations.push(
  394. `${use.sourcePath}:${String(use.line)}:${String(use.column)}: `
  395. + `${use.specifier}#${use.exportName} is not classified as safe or peer-required — ${use.sourceLine}`,
  396. )
  397. }
  398. }
  399. return violations.sort()
  400. }
  401. /** Read every package covered by the current dependency policy. */
  402. export function readPackageDependencyState(
  403. root: string,
  404. policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
  405. ): PackageDependencyState {
  406. const packages = readWorkspacePackageManifests(root)
  407. const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
  408. const discovered = discoverPackageDependencyScope(packages.release, policy)
  409. const facts = discovered.selected.map(pkg =>
  410. readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy))
  411. const selectedNames = new Set(facts.map(fact => fact.manifest.name))
  412. return {
  413. facts,
  414. packages: packages.release,
  415. policyViolations: [
  416. ...discovered.violations,
  417. ...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
  418. ...Object.keys(policy.configurationOnlyDevDependencies)
  419. .filter(name => !selectedNames.has(name))
  420. .map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`),
  421. ].sort(),
  422. workspaceNames,
  423. }
  424. }
  425. /** Derive the required npm section for each relationship owned by the policy. */
  426. export function expectedPackageDependencies(
  427. facts: PackageDependencyFacts,
  428. ): ReadonlyMap<string, ExpectedPackageDependency> {
  429. const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
  430. const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
  431. if (name === facts.manifest.name || name === CORDIS) return
  432. const current = expected.get(name)
  433. const section = current?.section === 'peer-dev' || sectionName === 'peer-dev'
  434. ? 'peer-dev'
  435. : current?.section === 'dependencies' || sectionName === 'dependencies'
  436. ? 'dependencies'
  437. : 'devDependencies'
  438. expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) })
  439. }
  440. expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) })
  441. for (const [name, paths] of facts.allSourceUses) {
  442. if (!facts.workspaceNames.has(name)) continue
  443. for (const path of paths) add(name, 'devDependencies', path)
  444. }
  445. for (const name of facts.clientInject) {
  446. if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
  447. }
  448. for (const name of facts.configurationOnlyDevDependencies) {
  449. if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
  450. }
  451. for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) {
  452. if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer')
  453. }
  454. for (const [name, paths] of facts.hostRuntimeSourceUses) {
  455. const expectedSection = facts.workspaceNames.has(name) && facts.peerRequiredHostDependencies.has(name)
  456. ? 'peer-dev'
  457. : 'dependencies'
  458. for (const path of paths) add(name, expectedSection, path)
  459. }
  460. return new Map([...expected].map(([name, rule]) => [name, {
  461. section: rule.section,
  462. origins: [...rule.origins].sort(),
  463. }]))
  464. }
  465. interface ManagedRuntimeEdge {
  466. readonly consumer: string
  467. readonly dependency: string
  468. readonly exports: readonly string[]
  469. }
  470. function managedRuntimeEdges(
  471. state: PackageDependencyState,
  472. expectedSection: 'dependencies' | 'peer-dev',
  473. ): ManagedRuntimeEdge[] {
  474. return state.facts.flatMap(facts => [...expectedPackageDependencies(facts)]
  475. .filter(([name, rule]) => name !== CORDIS && rule.section === expectedSection)
  476. .map(([dependency]) => ({
  477. consumer: facts.manifest.name ?? facts.manifestPath,
  478. dependency,
  479. exports: [...new Set(facts.hostRuntimeExportUses
  480. .filter(use => use.packageName === dependency)
  481. .map(use => `${use.specifier}#${use.exportName}`))].sort(),
  482. })))
  483. .sort((left, right) =>
  484. left.consumer.localeCompare(right.consumer) || left.dependency.localeCompare(right.dependency))
  485. }
  486. /** Format Host runtime edges whose reviewed exports permit ordinary dependencies. */
  487. export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] {
  488. const rows = managedRuntimeEdges(state, 'dependencies')
  489. const packages = new Set(rows.map(row => row.consumer)).size
  490. return [
  491. `${GATE}: ${String(rows.length)} managed Host runtime edge(s) remain in dependencies across ${String(packages)} package(s):`,
  492. ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
  493. ]
  494. }
  495. /** Format Host runtime edges retained as peers by their imported export classification. */
  496. export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] {
  497. const rows = managedRuntimeEdges(state, 'peer-dev')
  498. const packages = new Set(rows.map(row => row.consumer)).size
  499. return [
  500. `${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`,
  501. ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
  502. ]
  503. }
  504. function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
  505. return manifest[name] ?? {}
  506. }
  507. function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
  508. manifest[name] ??= {}
  509. return manifest[name]
  510. }
  511. function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
  512. return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
  513. .filter(sectionName => section(manifest, sectionName)[name] !== undefined)
  514. }
  515. function describeSections(sections: readonly DependencySection[]): string {
  516. return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
  517. }
  518. /** Return all manifest and policy violations in stable order. */
  519. export function collectPackageDependencyViolations(state: PackageDependencyState): string[] {
  520. const violations = [...state.policyViolations]
  521. if (violations.length > 0) return [...new Set(violations)].sort()
  522. for (const facts of state.facts) {
  523. for (const [name, rule] of expectedPackageDependencies(facts)) {
  524. const actual = declaredSections(facts.manifest, name)
  525. if (rule.section === 'peer-dev') {
  526. if (actual.length === 2
  527. && actual.includes('peerDependencies')
  528. && actual.includes('devDependencies')
  529. && section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE
  530. && section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE
  531. && facts.manifest.peerDependenciesMeta?.[name] === undefined) continue
  532. violations.push(
  533. `${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`,
  534. )
  535. continue
  536. }
  537. const expectedSection = rule.section
  538. const range = section(facts.manifest, expectedSection)[name]
  539. if (actual.length === 1
  540. && actual[0] === expectedSection
  541. && (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue
  542. violations.push(
  543. `${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only`
  544. + (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '')
  545. + `; found ${describeSections(actual)}`,
  546. )
  547. }
  548. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  549. for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) {
  550. if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue
  551. violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`)
  552. }
  553. }
  554. for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
  555. if (facts.manifest.peerDependencies?.[name] === undefined) {
  556. violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`)
  557. }
  558. }
  559. }
  560. return [...new Set(violations)].sort()
  561. }
  562. function deleteDependency(
  563. manifest: PackageDependencyManifest,
  564. sectionName: DependencySection,
  565. name: string,
  566. ): void {
  567. const dependencies = manifest[sectionName]
  568. if (dependencies?.[name] === undefined) return
  569. const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
  570. if (Object.keys(retained).length > 0) {
  571. manifest[sectionName] = retained
  572. return
  573. }
  574. switch (sectionName) {
  575. case 'dependencies': delete manifest.dependencies; break
  576. case 'devDependencies': delete manifest.devDependencies; break
  577. case 'optionalDependencies': delete manifest.optionalDependencies; break
  578. case 'peerDependencies': delete manifest.peerDependencies; break
  579. }
  580. }
  581. function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void {
  582. if (manifest.peerDependenciesMeta?.[name] === undefined) return
  583. const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta)
  584. .filter(([key]) => key !== name))
  585. if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained
  586. else delete manifest.peerDependenciesMeta
  587. }
  588. function preferredRange(
  589. facts: PackageDependencyFacts,
  590. name: string,
  591. target: ExpectedPackageDependency['section'],
  592. ): string | undefined {
  593. if (facts.workspaceNames.has(name)) return WORKSPACE_RANGE
  594. const order: readonly DependencySection[] = target === 'dependencies'
  595. ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
  596. : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies']
  597. return order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
  598. }
  599. /** Apply the dependency policy to one in-memory manifest. */
  600. export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void {
  601. for (const [name, rule] of expectedPackageDependencies(facts)) {
  602. if (rule.section === 'peer-dev') {
  603. for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
  604. deleteDependency(facts.manifest, sectionName, name)
  605. }
  606. mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE
  607. mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE
  608. deletePeerMeta(facts.manifest, name)
  609. continue
  610. }
  611. const range = preferredRange(facts, name, rule.section)
  612. if (range === undefined) continue
  613. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  614. if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name)
  615. }
  616. mutableSection(facts.manifest, rule.section)[name] = range
  617. deletePeerMeta(facts.manifest, name)
  618. }
  619. for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
  620. if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name)
  621. }
  622. for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
  623. for (const name of Object.keys(section(facts.manifest, sectionName))) {
  624. if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE
  625. }
  626. }
  627. }
  628. /** Repair every covered manifest and return repository-relative changed paths. */
  629. export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] {
  630. if (state.policyViolations.length > 0) return []
  631. const changed: string[] = []
  632. for (const facts of state.facts) {
  633. const before = `${JSON.stringify(facts.manifest, null, 2)}\n`
  634. repairPackageDependencyManifest(facts)
  635. const after = `${JSON.stringify(facts.manifest, null, 2)}\n`
  636. if (after === before) continue
  637. writeFileSync(resolve(root, facts.manifestPath), after)
  638. changed.push(facts.manifestPath)
  639. }
  640. return changed.sort()
  641. }
  642. function refreshPnpmLockfile(root: string): void {
  643. const result = spawnSync(
  644. 'pnpm',
  645. ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'],
  646. { cwd: root, shell: process.platform === 'win32', stdio: 'inherit' },
  647. )
  648. if (result.error !== undefined) throw new Error(`could not refresh pnpm-lock.yaml: ${result.error.message}`)
  649. if (result.status !== 0) throw new Error(`pnpm lockfile refresh exited with status ${String(result.status)}`)
  650. }
  651. function main(): void {
  652. const root = resolve(import.meta.dirname, '..')
  653. let state = readPackageDependencyState(root)
  654. const fix = process.argv.includes('--fix')
  655. if (fix) {
  656. if (state.policyViolations.length > 0) {
  657. console.error(`${GATE}: --fix skipped because dependency policy review failed.`)
  658. } else {
  659. const changed = fixPackageDependencies(root, state)
  660. console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
  661. refreshPnpmLockfile(root)
  662. const graphChanges = writeModuleGraph(root)
  663. console.log(
  664. `${GATE}: refreshed pnpm-lock.yaml and wrote ${String(graphChanges.length)} module-graph artifact(s).`,
  665. )
  666. state = readPackageDependencyState(root)
  667. }
  668. }
  669. const violations = collectPackageDependencyViolations(state)
  670. if (violations.length > 0) {
  671. console.error(`${GATE}: ${String(violations.length)} violation(s):`)
  672. for (const violation of violations) console.error(` ${violation}`)
  673. process.exitCode = 1
  674. return
  675. }
  676. const roles = Object.groupBy(state.facts, fact => fact.role)
  677. console.log(
  678. `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy`
  679. + ` (${String(roles['client-only']?.length ?? 0)} Client-only,`
  680. + ` ${String(roles['client-host']?.length ?? 0)} Client/Host,`
  681. + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`,
  682. )
  683. if (fix) {
  684. for (const line of formatManagedRuntimeDependencies(state)) console.log(line)
  685. for (const line of formatPeerRequiredRuntimeDependencies(state)) console.log(line)
  686. }
  687. }
  688. if (import.meta.main) main()