refactor: derive event graphs and scope invariants from TypeScript

This commit is contained in:
imccyu
2026-07-14 23:37:56 +08:00
parent 174619929d
commit c67c3d9413
20 changed files with 938 additions and 248 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* Shared TypeScript Program construction for repository gates that need real
* cross-file symbols and types instead of isolated syntax trees.
*/
import { relative, resolve } from 'node:path'
import ts from 'typescript'
interface ProjectGraph {
rootNames: string[]
options: ts.CompilerOptions
}
const configHost: ts.ParseConfigFileHost = {
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
readDirectory: ts.sys.readDirectory,
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
getCurrentDirectory: ts.sys.getCurrentDirectory,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
}
/** Parse a root tsconfig and flatten all referenced projects into one semantic graph. */
function loadProjectGraph(projectRoot: string): ProjectGraph {
const rootConfigPath = resolve(projectRoot, 'tsconfig.json')
const rootConfig = parseConfig(rootConfigPath)
const rootNames = new Set<string>()
const visited = new Set<string>()
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
if (visited.has(configPath)) return
visited.add(configPath)
for (const fileName of parsed.fileNames) rootNames.add(fileName)
for (const reference of parsed.projectReferences ?? []) {
const referencePath = ts.resolveProjectReferencePath(reference)
collect(referencePath, parseConfig(referencePath))
}
}
collect(rootConfigPath, rootConfig)
return {
rootNames: [...rootNames],
options: rootConfig.options,
}
}
/** Parse one config file and fail loud on any config diagnostic. */
function parseConfig(configPath: string): ts.ParsedCommandLine {
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
if (parsed.errors.length > 0) {
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
}
return parsed
}
/** Disable emit-only options after loading the root solution config. */
function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
return {
...options,
noEmit: true,
composite: false,
declaration: false,
declarationMap: false,
sourceMap: false,
incremental: false,
}
}
/** A repository-scoped TypeScript Program and its shared TypeChecker. */
export class TypeScriptProject {
/** The bound cross-file TypeScript program. */
readonly program: ts.Program
/** The checker shared by every semantic query in this project. */
readonly checker: ts.TypeChecker
constructor(private readonly projectRoot: string) {
const graph = loadProjectGraph(projectRoot)
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
this.checker = this.program.getTypeChecker()
}
/**
* Return every source file loaded into the flattened root project graph.
* @returns program source files, including libraries and external dependencies.
*/
sourceFiles(): readonly ts.SourceFile[] {
return this.program.getSourceFiles()
}
/**
* Render a loaded source file relative to the project root.
* @param sourceFile - a source file from this project.
* @returns a slash-separated repository-relative path.
*/
relativePath(sourceFile: ts.SourceFile): string {
return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
}
/**
* Return one program source file by repository-relative path.
* @param relativePath - path relative to the project root.
* @returns the source file bound into this project.
* @throws if a requested root or imported source was not loaded.
*/
sourceFile(relativePath: string): ts.SourceFile {
const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
return sourceFile
}
}