1
0
Эх сурвалжийг харах

website: gate every yaml config example against the real plugin surface

New doc-sync gate verify-website-yaml: each ```yaml block under
website/zh-CN (api/ excluded — generator-owned) must parse with the loader's
real schema (JSON_SCHEMA + !!js), use only EntryOptions keys, name only real
workspace packages, and pass only config keys the plugin's declared Config
type / schemastery schema accepts (collectConfigCatalog drives the key sets).
```yaml ignore-check opts out a deliberate-placeholder block (the
capability-trio tutorial keeps its fictional package names).
lintianle 3 сар өмнө
parent
commit
da26138592

+ 2 - 1
package.json

@@ -61,10 +61,11 @@
     "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
     "gen-module-graph": "tsx scripts/gen-module-graph.ts",
     "verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
+    "verify-website-yaml": "tsx scripts/verify-website-yaml.ts",
     "website:dev": "pnpm --filter @deepseek-ai/website run dev",
     "website:build": "pnpm --filter @deepseek-ai/website run build",
     "constraints": "tsx scripts/check-workspace-constraints.ts",
-    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
+    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-website-yaml",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
     "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
     "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",

+ 1 - 0
scripts/run-gates.ts

@@ -274,6 +274,7 @@ function docSyncLeafGates(): Gate[] {
     pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
     pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
     pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
+    pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
   ]
 }
 

+ 284 - 0
scripts/verify-website-yaml.ts

@@ -0,0 +1,284 @@
+/**
+ * Doc-sync gate: verify the fenced ```yaml examples in the website against
+ * the loader and the workspace truth. A `cordis.yml` example that names a
+ * plugin that does not exist, or passes a config key the plugin never
+ * declared, is worse than no example — it fails silently for the reader.
+ *
+ * Scope: `website/zh-CN/**​/*.md`, EXCLUDING `website/zh-CN/api/**` (the api
+ * pages are generator-owned — their yaml examples are verified at generation
+ * time by a later stream, not re-checked here). Blocks opt out with
+ * ` ```yaml ignore-check ` (same philosophy as doc-typecheck's opt-out: the
+ * count is reported, an unchecked block is a visible decision, not a silent
+ * hole — placeholder plugin names in tutorials are the legitimate case).
+ *
+ * Each checked block is parsed with the loader's REAL schema —
+ * `JSON_SCHEMA` extended with the `!!js` scalar type exactly as
+ * vendor/include/src/index.ts declares it — so `!!js process.env.X` parses
+ * here iff it parses at runtime. Then:
+ *
+ * - Root is an ARRAY → a cordis.yml entry list. Every item must be a mapping
+ *   with a string `name` and only the keys `EntryOptions` declares
+ *   (vendor/loader/src/config/entry.ts plus the isolate.ts merge:
+ *   id, name, config, group, disabled, inject, intercept, isolate).
+ *   - `./` / `../` names are illustrative local plugins — existence is not
+ *     checkable, skip. `group:*` names are loader built-ins; their `config`
+ *     is itself an entry list and is recursed into.
+ *   - Any other name must be a real workspace package (`packages/*​/*` and
+ *     `vendor/*` package.json names).
+ *   - For `@deepseek-ai/dsh-*` names the config-catalog generator is the
+ *     truth: kind `config` → the yaml `config`'s top-level keys must be
+ *     properties of the declared config type (member names of the first
+ *     catalog paste ∪ top-level segments of the runtime schema keys);
+ *     config-free kinds → a non-empty `config` mapping is a violation;
+ *     seam/library kinds → name existence only (loading one directly is
+ *     dubious, but that is a docs-prose concern, not this gate's).
+ * - Root is a MAPPING or scalar → a fragment (e.g. a bare `config:` excerpt):
+ *   syntax check only.
+ *
+ * This is a checker, not a fixer: it reports `file:line message` and exits 1.
+ *
+ * Run: `tsx scripts/verify-website-yaml.ts`.
+ */
+
+import { globSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import * as yaml from 'js-yaml'
+import ts from 'typescript'
+import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts'
+
+const root = resolve(import.meta.dirname, '..')
+
+/** Mirror of the loader's yaml schema (vendor/include/src/index.ts): the
+ * `!!js` tag parses to an expression wrapper, everything else is JSON. */
+const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
+  kind: 'scalar',
+  resolve: data => typeof data === 'string',
+  construct: (data: string) => ({ __jsExpr: data }),
+})
+const schema = yaml.JSON_SCHEMA.extend(JsExpr)
+
+/** The exact key set an entry mapping may carry: `EntryOptions` in
+ * vendor/loader/src/config/entry.ts plus the isolate.ts interface merge. */
+const ENTRY_KEYS = ['id', 'name', 'config', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
+
+/** One `file:line message` finding. */
+interface Violation {
+  file: string
+  /** 1-based line of the block's opening fence. */
+  line: number
+  message: string
+}
+
+/** One extracted ```yaml block. */
+interface Block {
+  file: string
+  /** 1-based line of the opening fence. */
+  line: number
+  kind: 'check' | 'ignore'
+  code: string
+}
+
+/** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */
+function extractBlocks(file: string): Block[] {
+  const text = readFileSync(resolve(root, file), 'utf8')
+  const lines = text.split('\n')
+  const blocks: Block[] = []
+  let open: { line: number; kind: Block['kind']; body: string[] } | null = null
+
+  lines.forEach((raw, i) => {
+    const fence = /^```(\s*)(\S.*)?$/.exec(raw)
+    if (!fence) {
+      if (open) open.body.push(raw)
+      return
+    }
+    if (open) {
+      // closing fence
+      blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
+      open = null
+      return
+    }
+    // opening fence — only yaml blocks participate
+    const info = (fence[2] ?? '').trim()
+    const kind: Block['kind'] | null =
+      info === 'yaml' ? 'check'
+        : info === 'yaml ignore-check' ? 'ignore'
+          : null
+    if (kind) open = { line: i + 1, kind, body: [] }
+  })
+  return blocks
+}
+
+/** Every workspace package name: `packages/<group>/<pkg>` and `vendor/<pkg>`. */
+function knownPackages(): Set<string> {
+  const names = new Set<string>()
+  for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
+    for (const match of globSync(pattern, { cwd: root })) {
+      const pkg: unknown = JSON.parse(readFileSync(resolve(root, match), 'utf8'))
+      if (typeof pkg === 'object' && pkg !== null && 'name' in pkg && typeof pkg.name === 'string') {
+        names.add(pkg.name)
+      }
+    }
+  }
+  return names
+}
+
+/** The catalog, built once on first `@deepseek-ai/dsh-*` name, keyed by pkg. */
+let catalogByPkg: Map<string, CatalogEntry> | null = null
+function catalogFor(pkg: string): CatalogEntry | undefined {
+  catalogByPkg ??= new Map(collectConfigCatalog().map(e => [e.pkg, e]))
+  return catalogByPkg.get(pkg)
+}
+
+/** Top-level property names of the first catalog paste (the verbatim config
+ * type declaration), parsed as source text. */
+function pasteKeys(paste: string): Set<string> {
+  const sf = ts.createSourceFile('paste.ts', paste, ts.ScriptTarget.Latest, true)
+  const keys = new Set<string>()
+  const addMembers = (members: ts.NodeArray<ts.TypeElement>): void => {
+    for (const m of members) {
+      if (ts.isPropertySignature(m) || ts.isMethodSignature(m)) {
+        const name = m.name
+        keys.add(ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf))
+      }
+    }
+  }
+  for (const stmt of sf.statements) {
+    if (ts.isInterfaceDeclaration(stmt)) addMembers(stmt.members)
+    else if (ts.isTypeAliasDeclaration(stmt) && ts.isTypeLiteralNode(stmt.type)) addMembers(stmt.type.members)
+  }
+  return keys
+}
+
+/** The allowed top-level config keys of a kind-`config` catalog entry: the
+ * first paste's member names ∪ the schema keys' top-level segments
+ * (`agents[].id` → `agents`). Cached per entry. */
+const allowedKeysCache = new Map<string, Set<string>>()
+function allowedConfigKeys(entry: CatalogEntry): Set<string> {
+  const cached = allowedKeysCache.get(entry.pkg)
+  if (cached) return cached
+  const keys = pasteKeys(entry.pastes?.[0]?.text ?? '')
+  for (const path of entry.schemaKeys ?? []) {
+    const top = path.split('.')[0]?.replace(/\[\]$/, '')
+    if (top) keys.add(top)
+  }
+  allowedKeysCache.set(entry.pkg, keys)
+  return keys
+}
+
+/** A parsed yaml mapping (arrays and `!!js` wrappers excluded). */
+function asMapping(value: unknown): Record<string, unknown> | null {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
+  if ('__jsExpr' in value) return null
+  return value as Record<string, unknown>
+}
+
+/** Check one cordis.yml entry list (recursing into `group:` sub-lists). */
+function checkEntryList(
+  items: unknown[],
+  known: Set<string>,
+  block: Block,
+  violations: Violation[],
+): void {
+  const flag = (message: string): void => {
+    violations.push({ file: block.file, line: block.line, message })
+  }
+  items.forEach((item, index) => {
+    const at = `entry ${index + 1}`
+    const entry = asMapping(item)
+    if (!entry) {
+      flag(`${at}: not a mapping`)
+      return
+    }
+    const name = entry['name']
+    if (typeof name !== 'string') {
+      flag(`${at}: missing string \`name\``)
+      return
+    }
+    for (const key of Object.keys(entry)) {
+      if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
+        flag(`${at} (${name}): unknown entry key \`${key}\` (EntryOptions allows: ${[...ENTRY_KEYS].join(', ')})`)
+      }
+    }
+    // Illustrative local plugin — nothing on disk to check against.
+    if (name.startsWith('./') || name.startsWith('../')) return
+    // Loader built-in group: its config is a nested entry list.
+    if (name.startsWith('group:')) {
+      if (Array.isArray(entry['config'])) checkEntryList(entry['config'], known, block, violations)
+      return
+    }
+    if (!known.has(name)) {
+      flag(`${at}: unknown plugin \`${name}\` (not a workspace package)`)
+      return
+    }
+    if (!name.startsWith('@deepseek-ai/dsh-')) return
+    const catalog = catalogFor(name)
+    if (!catalog) return
+    const config = asMapping(entry['config'])
+    if (catalog.kind === 'config') {
+      if (!config) return
+      const allowed = allowedConfigKeys(catalog)
+      for (const key of Object.keys(config)) {
+        if (!allowed.has(key)) {
+          flag(`${at}: \`${name}\` has no config key \`${key}\` (known keys: ${[...allowed].sort().join(', ')})`)
+        }
+      }
+    } else if (catalog.kind === 'no-config') {
+      if (config && Object.keys(config).length > 0) {
+        flag(`${at}: \`${name}\` declares no config, but the example passes one`)
+      }
+    }
+    // seam / library: loading one directly is dubious, but that is a prose
+    // concern — this gate only vouches for name existence.
+  })
+}
+
+const files = globSync('website/zh-CN/**/*.md', { cwd: root })
+  .filter(f => !f.startsWith('website/zh-CN/api/'))
+  .sort()
+
+const violations: Violation[] = []
+const known = knownPackages()
+let entryLists = 0
+let fragments = 0
+let ignored = 0
+let scanned = 0
+
+for (const file of files) {
+  for (const block of extractBlocks(file)) {
+    scanned++
+    if (block.kind === 'ignore') {
+      ignored++
+      continue
+    }
+    let parsed: unknown
+    try {
+      parsed = yaml.load(block.code, { schema })
+    } catch (error) {
+      const message = error instanceof Error ? error.message.split('\n')[0] ?? 'parse error' : String(error)
+      violations.push({ file: block.file, line: block.line, message: `yaml parse error: ${message}` })
+      continue
+    }
+    if (Array.isArray(parsed)) {
+      entryLists++
+      checkEntryList(parsed, known, block, violations)
+    } else {
+      // Mapping or scalar root: a fragment (e.g. a bare `config:` excerpt) —
+      // syntax is all there is to check.
+      fragments++
+    }
+  }
+}
+
+if (violations.length === 0) {
+  console.log(
+    `verify-website-yaml: ${scanned} yaml block(s) in ${files.length} file(s): `
+    + `${entryLists} entry list(s) + ${fragments} fragment(s) checked, ${ignored} ignore-check skipped.`,
+  )
+  process.exit(0)
+}
+
+console.error('verify-website-yaml: invalid yaml examples found:')
+for (const v of violations) {
+  console.error(`  ${v.file}:${v.line}  ${v.message}`)
+}
+process.exit(1)

+ 1 - 1
website/zh-CN/develop/practice/index.md

@@ -140,7 +140,7 @@ export function apply(ctx: Context) {
 
 ### 在 cordis.yml 中组合
 
-```yaml
+```yaml ignore-check
 - name: '@deepseek-ai/dsh-my-cap-local'
 - name: '@deepseek-ai/dsh-tool-my-cap'
 ```