verify-runtime-closure.ts 8.8 KB

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