From 9f0febe070aacdb4904b2a21dae9e11421e84c36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:19:47 +0800 Subject: [PATCH] perf: typert add cache --- packages/typert/generator/src/analyzer.ts | 147 ++++++++++++++++-- .../typert/generator/src/cordis-catalog.ts | 6 +- packages/typert/generator/src/index.ts | 2 +- .../tests/cordis-catalog-contract.spec.ts | 2 +- 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 32340ade20..1f545d9673 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -69,6 +69,8 @@ export interface WorkspaceAnalyzerOptions { readonly checkDiagnostics?: boolean /** Whether missing annotations fail or are written before a clean re-analysis. */ readonly mode?: AnalysisMode + /** Shared workspace memo; supply one instance to reuse parses across analyzers. */ + readonly caches?: WorkspaceCaches } /** One package face whose public export graph contains Typert business declarations. */ @@ -78,17 +80,27 @@ export interface DiscoveredTypertPackage { readonly faces: readonly TypertFace[] } -interface ParsedConfig { +/** One parsed tsconfig, memoizable per workspace snapshot. */ +export interface ParsedConfig { + /** Absolute config path. */ readonly path: string + /** The TypeScript parse result. */ readonly parsed: ts.ParsedCommandLine } -interface PackageRegistration { +/** One package face registration discovered from an aggregate tsconfig. */ +export interface PackageRegistration { + /** The face whose aggregate references this package project. */ readonly face: TypertFace + /** The package manifest name. */ readonly name: string + /** Real package root directory. */ readonly root: string + /** The package's own parsed tsconfig. */ readonly config: ParsedConfig + /** The parsed package.json content. */ readonly manifest: Record + /** Export subpaths owned by this face for dual-face packages. */ readonly exportSubpaths?: readonly string[] } @@ -114,6 +126,90 @@ type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts. const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] } +interface FaceProgramHost { + readonly host: ts.CompilerHost + readonly files: Map +} + +/** + * Shared memo over one immutable workspace snapshot. Passing one instance to + * several analyzers (the batched and write-mode children reuse their parent's + * automatically) reuses parsed tsconfigs, the registration inventory, and + * per-face compiler hosts whose parsed and bound source files and module + * resolutions carry across programs. Callers that mutate workspace files + * between analyses must start from a fresh instance; write-mode source edits + * invalidate themselves through {@link invalidate}. + */ +export class WorkspaceCaches { + /** Parsed tsconfig files by absolute config path. */ + readonly configs = new Map() + /** Registration inventories keyed by root and aggregate config paths. */ + readonly registrations = new Map() + private readonly hosts = new Map() + + /** + * Parse one tsconfig once per workspace snapshot. + * @param path - absolute config path. + * @returns the memoized parse result. + */ + config(path: string): ParsedConfig { + let parsed = this.configs.get(path) + if (parsed === undefined) { + parsed = parseConfig(path) + this.configs.set(path, parsed) + } + return parsed + } + + /** + * Return the shared compiler host for one face. Every program of one face + * is built from the same aggregate compiler options (the first call wins), + * so parsed source files, binder state, and module resolutions are safe to + * reuse across the face's batched programs. + * @param face - the face whose programs share this host. + * @param options - the face's effective compiler options. + * @returns a compiler host with source-file and module-resolution caches. + */ + programHost(face: TypertFace, options: ts.CompilerOptions): ts.CompilerHost { + let entry = this.hosts.get(face) + if (entry === undefined) { + const host = ts.createCompilerHost(options) + const files = new Map() + const resolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + fileName => host.getCanonicalFileName(fileName), + options, + ) + const base = host.getSourceFile.bind(host) + // The snapshot contract makes shouldCreateNewSourceFile irrelevant: it + // only fires under oldProgram reuse, which these fresh programs never + // request, and invalidate() is the one supported re-read path. + host.getSourceFile = (fileName, languageVersionOrOptions, onError) => { + if (!files.has(fileName)) files.set(fileName, base(fileName, languageVersionOrOptions, onError)) + return files.get(fileName) + } + host.getModuleResolutionCache = () => resolutionCache + entry = { host, files } + this.hosts.set(face, entry) + } + return entry.host + } + + /** + * Drop cached parses of one edited source file so the next analysis reads + * the written content. + * @param file - path of the edited file. + */ + invalidate(file: string): void { + const target = realPath(file) + for (const { files } of this.hosts.values()) { + for (const key of [...files.keys()]) { + if (realPath(key) === target) files.delete(key) + } + } + } +} + /** Analyze host and client as independent TypeScript programs. */ export class WorkspaceAnalyzer { private readonly options: Required() private readonly checkedProjects = new Set() private registrations: PackageRegistration[] = [] + private readonly caches: WorkspaceCaches constructor(options: WorkspaceAnalyzerOptions) { this.options = { @@ -135,6 +232,7 @@ export class WorkspaceAnalyzer { mode: options.mode ?? 'check', ...(options.packages === undefined ? {} : { packages: options.packages }), } + this.caches = options.caches ?? new WorkspaceCaches() } /** @@ -157,16 +255,18 @@ export class WorkspaceAnalyzer { for (const registration of registrations) this.checkProject(registration) } const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig) - const aggregate = parseConfig(aggregatePath) + const aggregate = this.caches.config(aggregatePath) const rootNames = [...new Set(registrations.flatMap(registration => registration.config.parsed.fileNames))] + const options: ts.CompilerOptions = { + ...aggregate.parsed.options, + composite: false, + incremental: false, + noEmit: true, + } const program = ts.createProgram({ rootNames, - options: { - ...aggregate.parsed.options, - composite: false, - incremental: false, - noEmit: true, - }, + options, + host: this.caches.programHost(face, options), }) faces.push(new FaceAnalyzer({ root: this.options.root, @@ -185,11 +285,11 @@ export class WorkspaceAnalyzer { if (this.queuedEdit !== undefined) { this.applyEdit(this.queuedEdit) - return new WorkspaceAnalyzer({ ...this.options, mode: 'write' }).analyze() + return new WorkspaceAnalyzer({ ...this.options, caches: this.caches, mode: 'write' }).analyze() } if (this.options.mode === 'write') { - return new WorkspaceAnalyzer({ ...this.options, mode: 'check' }).analyze() + return new WorkspaceAnalyzer({ ...this.options, caches: this.caches, mode: 'check' }).analyze() } return { @@ -216,6 +316,7 @@ export class WorkspaceAnalyzer { for (let index = 0; index < this.options.packages.length; index += batchSize) { batches.push(new WorkspaceAnalyzer({ ...this.options, + caches: this.caches, packages: this.options.packages.slice(index, index + batchSize), }).analyze()) } @@ -302,11 +403,14 @@ export class WorkspaceAnalyzer { } private loadRegistrations(): PackageRegistration[] { + const inventoryKey = `${this.options.root}\0${this.options.hostConfig}\0${this.options.clientConfig}` + const cached = this.caches.registrations.get(inventoryKey) + if (cached !== undefined) return cached const registrations: PackageRegistration[] = [] for (const face of ['host', 'client'] as const) { const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig) if (!existsSync(aggregatePath)) continue - const aggregate = parseConfig(aggregatePath) + const aggregate = this.caches.config(aggregatePath) for (const reference of aggregate.parsed.projectReferences ?? []) { const configPath = projectConfigPath(reference.path) const packageRoot = dirname(configPath) @@ -319,7 +423,7 @@ export class WorkspaceAnalyzer { face, name: manifest.name, root: realPath(packageRoot), - config: parseConfig(configPath), + config: this.caches.config(configPath), manifest, } const packagePath = slash(relative(this.options.root, packageRoot)) @@ -334,9 +438,11 @@ export class WorkspaceAnalyzer { } } } - return uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`) + const inventory = uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`) .sort((left, right) => left.face.localeCompare(right.face) || left.name.localeCompare(right.name)) + this.caches.registrations.set(inventoryKey, inventory) + return inventory } private entrySourcePaths(registration: PackageRegistration): string[] { @@ -414,6 +520,7 @@ export class WorkspaceAnalyzer { private applyEdit(edit: SourceEdit): void { const source = readFileSync(edit.file, 'utf8') writeFileSync(edit.file, source.slice(0, edit.position) + edit.text + source.slice(edit.position)) + this.caches.invalidate(edit.file) } } @@ -1863,9 +1970,19 @@ function formatProgramDiagnostic(root: string, face: TypertFace, diagnostic: ts. return `typert(${face}): ${file}:${String(position.line + 1)}:${String(position.character + 1)}: TypeScript TS${String(diagnostic.code)}: ${message}` } +const realPathCache = new Map() + function realPath(path: string): string { const absolute = resolve(path) - return existsSync(absolute) ? realpathSync(absolute) : absolute + const cached = realPathCache.get(absolute) + if (cached !== undefined) return cached + // Only existing paths are memoized: a path can come into existence later, + // but an existing path's canonical form is stable for the process lifetime + // (analysis edits rewrite file contents, never the directory tree). + if (!existsSync(absolute)) return absolute + const resolved = realpathSync(absolute) + realPathCache.set(absolute, resolved) + return resolved } function isWithin(path: string, root: string): boolean { diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 89c8449b1b..1bcb1ca72a 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-typert-generator */ -import { WorkspaceAnalyzer } from './analyzer.ts' +import { WorkspaceAnalyzer, WorkspaceCaches } from './analyzer.ts' import { childTypeNodeIds } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' import type { @@ -302,10 +302,12 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli readonly projector: CordisCatalogProjector readonly model: CordisCatalogModel } { + const caches = new WorkspaceCaches() const discovery = new WorkspaceAnalyzer({ root: scanRoot, faces: ['host'], checkDiagnostics: false, + caches, }).discoverPackages() const packages = discovery.filter(candidate => candidate.faces.includes('host')) .map(candidate => candidate.package) @@ -314,6 +316,7 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli faces: ['host'], packages, checkDiagnostics: false, + caches, }).analyzeInBatches() const face = workspace.faces.find(candidate => candidate.face === 'host') if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face') @@ -321,6 +324,7 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli root: scanRoot, faces: ['host'], checkDiagnostics: false, + caches, }).indexSourceDeclarations() const projector = new CordisCatalogProjector(face, sourceDeclarations, policy) return { projector, model: projector.project() } diff --git a/packages/typert/generator/src/index.ts b/packages/typert/generator/src/index.ts index 77d85e9e87..b27b2be3af 100644 --- a/packages/typert/generator/src/index.ts +++ b/packages/typert/generator/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-typert-generator */ -export { WorkspaceAnalyzer, TypertAnalysisError } from './analyzer.ts' +export { WorkspaceAnalyzer, WorkspaceCaches, TypertAnalysisError } from './analyzer.ts' export type { AnalysisMode, DiscoveredTypertPackage, WorkspaceAnalyzerOptions } from './analyzer.ts' export { FaceModelEmitter, TypertEmitError } from './emitter.ts' export type { ModelEmitResult } from './emitter.ts' diff --git a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts index 4092ac7e63..a366037ce6 100644 --- a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts @@ -125,7 +125,7 @@ afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) }) -describe('gen-cordis-catalog collectEvents', () => { +describe('gen-cordis-catalog collectEvents', { timeout: 60_000 }, () => { it('extracts a well-formed event with its @mode and JSDoc', () => { const events = collectEvents(make( ' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',