verify-cordis-config.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /**
  2. * Validate Cordis Loader entry metadata and package resolution.
  3. *
  4. * The Loader interpolates only a plugin entry's `config`; expression objects in
  5. * fields such as `disabled` remain truthy data and silently change composition.
  6. * Example configs and the dsh Web composition resolve named plugins from their
  7. * owning workspace manifests. Local example packages must also be in the root
  8. * TypeScript project graph.
  9. */
  10. import { globSync, readFileSync } from 'node:fs'
  11. import { dirname, relative, resolve } from 'node:path'
  12. import * as yaml from 'js-yaml'
  13. import ts from 'typescript'
  14. import { cordisConfigFiles } from './cordis-config-files.ts'
  15. interface JsExpr {
  16. __jsExpr: string
  17. }
  18. interface PackageManifest {
  19. name?: string
  20. dependencies?: Record<string, string>
  21. }
  22. interface PluginReference {
  23. file: string
  24. name: string
  25. }
  26. const root = resolve(import.meta.dirname, '..')
  27. // These example files are overlays consumed by the built dsh app, so their bare
  28. // specifiers resolve from apps/cli rather than the examples workspace.
  29. const appOverlayFiles = new Set([
  30. 'examples/web-cordis/cordis.yml',
  31. ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }),
  32. ])
  33. const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
  34. /** The adaptive directory-picker chooser package (mounts a backend row at boot). */
  35. const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto'
  36. /**
  37. * The backends the chooser mounts by runtime string (mirror of its exported
  38. * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting
  39. * the chooser must resolve both, or keyless Linux CI (which only ever
  40. * resolves `browse`) hides a dropped `-native` dependency until a macOS boot.
  41. */
  42. const CHOOSER_BACKEND_PACKAGES = [
  43. '@deepseek-ai/dsh-host-directory-picker-native',
  44. '@deepseek-ai/dsh-host-directory-picker-browse',
  45. ]
  46. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  47. kind: 'scalar',
  48. resolve: data => typeof data === 'string',
  49. construct: (data: unknown): JsExpr => {
  50. if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
  51. return { __jsExpr: data }
  52. },
  53. })
  54. const schema = yaml.JSON_SCHEMA.extend(jsExprType)
  55. const files = cordisConfigFiles(root)
  56. const errors: string[] = []
  57. const pluginReferences: PluginReference[] = []
  58. for (const file of files) {
  59. const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
  60. if (!isUnknownArray(document)) {
  61. errors.push(`${file}: root must be a Loader entry array`)
  62. continue
  63. }
  64. for (let index = 0; index < document.length; index++) {
  65. validateEntry(document[index], file, `[${index}]`)
  66. }
  67. }
  68. errors.push(...validateExampleResolution())
  69. errors.push(...validateAppResolution())
  70. errors.push(...validateSourcePlaneResolution())
  71. if (errors.length > 0) {
  72. console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
  73. for (const error of errors) console.error(`- ${error}`)
  74. process.exitCode = 1
  75. } else {
  76. console.log(`verify-cordis-config: ${files.length} config files passed.`)
  77. }
  78. function validateEntry(value: unknown, file: string, path: string): void {
  79. if (!isRecord(value)) {
  80. errors.push(`${file}${path}: entry must be an object`)
  81. return
  82. }
  83. recordPlugin(value, file)
  84. validateMetadata(value, file, path)
  85. if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
  86. for (let index = 0; index < value.config.length; index++) {
  87. validateEntry(value.config[index], file, `${path}.config[${index}]`)
  88. }
  89. }
  90. if (isUnknownArray(value.insert)) {
  91. for (let index = 0; index < value.insert.length; index++) {
  92. validateEntry(value.insert[index], file, `${path}.insert[${index}]`)
  93. }
  94. }
  95. if (value.name !== '@cordisjs/plugin-include') return
  96. const config = value.config
  97. if (!isRecord(config) || !isUnknownArray(config.patches)) return
  98. for (let index = 0; index < config.patches.length; index++) {
  99. const patch = config.patches[index]
  100. const patchPath = `${path}.config.patches[${index}]`
  101. if (!isRecord(patch)) continue
  102. recordPlugin(patch, file)
  103. validateMetadata(patch, file, patchPath)
  104. if (!isUnknownArray(patch.insert)) continue
  105. for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
  106. validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
  107. }
  108. }
  109. }
  110. function recordPlugin(entry: Record<string, unknown>, file: string): void {
  111. if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name })
  112. }
  113. function validateExampleResolution(): string[] {
  114. const violations: string[] = []
  115. const exampleManifest = readManifest('examples/package.json')
  116. const dependencies = exampleManifest.dependencies ?? {}
  117. const localPackages = localPackageDirectories()
  118. const rootReferences = rootProjectReferences()
  119. const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/') && !appOverlayFiles.has(reference.file))
  120. violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
  121. const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
  122. const localExamplePackages = new Set([
  123. ...Object.keys(dependencies),
  124. ...[...requiredPackages].filter(packageName => packageName !== undefined),
  125. ])
  126. for (const packageName of localExamplePackages) {
  127. const packageDirectory = localPackages.get(packageName)
  128. if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
  129. const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
  130. violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
  131. }
  132. return violations
  133. }
  134. function validateAppResolution(): string[] {
  135. const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
  136. const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') })
  137. .map(file => `apps/cli/config/${file}`))
  138. const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file))
  139. return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
  140. }
  141. /**
  142. * Every configured specifier of a local workspace package must resolve through
  143. * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source
  144. * launch (tsx) and vitest resolve in the source plane; without a `paths` match
  145. * they fall back to package `exports`, which reach built `lib/` — present on a
  146. * built dev tree, absent on a clean one — so a missing mapping boots locally
  147. * yet breaks every clean checkout. Anything but a `.ts`/`.tsx` hit (a `.d.ts`
  148. * or `.js` under built `lib/`) is that artifact-plane fallback, not source.
  149. */
  150. function validateSourcePlaneResolution(): string[] {
  151. const violations: string[] = []
  152. const localPackages = localPackageDirectories()
  153. const config = ts.readConfigFile(resolve(root, 'tsconfig.base.json'), path => ts.sys.readFile(path))
  154. if (config.error !== undefined) {
  155. throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  156. }
  157. const { options, errors: optionErrors } = ts.convertCompilerOptionsFromJson(
  158. (config.config as { compilerOptions?: unknown }).compilerOptions,
  159. root,
  160. 'tsconfig.base.json',
  161. )
  162. if (optionErrors.length > 0) {
  163. throw new Error(optionErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
  164. }
  165. // convertCompilerOptionsFromJson leaves `pathsBasePath` unset, so relative
  166. // `paths` targets resolve against the host's current directory; anchor it to
  167. // the repository root to keep the gate cwd-independent.
  168. const host: ts.ModuleResolutionHost = {
  169. fileExists: path => ts.sys.fileExists(path),
  170. readFile: path => ts.sys.readFile(path),
  171. directoryExists: path => ts.sys.directoryExists(path),
  172. getCurrentDirectory: () => root,
  173. }
  174. const sourceExtensions = new Set<string>([ts.Extension.Ts, ts.Extension.Tsx])
  175. const containingFile = resolve(root, 'scripts/verify-cordis-config.ts')
  176. const locationsBySpecifier = new Map<string, Set<string>>()
  177. for (const reference of pluginReferences) {
  178. const packageName = packageNameFromSpecifier(reference.name)
  179. if (packageName === undefined || !localPackages.has(packageName)) continue
  180. const locations = locationsBySpecifier.get(reference.name) ?? new Set<string>()
  181. locations.add(reference.file)
  182. locationsBySpecifier.set(reference.name, locations)
  183. }
  184. for (const [specifier, locations] of locationsBySpecifier) {
  185. const resolved = ts.resolveModuleName(specifier, containingFile, options, host).resolvedModule
  186. if (resolved !== undefined && sourceExtensions.has(resolved.extension)) continue
  187. violations.push(`${[...locations].join(', ')}: ${specifier} does not resolve to workspace source through tsconfig.base.json paths (add a mapping so the tsx source launch does not depend on built lib/)`)
  188. }
  189. return violations
  190. }
  191. function missingPluginDependencies(
  192. references: readonly PluginReference[],
  193. dependencies: Readonly<Record<string, string>>,
  194. manifestPath: string,
  195. ): string[] {
  196. const requiredPackages = new Map<string, Set<string>>()
  197. const require = (packageName: string, file: string): void => {
  198. const locations = requiredPackages.get(packageName) ?? new Set<string>()
  199. locations.add(file)
  200. requiredPackages.set(packageName, locations)
  201. }
  202. for (const reference of references) {
  203. const packageName = packageNameFromSpecifier(reference.name)
  204. if (packageName === undefined) continue
  205. require(packageName, reference.file)
  206. if (packageName === CHOOSER_PACKAGE) {
  207. for (const backend of CHOOSER_BACKEND_PACKAGES) require(backend, reference.file)
  208. }
  209. }
  210. return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
  211. ? []
  212. : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`)
  213. }
  214. function readManifest(path: string): PackageManifest {
  215. return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
  216. }
  217. function localPackageDirectories(): Map<string, string> {
  218. const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
  219. const packages = new Map<string, string>()
  220. for (const manifestPath of manifests) {
  221. const manifest = readManifest(manifestPath)
  222. if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
  223. }
  224. return packages
  225. }
  226. function rootProjectReferences(): Set<string> {
  227. // The root solution references the host and client aggregates (the two
  228. // sides merge cordis Context under the same keys, so one program cannot see
  229. // both — but this BFS only collects reference paths, it never forms a
  230. // program). Seed the solution and follow nested aggregate references to
  231. // collect the covered leaf project set.
  232. const collected = new Set<string>()
  233. const queue = [resolve(root, 'tsconfig.json')]
  234. const seen = new Set<string>()
  235. for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
  236. if (seen.has(file)) continue
  237. seen.add(file)
  238. const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
  239. if (config.error !== undefined) {
  240. throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  241. }
  242. const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
  243. for (const reference of references) {
  244. if (typeof reference.path !== 'string') continue
  245. const target = resolve(dirname(file), reference.path)
  246. if (target.endsWith('.json')) queue.push(target)
  247. else collected.add(target)
  248. }
  249. }
  250. return collected
  251. }
  252. function packageNameFromSpecifier(specifier: string): string | undefined {
  253. if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined
  254. const segments = specifier.split('/')
  255. if (specifier.startsWith('@')) {
  256. return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
  257. }
  258. return segments[0] || undefined
  259. }
  260. function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
  261. for (const field of metadataFields) {
  262. if (!(field in entry)) continue
  263. const expressionPaths: string[] = []
  264. collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
  265. for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
  266. }
  267. }
  268. function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
  269. if (isJsExpr(value)) {
  270. output.push(path)
  271. return
  272. }
  273. if (isUnknownArray(value)) {
  274. for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
  275. return
  276. }
  277. if (!isRecord(value)) return
  278. for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
  279. }
  280. function isJsExpr(value: unknown): value is JsExpr {
  281. return isRecord(value) && typeof value.__jsExpr === 'string'
  282. }
  283. function isRecord(value: unknown): value is Record<string, unknown> {
  284. return value !== null && typeof value === 'object'
  285. }
  286. function isUnknownArray(value: unknown): value is unknown[] {
  287. return Array.isArray(value)
  288. }