refactor: derive event graphs and scope invariants from TypeScript
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user