project-reference-faces.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. /** Validate compiler-face isolation across workspace Project Reference graphs. */
  2. import { existsSync, globSync } from 'node:fs'
  3. import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
  4. import ts from 'typescript'
  5. type ProjectFace = 'host' | 'client'
  6. interface ProjectReferenceConfig {
  7. readonly extends?: unknown
  8. readonly references?: ReadonlyArray<{ readonly path?: unknown }>
  9. }
  10. const WORKSPACE_MANIFESTS = [
  11. 'packages/*/*/package.json',
  12. 'apps/*/package.json',
  13. 'vendor/*/package.json',
  14. ] as const
  15. /**
  16. * Find references that enter the wrong leaf of a split Host/Client project.
  17. *
  18. * A single-config project is neutral and may participate in either graph. Once
  19. * a package declares both face configs, every reachable reference must name
  20. * the leaf matching the aggregate from which traversal began.
  21. *
  22. * @param root - Repository root containing both aggregate tsconfigs.
  23. * @returns Repo-relative diagnostics for every mismatched reference edge.
  24. */
  25. export function collectProjectReferenceFaceViolations(root: string): string[] {
  26. const splitRoots = splitProjectRoots(root)
  27. const violations: string[] = []
  28. const pending = [resolve(root, 'tsconfig.host.json'), resolve(root, 'tsconfig.client.json')]
  29. const visited = new Set<string>()
  30. for (let configPath = pending.pop(); configPath !== undefined; configPath = pending.pop()) {
  31. if (visited.has(configPath) || !existsSync(configPath)) continue
  32. visited.add(configPath)
  33. const config = projectConfig(root, configPath)
  34. const face = projectFace(root, configPath, config)
  35. for (const reference of projectReferences(config)) {
  36. const targetConfig = referenceConfigPath(configPath, reference)
  37. const splitRoot = containingSplitRoot(splitRoots, targetConfig)
  38. if (splitRoot !== undefined) {
  39. if (face === undefined) {
  40. violations.push(
  41. `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a config with no Host/Client face`,
  42. )
  43. continue
  44. }
  45. const expected = resolve(splitRoot, `tsconfig.${face}.json`)
  46. if (targetConfig !== expected) {
  47. violations.push(
  48. `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a ${faceLabel(face)} config; reference ${JSON.stringify(repoPath(root, expected))} instead`,
  49. )
  50. continue
  51. }
  52. }
  53. pending.push(targetConfig)
  54. }
  55. }
  56. return violations.sort()
  57. }
  58. function splitProjectRoots(root: string): string[] {
  59. return globSync(WORKSPACE_MANIFESTS, { cwd: root })
  60. .map(manifest => resolve(root, dirname(manifest)))
  61. .filter(dir => existsSync(resolve(dir, 'tsconfig.host.json'))
  62. && existsSync(resolve(dir, 'tsconfig.client.json')))
  63. .sort((left, right) => right.length - left.length)
  64. }
  65. function projectConfig(root: string, configPath: string): ProjectReferenceConfig {
  66. const read = ts.readConfigFile(configPath, path => ts.sys.readFile(path))
  67. if (read.error !== undefined) {
  68. const message = ts.flattenDiagnosticMessageText(read.error.messageText, '\n')
  69. throw new Error(`${repoPath(root, configPath)}: ${message}`)
  70. }
  71. return read.config as ProjectReferenceConfig
  72. }
  73. function projectReferences(config: ProjectReferenceConfig): string[] {
  74. return (config.references ?? [])
  75. .map(reference => reference.path)
  76. .filter((path): path is string => typeof path === 'string')
  77. }
  78. function projectFace(
  79. root: string,
  80. configPath: string,
  81. config: ProjectReferenceConfig,
  82. seen = new Set<string>(),
  83. ): ProjectFace | undefined {
  84. if (basename(configPath) === 'tsconfig.host.json') return 'host'
  85. if (basename(configPath) === 'tsconfig.client.json') return 'client'
  86. if (configPath === resolve(root, 'tsconfig.base.json')) return 'host'
  87. if (configPath === resolve(root, 'tsconfig.base.client.json')) return 'client'
  88. if (seen.has(configPath)) return undefined
  89. seen.add(configPath)
  90. const parent = localExtendsConfig(configPath, config.extends)
  91. if (parent === undefined || !existsSync(parent)) return undefined
  92. return projectFace(root, parent, projectConfig(root, parent), seen)
  93. }
  94. function localExtendsConfig(configPath: string, value: unknown): string | undefined {
  95. if (typeof value !== 'string' || !value.startsWith('.')) return undefined
  96. const target = resolve(dirname(configPath), value)
  97. return target.endsWith('.json') ? target : `${target}.json`
  98. }
  99. function referenceConfigPath(sourceConfig: string, reference: string): string {
  100. const target = resolve(dirname(sourceConfig), reference)
  101. return target.endsWith('.json') ? target : resolve(target, 'tsconfig.json')
  102. }
  103. function containingSplitRoot(splitRoots: readonly string[], targetConfig: string): string | undefined {
  104. return splitRoots.find((root) => {
  105. const path = relative(root, targetConfig)
  106. return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
  107. })
  108. }
  109. function repoPath(root: string, path: string): string {
  110. return relative(root, path).split(sep).join('/')
  111. }
  112. function faceLabel(face: ProjectFace): string {
  113. return face === 'host' ? 'Host' : 'Client'
  114. }