verify-runtime-closure.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /**
  2. * Verify that the executable deploy manifest supplies every plugin referenced
  3. * by a shipped agent preset and every required workspace peer in its dependency
  4. * graph. With auto peer installation disabled, either omission can otherwise
  5. * fail only when Cordis loads the packaged plugin.
  6. */
  7. import { globSync } from 'node:fs'
  8. import { readFile } from 'node:fs/promises'
  9. import { basename, dirname, resolve } from 'node:path'
  10. import { parseArgs } from 'node:util'
  11. import { loadCordisYaml } from './cordis-yaml.ts'
  12. interface PackageManifest {
  13. name?: string
  14. dependencies?: Record<string, string>
  15. optionalDependencies?: Record<string, string>
  16. peerDependencies?: Record<string, string>
  17. peerDependenciesMeta?: Record<string, { optional?: boolean }>
  18. }
  19. interface WorkspacePackage {
  20. path: string
  21. manifest: PackageManifest
  22. }
  23. interface RuntimePlatform {
  24. tag: string
  25. executable: string
  26. }
  27. type RuntimePlatformManifest = Record<string, RuntimePlatform>
  28. const root = resolve(import.meta.dirname, '..')
  29. const { values } = parseArgs({
  30. args: process.argv.slice(2),
  31. options: { manifest: { type: 'string' } },
  32. })
  33. const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json')
  34. const runtimeManifest = await loadManifest(runtimeManifestPath)
  35. const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime'
  36. const workspace = await loadWorkspacePackages()
  37. const runtimeDependencies = runtimeManifest.dependencies ?? {}
  38. const platforms = await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json'))
  39. const parents = new Map<string, string | undefined>()
  40. const queue: string[] = []
  41. for (const dependency of Object.keys(runtimeDependencies).sort()) {
  42. if (!workspace.has(dependency)) continue
  43. parents.set(dependency, undefined)
  44. queue.push(dependency)
  45. }
  46. const failures = await missingPresetPlugins(runtimeDependencies, platforms)
  47. for (let index = 0; index < queue.length; index += 1) {
  48. const packageName = queue[index]
  49. if (packageName === undefined) continue
  50. const current = workspace.get(packageName)
  51. if (current === undefined) continue
  52. const peers = current.manifest.peerDependencies ?? {}
  53. const peerMeta = current.manifest.peerDependenciesMeta ?? {}
  54. for (const peer of Object.keys(peers).sort()) {
  55. if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue
  56. if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue
  57. failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`)
  58. }
  59. const dependencies = {
  60. ...current.manifest.dependencies,
  61. ...current.manifest.optionalDependencies,
  62. }
  63. for (const dependency of Object.keys(dependencies).sort()) {
  64. if (!workspace.has(dependency) || parents.has(dependency)) continue
  65. parents.set(dependency, packageName)
  66. queue.push(dependency)
  67. }
  68. }
  69. if (failures.length > 0) {
  70. console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:')
  71. for (const failure of failures) console.error(` ${failure}`)
  72. process.exit(1)
  73. }
  74. const presetCount = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length
  75. console.log(
  76. `verify-runtime-closure: ${presetCount} agent presets and ${queue.length} workspace packages form a closed runtime dependency graph.`,
  77. )
  78. async function missingPresetPlugins(
  79. runtimeDependencies: Readonly<Record<string, string>>,
  80. platforms: RuntimePlatformManifest,
  81. ): Promise<string[]> {
  82. const missing = new Map<string, Set<string>>()
  83. const failures: string[] = []
  84. const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort()
  85. for (const presetPath of presetPaths) {
  86. const document = loadCordisYaml(await readFile(resolve(root, presetPath), 'utf8'))
  87. if (!Array.isArray(document)) {
  88. failures.push(`${presetPath}: preset root must be a Loader entry array`)
  89. continue
  90. }
  91. for (const target of Object.keys(platforms).sort()) {
  92. const processPlatform = processPlatformForTarget(target)
  93. for (const plugin of activeBarePluginPackages(document, processPlatform)) {
  94. if (runtimeDependencies[plugin] !== undefined) continue
  95. const preset = basename(dirname(presetPath))
  96. const key = `${preset} preset -> ${plugin}`
  97. const targets = missing.get(key) ?? new Set<string>()
  98. targets.add(target)
  99. missing.set(key, targets)
  100. }
  101. }
  102. }
  103. failures.push(...[...missing.entries()].map(([chain, targets]) =>
  104. `${chain} (${[...targets].sort().join(', ')})`))
  105. return failures
  106. }
  107. function activeBarePluginPackages(entries: unknown[], processPlatform: string): Set<string> {
  108. const packages = new Set<string>()
  109. const visit = (value: unknown, parentDisabled: boolean): void => {
  110. if (!isRecord(value)) return
  111. const disabled = parentDisabled || disabledOnPlatform(value.disabled, processPlatform)
  112. if (disabled) return
  113. if (typeof value.name === 'string') {
  114. const packageName = barePackageName(value.name)
  115. if (packageName !== undefined) packages.add(packageName)
  116. }
  117. if (Array.isArray(value.config)) {
  118. for (const child of value.config) visit(child, disabled)
  119. }
  120. }
  121. for (const entry of entries) visit(entry, false)
  122. return packages
  123. }
  124. function disabledOnPlatform(value: unknown, processPlatform: string): boolean {
  125. if (typeof value === 'boolean') return value
  126. if (!isRecord(value) || typeof value.__jsExpr !== 'string') return false
  127. const match = /^process\.platform\s*(===|!==)\s*(['"])(win32|linux|darwin)\2$/.exec(value.__jsExpr.trim())
  128. if (match === null) return false
  129. const [, operator, , expected] = match
  130. return operator === '===' ? processPlatform === expected : processPlatform !== expected
  131. }
  132. function processPlatformForTarget(target: string): string {
  133. if (target.startsWith('linux-')) return 'linux'
  134. if (target.startsWith('macos-')) return 'darwin'
  135. throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`)
  136. }
  137. function barePackageName(specifier: string): string | undefined {
  138. if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) return undefined
  139. const parts = specifier.split('/')
  140. if (specifier.startsWith('@')) {
  141. return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined
  142. }
  143. return parts[0] || undefined
  144. }
  145. function isRecord(value: unknown): value is Record<string, unknown> {
  146. return typeof value === 'object' && value !== null && !Array.isArray(value)
  147. }
  148. async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
  149. const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
  150. .sort()
  151. .map(relative => resolve(root, relative))
  152. const result = new Map<string, WorkspacePackage>()
  153. for (const path of paths) {
  154. const manifest = await loadManifest(path)
  155. if (manifest.name !== undefined) result.set(manifest.name, { path, manifest })
  156. }
  157. return result
  158. }
  159. async function loadManifest(path: string): Promise<PackageManifest> {
  160. return loadJson<PackageManifest>(path)
  161. }
  162. async function loadJson<T>(path: string): Promise<T> {
  163. return JSON.parse(await readFile(path, 'utf8')) as T
  164. }
  165. function formatChain(
  166. runtimeName: string,
  167. packageName: string,
  168. parents: ReadonlyMap<string, string | undefined>,
  169. ): string {
  170. const chain = [packageName]
  171. let parent = parents.get(packageName)
  172. while (parent !== undefined) {
  173. chain.unshift(parent)
  174. parent = parents.get(parent)
  175. }
  176. return [runtimeName, ...chain].join(' -> ')
  177. }