|
|
|
|
@@ -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 {
|
|
|
|
|
|