perf: typert add cache
This commit is contained in:
4 files changed
+139
-18
No files matched your search
@@ -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<string, unknown>
|
||||
/** 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<string, ts.SourceFile | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ParsedConfig>()
|
||||
/** Registration inventories keyed by root and aggregate config paths. */
|
||||
readonly registrations = new Map<string, PackageRegistration[]>()
|
||||
private readonly hosts = new Map<TypertFace, FaceProgramHost>()
|
||||
|
||||
/**
|
||||
* 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<string, ts.SourceFile | undefined>()
|
||||
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<Pick<
|
||||
@@ -124,6 +220,7 @@ export class WorkspaceAnalyzer {
|
||||
private readonly crossFaceLinks = new Map<string, CrossFaceLink>()
|
||||
private readonly checkedProjects = new Set<string>()
|
||||
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<string, string>()
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user