verify-website-yaml.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /**
  2. * Doc-sync gate: verify the fenced ```yaml examples in the website against
  3. * the loader and the workspace truth. A `cordis.yml` example that names a
  4. * plugin that does not exist, or passes a config key the plugin never
  5. * declared, is worse than no example — it fails silently for the reader.
  6. *
  7. * Scope: `website/zh-CN/**​/*.md`, EXCLUDING `website/zh-CN/api/**` (the api
  8. * pages are generator-owned — their yaml examples are verified at generation
  9. * time by a later stream, not re-checked here). Blocks opt out with
  10. * ` ```yaml ignore-check ` (same philosophy as doc-typecheck's opt-out: the
  11. * count is reported, an unchecked block is a visible decision, not a silent
  12. * hole — placeholder plugin names in tutorials are the legitimate case).
  13. *
  14. * Each checked block is parsed with the loader's REAL schema —
  15. * `JSON_SCHEMA` extended with the `!!js` scalar type exactly as
  16. * vendor/include/src/index.ts declares it — so `!!js process.env.X` parses
  17. * here iff it parses at runtime. Then:
  18. *
  19. * - Root is an ARRAY → a cordis.yml entry list. Every item must be a mapping
  20. * with a string `name` and only the keys `EntryOptions` declares
  21. * (vendor/loader/src/config/entry.ts plus the isolate.ts merge:
  22. * id, name, config, group, disabled, inject, intercept, isolate).
  23. * - `./` / `../` names are illustrative local plugins — existence is not
  24. * checkable, skip. `group:*` names are loader built-ins; their `config`
  25. * is itself an entry list and is recursed into.
  26. * - Any other name must be a real workspace package (`packages/*​/*` and
  27. * `vendor/*` package.json names).
  28. * - For `@deepseek-ai/dsh-*` names the config-catalog generator is the
  29. * truth: kind `config` → the yaml `config`'s top-level keys must be
  30. * properties of the declared config type (member names of the first
  31. * catalog paste ∪ top-level segments of the runtime schema keys);
  32. * config-free kinds → a non-empty `config` mapping is a violation;
  33. * seam/library kinds → name existence only (loading one directly is
  34. * dubious, but that is a docs-prose concern, not this gate's).
  35. * - Root is a MAPPING or scalar → a fragment (e.g. a bare `config:` excerpt):
  36. * syntax check only.
  37. *
  38. * This is a checker, not a fixer: it reports `file:line message` and exits 1.
  39. *
  40. * Run: `tsx scripts/verify-website-yaml.ts`.
  41. */
  42. import { globSync, readFileSync } from 'node:fs'
  43. import { resolve } from 'node:path'
  44. import * as yaml from 'js-yaml'
  45. import ts from 'typescript'
  46. import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts'
  47. import { extractFences } from './md-fences.ts'
  48. const root = resolve(import.meta.dirname, '..')
  49. /** Mirror of the loader's yaml schema (vendor/include/src/index.ts): the
  50. * `!!js` tag parses to an expression wrapper, everything else is JSON. */
  51. const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
  52. kind: 'scalar',
  53. resolve: data => typeof data === 'string',
  54. construct: (data: string) => ({ __jsExpr: data }),
  55. })
  56. const schema = yaml.JSON_SCHEMA.extend(JsExpr)
  57. /** The exact key set an entry mapping may carry: `EntryOptions` in
  58. * vendor/loader/src/config/entry.ts plus the isolate.ts interface merge. */
  59. const ENTRY_KEYS = ['id', 'name', 'config', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
  60. /** One `file:line message` finding. */
  61. interface Violation {
  62. file: string
  63. /** 1-based line of the block's opening fence. */
  64. line: number
  65. message: string
  66. }
  67. /** One extracted ```yaml block. */
  68. interface Block {
  69. file: string
  70. /** 1-based line of the opening fence. */
  71. line: number
  72. kind: 'check' | 'ignore'
  73. code: string
  74. }
  75. /** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */
  76. function extractBlocks(file: string): Block[] {
  77. return extractFences(resolve(root, file), info =>
  78. info === 'yaml' ? 'check' : info === 'yaml ignore-check' ? 'ignore' : null)
  79. .map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
  80. }
  81. /** Every workspace package name: `packages/<group>/<pkg>` and `vendor/<pkg>`. */
  82. function knownPackages(): Set<string> {
  83. const names = new Set<string>()
  84. for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
  85. for (const match of globSync(pattern, { cwd: root })) {
  86. const pkg: unknown = JSON.parse(readFileSync(resolve(root, match), 'utf8'))
  87. if (typeof pkg === 'object' && pkg !== null && 'name' in pkg && typeof pkg.name === 'string') {
  88. names.add(pkg.name)
  89. }
  90. }
  91. }
  92. return names
  93. }
  94. /** The catalog, built once on first `@deepseek-ai/dsh-*` name, keyed by pkg. */
  95. let catalogByPkg: Map<string, CatalogEntry> | null = null
  96. function catalogFor(pkg: string): CatalogEntry | undefined {
  97. catalogByPkg ??= new Map(collectConfigCatalog().map(e => [e.pkg, e]))
  98. return catalogByPkg.get(pkg)
  99. }
  100. /** Top-level property names of the first catalog paste (the verbatim config
  101. * type declaration), parsed as source text. */
  102. function pasteKeys(paste: string): Set<string> {
  103. const sf = ts.createSourceFile('paste.ts', paste, ts.ScriptTarget.Latest, true)
  104. const keys = new Set<string>()
  105. const addMembers = (members: ts.NodeArray<ts.TypeElement>): void => {
  106. for (const m of members) {
  107. if (ts.isPropertySignature(m) || ts.isMethodSignature(m)) {
  108. const name = m.name
  109. keys.add(ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf))
  110. }
  111. }
  112. }
  113. for (const stmt of sf.statements) {
  114. if (ts.isInterfaceDeclaration(stmt)) addMembers(stmt.members)
  115. else if (ts.isTypeAliasDeclaration(stmt) && ts.isTypeLiteralNode(stmt.type)) addMembers(stmt.type.members)
  116. }
  117. return keys
  118. }
  119. /** The allowed top-level config keys of a kind-`config` catalog entry: the
  120. * first paste's member names ∪ the schema keys' top-level segments
  121. * (`agents[].id` → `agents`). Cached per entry. */
  122. const allowedKeysCache = new Map<string, Set<string>>()
  123. function allowedConfigKeys(entry: CatalogEntry): Set<string> {
  124. const cached = allowedKeysCache.get(entry.pkg)
  125. if (cached) return cached
  126. const keys = pasteKeys(entry.pastes?.[0]?.text ?? '')
  127. for (const path of entry.schemaKeys ?? []) {
  128. const top = path.split('.')[0]?.replace(/\[\]$/, '')
  129. if (top) keys.add(top)
  130. }
  131. allowedKeysCache.set(entry.pkg, keys)
  132. return keys
  133. }
  134. /** A parsed yaml mapping (arrays and `!!js` wrappers excluded). */
  135. function asMapping(value: unknown): Record<string, unknown> | null {
  136. if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
  137. if ('__jsExpr' in value) return null
  138. return value as Record<string, unknown>
  139. }
  140. /** Check one cordis.yml entry list (recursing into `group:` sub-lists). */
  141. function checkEntryList(
  142. items: unknown[],
  143. known: Set<string>,
  144. block: Block,
  145. violations: Violation[],
  146. ): void {
  147. const flag = (message: string): void => {
  148. violations.push({ file: block.file, line: block.line, message })
  149. }
  150. items.forEach((item, index) => {
  151. const at = `entry ${index + 1}`
  152. const entry = asMapping(item)
  153. if (!entry) {
  154. flag(`${at}: not a mapping`)
  155. return
  156. }
  157. const name = entry['name']
  158. if (typeof name !== 'string') {
  159. flag(`${at}: missing string \`name\``)
  160. return
  161. }
  162. for (const key of Object.keys(entry)) {
  163. if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
  164. flag(`${at} (${name}): unknown entry key \`${key}\` (EntryOptions allows: ${[...ENTRY_KEYS].join(', ')})`)
  165. }
  166. }
  167. // Illustrative local plugin — nothing on disk to check against.
  168. if (name.startsWith('./') || name.startsWith('../')) return
  169. // A `group:`-style pseudo-name is NOT loadable: tree.import() only
  170. // special-cases the `cordis:` prefix, and nothing in this repo registers
  171. // loader builtins — reject it and point at the real group plugin.
  172. if (name.startsWith('group:')) {
  173. flag(`${at}: \`${name}\` is not loadable (no loader builtin is registered); use \`@cordisjs/plugin-group\` with \`group: true\``)
  174. return
  175. }
  176. // The vendored group plugin: its config is a nested entry list.
  177. if (name === '@cordisjs/plugin-group') {
  178. if (Array.isArray(entry['config'])) checkEntryList(entry['config'], known, block, violations)
  179. return
  180. }
  181. if (!known.has(name)) {
  182. flag(`${at}: unknown plugin \`${name}\` (not a workspace package)`)
  183. return
  184. }
  185. if (!name.startsWith('@deepseek-ai/dsh-')) return
  186. const catalog = catalogFor(name)
  187. if (!catalog) return
  188. const config = asMapping(entry['config'])
  189. if (catalog.kind === 'config') {
  190. if (!config) return
  191. const allowed = allowedConfigKeys(catalog)
  192. for (const key of Object.keys(config)) {
  193. if (!allowed.has(key)) {
  194. flag(`${at}: \`${name}\` has no config key \`${key}\` (known keys: ${[...allowed].sort().join(', ')})`)
  195. }
  196. }
  197. } else if (catalog.kind === 'no-config') {
  198. if (config && Object.keys(config).length > 0) {
  199. flag(`${at}: \`${name}\` declares no config, but the example passes one`)
  200. }
  201. }
  202. // seam / library: loading one directly is dubious, but that is a prose
  203. // concern — this gate only vouches for name existence.
  204. })
  205. }
  206. const files = globSync('website/zh-CN/**/*.md', { cwd: root })
  207. .filter(f => !f.startsWith('website/zh-CN/api/'))
  208. .sort()
  209. const violations: Violation[] = []
  210. const known = knownPackages()
  211. let entryLists = 0
  212. let fragments = 0
  213. let ignored = 0
  214. let scanned = 0
  215. for (const file of files) {
  216. for (const block of extractBlocks(file)) {
  217. scanned++
  218. if (block.kind === 'ignore') {
  219. ignored++
  220. continue
  221. }
  222. let parsed: unknown
  223. try {
  224. parsed = yaml.load(block.code, { schema })
  225. } catch (error) {
  226. const message = error instanceof Error ? error.message.split('\n')[0] ?? 'parse error' : String(error)
  227. violations.push({ file: block.file, line: block.line, message: `yaml parse error: ${message}` })
  228. continue
  229. }
  230. if (Array.isArray(parsed)) {
  231. entryLists++
  232. checkEntryList(parsed, known, block, violations)
  233. } else {
  234. // Mapping or scalar root: a fragment (e.g. a bare `config:` excerpt) —
  235. // syntax is all there is to check.
  236. fragments++
  237. }
  238. }
  239. }
  240. if (violations.length === 0) {
  241. console.log(
  242. `verify-website-yaml: ${scanned} yaml block(s) in ${files.length} file(s): `
  243. + `${entryLists} entry list(s) + ${fragments} fragment(s) checked, ${ignored} ignore-check skipped.`,
  244. )
  245. process.exit(0)
  246. }
  247. console.error('verify-website-yaml: invalid yaml examples found:')
  248. for (const v of violations) {
  249. console.error(` ${v.file}:${v.line} ${v.message}`)
  250. }
  251. process.exit(1)