verify-cordis-config.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. /**
  2. * Reject JavaScript expressions in Cordis Loader entry metadata.
  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. */
  7. import { globSync, readFileSync } from 'node:fs'
  8. import { resolve } from 'node:path'
  9. import * as yaml from 'js-yaml'
  10. interface JsExpr {
  11. __jsExpr: string
  12. }
  13. const root = resolve(import.meta.dirname, '..')
  14. const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
  15. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  16. kind: 'scalar',
  17. resolve: data => typeof data === 'string',
  18. construct: (data: unknown): JsExpr => {
  19. if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
  20. return { __jsExpr: data }
  21. },
  22. })
  23. const schema = yaml.JSON_SCHEMA.extend(jsExprType)
  24. const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
  25. cwd: root,
  26. exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
  27. }).sort()
  28. const errors: string[] = []
  29. for (const file of files) {
  30. const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
  31. if (!isUnknownArray(document)) {
  32. errors.push(`${file}: root must be a Loader entry array`)
  33. continue
  34. }
  35. for (let index = 0; index < document.length; index++) {
  36. validateEntry(document[index], file, `[${index}]`)
  37. }
  38. }
  39. if (errors.length > 0) {
  40. console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
  41. for (const error of errors) console.error(`- ${error}`)
  42. process.exitCode = 1
  43. } else {
  44. console.log(`verify-cordis-config: ${files.length} config files passed.`)
  45. }
  46. function validateEntry(value: unknown, file: string, path: string): void {
  47. if (!isRecord(value)) {
  48. errors.push(`${file}${path}: entry must be an object`)
  49. return
  50. }
  51. validateMetadata(value, file, path)
  52. if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
  53. for (let index = 0; index < value.config.length; index++) {
  54. validateEntry(value.config[index], file, `${path}.config[${index}]`)
  55. }
  56. }
  57. if (value.name !== '@cordisjs/plugin-include') return
  58. const config = value.config
  59. if (!isRecord(config) || !isUnknownArray(config.patches)) return
  60. for (let index = 0; index < config.patches.length; index++) {
  61. const patch = config.patches[index]
  62. const patchPath = `${path}.config.patches[${index}]`
  63. if (!isRecord(patch)) continue
  64. validateMetadata(patch, file, patchPath)
  65. if (!isUnknownArray(patch.insert)) continue
  66. for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
  67. validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
  68. }
  69. }
  70. }
  71. function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
  72. for (const field of metadataFields) {
  73. if (!(field in entry)) continue
  74. const expressionPaths: string[] = []
  75. collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
  76. for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
  77. }
  78. }
  79. function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
  80. if (isJsExpr(value)) {
  81. output.push(path)
  82. return
  83. }
  84. if (isUnknownArray(value)) {
  85. for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
  86. return
  87. }
  88. if (!isRecord(value)) return
  89. for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
  90. }
  91. function isJsExpr(value: unknown): value is JsExpr {
  92. return isRecord(value) && typeof value.__jsExpr === 'string'
  93. }
  94. function isRecord(value: unknown): value is Record<string, unknown> {
  95. return value !== null && typeof value === 'object'
  96. }
  97. function isUnknownArray(value: unknown): value is unknown[] {
  98. return Array.isArray(value)
  99. }