verify-scoped-dispatch.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. /**
  2. * Scoped-dispatch drift gate: the set of scope-filtered events is declared in
  3. * TWO places that must never diverge — the dev-invariants runtime table (the
  4. * `scopedSubject` map in `packages/support/invariants/src/index.ts`, which
  5. * enforces carriers at dispatch time) and the event declarations' JSDoc (the
  6. * "Scope-filtered dispatch" sentence rendered into the events catalog, which
  7. * tells plugin authors what a scoped listener will and won't hear). An event
  8. * added to one side without the other either silently escapes runtime
  9. * enforcement or documents filtering that never happens; this gate fails the
  10. * build instead.
  11. *
  12. * Sources of truth: the invariant table is parsed from the invariants source;
  13. * the documented set is parsed from every `declare module 'cordis'` Events
  14. * JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject
  15. * notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`)
  16. * are deliberately unfiltered and must appear in NEITHER set.
  17. */
  18. import { globSync, readFileSync } from 'node:fs'
  19. import { resolve } from 'node:path'
  20. const root = resolve(import.meta.dirname, '..')
  21. /** The marker sentence every scope-filtered event's JSDoc carries. */
  22. const MARKER = 'Scope-filtered dispatch'
  23. /** Events that are deliberately UNFILTERED registry-subject notifications. */
  24. const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed'])
  25. function invariantTable(): Set<string> {
  26. const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8')
  27. const start = source.indexOf('const scopedSubject')
  28. if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants')
  29. const block = source.slice(start, source.indexOf('}', start))
  30. return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]))
  31. }
  32. function documentedSet(): Set<string> {
  33. const documented = new Set<string>()
  34. for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) {
  35. const source = readFileSync(resolve(root, rel), 'utf8')
  36. if (!source.includes(MARKER)) continue
  37. // Each event declaration: a JSDoc block followed by the quoted event name.
  38. // Tolerate `//` comment lines between the JSDoc and the declaration
  39. // (e.g. an inline TODO under the doc block).
  40. for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) {
  41. const [, doc, event] = match
  42. if (doc === undefined || event === undefined) continue
  43. if (doc.includes(MARKER)) documented.add(event)
  44. }
  45. }
  46. return documented
  47. }
  48. const table = invariantTable()
  49. const documented = documentedSet()
  50. const problems: string[] = []
  51. for (const event of table) {
  52. if (!documented.has(event)) {
  53. problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`)
  54. }
  55. if (REGISTRY_SUBJECT.has(event)) {
  56. problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`)
  57. }
  58. }
  59. for (const event of documented) {
  60. if (!table.has(event)) {
  61. problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`)
  62. }
  63. }
  64. if (problems.length > 0) {
  65. console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`)
  66. for (const problem of problems) console.error(` - ${problem}`)
  67. process.exit(1)
  68. }
  69. console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`)