verify-cordis-config.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. /**
  2. * Validate Cordis Loader entry metadata and package resolution.
  3. *
  4. * The Loader interpolates a plugin entry's `config` (after declared injections
  5. * activate, against that plugin context) and the entry `disabled` field (at
  6. * every mount decision, against the loader context). Every other entry
  7. * metadata field stays static, so an expression there remains truthy data and
  8. * silently changes composition. Example configs and the dsh Web composition
  9. * resolve named plugins from their owning workspace manifests. Local example
  10. * packages must also be in the root TypeScript project graph.
  11. */
  12. import { globSync, readFileSync } from 'node:fs'
  13. import { dirname, relative, resolve } from 'node:path'
  14. import * as yaml from 'js-yaml'
  15. import ts from 'typescript'
  16. import { cordisConfigFiles } from './cordis-config-files.ts'
  17. interface JsExpr {
  18. __jsExpr: string
  19. }
  20. interface PackageManifest {
  21. name?: string
  22. dependencies?: Record<string, string>
  23. }
  24. interface PluginReference {
  25. file: string
  26. name: string
  27. }
  28. const root = resolve(import.meta.dirname, '..')
  29. // These example files are overlays consumed by the built dsh app, so their bare
  30. // specifiers resolve from apps/cli rather than the examples workspace.
  31. const appOverlayFiles = new Set([
  32. 'examples/web-cordis/cordis.yml',
  33. 'examples/web-schedule/cordis.yml',
  34. ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }),
  35. ])
  36. const metadataFields = ['id', 'name', 'group', 'inject', 'intercept', 'isolate'] as const
  37. /** The adaptive directory-picker chooser package (mounts a backend row at boot). */
  38. const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto'
  39. /**
  40. * The packages the chooser mounts by runtime string (mirror of its exported
  41. * `BACKEND_PACKAGES` and `SURFACE_PACKAGES`), invisible to yml-row scanning: a
  42. * composition mounting the chooser must resolve every one, or keyless Linux CI
  43. * (which only ever resolves `browse`) hides a dropped `-native` dependency
  44. * until a macOS boot.
  45. */
  46. const CHOOSER_BACKEND_PACKAGES = [
  47. '@deepseek-ai/dsh-host-directory-picker-native',
  48. '@deepseek-ai/dsh-host-directory-picker-browse',
  49. '@deepseek-ai/dsh-client-ui-directory-picker-browse',
  50. '@deepseek-ai/dsh-client-ui-directory-picker-native',
  51. ]
  52. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  53. kind: 'scalar',
  54. resolve: data => typeof data === 'string',
  55. construct: (data: unknown): JsExpr => {
  56. if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
  57. return { __jsExpr: data }
  58. },
  59. })
  60. const schema = yaml.JSON_SCHEMA.extend(jsExprType)
  61. const errors: string[] = []
  62. const pluginReferences: PluginReference[] = []
  63. if (import.meta.main) {
  64. const files = cordisConfigFiles(root)
  65. for (const file of files) {
  66. const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
  67. if (!isUnknownArray(document)) {
  68. errors.push(`${file}: root must be a Loader entry array`)
  69. continue
  70. }
  71. for (let index = 0; index < document.length; index++) {
  72. validateEntry(document[index], file, `[${index}]`)
  73. }
  74. }
  75. errors.push(...validateExampleResolution())
  76. errors.push(...validateAppResolution())
  77. errors.push(...validateSourcePlaneResolution())
  78. errors.push(...validatePresetPlaneSeparation())
  79. errors.push(...validateClientHalvesDeclared())
  80. if (errors.length > 0) {
  81. console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
  82. for (const error of errors) console.error(`- ${error}`)
  83. process.exitCode = 1
  84. } else {
  85. console.log(`verify-cordis-config: ${files.length} config files passed.`)
  86. }
  87. }
  88. /**
  89. * A browser plugin must declare the browser half it ships.
  90. *
  91. * The browser roster is discovered by scanning composed packages for a
  92. * `dsh.client` block, and the node half of a surface plugin is an empty
  93. * `apply`. A `packages/client` package that exports `./client` without that
  94. * block therefore composes, activates, and contributes nothing — its bundle is
  95. * never served and no error is raised anywhere. The mismatch is invisible in
  96. * the composition file, so it is checked against the manifests instead. Only
  97. * this group is checked: a Host package's `./client` export is the typed wire
  98. * face its browser consumers import, not a plugin the roster serves.
  99. * @returns one violation per client package whose `./client` export and
  100. * `dsh.client` declaration disagree.
  101. */
  102. function validateClientHalvesDeclared(): string[] {
  103. return globSync('packages/client/*/package.json', { cwd: root }).flatMap((manifestPath) => {
  104. const manifest = readManifest(manifestPath) as PackageManifest & {
  105. exports?: Record<string, unknown>
  106. dsh?: { client?: unknown }
  107. }
  108. const shipsClient = manifest.exports !== undefined && Object.hasOwn(manifest.exports, './client')
  109. const declaresClient = manifest.dsh?.client !== undefined
  110. if (shipsClient === declaresClient) return []
  111. return [shipsClient
  112. ? `${manifestPath}: exports "./client" but declares no dsh.client, so its browser half is never served`
  113. : `${manifestPath}: declares dsh.client but exports no "./client" entry to serve`]
  114. })
  115. }
  116. /**
  117. * No shipped agent preset may repeat a row the host composition still runs.
  118. *
  119. * A preset contributes what ONE session adds to the host's registries. A row
  120. * active on both planes is therefore mounted twice — once per process and once
  121. * per session — and what that costs depends on what the row does: a provider
  122. * behind an `isolate` realm shadows the host's for its own consumers, so a host
  123. * contributor to that service reaches nobody; a row that registers into a host
  124. * singleton registers once per live session, so the second one collides.
  125. *
  126. * Both have happened. `shell-env` in a preset realm left `DSH_WEB_URL` reaching
  127. * no shell, and `tool-subagent-report` handed every child `report` once per live
  128. * session until the second registration threw. Neither changes a tool catalog,
  129. * so no catalog assertion can see them — and the shipped presets are near-copies
  130. * of each other, so a fix applied to three of four is the normal failure.
  131. * @returns one diagnostic per preset row that is also active on the host plane.
  132. */
  133. function validatePresetPlaneSeparation(): string[] {
  134. const problems: string[] = []
  135. // The shipped Web surface is two bundle patch layers over an empty root.
  136. const hostFile = 'packages/bundle/base/cordis.patch.yml'
  137. const overlayFile = 'packages/bundle/web-app/cordis.patch.yml'
  138. const hostRows = rowIds(hostFile)
  139. const overlay = loadEntries(overlayFile)
  140. const disabled = new Set<string>()
  141. for (const entry of overlay) {
  142. if (!isRecord(entry)) continue
  143. if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id)
  144. }
  145. // The overlay's own inserts are host-plane too; its disables take them back out.
  146. const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id)))
  147. for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) {
  148. for (const id of rowIds(file)) {
  149. if (!active.has(id)) continue
  150. problems.push(
  151. `${file}: row "${id}" is also active in the host composition; `
  152. + 'a row belongs to exactly one plane',
  153. )
  154. }
  155. }
  156. return problems
  157. }
  158. /** Every entry of one config file, or an empty list when it is not an entry array. */
  159. function loadEntries(file: string): unknown[] {
  160. const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
  161. return isUnknownArray(document) ? document : []
  162. }
  163. /**
  164. * Row ids declared anywhere in one config file, including inside group `config`
  165. * lists — a preset nests most of its rows in `isolate` groups.
  166. * @param file - repository-relative config path.
  167. * @returns the declared ids.
  168. */
  169. function rowIds(file: string): Set<string> {
  170. const ids = new Set<string>()
  171. const walk = (value: unknown): void => {
  172. if (isUnknownArray(value)) {
  173. for (const item of value) walk(item)
  174. return
  175. }
  176. if (!isRecord(value)) return
  177. if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id)
  178. for (const child of Object.values(value)) walk(child)
  179. }
  180. walk(loadEntries(file))
  181. return ids
  182. }
  183. function validateEntry(value: unknown, file: string, path: string): void {
  184. if (!isRecord(value)) {
  185. errors.push(`${file}${path}: entry must be an object`)
  186. return
  187. }
  188. recordPlugin(value, file)
  189. validateMetadata(value, file, path)
  190. if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) {
  191. for (let index = 0; index < value.config.length; index++) {
  192. validateEntry(value.config[index], file, `${path}.config[${index}]`)
  193. }
  194. }
  195. if (isUnknownArray(value.insert)) {
  196. for (let index = 0; index < value.insert.length; index++) {
  197. validateEntry(value.insert[index], file, `${path}.insert[${index}]`)
  198. }
  199. }
  200. if (value.name !== '@deepseek-ai/cordis-plugin-include') return
  201. const config = value.config
  202. if (!isRecord(config) || !isUnknownArray(config.patches)) return
  203. for (let index = 0; index < config.patches.length; index++) {
  204. const patch = config.patches[index]
  205. const patchPath = `${path}.config.patches[${index}]`
  206. if (!isRecord(patch)) continue
  207. recordPlugin(patch, file)
  208. validateMetadata(patch, file, patchPath)
  209. if (!isUnknownArray(patch.insert)) continue
  210. for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
  211. validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
  212. }
  213. }
  214. }
  215. function recordPlugin(entry: Record<string, unknown>, file: string): void {
  216. if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name })
  217. }
  218. function validateExampleResolution(): string[] {
  219. const violations: string[] = []
  220. const exampleManifest = readManifest('examples/package.json')
  221. const dependencies = exampleManifest.dependencies ?? {}
  222. const localPackages = localPackageDirectories()
  223. const rootReferences = rootProjectReferences()
  224. const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/') && !appOverlayFiles.has(reference.file))
  225. violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
  226. const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
  227. const localExamplePackages = new Set([
  228. ...Object.keys(dependencies),
  229. ...[...requiredPackages].filter(packageName => packageName !== undefined),
  230. ])
  231. for (const packageName of localExamplePackages) {
  232. const packageDirectory = localPackages.get(packageName)
  233. if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
  234. const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
  235. violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
  236. }
  237. return violations
  238. }
  239. function validateAppResolution(): string[] {
  240. const violations: string[] = []
  241. // App overlays (and any config left under apps/cli/config) resolve from the
  242. // dsh app's own dependency surface — the profile module fallback mirrors it.
  243. const appDependencies = {
  244. ...readManifest('apps/cli/package.json').dependencies,
  245. // The fallback also links every bundle's own dependencies (healProfilesModuleFallback).
  246. ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root })
  247. .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))),
  248. }
  249. const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') })
  250. .map(file => `apps/cli/config/${file}`))
  251. const appReferences = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file))
  252. violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest'))
  253. // Each bundle's patch rows must resolve from that bundle's own dependencies:
  254. // per-layer resolution anchors on the bundle package directory.
  255. for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) {
  256. const bundleDir = manifestPath.replace(/\/package\.json$/, '')
  257. const manifest = readManifest(manifestPath)
  258. const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`))
  259. violations.push(...missingPluginDependencies(
  260. // A bundle may mount its own package (the web-app runtime row).
  261. references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name),
  262. manifest.dependencies ?? {},
  263. manifestPath,
  264. ))
  265. }
  266. return violations
  267. }
  268. /**
  269. * Every configured specifier of a local workspace package must resolve through
  270. * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source
  271. * launch (tsx) and vitest resolve in the source plane; without a `paths` match
  272. * they fall back to package `exports`, which reach built `lib/` — present on a
  273. * built dev tree, absent on a clean one — so a missing mapping boots locally
  274. * yet breaks every clean checkout. Anything but a `.ts`/`.tsx` hit (a `.d.ts`
  275. * or `.js` under built `lib/`) is that artifact-plane fallback, not source.
  276. */
  277. function validateSourcePlaneResolution(): string[] {
  278. const violations: string[] = []
  279. const localPackages = localPackageDirectories()
  280. const config = ts.readConfigFile(resolve(root, 'tsconfig.base.json'), path => ts.sys.readFile(path))
  281. if (config.error !== undefined) {
  282. throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  283. }
  284. const { options, errors: optionErrors } = ts.convertCompilerOptionsFromJson(
  285. (config.config as { compilerOptions?: unknown }).compilerOptions,
  286. root,
  287. 'tsconfig.base.json',
  288. )
  289. if (optionErrors.length > 0) {
  290. throw new Error(optionErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
  291. }
  292. // convertCompilerOptionsFromJson leaves `pathsBasePath` unset, so relative
  293. // `paths` targets resolve against the host's current directory; anchor it to
  294. // the repository root to keep the gate cwd-independent.
  295. const host: ts.ModuleResolutionHost = {
  296. fileExists: path => ts.sys.fileExists(path),
  297. readFile: path => ts.sys.readFile(path),
  298. directoryExists: path => ts.sys.directoryExists(path),
  299. getCurrentDirectory: () => root,
  300. }
  301. const sourceExtensions = new Set<string>([ts.Extension.Ts, ts.Extension.Tsx])
  302. const containingFile = resolve(root, 'scripts/verify-cordis-config.ts')
  303. const locationsBySpecifier = new Map<string, Set<string>>()
  304. for (const reference of pluginReferences) {
  305. const packageName = packageNameFromSpecifier(reference.name)
  306. if (packageName === undefined || !localPackages.has(packageName)) continue
  307. const locations = locationsBySpecifier.get(reference.name) ?? new Set<string>()
  308. locations.add(reference.file)
  309. locationsBySpecifier.set(reference.name, locations)
  310. }
  311. for (const [specifier, locations] of locationsBySpecifier) {
  312. const resolved = ts.resolveModuleName(specifier, containingFile, options, host).resolvedModule
  313. if (resolved !== undefined && sourceExtensions.has(resolved.extension)) continue
  314. 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/)`)
  315. }
  316. return violations
  317. }
  318. function missingPluginDependencies(
  319. references: readonly PluginReference[],
  320. dependencies: Readonly<Record<string, string>>,
  321. manifestPath: string,
  322. ): string[] {
  323. const requiredPackages = new Map<string, Set<string>>()
  324. const require = (packageName: string, file: string): void => {
  325. const locations = requiredPackages.get(packageName) ?? new Set<string>()
  326. locations.add(file)
  327. requiredPackages.set(packageName, locations)
  328. }
  329. for (const reference of references) {
  330. const packageName = packageNameFromSpecifier(reference.name)
  331. if (packageName === undefined) continue
  332. require(packageName, reference.file)
  333. if (packageName === CHOOSER_PACKAGE) {
  334. for (const backend of CHOOSER_BACKEND_PACKAGES) require(backend, reference.file)
  335. }
  336. }
  337. return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
  338. ? []
  339. : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`)
  340. }
  341. function readManifest(path: string): PackageManifest {
  342. return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
  343. }
  344. function localPackageDirectories(): Map<string, string> {
  345. const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
  346. const packages = new Map<string, string>()
  347. for (const manifestPath of manifests) {
  348. const manifest = readManifest(manifestPath)
  349. if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
  350. }
  351. return packages
  352. }
  353. function rootProjectReferences(): Set<string> {
  354. // The root solution references the host and client aggregates (the two
  355. // sides merge cordis Context under the same keys, so one program cannot see
  356. // both — but this BFS only collects reference paths, it never forms a
  357. // program). Seed the solution and follow nested aggregate references to
  358. // collect the covered leaf project set.
  359. const collected = new Set<string>()
  360. const queue = [resolve(root, 'tsconfig.json')]
  361. const seen = new Set<string>()
  362. for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
  363. if (seen.has(file)) continue
  364. seen.add(file)
  365. const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
  366. if (config.error !== undefined) {
  367. throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  368. }
  369. const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
  370. for (const reference of references) {
  371. if (typeof reference.path !== 'string') continue
  372. const target = resolve(dirname(file), reference.path)
  373. if (target.endsWith('.json')) queue.push(target)
  374. else collected.add(target)
  375. }
  376. }
  377. return collected
  378. }
  379. function packageNameFromSpecifier(specifier: string): string | undefined {
  380. if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined
  381. const segments = specifier.split('/')
  382. if (specifier.startsWith('@')) {
  383. return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
  384. }
  385. return segments[0] || undefined
  386. }
  387. function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
  388. for (const problem of metadataExpressionErrors(entry, path)) {
  389. errors.push(`${file}${problem}`)
  390. }
  391. }
  392. /**
  393. * Expression-node diagnostics for one entry. `disabled` is the single
  394. * interpolated metadata field: its own `!!js` expression node is allowed and
  395. * must parse, while expressions nested below it stay truthy data; every other
  396. * metadata field must stay fully static.
  397. * @param entry - one loader entry (or patch row).
  398. * @param path - the entry's diagnostic path prefix.
  399. * @returns one diagnostic per offending expression.
  400. */
  401. export function metadataExpressionErrors(entry: Record<string, unknown>, path: string): string[] {
  402. const problems: string[] = []
  403. for (const field of metadataFields) {
  404. if (!(field in entry)) continue
  405. const expressionPaths: string[] = []
  406. collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
  407. for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`)
  408. }
  409. const disabled = entry.disabled
  410. if (disabled !== undefined) {
  411. if (isJsExpr(disabled)) {
  412. const detail = disabledExpressionProblem(disabled.__jsExpr)
  413. if (detail !== undefined) problems.push(`${path}.disabled${detail}`)
  414. } else {
  415. // A non-expression value gates on Boolean() at mount; an expression
  416. // nested anywhere below it never evaluates, so it must stay literal.
  417. const expressionPaths: string[] = []
  418. collectExpressionPaths(disabled, `${path}.disabled`, expressionPaths)
  419. for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`)
  420. }
  421. }
  422. return problems
  423. }
  424. /**
  425. * Parse-only validation of a `disabled` expression: the Loader evaluates it
  426. * at every mount decision, and a syntax error would fail the boot — rejecting
  427. * it here moves that failure to the earliest resolvable point.
  428. * @param expression - the `!!js` expression text.
  429. * @returns the diagnostic suffix, or `undefined` when the expression parses.
  430. */
  431. function disabledExpressionProblem(expression: string): string | undefined {
  432. try {
  433. // Compilation only — the constructor never executes the body.
  434. // oxlint-disable-next-line typescript/no-implied-eval
  435. new Function(`return (${expression})`)
  436. return undefined
  437. } catch (error) {
  438. const detail = error instanceof Error ? error.message : String(error)
  439. return `: disabled expression does not parse: ${detail}`
  440. }
  441. }
  442. function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
  443. if (isJsExpr(value)) {
  444. output.push(path)
  445. return
  446. }
  447. if (isUnknownArray(value)) {
  448. for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
  449. return
  450. }
  451. if (!isRecord(value)) return
  452. for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
  453. }
  454. function isJsExpr(value: unknown): value is JsExpr {
  455. return isRecord(value) && typeof value.__jsExpr === 'string'
  456. }
  457. function isRecord(value: unknown): value is Record<string, unknown> {
  458. return value !== null && typeof value === 'object'
  459. }
  460. function isUnknownArray(value: unknown): value is unknown[] {
  461. return Array.isArray(value)
  462. }