verify-scoped-dispatch.ts 3.4 KB

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