From 044555397a87df02d010369d0d39398c18aa5384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 28 Aug 2026 09:39:42 +0200 Subject: [PATCH 1/3] Add a TypeScript 7 backend for Glint type extraction Type information now comes from one of two backends behind the same extraction algorithm: - ts6: the project's typescript 5/6 as a library plus Glint's rewriteModule (the existing pipeline, moved to lib/backend/ts6.ts). - tsgo: typescript/unstable/sync, TypeScript 7's synchronous IPC API. The project is opened with runExternalCode so the tsconfig's contentMappers transform .gts files inside the compiler; spanMap maps the template back. No Glint rewrite and no typescript library run in this process. Selected when the tsconfig declares contentMappers and a TypeScript 7 package resolves; HVE_TS_BACKEND forces either. lib/glint.ts and lib/resolver/* see a TsSyntax facade instead of the typescript module. The ts6 backend always adds ember-source/types and @glint/ember-tsc/types to the program so projects that dropped them for the content mapper keep their types. The cache key includes the backend. Both vitest lanes (pnpm test, pnpm test:tsgo) pass. On a 244-template app the backends agree on every string-literal narrowing and component substitution; tsgo resolves eight sites the TS6 program reported as any. Cowritten by Claude --- README.md | 14 + examples/tsconfig.json | 28 + lib/backend/index.ts | 124 +++ lib/backend/ts6.ts | 566 +++++++++++++ lib/backend/tsgo.ts | 563 +++++++++++++ lib/backend/types.ts | 162 ++++ lib/cache.ts | 9 +- lib/glint.ts | 1064 +++++++------------------ lib/resolver/build-maps.ts | 15 +- lib/resolver/template-source.ts | 51 +- lib/resolver/walk.ts | 33 +- package.json | 15 +- pnpm-lock.yaml | 430 +++++++++- run.ts | 2 +- test/blank.test.ts | 9 +- test/cache.test.ts | 32 +- test/glint-fixtures/tsconfig.json | 20 +- test/glint.test.ts | 8 + test/resolver/template-source.test.ts | 3 +- test/resolver/walk.test.ts | 3 +- vitest.config.ts | 10 + vitest.tsgo.config.ts | 10 + 22 files changed, 2288 insertions(+), 883 deletions(-) create mode 100644 examples/tsconfig.json create mode 100644 lib/backend/index.ts create mode 100644 lib/backend/ts6.ts create mode 100644 lib/backend/tsgo.ts create mode 100644 lib/backend/types.ts create mode 100644 vitest.config.ts create mode 100644 vitest.tsgo.config.ts diff --git a/README.md b/README.md index e7690fd..bc025fa 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,20 @@ Add the Ember/Glimmer language IDs to your project's `.vscode/settings.json`: Install `@glint/ember-tsc` in your project and the transformer extracts TypeScript type information for two patterns. Default on when installed; pass `--no-glint` (or `HVE_GLINT=0`) to opt out. +### TypeScript 7 + +Type information comes from one of two backends, chosen per `tsconfig.json`: + +- **TypeScript 5/6 + `@glint/ember-tsc`** (default). The project's `typescript` is used as a library and Glint's transform rewrites `.gts` files in-process. +- **TypeScript 7** (`typescript/unstable/sync`). Used when the tsconfig declares `contentMappers` (see [ember-content-mapper](https://github.com/NullVoxPopuli/ember-content-mapper)) and a TypeScript 7 package resolves from the project: `typescript` itself when it is 7.x, or the aliases `@typescript/native` / `typescript-7`. The compiler runs the content mapper, so no Glint rewrite and no `typescript` library run in this process. Needs Node 22.12+. + +Both produce the same results; the TypeScript 7 backend opens a project once (a few seconds for a large app) and then answers type queries in milliseconds. + +- `HVE_TS_BACKEND=tsgo` forces TypeScript 7 (Glint integration is disabled with a message when it is not resolvable); `HVE_TS_BACKEND=ts6` forces the TypeScript 5/6 pipeline. +- `HVE_TSGO=` names the TypeScript 7 package when it is installed under another alias. + +Projects on the TypeScript 5/6 backend that migrated to the content mapper can keep `"ember-source/types"` and `"@glint/ember-tsc/types"` out of `compilerOptions.types`: the backend adds them itself. + ### 1. String-literal-union narrowing in attribute positions ```ts diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..3d331fd --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": false, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "lib": [ + "ES2022", + "DOM" + ], + "types": [ + "@glint/ember-tsc/types" + ] + }, + "contentMappers": [ + { + "package": "ember-content-mapper", + "extensions": [ + ".gts", + ".gjs" + ] + } + ] +} diff --git a/lib/backend/index.ts b/lib/backend/index.ts new file mode 100644 index 0000000..e517acb --- /dev/null +++ b/lib/backend/index.ts @@ -0,0 +1,124 @@ +// Backend selection, one per tsconfig. +// +// HVE_TS_BACKEND=tsgo force TypeScript 7 (fails closed when unavailable) +// HVE_TS_BACKEND=ts6 force the TypeScript 5/6 + Glint pipeline +// (unset) tsgo when the tsconfig declares `contentMappers` +// and a TypeScript 7 package resolves, else ts6 + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import type * as TS from 'typescript'; + +import { createTs6Backend, loadTs6Deps, ts6Syntax } from './ts6.js'; +import { createTsgoBackend, loadTsgo } from './tsgo.js'; +import type { TsSyntax, TypeBackend } from './types.js'; + +export type { + CheckerLike, + OpenedFile, + PreloadProgress, + PreloadStats, + ProgramLike, + SymbolLike, + TemplateSite, + TsSyntax, + TypeBackend, + TypeLike, + VirtualRange, +} from './types.js'; + +const backendByTsconfig = new Map(); +const warned = new Set(); + +function warnOnce(key: string, message: string): void { + if (warned.has(key)) return; + warned.add(key); + process.stderr.write(`[html-validate-ember] ${message}\n`); +} + +function isDirectory(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +export function findTsconfig(start: string): string | null { + let dir = isDirectory(start) ? start : path.dirname(start); + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'tsconfig.json'); + if (fs.existsSync(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } + return null; +} + +// tsconfig.json allows comments and trailing commas, so a text match is +// used rather than JSON.parse. `extends` chains are not followed. +function declaresContentMappers(tsconfigPath: string): boolean { + try { + return /"contentMappers"\s*:/.test(fs.readFileSync(tsconfigPath, 'utf8')); + } catch { + return false; + } +} + +function selectBackend(tsconfigPath: string, filename: string): TypeBackend | null { + const forced = process.env['HVE_TS_BACKEND']; + const projectRoot = path.dirname(tsconfigPath); + if (forced === 'tsgo' || (forced !== 'ts6' && declaresContentMappers(tsconfigPath))) { + const mods = loadTsgo(projectRoot); + if (mods) { + return createTsgoBackend(mods, tsconfigPath); + } + if (forced === 'tsgo') { + warnOnce( + `tsgo:${tsconfigPath}`, + `HVE_TS_BACKEND=tsgo but no TypeScript 7 package resolves from ${projectRoot} (tried typescript, @typescript/native, typescript-7; set HVE_TSGO=). Glint integration disabled.`, + ); + return null; + } + warnOnce( + `tsgo-fallback:${tsconfigPath}`, + `${tsconfigPath} declares contentMappers but no TypeScript 7 package resolves from ${projectRoot}; using typescript 5/6 with @glint/ember-tsc instead.`, + ); + } + const deps = loadTs6Deps(filename); + if (!deps) return null; + return createTs6Backend(deps, tsconfigPath); +} + +/** The backend for the project `filename` belongs to; null without a tsconfig or a usable TypeScript. */ +export function backendFor(filename: string): TypeBackend | null { + const tsconfigPath = findTsconfig(filename); + if (!tsconfigPath) return null; + const cached = backendByTsconfig.get(tsconfigPath); + if (cached !== undefined) return cached; + const backend = selectBackend(tsconfigPath, filename); + backendByTsconfig.set(tsconfigPath, backend); + return backend; +} + +let ownSyntax: TsSyntax | null | undefined; + +// The `typescript` this package itself resolves — for syntactic walks in +// projects without a tsconfig-backed backend (classic `.hbs` addons). +function ownTs6Syntax(): TsSyntax | null { + if (ownSyntax !== undefined) return ownSyntax; + try { + const ts = createRequire(import.meta.url)('typescript') as typeof TS; + ownSyntax = typeof ts.createProgram === 'function' ? ts6Syntax(ts) : null; + } catch { + ownSyntax = null; + } + return ownSyntax; +} + +/** Syntax facade for parsing files reached from `filename`. */ +export function syntaxFor(filename: string): TsSyntax | null { + return backendFor(filename)?.syntax ?? ownTs6Syntax(); +} diff --git a/lib/backend/ts6.ts b/lib/backend/ts6.ts new file mode 100644 index 0000000..2921366 --- /dev/null +++ b/lib/backend/ts6.ts @@ -0,0 +1,566 @@ +// TypeScript 5/6 backend: the host project's `typescript` as an in-process +// library, Glint's `rewriteModule` for the `.gts` → TypeScript transform, +// and a compiler host that serves the rewritten text as virtual `.ts` +// files. Sites come from Glint's own Glimmer-AST mapping tree. + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import type * as TS from 'typescript'; + +import { isComponentTag } from '../../blank.js'; +import { readCache, writeCache } from '../cache.js'; +import type { + OpenedFile, + PreloadProgress, + PreloadStats, + TemplateSite, + TsSyntax, + TypeBackend, + TypeLike, +} from './types.js'; + +// Minimal local typing for the @glint/ember-tsc API surface we use. +// Avoids importing types from @glint/ember-tsc (an optional peerDep) +// into our shipped .d.ts, which would force downstream consumers to +// install Glint just to read our types. +interface GlintEnvironment { + // We treat as opaque — passed through to rewriteModule. +} +interface GlintConfig { + environment: GlintEnvironment; +} +interface GlintRewriteResult { + transformedContents: string; + correlatedSpans: Array<{ + glimmerAstMapping?: GlimmerAstMappingNode | undefined; + transformedStart: number; + }>; +} +interface GlimmerAstMappingNode { + sourceNode?: { + type?: string; + loc?: { start: { line: number; column: number } }; + tag?: string; + }; + parent?: { sourceNode?: { type?: string; tag?: string; loc?: { start: { line: number; column: number } } } }; + transformedRange?: { start: number; end: number }; + children?: GlimmerAstMappingNode[]; +} +export interface Ts6Deps { + ts: typeof TS; + rewriteModule( + ts: typeof TS, + script: { script: { filename: string; contents: string } }, + environment: GlintEnvironment, + ): GlintRewriteResult | null; + createDefaultConfig(ts: typeof TS, projectRoot: string): GlintConfig; +} + +// Cache deps per project root — `createRequire` resolves @glint/ember-tsc +// from the project's installed packages, not ours. This means +// `html-validate-ember` doesn't ship Glint as a runtime dep; if the +// consumer wants Glint integration, they install `@glint/ember-tsc` +// themselves. +// +// Module-level state — `depsByRoot` and the per-tsconfig backends are +// shared across calls in the same Node process. Safe under the +// single-threaded CLI / Node main-thread assumption; would need +// per-thread isolation if ever invoked concurrently from worker_threads. +const depsByRoot = new Map(); + +export function loadTs6Deps(filename: string): Ts6Deps | null { + const projectReq = createRequire(path.resolve(filename)); + let glintPath: string; + try { + glintPath = projectReq.resolve('@glint/ember-tsc'); + } catch { + return null; + } + const projectRoot = path.dirname(glintPath); + const cached = depsByRoot.get(projectRoot); + if (cached !== undefined) { + return cached; + } + let deps: Ts6Deps | null; + try { + const ts = projectReq('typescript') as typeof TS; + if (typeof ts.createProgram !== 'function') { + throw new Error(`'typescript' resolved to ${ts.version}, which has no library API`); + } + const transform = projectReq('@glint/ember-tsc/transform/index') as { + rewriteModule: Ts6Deps['rewriteModule']; + }; + const config = projectReq('@glint/ember-tsc') as { + createDefaultConfig: Ts6Deps['createDefaultConfig']; + }; + deps = { + ts, + rewriteModule: transform.rewriteModule, + createDefaultConfig: config.createDefaultConfig, + }; + } catch (err) { + process.stderr.write( + `[html-validate-ember] @glint/ember-tsc found but Glint integration is unavailable: ${ + err instanceof Error ? err.message : String(err) + }. Keep typescript 5/6 installed; TypeScript 7 has no library API.\n`, + ); + deps = null; + } + depsByRoot.set(projectRoot, deps); + return deps; +} + +export function ts6Syntax(ts: typeof TS): TsSyntax { + return { + SyntaxKind: ts.SyntaxKind, + TypeFlags: ts.TypeFlags, + SymbolFlags: ts.SymbolFlags, + ObjectFlags: ts.ObjectFlags, + parseFile: (fileName, contents, kind) => + ts.createSourceFile( + fileName, + contents, + ts.ScriptTarget.Latest, + true, + kind === 'js' ? ts.ScriptKind.JS : ts.ScriptKind.TS, + ), + forEachChild: (node, visit) => { + ts.forEachChild(node, visit); + }, + isBinaryExpression: ts.isBinaryExpression, + isCallExpression: ts.isCallExpression, + isClassDeclaration: ts.isClassDeclaration, + isClassExpression: ts.isClassExpression, + isEnumDeclaration: ts.isEnumDeclaration, + isExportDeclaration: ts.isExportDeclaration, + isGetAccessor: ts.isGetAccessor, + isIdentifier: ts.isIdentifier, + isImportDeclaration: ts.isImportDeclaration, + isInterfaceDeclaration: ts.isInterfaceDeclaration, + isNamedExports: ts.isNamedExports, + isNamedImports: ts.isNamedImports, + isObjectBindingPattern: ts.isObjectBindingPattern, + isPropertyAccessExpression: ts.isPropertyAccessExpression, + isPropertyDeclaration: ts.isPropertyDeclaration, + isPropertySignature: ts.isPropertySignature, + isQualifiedName: ts.isQualifiedName, + isReturnStatement: ts.isReturnStatement, + isSatisfiesExpression: ts.isSatisfiesExpression, + isStringLiteral: ts.isStringLiteral, + isStringLiteralLike: ts.isStringLiteralLike, + isTypeReferenceNode: ts.isTypeReferenceNode, + isVariableDeclaration: ts.isVariableDeclaration, + isVariableStatement: ts.isVariableStatement, + // The checker hands out `TS.Type` objects; `TypeLike` is their + // structural subset, so the narrowing methods are reached by + // widening back to the concrete type here. + declarations: (symbol) => (symbol as TS.Symbol).declarations ?? [], + aliasTypeArguments: (type: TypeLike) => (type as TS.Type).aliasTypeArguments, + unionMembers: (type: TypeLike) => { + const t = type as TS.Type; + return t.isUnion() ? t.types : null; + }, + stringLiteralValue: (type: TypeLike) => { + const t = type as TS.Type; + return t.isStringLiteral() ? t.value : null; + }, + }; +} + +interface ProgramContext { + parsed: TS.ParsedCommandLine; + virtualFiles: Map; + compilerHost: TS.CompilerHost; + program: TS.Program | null; + extraRootNames: string[]; + lastRootKey?: string; +} + +function locKey(line: number, column: number): string { + return `${line}:${column}`; +} + +// Sites in the order Glint's mapping tree visits them: an element's +// PathExpression before its attributes, parents before children. +function collectSites(transformed: GlintRewriteResult): TemplateSite[] { + const sites: TemplateSite[] = []; + function walk(node: GlimmerAstMappingNode | undefined, spanTransformedStart: number): void { + if (!node) return; + const sourceNode = node.sourceNode; + if ( + sourceNode?.type === 'MustacheStatement' && + node.parent?.sourceNode?.type === 'AttrNode' && + node.transformedRange && + sourceNode.loc?.start + ) { + sites.push({ + kind: 'attr-mustache', + key: locKey(sourceNode.loc.start.line, sourceNode.loc.start.column), + range: { + start: node.transformedRange.start + spanTransformedStart, + end: node.transformedRange.end + spanTransformedStart, + }, + }); + } + if ( + sourceNode?.type === 'PathExpression' && + node.parent?.sourceNode?.type === 'ElementNode' && + node.parent.sourceNode.tag && + isComponentTag(node.parent.sourceNode.tag) && + node.transformedRange && + node.parent.sourceNode.loc?.start + ) { + const elementLoc = node.parent.sourceNode.loc.start; + sites.push({ + kind: 'component', + key: locKey(elementLoc.line, elementLoc.column), + tag: node.parent.sourceNode.tag, + range: { + start: node.transformedRange.start + spanTransformedStart, + end: node.transformedRange.end + spanTransformedStart, + }, + }); + } + for (const child of node.children ?? []) { + walk(child, spanTransformedStart); + } + } + for (const span of transformed.correlatedSpans) { + if (span.glimmerAstMapping) { + walk(span.glimmerAstMapping, span.transformedStart); + } + } + return sites; +} + +export function createTs6Backend(deps: Ts6Deps, tsconfigPath: string): TypeBackend { + const { ts } = deps; + const projectRoot = path.dirname(tsconfigPath); + const tsconfigSrc = ts.readConfigFile(tsconfigPath, ts.sys.readFile).config as unknown; + const parsed = ts.parseJsonConfigFileContent(tsconfigSrc, ts.sys, projectRoot); + + // TypeScript's `types` compiler option uses legacy resolution — it doesn't + // consult package.json `exports` for path-style names like + // `@glint/ember-tsc/types`. We resolve those via Node's import resolver + // (which DOES honor exports) and add them to rootNames so the type defs + // are loaded into the program. Skip what doesn't resolve (likely a + // virtual-module type that only exists at build time). + // + // A project on the TypeScript 7 content mapper may have dropped + // `ember-source/types` and `@glint/ember-tsc/types` from `types` (the + // mapper references them itself). This program needs them regardless, + // so they are always added when they resolve. + const projectReq = createRequire(path.resolve(projectRoot, 'package.json')); + const extraRootNames: string[] = []; + const remainingTypes: string[] = []; + const declaredTypes = parsed.options.types ?? []; + const glintTypes = ['ember-source/types', '@glint/ember-tsc/types'].filter((t) => !declaredTypes.includes(t)); + for (const t of [...declaredTypes, ...glintTypes]) { + try { + let resolved = projectReq.resolve(t); + // Node resolution returns the `.js` entry. TS's `types` option needs a + // `.d.ts`. Look for the sibling .d.ts (the canonical layout in modern + // packages: `dist/index.js` + `dist/index.d.ts`). + if (resolved.endsWith('.js')) { + const sibling = resolved.slice(0, -3) + '.d.ts'; + if (fs.existsSync(sibling)) { + resolved = sibling; + } + } + if (resolved.endsWith('.d.ts') || resolved.endsWith('.ts')) { + extraRootNames.push(resolved); + continue; + } + } catch { + // not resolvable via Node — leave declared entries to TS's own lookup + if (glintTypes.includes(t)) continue; + } + remainingTypes.push(t); + } + parsed.options.types = remainingTypes; + + // virtualFiles: file path (.ts shadow OR .gts directly) → rewritten + // contents. TS may ask for either path depending on how the import was + // written in source (`from './foo'` → tries `.ts`; `from './foo.gts'` + // → asks for `.gts` directly via our `resolveModuleNameLiterals` shim). + const virtualFiles = new Map(); + + const ctx: ProgramContext = { + parsed, + virtualFiles, + // Filled in below — declare the property up front so we can reference + // `ctx` inside the host shim closures without TS complaining. + compilerHost: undefined as unknown as TS.CompilerHost, + program: null, + extraRootNames, + }; + + function rewrite(filename: string, contents: string): GlintRewriteResult | null { + const glintConfig = deps.createDefaultConfig(ts, projectRoot); + return deps.rewriteModule(ts, { script: { filename, contents } }, glintConfig.environment); + } + + // Rewrite a .gts file via Glint's rewriteModule and return the transformed + // TypeScript source. Returns null on parse failure. Result is suitable as a + // virtual .ts file for the TS program. + function rewriteGtsToShadow(gtsPath: string): string | null { + let contents: string; + try { + contents = fs.readFileSync(gtsPath, 'utf8'); + } catch { + return null; + } + try { + return rewrite(gtsPath, contents)?.transformedContents ?? null; + } catch { + return null; + } + } + + // For an arbitrary path requested by TS module resolution, return the + // corresponding `.gts` or `.gjs` source path that should be rewritten + // and served as the file's content. Three cases: + // - Path ends in `.gts` / `.gjs` — return it directly (TS asked for + // the literal extension via `resolveModuleNameLiterals`). + // - Path ends in `.ts` (and not `.d.ts`) — return sibling `.gts` or + // `.gjs` if either exists. Catches imports written without an + // extension that TS resolves through `.ts` lookups. + function gtsForRequest(reqPath: string): string | null { + if (reqPath.endsWith('.gts') || reqPath.endsWith('.gjs')) { + return fs.existsSync(reqPath) ? reqPath : null; + } + if (reqPath.endsWith('.ts') && !reqPath.endsWith('.d.ts')) { + const base = reqPath.slice(0, -3); + for (const ext of ['.gts', '.gjs']) { + const candidate = base + ext; + if (fs.existsSync(candidate)) return candidate; + } + } + return null; + } + + const compilerHost = ts.createCompilerHost(parsed.options, true); + const realReadFile = compilerHost.readFile.bind(compilerHost); + const realFileExists = compilerHost.fileExists.bind(compilerHost); + const realGetSourceFile = compilerHost.getSourceFile.bind(compilerHost); + + compilerHost.fileExists = (name: string) => { + if (virtualFiles.has(path.normalize(name))) return true; + if (realFileExists(name)) return true; + return Boolean(gtsForRequest(name)); + }; + + compilerHost.readFile = (name: string) => { + const norm = path.normalize(name); + const v = virtualFiles.get(norm); + if (v !== undefined) return v; + if (realFileExists(name)) return realReadFile(name); + const gtsPath = gtsForRequest(name); + if (gtsPath) { + const rewritten = rewriteGtsToShadow(gtsPath); + if (rewritten !== null) { + virtualFiles.set(norm, rewritten); + return rewritten; + } + } + return realReadFile(name); + }; + + compilerHost.getSourceFile = (name: string, langVersion, onError) => { + const norm = path.normalize(name); + let v = virtualFiles.get(norm); + if (v === undefined) { + const gtsPath = gtsForRequest(name); + if (gtsPath) { + const rewritten = rewriteGtsToShadow(gtsPath); + if (rewritten !== null) { + v = rewritten; + virtualFiles.set(norm, v); + } + } + } + if (typeof v === 'string') { + return ts.createSourceFile(name, v, langVersion, true, ts.ScriptKind.TS); + } + return realGetSourceFile(name, langVersion, onError); + }; + + // Module resolution shim for `import './foo.gts'` literal extensions. + // TS's standard resolution doesn't recognize `.gts` as a module-bearing + // extension; we have to intervene and tell it "yes that path resolves, + // treat it as a `.ts` source." The file's contents are then loaded via + // the `getSourceFile` shim above (which sees the `.gts` path and serves + // the rewritten content). + compilerHost.resolveModuleNameLiterals = (literals, containingFile, _redirectedReference, options) => { + return literals.map((literal) => { + const moduleName = literal.text; + if (moduleName.endsWith('.gts') || moduleName.endsWith('.gjs')) { + // Containing file may be a `.ts` shadow we created; resolving relative + // to its directory still lands on the right `.gts`/`.gjs` path because + // we lay shadows alongside originals. + const target = path.resolve(path.dirname(containingFile), moduleName); + if (fs.existsSync(target)) { + return { + resolvedModule: { + resolvedFileName: target, + extension: ts.Extension.Ts, + isExternalLibraryImport: false, + }, + }; + } + } + const r = ts.resolveModuleName(moduleName, containingFile, options, compilerHost); + return { resolvedModule: r.resolvedModule }; + }); + }; + ctx.compilerHost = compilerHost; + + function ensureProgram(): TS.Program { + // Build the rootNames set. Cheap re-walk every call. + const rootNames = [...new Set([...virtualFiles.keys(), ...parsed.fileNames, ...extraRootNames])]; + // Skip ts.createProgram when rootNames haven't changed since last call — + // big cold-run speedup paired with `preload`, which populates + // virtualFiles up-front so subsequent `open` calls don't each trigger + // an incremental rebuild. (Even with `oldProgram`, each createProgram + // costs hundreds of ms per file × N files = real time.) + const rootKey = `${rootNames.length}|${rootNames.slice().sort().join('|')}`; + if (ctx.lastRootKey === rootKey && ctx.program) { + return ctx.program; + } + ctx.program = ts.createProgram({ + rootNames, + options: parsed.options, + host: compilerHost, + oldProgram: ctx.program ?? undefined, + }); + ctx.lastRootKey = rootKey; + return ctx.program; + } + + function preload(filenames: readonly string[], onProgress?: (p: PreloadProgress) => void): PreloadStats { + let loaded = 0; + let cached = 0; + const skips: PreloadStats['skips'] = { + nonGts: [], + readError: [], + rewriteError: [], + rewriteEmpty: [], + }; + let done = 0; + const skippedTotal = (): number => + skips.nonGts.length + skips.readError.length + skips.rewriteError.length + skips.rewriteEmpty.length; + for (const filename of filenames) { + done++; + if (!filename.endsWith('.gts') && !filename.endsWith('.gjs')) { + skips.nonGts.push({ file: filename }); + onProgress?.({ done, total: filenames.length, phase: 'rewrite' }); + continue; + } + let contents: string; + try { + contents = fs.readFileSync(filename, 'utf8'); + } catch (err) { + skips.readError.push({ + file: filename, + message: err instanceof Error ? err.message : String(err), + }); + onProgress?.({ done, total: filenames.length, phase: 'rewrite' }); + continue; + } + // If a cached extraction exists for this file, skip the rewrite — + // we'll never need its rewritten contents in the program. + if (readCache(filename, contents, tsconfigPath, 'ts6')) { + cached++; + onProgress?.({ done, total: filenames.length, phase: 'rewrite' }); + continue; + } + let transformed: GlintRewriteResult | null; + try { + transformed = rewrite(filename, contents); + } catch (err) { + skips.rewriteError.push({ + file: filename, + message: err instanceof Error ? err.message : String(err), + }); + onProgress?.({ done, total: filenames.length, phase: 'rewrite' }); + continue; + } + if (!transformed) { + // Negative cache: stash an empty result so subsequent runs hit + // the cache instead of re-parsing this file as "rewrite returned + // empty" every time. Stable for this (content + tsconfig + + // plugin version) — typically a `.gts` service file with no + // `