build: enforce split project reference faces

This commit is contained in:
imccyu
2026-08-08 12:25:41 +08:00
parent d85d0806cd
commit 863abcb427
9 changed files with 239 additions and 8 deletions

View File

@@ -8,6 +8,7 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
const root = resolve(import.meta.dirname, '..')
// vendor/* is single-level; packages/<group>/<pkg> nests one level deeper
@@ -305,6 +306,7 @@ const errors = [
...checkRepositoryVersion(),
...workspaceManifests().flatMap(checkWorkspace),
...checkHierarchyShape(),
...collectProjectReferenceFaceViolations(root),
]
if (errors.length > 0) {
console.error(errors.join('\n'))

View File

@@ -0,0 +1,100 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function writeJson(path: string, value: unknown): void {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
}
function workspaceFixture(options: {
readonly host: readonly string[]
readonly client: readonly string[]
}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-project-reference-faces-'))
roots.push(root)
const shared = join(root, 'packages/core/shared')
const split = join(root, 'packages/api/split')
mkdirSync(shared, { recursive: true })
mkdirSync(split, { recursive: true })
writeJson(join(root, 'tsconfig.base.json'), {})
writeJson(join(root, 'tsconfig.base.client.json'), { extends: './tsconfig.base.json' })
writeJson(join(shared, 'package.json'), { name: '@deepseek-ai/dsh-shared' })
writeJson(join(shared, 'tsconfig.json'), {
extends: '../../../tsconfig.base.json',
references: [],
})
writeJson(join(split, 'package.json'), { name: '@deepseek-ai/dsh-split' })
writeJson(join(split, 'tsconfig.json'), {
files: [],
references: [{ path: './tsconfig.host.json' }, { path: './tsconfig.client.json' }],
})
writeJson(join(split, 'tsconfig.host.json'), { references: [{ path: '../../core/shared' }] })
writeJson(join(split, 'tsconfig.client.json'), { references: [{ path: '../../core/shared' }] })
writeJson(join(root, 'tsconfig.host.json'), {
references: options.host.map(path => ({ path })),
})
writeJson(join(root, 'tsconfig.client.json'), {
references: options.client.map(path => ({ path })),
})
return root
}
describe('Project Reference compiler faces', () => {
it('allows neutral projects in either graph and matching split leaves', () => {
const root = workspaceFixture({
host: ['./packages/core/shared', './packages/api/split/tsconfig.host.json'],
client: ['./packages/core/shared', './packages/api/split/tsconfig.client.json'],
})
expect(collectProjectReferenceFaceViolations(root)).toEqual([])
})
it('rejects the opposite leaf and the solution root of a split project', () => {
const root = workspaceFixture({
host: [
'./packages/api/split/tsconfig.host.json',
'./packages/api/split/tsconfig.client.json',
],
client: ['./packages/api/split'],
})
expect(collectProjectReferenceFaceViolations(root)).toEqual([
'tsconfig.client.json: Project Reference "./packages/api/split" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead',
'tsconfig.host.json: Project Reference "./packages/api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead',
])
})
it('uses the referencing project face throughout the reachable graph', () => {
const root = workspaceFixture({
host: ['./packages/core/host-consumer'],
client: ['./packages/core/client-consumer'],
})
const hostConsumer = join(root, 'packages/core/host-consumer')
mkdirSync(hostConsumer, { recursive: true })
writeJson(join(hostConsumer, 'package.json'), { name: '@deepseek-ai/dsh-host-consumer' })
writeJson(join(hostConsumer, 'tsconfig.json'), {
extends: '../../../tsconfig.base.json',
references: [{ path: '../../api/split/tsconfig.client.json' }],
})
const clientConsumer = join(root, 'packages/core/client-consumer')
mkdirSync(clientConsumer, { recursive: true })
writeJson(join(clientConsumer, 'package.json'), { name: '@deepseek-ai/dsh-client-consumer' })
writeJson(join(clientConsumer, 'tsconfig.json'), {
extends: '../../../tsconfig.base.client.json',
references: [{ path: '../../api/split/tsconfig.host.json' }],
})
expect(collectProjectReferenceFaceViolations(root)).toEqual([
'packages/core/client-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.host.json" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead',
'packages/core/host-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead',
])
})
})

View File

@@ -0,0 +1,129 @@
/** 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'
}