verify-cordis-config.ts 24 KB

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