verify-cordis-config.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /**
  2. * Validate Cordis Loader entry metadata and example 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 run from built packages, so every named package must resolve
  7. * from the examples workspace and every local package must 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. interface JsExpr {
  15. __jsExpr: string
  16. }
  17. interface PackageManifest {
  18. name?: string
  19. dependencies?: Record<string, string>
  20. }
  21. interface PluginReference {
  22. file: string
  23. name: string
  24. }
  25. const root = resolve(import.meta.dirname, '..')
  26. const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
  27. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  28. kind: 'scalar',
  29. resolve: data => typeof data === 'string',
  30. construct: (data: unknown): JsExpr => {
  31. if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
  32. return { __jsExpr: data }
  33. },
  34. })
  35. const schema = yaml.JSON_SCHEMA.extend(jsExprType)
  36. const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
  37. cwd: root,
  38. exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
  39. }).sort()
  40. const errors: string[] = []
  41. const examplePluginReferences: PluginReference[] = []
  42. for (const file of files) {
  43. const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
  44. if (!isUnknownArray(document)) {
  45. errors.push(`${file}: root must be a Loader entry array`)
  46. continue
  47. }
  48. for (let index = 0; index < document.length; index++) {
  49. validateEntry(document[index], file, `[${index}]`)
  50. }
  51. }
  52. errors.push(...validateExampleResolution())
  53. if (errors.length > 0) {
  54. console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
  55. for (const error of errors) console.error(`- ${error}`)
  56. process.exitCode = 1
  57. } else {
  58. console.log(`verify-cordis-config: ${files.length} config files passed.`)
  59. }
  60. function validateEntry(value: unknown, file: string, path: string): void {
  61. if (!isRecord(value)) {
  62. errors.push(`${file}${path}: entry must be an object`)
  63. return
  64. }
  65. recordExamplePlugin(value, file)
  66. validateMetadata(value, file, path)
  67. if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
  68. for (let index = 0; index < value.config.length; index++) {
  69. validateEntry(value.config[index], file, `${path}.config[${index}]`)
  70. }
  71. }
  72. if (value.name !== '@cordisjs/plugin-include') return
  73. const config = value.config
  74. if (!isRecord(config) || !isUnknownArray(config.patches)) return
  75. for (let index = 0; index < config.patches.length; index++) {
  76. const patch = config.patches[index]
  77. const patchPath = `${path}.config.patches[${index}]`
  78. if (!isRecord(patch)) continue
  79. recordExamplePlugin(patch, file)
  80. validateMetadata(patch, file, patchPath)
  81. if (!isUnknownArray(patch.insert)) continue
  82. for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
  83. validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
  84. }
  85. }
  86. }
  87. function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
  88. if (file.startsWith('examples/') && typeof entry.name === 'string') {
  89. examplePluginReferences.push({ file, name: entry.name })
  90. }
  91. }
  92. function validateExampleResolution(): string[] {
  93. const violations: string[] = []
  94. const exampleManifest = readManifest('examples/package.json')
  95. const dependencies = exampleManifest.dependencies ?? {}
  96. const localPackages = localPackageDirectories()
  97. const rootReferences = rootProjectReferences()
  98. const requiredPackages = new Map<string, Set<string>>()
  99. for (const reference of examplePluginReferences) {
  100. const packageName = packageNameFromSpecifier(reference.name)
  101. if (packageName === undefined) continue
  102. const locations = requiredPackages.get(packageName) ?? new Set<string>()
  103. locations.add(reference.file)
  104. requiredPackages.set(packageName, locations)
  105. }
  106. for (const [packageName, locations] of requiredPackages) {
  107. if (!(packageName in dependencies)) {
  108. violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
  109. }
  110. }
  111. const localExamplePackages = new Set([
  112. ...Object.keys(dependencies),
  113. ...requiredPackages.keys(),
  114. ])
  115. for (const packageName of localExamplePackages) {
  116. const packageDirectory = localPackages.get(packageName)
  117. if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
  118. const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
  119. violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
  120. }
  121. return violations
  122. }
  123. function readManifest(path: string): PackageManifest {
  124. return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
  125. }
  126. function localPackageDirectories(): Map<string, string> {
  127. const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
  128. const packages = new Map<string, string>()
  129. for (const manifestPath of manifests) {
  130. const manifest = readManifest(manifestPath)
  131. if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
  132. }
  133. return packages
  134. }
  135. function rootProjectReferences(): Set<string> {
  136. const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path))
  137. if (config.error !== undefined) {
  138. throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  139. }
  140. const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
  141. return new Set(references.flatMap((reference) => {
  142. if (typeof reference.path !== 'string') return []
  143. return [resolve(root, reference.path)]
  144. }))
  145. }
  146. function packageNameFromSpecifier(specifier: string): string | undefined {
  147. if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
  148. const segments = specifier.split('/')
  149. if (specifier.startsWith('@')) {
  150. return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
  151. }
  152. return segments[0] || undefined
  153. }
  154. function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
  155. for (const field of metadataFields) {
  156. if (!(field in entry)) continue
  157. const expressionPaths: string[] = []
  158. collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
  159. for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
  160. }
  161. }
  162. function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
  163. if (isJsExpr(value)) {
  164. output.push(path)
  165. return
  166. }
  167. if (isUnknownArray(value)) {
  168. for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
  169. return
  170. }
  171. if (!isRecord(value)) return
  172. for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
  173. }
  174. function isJsExpr(value: unknown): value is JsExpr {
  175. return isRecord(value) && typeof value.__jsExpr === 'string'
  176. }
  177. function isRecord(value: unknown): value is Record<string, unknown> {
  178. return value !== null && typeof value === 'object'
  179. }
  180. function isUnknownArray(value: unknown): value is unknown[] {
  181. return Array.isArray(value)
  182. }