verify-client-domain-graph.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. /**
  2. * Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
  3. * verify-module-graph covers package-level edges; this gate covers the
  4. * directory level the future package split will land on: domain directories
  5. * may import `contract/` and never each other, and only the assembly point
  6. * (`apply.ts` / `index.ts`) may import across domains.
  7. *
  8. * Layer model (lower may not import higher):
  9. * 0 contract/ shared contract surface (types + slot declarations)
  10. * 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
  11. * 2 apply.ts, index.ts assembly point and re-export shell
  12. *
  13. * Not yet wired into the gate sequence (loose-gate window); run directly:
  14. * pnpm exec tsx scripts/verify-client-domain-graph.ts
  15. */
  16. import { readdirSync, readFileSync, statSync } from 'node:fs'
  17. import { join, resolve } from 'node:path'
  18. const root = resolve(import.meta.dirname, '..')
  19. const CLIENT_DIR = join(root, 'packages/client')
  20. /** Directory names treated as the shared contract layer (importable by all). */
  21. const CONTRACT_DIRS = new Set(['contract'])
  22. /** Top-level client files allowed to import across domains (assembly layer). */
  23. const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
  24. interface Violation { file: string; imported: string; reason: string }
  25. /** Recursively list .ts/.tsx files under dir (relative paths). */
  26. function listSources(dir: string, prefix = ''): string[] {
  27. const out: string[] = []
  28. for (const name of readdirSync(dir)) {
  29. const full = join(dir, name)
  30. const rel = prefix ? `${prefix}/${name}` : name
  31. if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
  32. else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
  33. }
  34. return out
  35. }
  36. /** First path segment of a client-relative file, or '' for top-level files. */
  37. function domainOf(rel: string): string {
  38. const ix = rel.indexOf('/')
  39. return ix === -1 ? '' : rel.slice(0, ix)
  40. }
  41. function checkPackage(pkgName: string, clientDir: string): Violation[] {
  42. const violations: Violation[] = []
  43. const files = listSources(clientDir)
  44. for (const rel of files) {
  45. const fromDomain = domainOf(rel)
  46. const isAssembly = fromDomain === '' && ASSEMBLY_FILES.has(rel)
  47. if (isAssembly) continue
  48. const source = readFileSync(join(clientDir, rel), 'utf8')
  49. for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
  50. const spec = match[1]
  51. if (spec === undefined) continue
  52. // Resolve the relative specifier against the importing file's directory
  53. // to a client-dir-relative path.
  54. const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
  55. const parts = (fromDir ? fromDir.split('/') : [])
  56. for (const seg of spec.split('/')) {
  57. if (seg === '.') continue
  58. if (seg === '..') parts.pop()
  59. else parts.push(seg)
  60. }
  61. const target = parts.join('/')
  62. if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
  63. const toDomain = domainOf(target)
  64. if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
  65. if (fromDomain === toDomain) continue // inside one domain
  66. violations.push({
  67. file: `${pkgName}/src/client/${rel}`,
  68. imported: spec,
  69. reason: fromDomain === ''
  70. ? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
  71. : `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
  72. })
  73. }
  74. }
  75. return violations
  76. }
  77. const violations: Violation[] = []
  78. for (const pkg of readdirSync(CLIENT_DIR)) {
  79. const clientDir = join(CLIENT_DIR, pkg, 'src/client')
  80. try {
  81. if (!statSync(clientDir).isDirectory()) continue
  82. } catch {
  83. // No client half in this package — nothing to layer-check.
  84. continue
  85. }
  86. violations.push(...checkPackage(pkg, clientDir))
  87. }
  88. if (violations.length > 0) {
  89. console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
  90. for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
  91. process.exit(1)
  92. }
  93. console.log('verify-client-domain-graph: client domain layering clean.')