verify-runtime-closure.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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. export interface RuntimeClosureResult {
  29. failures: string[]
  30. presetCount: number
  31. workspacePackageCount: number
  32. }
  33. /**
  34. * Check that the runtime manifest contains every shipped-preset plugin and workspace peer.
  35. * @param root repository root containing the runtime manifest and shipped presets.
  36. * @param manifestPath runtime manifest path relative to {@link root}.
  37. * @returns the discovered preset count, reachable workspace package count, and violations.
  38. */
  39. export async function verifyRuntimeClosure(
  40. root: string,
  41. manifestPath = 'python/sdk-runtime/package.json',
  42. ): Promise<RuntimeClosureResult> {
  43. const runtimeManifest = await loadManifest(resolve(root, manifestPath))
  44. const runtimeName = runtimeManifest.name ?? manifestPath
  45. const workspace = await loadWorkspacePackages(root)
  46. const runtimeDependencies = runtimeManifest.dependencies ?? {}
  47. const platforms = await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json'))
  48. const parents = new Map<string, string | undefined>()
  49. const queue: string[] = []
  50. for (const dependency of Object.keys(runtimeDependencies).sort()) {
  51. if (!workspace.has(dependency)) continue
  52. parents.set(dependency, undefined)
  53. queue.push(dependency)
  54. }
  55. const failures = await missingPresetPlugins(root, runtimeDependencies, platforms)
  56. for (let index = 0; index < queue.length; index += 1) {
  57. const packageName = queue[index]
  58. if (packageName === undefined) continue
  59. const current = workspace.get(packageName)
  60. if (current === undefined) continue
  61. const peers = current.manifest.peerDependencies ?? {}
  62. const peerMeta = current.manifest.peerDependenciesMeta ?? {}
  63. for (const peer of Object.keys(peers).sort()) {
  64. if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue
  65. if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue
  66. failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`)
  67. }
  68. const dependencies = {
  69. ...current.manifest.dependencies,
  70. ...current.manifest.optionalDependencies,
  71. }
  72. for (const dependency of Object.keys(dependencies).sort()) {
  73. if (!workspace.has(dependency) || parents.has(dependency)) continue
  74. parents.set(dependency, packageName)
  75. queue.push(dependency)
  76. }
  77. }
  78. return {
  79. failures,
  80. presetCount: globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length,
  81. workspacePackageCount: queue.length,
  82. }
  83. }
  84. if (import.meta.main) {
  85. const root = resolve(import.meta.dirname, '..')
  86. const { values } = parseArgs({
  87. args: process.argv.slice(2),
  88. options: { manifest: { type: 'string' } },
  89. })
  90. const result = await verifyRuntimeClosure(root, values.manifest)
  91. if (result.failures.length > 0) {
  92. console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:')
  93. for (const failure of result.failures) console.error(` ${failure}`)
  94. process.exitCode = 1
  95. } else {
  96. console.log(
  97. `verify-runtime-closure: ${result.presetCount} agent presets and ${result.workspacePackageCount} workspace packages form a closed runtime dependency graph.`,
  98. )
  99. }
  100. }
  101. async function missingPresetPlugins(
  102. root: string,
  103. runtimeDependencies: Readonly<Record<string, string>>,
  104. platforms: RuntimePlatformManifest,
  105. ): Promise<string[]> {
  106. const missing = new Map<string, Set<string>>()
  107. const failures: string[] = []
  108. const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort()
  109. for (const presetPath of presetPaths) {
  110. const document = loadCordisYaml(await readFile(resolve(root, presetPath), 'utf8'))
  111. if (!Array.isArray(document)) {
  112. failures.push(`${presetPath}: preset root must be a Loader entry array`)
  113. continue
  114. }
  115. for (const target of Object.keys(platforms).sort()) {
  116. const processPlatform = processPlatformForTarget(target)
  117. for (const plugin of activeBarePluginPackages(document, processPlatform)) {
  118. if (runtimeDependencies[plugin] !== undefined) continue
  119. const preset = basename(dirname(presetPath))
  120. const key = `${preset} preset -> ${plugin}`
  121. const targets = missing.get(key) ?? new Set<string>()
  122. targets.add(target)
  123. missing.set(key, targets)
  124. }
  125. }
  126. }
  127. failures.push(...[...missing.entries()].map(([chain, targets]) =>
  128. `${chain} (${[...targets].sort().join(', ')})`))
  129. return failures
  130. }
  131. function activeBarePluginPackages(entries: unknown[], processPlatform: string): Set<string> {
  132. const packages = new Set<string>()
  133. const visit = (value: unknown, parentDisabled: boolean): void => {
  134. if (!isRecord(value)) return
  135. const disabled = parentDisabled || disabledOnPlatform(value.disabled, processPlatform)
  136. if (disabled) return
  137. if (typeof value.name === 'string') {
  138. const packageName = barePackageName(value.name)
  139. if (packageName !== undefined) packages.add(packageName)
  140. }
  141. if (Array.isArray(value.config)) {
  142. for (const child of value.config) visit(child, disabled)
  143. }
  144. }
  145. for (const entry of entries) visit(entry, false)
  146. return packages
  147. }
  148. function disabledOnPlatform(value: unknown, processPlatform: string): boolean {
  149. if (typeof value === 'boolean') return value
  150. if (!isRecord(value) || typeof value.__jsExpr !== 'string') return false
  151. const match = /^process\.platform\s*(===|!==)\s*(['"])(win32|linux|darwin)\2$/.exec(value.__jsExpr.trim())
  152. if (match === null) return false
  153. const [, operator, , expected] = match
  154. return operator === '===' ? processPlatform === expected : processPlatform !== expected
  155. }
  156. function processPlatformForTarget(target: string): string {
  157. if (target.startsWith('linux-')) return 'linux'
  158. if (target.startsWith('macos-')) return 'darwin'
  159. throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`)
  160. }
  161. function barePackageName(specifier: string): string | undefined {
  162. if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) return undefined
  163. const parts = specifier.split('/')
  164. if (specifier.startsWith('@')) {
  165. return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined
  166. }
  167. return parts[0] || undefined
  168. }
  169. function isRecord(value: unknown): value is Record<string, unknown> {
  170. return typeof value === 'object' && value !== null && !Array.isArray(value)
  171. }
  172. async function loadWorkspacePackages(root: string): Promise<Map<string, WorkspacePackage>> {
  173. const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
  174. .sort()
  175. .map(relative => resolve(root, relative))
  176. const result = new Map<string, WorkspacePackage>()
  177. for (const path of paths) {
  178. const manifest = await loadManifest(path)
  179. if (manifest.name !== undefined) result.set(manifest.name, { path, manifest })
  180. }
  181. return result
  182. }
  183. async function loadManifest(path: string): Promise<PackageManifest> {
  184. return loadJson<PackageManifest>(path)
  185. }
  186. async function loadJson<T>(path: string): Promise<T> {
  187. return JSON.parse(await readFile(path, 'utf8')) as T
  188. }
  189. function formatChain(
  190. runtimeName: string,
  191. packageName: string,
  192. parents: ReadonlyMap<string, string | undefined>,
  193. ): string {
  194. const chain = [packageName]
  195. let parent = parents.get(packageName)
  196. while (parent !== undefined) {
  197. chain.unshift(parent)
  198. parent = parents.get(parent)
  199. }
  200. return [runtimeName, ...chain].join(' -> ')
  201. }