verify-cordis-config.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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. const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
  28. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  29. kind: 'scalar',
  30. resolve: data => typeof data === 'string',
  31. construct: (data: unknown): JsExpr => {
  32. if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
  33. return { __jsExpr: data }
  34. },
  35. })
  36. const schema = yaml.JSON_SCHEMA.extend(jsExprType)
  37. const files = cordisConfigFiles(root)
  38. const errors: string[] = []
  39. const pluginReferences: PluginReference[] = []
  40. for (const file of files) {
  41. const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
  42. if (!isUnknownArray(document)) {
  43. errors.push(`${file}: root must be a Loader entry array`)
  44. continue
  45. }
  46. for (let index = 0; index < document.length; index++) {
  47. validateEntry(document[index], file, `[${index}]`)
  48. }
  49. }
  50. errors.push(...validateExampleResolution())
  51. errors.push(...validateAppResolution())
  52. if (errors.length > 0) {
  53. console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
  54. for (const error of errors) console.error(`- ${error}`)
  55. process.exitCode = 1
  56. } else {
  57. console.log(`verify-cordis-config: ${files.length} config files passed.`)
  58. }
  59. function validateEntry(value: unknown, file: string, path: string): void {
  60. if (!isRecord(value)) {
  61. errors.push(`${file}${path}: entry must be an object`)
  62. return
  63. }
  64. recordPlugin(value, file)
  65. validateMetadata(value, file, path)
  66. if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
  67. for (let index = 0; index < value.config.length; index++) {
  68. validateEntry(value.config[index], file, `${path}.config[${index}]`)
  69. }
  70. }
  71. if (value.name !== '@cordisjs/plugin-include') return
  72. const config = value.config
  73. if (!isRecord(config) || !isUnknownArray(config.patches)) return
  74. for (let index = 0; index < config.patches.length; index++) {
  75. const patch = config.patches[index]
  76. const patchPath = `${path}.config.patches[${index}]`
  77. if (!isRecord(patch)) continue
  78. recordPlugin(patch, file)
  79. validateMetadata(patch, file, patchPath)
  80. if (!isUnknownArray(patch.insert)) continue
  81. for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
  82. validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
  83. }
  84. }
  85. }
  86. function recordPlugin(entry: Record<string, unknown>, file: string): void {
  87. if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name })
  88. }
  89. function validateExampleResolution(): string[] {
  90. const violations: string[] = []
  91. const exampleManifest = readManifest('examples/package.json')
  92. const dependencies = exampleManifest.dependencies ?? {}
  93. const localPackages = localPackageDirectories()
  94. const rootReferences = rootProjectReferences()
  95. const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/'))
  96. violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
  97. const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
  98. const localExamplePackages = new Set([
  99. ...Object.keys(dependencies),
  100. ...[...requiredPackages].filter(packageName => packageName !== undefined),
  101. ])
  102. for (const packageName of localExamplePackages) {
  103. const packageDirectory = localPackages.get(packageName)
  104. if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
  105. const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
  106. violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
  107. }
  108. return violations
  109. }
  110. function validateAppResolution(): string[] {
  111. const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
  112. const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml')
  113. return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
  114. }
  115. function missingPluginDependencies(
  116. references: readonly PluginReference[],
  117. dependencies: Readonly<Record<string, string>>,
  118. manifestPath: string,
  119. ): string[] {
  120. const requiredPackages = new Map<string, Set<string>>()
  121. for (const reference of references) {
  122. const packageName = packageNameFromSpecifier(reference.name)
  123. if (packageName === undefined) continue
  124. const locations = requiredPackages.get(packageName) ?? new Set<string>()
  125. locations.add(reference.file)
  126. requiredPackages.set(packageName, locations)
  127. }
  128. return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
  129. ? []
  130. : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`)
  131. }
  132. function readManifest(path: string): PackageManifest {
  133. return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
  134. }
  135. function localPackageDirectories(): Map<string, string> {
  136. const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
  137. const packages = new Map<string, string>()
  138. for (const manifestPath of manifests) {
  139. const manifest = readManifest(manifestPath)
  140. if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
  141. }
  142. return packages
  143. }
  144. function rootProjectReferences(): Set<string> {
  145. // The root solution references the host and client aggregates (the two
  146. // sides merge cordis Context under the same keys, so one program cannot see
  147. // both — but this BFS only collects reference paths, it never forms a
  148. // program). Seed the solution and follow nested aggregate references to
  149. // collect the covered leaf project set.
  150. const collected = new Set<string>()
  151. const queue = [resolve(root, 'tsconfig.json')]
  152. const seen = new Set<string>()
  153. for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
  154. if (seen.has(file)) continue
  155. seen.add(file)
  156. const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
  157. if (config.error !== undefined) {
  158. throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  159. }
  160. const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
  161. for (const reference of references) {
  162. if (typeof reference.path !== 'string') continue
  163. const target = resolve(dirname(file), reference.path)
  164. if (target.endsWith('.json')) queue.push(target)
  165. else collected.add(target)
  166. }
  167. }
  168. return collected
  169. }
  170. function packageNameFromSpecifier(specifier: string): string | undefined {
  171. if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined
  172. const segments = specifier.split('/')
  173. if (specifier.startsWith('@')) {
  174. return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
  175. }
  176. return segments[0] || undefined
  177. }
  178. function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
  179. for (const field of metadataFields) {
  180. if (!(field in entry)) continue
  181. const expressionPaths: string[] = []
  182. collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
  183. for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
  184. }
  185. }
  186. function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
  187. if (isJsExpr(value)) {
  188. output.push(path)
  189. return
  190. }
  191. if (isUnknownArray(value)) {
  192. for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
  193. return
  194. }
  195. if (!isRecord(value)) return
  196. for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
  197. }
  198. function isJsExpr(value: unknown): value is JsExpr {
  199. return isRecord(value) && typeof value.__jsExpr === 'string'
  200. }
  201. function isRecord(value: unknown): value is Record<string, unknown> {
  202. return value !== null && typeof value === 'object'
  203. }
  204. function isUnknownArray(value: unknown): value is unknown[] {
  205. return Array.isArray(value)
  206. }