| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 |
- /** Validate compiler-face isolation across workspace Project Reference graphs. */
- import { existsSync, globSync } from 'node:fs'
- import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
- import ts from 'typescript'
- type ProjectFace = 'host' | 'client'
- interface ProjectReferenceConfig {
- readonly extends?: unknown
- readonly references?: ReadonlyArray<{ readonly path?: unknown }>
- }
- const WORKSPACE_MANIFESTS = [
- 'packages/*/*/package.json',
- 'apps/*/package.json',
- 'vendor/*/package.json',
- ] as const
- /**
- * Find references that enter the wrong leaf of a split Host/Client project.
- *
- * A single-config project is neutral and may participate in either graph. Once
- * a package declares both face configs, every reachable reference must name
- * the leaf matching the aggregate from which traversal began.
- *
- * @param root - Repository root containing both aggregate tsconfigs.
- * @returns Repo-relative diagnostics for every mismatched reference edge.
- */
- export function collectProjectReferenceFaceViolations(root: string): string[] {
- const splitRoots = splitProjectRoots(root)
- const violations: string[] = []
- const pending = [resolve(root, 'tsconfig.host.json'), resolve(root, 'tsconfig.client.json')]
- const visited = new Set<string>()
- for (let configPath = pending.pop(); configPath !== undefined; configPath = pending.pop()) {
- if (visited.has(configPath) || !existsSync(configPath)) continue
- visited.add(configPath)
- const config = projectConfig(root, configPath)
- const face = projectFace(root, configPath, config)
- for (const reference of projectReferences(config)) {
- const targetConfig = referenceConfigPath(configPath, reference)
- const splitRoot = containingSplitRoot(splitRoots, targetConfig)
- if (splitRoot !== undefined) {
- if (face === undefined) {
- violations.push(
- `${repoPath(root, configPath)}: Project Reference ${JSON.stringify(reference)} enters split project ${repoPath(root, splitRoot)} from a config with no Host/Client face`,
- )
- continue
- }
- const expected = resolve(splitRoot, `tsconfig.${face}.json`)
- if (targetConfig !== expected) {
- violations.push(
- `${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`,
- )
- continue
- }
- }
- pending.push(targetConfig)
- }
- }
- return violations.sort()
- }
- function splitProjectRoots(root: string): string[] {
- return globSync(WORKSPACE_MANIFESTS, { cwd: root })
- .map(manifest => resolve(root, dirname(manifest)))
- .filter(dir => existsSync(resolve(dir, 'tsconfig.host.json'))
- && existsSync(resolve(dir, 'tsconfig.client.json')))
- .sort((left, right) => right.length - left.length)
- }
- function projectConfig(root: string, configPath: string): ProjectReferenceConfig {
- const read = ts.readConfigFile(configPath, path => ts.sys.readFile(path))
- if (read.error !== undefined) {
- const message = ts.flattenDiagnosticMessageText(read.error.messageText, '\n')
- throw new Error(`${repoPath(root, configPath)}: ${message}`)
- }
- return read.config as ProjectReferenceConfig
- }
- function projectReferences(config: ProjectReferenceConfig): string[] {
- return (config.references ?? [])
- .map(reference => reference.path)
- .filter((path): path is string => typeof path === 'string')
- }
- function projectFace(
- root: string,
- configPath: string,
- config: ProjectReferenceConfig,
- seen = new Set<string>(),
- ): ProjectFace | undefined {
- if (basename(configPath) === 'tsconfig.host.json') return 'host'
- if (basename(configPath) === 'tsconfig.client.json') return 'client'
- if (configPath === resolve(root, 'tsconfig.base.json')) return 'host'
- if (configPath === resolve(root, 'tsconfig.base.client.json')) return 'client'
- if (seen.has(configPath)) return undefined
- seen.add(configPath)
- const parent = localExtendsConfig(configPath, config.extends)
- if (parent === undefined || !existsSync(parent)) return undefined
- return projectFace(root, parent, projectConfig(root, parent), seen)
- }
- function localExtendsConfig(configPath: string, value: unknown): string | undefined {
- if (typeof value !== 'string' || !value.startsWith('.')) return undefined
- const target = resolve(dirname(configPath), value)
- return target.endsWith('.json') ? target : `${target}.json`
- }
- function referenceConfigPath(sourceConfig: string, reference: string): string {
- const target = resolve(dirname(sourceConfig), reference)
- return target.endsWith('.json') ? target : resolve(target, 'tsconfig.json')
- }
- function containingSplitRoot(splitRoots: readonly string[], targetConfig: string): string | undefined {
- return splitRoots.find((root) => {
- const path = relative(root, targetConfig)
- return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
- })
- }
- function repoPath(root: string, path: string): string {
- return relative(root, path).split(sep).join('/')
- }
- function faceLabel(face: ProjectFace): string {
- return face === 'host' ? 'Host' : 'Client'
- }
|