/**
* Persistent analysis session — δ-shaped API over a `ts.LanguageService`.
*
* Maps cleanly onto LSP and Vite/HMR consumers:
*
* - `setFile` / `setFiles` — additive ingest; transform-if-Svelte, lex
* specifiers, resolve imports (parallel), push content/virtual to the LS.
* Returns ingest-time diagnostics + a `changed` flag. Cache-hit no-op
* when content matches AND the mode-specific cache key matches (resolver
* identity for lex+resolve, dependency-snapshot equality for pre-resolved).
* - `deleteFile` — drop owned entry, evict from LS.
* - `has` / `list` — owned-set introspection (covers what consumers used to
* get from their own mirror caches).
* - `query` — sync analysis pass against the current owned set; returns
* analysis-pass diagnostics only (ingest diagnostics surface via the
* `setFile`/`setFiles` returns).
* - `dispose` — release LS resources.
*
* The session owns a single `Map<id, OwnedEntry>` covering content, svelte
* virtuals, unfiltered deps, the mode-specific cache key (resolver identity
* or pre-resolved snapshot), and ingest-time diagnostics. svelte2tsx runs
* at most once per content change. Resolver work parallelizes across the
* batch in phase 2 of the three-phase setFiles pipeline; fully pre-resolved
* batches skip phase 2 (and the default-resolver construction) entirely.
*
* @see `analyze-core.ts` for the two-phase analysis orchestrator
* @see `dep-resolver.ts` for the `ImportResolver` token contract
*
* @module
*/
import {
createAnalysisLanguageService,
loadTsconfig,
type AnalysisLanguageService,
type AnalysisLanguageServiceOptions,
} from './typescript-program.js';
import type {Diagnostic} from './diagnostics.js';
import type {AnalysisLog} from './log.js';
import {transformSvelteSource, type SvelteVirtualFile} from './svelte.js';
import {
type ImportResolver,
type ResolveImport,
createDefaultResolver,
ensureLexerReady,
isNodeBuiltin,
lexImports,
normalizeResolveImport,
} from './dep-resolver.js';
import type {SourceFileInfo} from './source.js';
import {type ModuleSourceOptions, isSource, normalizeSourceOptions} from './source-config.js';
import {toPosixPath} from './paths.js';
import {MAX_RESOLVE_CONCURRENCY, map_concurrent} from './concurrency.js';
import {
analyzeCore,
normalizeDiagnosticPaths,
AnalyzeResultJson,
type OnDuplicates,
} from './analyze-core.js';
import {computeDependents} from './postprocess.js';
/**
* Options for a per-file or per-batch resolver override.
*
* Identity is required (not optional) — silently coalescing missing identities
* to a function reference would destroy cache reuse when the same logical
* resolver is wrapped in fresh closures across calls.
*/
export interface SetFileOptions {
/**
* Per-call override of the session-default resolver — a bare
* `ResolveImportFn` or a token-paired `ImportResolver` (see `ResolveImport`).
*
* A bare function is normalized with a fresh identity on each call, so the
* files touched by this call re-resolve rather than cache-hitting — the
* expected behavior for a deliberate one-off override. To reuse the resolve
* cache across calls, pass an `ImportResolver` with a stable `identity`.
*/
resolveImport?: ResolveImport;
}
/**
* Result of `setFile` (single-file ingest).
*
* `changed` is `true` when content or the mode-specific cache key (resolver
* identity for lex+resolve; dependency snapshot for pre-resolved) differed
* from the cached entry — the owned entry was rewritten. An LS push
* accompanies the entry write only when the file is TS/JS or has a
* successful Svelte virtual; CSS/JSON and transform-failed Svelte rewrite
* the entry without touching the LS. `false` indicates a cache-hit no-op:
* the cached `ingestDiagnostics` are returned but no work ran.
*/
export interface SetFileResult {
/** Whether content or the mode-specific cache key differed from the cached entry. */
changed: boolean;
/** Ingest-time diagnostics for this file (durable on the entry). */
diagnostics: Array<Diagnostic>;
}
/**
* Result of `setFiles` (batch ingest).
*
* Carries both aggregate views (`changedIds`, pre-flattened `diagnostics`)
* and a structured `perFile` map. HMR-style consumers want
* `changedIds.size > 0` as the hot check; LSP-style consumers want per-file
* diagnostic association via `perFile`. Both are populated in the same
* single-pass walk over the batch — no extra cost.
*/
export interface SetFilesResult {
/**
* IDs whose content or mode-specific cache key differed from the cached
* entry — the subset of input file IDs that actually triggered work.
* Empty when every file was a cache-hit no-op.
*/
changedIds: ReadonlySet<string>;
/**
* Pre-flattened union of every file's `ingestDiagnostics`. Consumers
* can group by `Diagnostic.file` for per-file publish.
*/
diagnostics: Array<Diagnostic>;
/**
* Per-file `SetFileResult` keyed by input file ID. Use this when the
* grouping `Diagnostic.file` would do isn't enough — e.g., LSP wanting
* to publish empty-diagnostic-list updates for files that ingested
* cleanly.
*/
perFile: ReadonlyMap<string, SetFileResult>;
}
/**
* Per-call input to `query`.
*/
export interface QueryOptions {
/** Behavior when duplicate declaration names are found across modules. */
onDuplicates?: OnDuplicates;
/** Per-call logger override (defaults to the session-level logger). */
log?: AnalysisLog;
}
/**
* Persistent analysis handle.
*
* **Concurrency**: not safe across overlapping calls. Serialize externally
* (each caller awaits the previous `setFile`/`setFiles` before starting the
* next). The LS underneath is sync, but the resolver phase awaits I/O for
* async resolvers (Vite/Rollup), so the session does cross await boundaries.
*
* **Cache-hit semantics**: per-entry, all-or-nothing. The implementation
* must not split the guarantee across separate caches (e.g. transform-cache
* hit + lex re-run). The match criterion is mode-discriminated:
*
* - lex+resolve mode: `existing.content === incoming.content` AND
* `existing.resolverIdentity === incoming.resolverIdentity`.
* - pre-resolved mode: `existing.content === incoming.content` AND
* `arraysShallowEqual(existing.preResolvedDepsSnapshot, incoming.dependencies)`.
*
* Mode flips (an entry previously ingested as lex+resolve now arrives with
* `dependencies`, or vice versa) always cache-miss.
*
* **Promise resolution**: `setFile` / `setFiles` resolve only after the
* serial LS push (phase 3) completes for every file in the batch. Awaiting
* the returned promise is sufficient — no separate flush step.
*/
export interface AnalysisSession {
/**
* Ingest one file's content into the session. Idempotent on cache hit.
*
* @returns `{changed, diagnostics}` — `changed: false` indicates a
* cache-hit no-op where the cached ingest diagnostics are returned.
*/
setFile(file: SourceFileInfo, opts?: SetFileOptions): Promise<SetFileResult>;
/**
* Ingest a batch of files. Additive — never removes; use `deleteFile` for
* removal. Cache hits are folded into the result with `changed: false`.
*/
setFiles(files: ReadonlyArray<SourceFileInfo>, opts?: SetFileOptions): Promise<SetFilesResult>;
/** Drop a file from the session and evict from the LS. */
deleteFile(id: string): Promise<void>;
/** Whether the given file ID is currently owned by the session. */
has(id: string): boolean;
/** Snapshot of currently-owned file IDs (sort order is insertion order). */
list(): ReadonlyArray<string>;
/**
* Run a two-phase analysis pass against the current owned set.
*
* @returns analyzed modules and analysis-pass diagnostics. Ingest
* diagnostics from prior `setFile`/`setFiles` calls are NOT included
* here — concat with those returns for the full picture.
* @throws Error if `onDuplicates: 'throw'` and duplicates exist
*/
query(opts?: QueryOptions): AnalyzeResultJson;
/**
* Concatenated ingest-time diagnostics across every owned entry — the
* cumulative view of every `setFile`/`setFiles` return, kept current as
* entries are added/replaced/deleted.
*
* Lets long-lived consumers (Vite plugin, LSP) publish the full ingest
* picture without tracking per-batch returns themselves. Cheap: walks
* the owned map.
*/
allIngestDiagnostics(): Array<Diagnostic>;
/**
* Release LS resources and clear the owned set. The session must not be
* used after disposal.
*/
dispose(): void;
}
/**
* Options for `createAnalysisSession`.
*
* `documentRegistry` flows through to the underlying `LanguageService` only.
* `tsconfig` and `compilerOptions` flow to both the LS *and* the lazy default
* `ImportResolver` (`getDefaultResolver` re-invokes `loadTsconfig` with them
* to produce a merged `ts.CompilerOptions` for module resolution). The two
* paths share the same merge semantics — user-supplied `compilerOptions`
* override parsed tsconfig keys, but never bypass the tsconfig.json file
* requirement.
*
* `projectRoot` and `virtualFiles` from the LS options shape are excluded —
* the session derives `projectRoot` from `sourceOptions` and manages
* svelte2tsx virtuals internally per file.
*/
export interface AnalysisSessionOptions extends Omit<
AnalysisLanguageServiceOptions,
'projectRoot' | 'virtualFiles'
> {
/**
* Module source options for path extraction and source filtering.
*
* Must be a fully-constructed `ModuleSourceOptions` — the session re-runs
* `normalizeSourceOptions` (idempotent) but does not apply any defaults.
* Pass through `createSourceOptions(projectRoot, overrides?)` to merge with
* `DEFAULT_SOURCE_OPTIONS`. (The `Partial<SourceOptionsDefaults>` ergonomic
* shape exists only on `AnalyzeFromFilesOptions.sourceOptions`, where the
* defaults merge happens inside `analyzeFromFiles`.)
*/
sourceOptions: ModuleSourceOptions;
/**
* Session-default custom import resolver used when no per-call override is
* supplied — a bare `ResolveImportFn` or a token-paired `ImportResolver`
* (see `ResolveImport`). A bare function is normalized once at construction,
* so its synthesized identity is stable for the session's lifetime (cache
* reuse works). When omitted, the session lazily constructs the TS+tsconfig
* default on first use.
*/
resolveImport?: ResolveImport;
/** Optional logger for session-level messages. */
log?: AnalysisLog;
}
/**
* Element-wise equality on two readonly string arrays. Used for the
* pre-resolved-deps cache key: a fresh array with identical contents
* cache-hits (matches Gro filer's `[...Map.keys()]`-per-call pattern), while
* any length, element, or order difference cache-misses. Order-sensitive
* because `unfilteredDeps` ordering reflects the caller's declared graph
* shape — reordering without a content change is a caller-driven intent
* signal worth honoring.
*/
const arraysShallowEqual = (a: ReadonlyArray<string>, b: ReadonlyArray<string>): boolean => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
};
/**
* Shared shape across both ingest modes. `mode` discriminates whether the
* entry's cache key is resolver-identity-based (`'lex+resolve'`) or
* dependency-snapshot-based (`'pre-resolved'`).
*/
interface OwnedEntryBase {
content: string;
virtual?: SvelteVirtualFile;
/**
* All absolute paths this file depends on (post `isSource` filter, pre
* owned-set filter). Cache strategy A — query-time filter to the current
* owned set covers transient absences correctly. Populated either from
* resolver output (lex+resolve path) or from caller-supplied
* `SourceFileInfo.dependencies` (pre-resolved path).
*/
unfilteredDeps: Array<string>;
/** Ingest-time diagnostics, durable across cache hits. */
ingestDiagnostics: Array<Diagnostic>;
/** svelte2tsx threw during ingest — query synthesizes placeholder ModuleJson. */
transformFailed?: boolean;
}
interface OwnedEntryLexResolve extends OwnedEntryBase {
mode: 'lex+resolve';
/** Identity that produced `unfilteredDeps`. Cache key for re-resolve elision. */
resolverIdentity: string | symbol;
}
interface OwnedEntryPreResolved extends OwnedEntryBase {
mode: 'pre-resolved';
/**
* Snapshot of the caller's `SourceFileInfo.dependencies` at storage time.
* Used as the cache key — element-wise (shallow) equality + content
* equality → cache hit; any length or per-element difference invalidates.
* Owning a snapshot rather than the caller's reference means mid-flight
* mutation by the caller doesn't produce false cache hits.
*/
preResolvedDepsSnapshot: ReadonlyArray<string>;
}
type OwnedEntry = OwnedEntryLexResolve | OwnedEntryPreResolved;
interface PendingIngest {
file: SourceFileInfo;
virtual: SvelteVirtualFile | undefined;
transformFailed: boolean;
ingestDiagnostics: Array<Diagnostic>;
/** Specifiers from lex; empty for cache hits, CSS/JSON, transform-failed Svelte, pre-resolved deps. */
specifiers: Array<string>;
/**
* Resolver identity in effect for this file. `undefined` when the file is
* pre-resolved (no resolver consulted, snapshot drives the cache key) or
* when the entire batch is pre-resolved (resolver never constructed).
*/
resolverIdentity: string | symbol | undefined;
/** True when an existing entry matched on content + (identity or pre-resolved snapshot). */
cacheHit: boolean;
/**
* Caller-supplied pre-resolved dependencies for this file, when present.
* Phase 3 uses these directly (filtered through `isSource`) instead of the
* resolver output, and stores a snapshot on the entry as
* `preResolvedDepsSnapshot` for the next cache check.
*/
preResolvedDeps?: ReadonlyArray<string>;
/**
* Virtual path of the *previous* successful Svelte transform, captured at
* cache-miss time. Phase 3 evicts it from the LS when the new ingest
* doesn't push a fresh virtual (transform regressed to `transform_failed`),
* so other files importing this `.svelte` don't see stale checker state.
* `undefined` when the file had no prior virtual (cold ingest, or prior
* transform also failed).
*/
previousVirtualPath: string | undefined;
}
/**
* Create a persistent analysis session.
*
* @example Vite plugin integration
* ```ts
* const session = createAnalysisSession({sourceOptions, resolveImport, log});
* await session.setFiles(initialFiles);
* const result = session.query();
* // on watcher events:
* await session.setFile({id, content});
* await session.deleteFile(removedId);
* const next = session.query();
* // on shutdown:
* session.dispose();
* ```
*
* @example One-shot via the public wrapper
* ```ts
* // Equivalent to `analyze(...)` — the wrapper goes through a session internally.
* const session = createAnalysisSession({sourceOptions});
* try {
* await session.setFiles(sourceFiles);
* return session.query({onDuplicates: 'throw'});
* } finally {
* session.dispose();
* }
* ```
*/
export const createAnalysisSession = (options: AnalysisSessionOptions): AnalysisSession => {
const sourceOptions = normalizeSourceOptions(options.sourceOptions);
const ls: AnalysisLanguageService = createAnalysisLanguageService(
{
projectRoot: sourceOptions.projectRoot,
tsconfig: options.tsconfig,
compilerOptions: options.compilerOptions,
documentRegistry: options.documentRegistry,
},
options.log,
);
const owned = new Map<string, OwnedEntry>();
// TODO: the owned-entry cache is in-memory only, so cold one-shot runs
// (`analyzeFromFiles` from the CLI, `vite build`) re-transform and re-analyze
// every file from scratch — the expensive case given svelte2tsx + the TS
// checker. A schema-versioned disk cache (cf. fuz_css `css_cache.ts`: mirror
// the source tree, key each file's extracted JSON by content hash, stamp an
// integer cache-version bumped on output-shape changes, self-heal on
// hash/version/parse mismatch, atomic temp-file+rename writes, skip on CI)
// would make those cold paths incremental across process restarts. The
// existing per-entry content-equality check is the in-memory analog; the
// cache key would need the same `(content, mode-specific key)` discipline
// the session already uses, plus the version stamp.
// Lazy default resolver — only constructed when needed: a `setFiles` batch
// has at least one file lacking `dependencies`, the call doesn't supply a
// per-call override, and no session-default resolver was configured. Fully
// pre-resolved batches skip the construction entirely (see `needsResolver`
// gating below). The TS module-resolution cache is kept on the resolver so
// consecutive resolves share state.
let lazyDefault: ImportResolver | undefined;
const getDefaultResolver = (): ImportResolver => {
if (lazyDefault) return lazyDefault;
const {compilerOptions} = loadTsconfig(
{
projectRoot: sourceOptions.projectRoot,
tsconfig: options.tsconfig,
compilerOptions: options.compilerOptions,
},
options.log,
);
lazyDefault = createDefaultResolver(compilerOptions, sourceOptions.projectRoot);
return lazyDefault;
};
// Normalize the session default once at construction. A bare function gets a
// single synthesized identity here, stable for the session's lifetime — so
// repeated ingests of byte-identical content cache-hit on identity.
const sessionDefaultResolver = normalizeResolveImport(options.resolveImport);
const pickResolver = (override?: ImportResolver): ImportResolver => {
if (override) return override;
if (sessionDefaultResolver) return sessionDefaultResolver;
return getDefaultResolver();
};
// ── Phase 1: sync per-file transform/lex ─────────────────────────────────
// `resolver` is `null` when the entire batch is pre-resolved — phase 1
// needs no resolver identity in that case, and skipping `pickResolver`
// avoids constructing the lazy default for fully pre-resolved consumers.
const phase1 = (file: SourceFileInfo, resolver: ImportResolver | null): PendingIngest => {
const existing = owned.get(file.id);
// Pre-resolved deps mode: caller asserted `file.dependencies` is the
// authoritative absolute-path list. Skip lex+resolve; Svelte still gets
// transformed because the LS needs the virtual.
const preResolvedDeps = file.dependencies;
const usePreResolved = preResolvedDeps !== undefined;
// Cache hit shape depends on mode:
// pre-resolved → match content + element-wise deps equality (a fresh
// array with the same contents still hits — Gro's
// `[...Map.keys()]` per-call pattern cache-reuses
// cleanly across persistent-session calls)
// lex+resolve → match content + same resolver identity AND prior
// entry was lex+resolve mode (mode flip invalidates)
// All-or-nothing per the spec (no half-runs).
const cacheHit =
existing !== undefined &&
existing.content === file.content &&
(usePreResolved
? existing.mode === 'pre-resolved' &&
arraysShallowEqual(existing.preResolvedDepsSnapshot, preResolvedDeps)
: existing.mode === 'lex+resolve' &&
resolver !== null &&
existing.resolverIdentity === resolver.identity);
if (cacheHit) {
return {
file,
virtual: existing.virtual,
transformFailed: existing.transformFailed === true,
ingestDiagnostics: existing.ingestDiagnostics,
specifiers: [],
// Phase 3 short-circuits on `cacheHit: true` before reading this
// field — leave it undefined to document that it's unused on
// the cache-hit path (the entry in `owned` already carries the
// authoritative resolver identity / deps snapshot).
resolverIdentity: undefined,
cacheHit: true,
previousVirtualPath: undefined,
preResolvedDeps: usePreResolved ? preResolvedDeps : undefined,
};
}
// Capture the prior virtual path before we replace the entry. If the new
// transform fails, phase 3 evicts the stale virtual from the LS.
const previousVirtualPath = existing?.virtual?.virtualPath;
const ingestDiagnostics: Array<Diagnostic> = [];
const analyzer = sourceOptions.getAnalyzerType(file.id);
// Transform if Svelte. transformSvelteSource returns ingest diagnostics
// directly (transform_failed on throw, source_map_failed on map error).
// Runs regardless of mode — the LS host needs the virtual for checker
// state, independent of how we obtain dependency edges.
let virtual: SvelteVirtualFile | undefined;
let transformFailed = false;
if (analyzer === 'svelte') {
const tres = transformSvelteSource(file);
for (const d of tres.diagnostics) ingestDiagnostics.push(d);
if (tres.virtual) {
virtual = tres.virtual;
} else {
transformFailed = true;
}
}
// Lex import specifiers — skipped when caller supplied pre-resolved deps.
// Sync — caller awaited ensureLexerReady. CSS/JSON files have nothing to
// lex; transform_failed Svelte has no virtual content to lex.
let specifiers: Array<string> = [];
if (
!usePreResolved &&
!transformFailed &&
(analyzer === 'typescript' || analyzer === 'svelte')
) {
try {
const contentToLex = virtual ? virtual.content : file.content;
specifiers = lexImports(contentToLex, file.id);
} catch (err) {
ingestDiagnostics.push({
kind: 'import_parse_failed',
file: file.id,
message: `Failed to parse imports: ${err instanceof Error ? err.message : String(err)}`,
severity: 'warning',
});
}
}
return {
file,
virtual,
transformFailed,
ingestDiagnostics,
specifiers,
resolverIdentity: resolver?.identity,
cacheHit: false,
previousVirtualPath,
preResolvedDeps: usePreResolved ? preResolvedDeps : undefined,
};
};
// ── Phase 3: serial per-file LS push + entry write ───────────────────────
const phase3 = (
pending: PendingIngest,
resolved: ReadonlyArray<string | null>,
): SetFileResult => {
if (pending.cacheHit) {
// Cached SetFileResult: same diagnostics array reference, changed: false.
// `[...]` clone keeps the caller from mutating the entry's stored array.
// Cached entry's diagnostics were normalized at the time the entry was stored.
return {changed: false, diagnostics: [...pending.ingestDiagnostics]};
}
// Normalize ingest diagnostics to project-root-relative paths before
// the entry is stored. After this point, `entry.ingestDiagnostics` is
// normalized at rest — `allIngestDiagnostics()` can read it directly
// without depending on a later in-place mutation via the aggregate.
normalizeDiagnosticPaths(pending.ingestDiagnostics, sourceOptions.projectRoot);
// Build `unfilteredDeps` from one of two sources:
// pre-resolved → caller's `file.dependencies`, posixified + filtered
// lex+resolve → resolver task outputs, already posixified, filtered
const unfilteredDeps: Array<string> = [];
if (pending.preResolvedDeps !== undefined) {
for (const raw of pending.preResolvedDeps) {
const posix = toPosixPath(raw);
if (!isSource(posix, sourceOptions)) continue;
unfilteredDeps.push(posix);
}
} else {
for (const r of resolved) {
if (r === null) continue;
if (!isSource(r, sourceOptions)) continue;
unfilteredDeps.push(r);
}
}
// Build a mode-specific entry. The discriminator (`mode`) tags which
// cache-key field to read on the next ingest: `resolverIdentity` for
// lex+resolve, `preResolvedDepsSnapshot` for pre-resolved. Snapshot
// the caller's array on the pre-resolved branch — owning a copy means
// subsequent caller-side mutation doesn't produce false cache hits.
const entry: OwnedEntry =
pending.preResolvedDeps !== undefined
? {
mode: 'pre-resolved',
content: pending.file.content,
virtual: pending.virtual,
unfilteredDeps,
preResolvedDepsSnapshot: pending.preResolvedDeps.slice(),
ingestDiagnostics: pending.ingestDiagnostics,
}
: {
mode: 'lex+resolve',
content: pending.file.content,
virtual: pending.virtual,
unfilteredDeps,
// Non-null assert: lex+resolve branch implies the batch picked a
// resolver, so phase 1 stored its identity on the pending entry.
resolverIdentity: pending.resolverIdentity!,
ingestDiagnostics: pending.ingestDiagnostics,
};
if (pending.transformFailed) entry.transformFailed = true;
owned.set(pending.file.id, entry);
// LS push — virtual path for successful Svelte transforms, real path
// for TS/JS. CSS/JSON aren't TypeScript-resolvable; skip the push.
// Transform-failed Svelte: no virtual; skip.
const analyzer = sourceOptions.getAnalyzerType(pending.file.id);
if (pending.virtual) {
ls.setFile(pending.virtual.virtualPath, pending.virtual.content);
} else if (analyzer === 'typescript') {
ls.setFile(pending.file.id, pending.file.content);
} else if (pending.previousVirtualPath) {
// Transform regressed (had virtual → now transform_failed). Evict the
// stale virtual from the LS so other files importing this `.svelte`
// don't see the prior svelte2tsx output via the checker.
ls.deleteFile(pending.previousVirtualPath);
}
return {changed: true, diagnostics: [...pending.ingestDiagnostics]};
};
// ── setFiles: orchestrate three phases ───────────────────────────────────
const setFiles = async (
files: ReadonlyArray<SourceFileInfo>,
opts?: SetFileOptions,
): Promise<SetFilesResult> => {
await ensureLexerReady();
// Resolver gating: pick one only if any file in the batch lacks
// `dependencies` (i.e., needs lex+resolve). For fully pre-resolved
// batches the resolver is never consulted, so we skip `pickResolver`
// entirely — that avoids constructing the lazy default
// (`loadTsconfig` + `createDefaultResolver`) for consumers like the
// Gro filer that always hand over pre-resolved deps.
const needsResolver = files.some((f) => f.dependencies === undefined);
// Normalize the per-call override (bare fn → fresh identity each call).
const resolver: ImportResolver | null = needsResolver
? pickResolver(normalizeResolveImport(opts?.resolveImport))
: null;
// Posixify ids at ingest — the internal contract is forward-slash
// everywhere. Skip the clone when the input is already POSIX (common
// path on Linux/macOS).
const normalizedFiles = files.map((f) => {
const posixId = toPosixPath(f.id);
return posixId === f.id ? f : {...f, id: posixId};
});
// Phase 1: sync per-file transform + lex.
const pendings: Array<PendingIngest> = [];
for (const file of normalizedFiles) {
pendings.push(phase1(file, resolver));
}
// Phase 2: parallel resolve. `Promise.resolve(sync)` adapts sync resolvers
// without per-call branching; async resolvers (Vite/Rollup) parallelize
// naturally. Each task returns its (file, idx, resolved) tuple so we can
// scatter results back into the right pending entry.
//
// Resolver throws are caught per-task and emitted as `resolver_failed`
// ingest diagnostics on the importing file. Treating throws as `null`
// (legitimately unresolvable) would silently mask buggy resolvers; the
// distinction matters for LSP-style consumers that publish failures.
interface ResolveTask {
pendingIdx: number;
specIdx: number;
specifier: string;
}
const tasks: Array<ResolveTask> = [];
for (let pi = 0; pi < pendings.length; pi++) {
const p = pendings[pi]!;
if (p.cacheHit) continue;
for (let si = 0; si < p.specifiers.length; si++) {
// Skip Node builtins — never a source file, and routing them
// through a host resolver (Vite/Rollup) provokes spurious
// "externalized for browser compatibility" warnings. The
// resolved slot stays `null` (its pre-filled default).
if (isNodeBuiltin(p.specifiers[si]!)) continue;
tasks.push({pendingIdx: pi, specIdx: si, specifier: p.specifiers[si]!});
}
}
// Resolver invariant: `tasks` non-empty implies `resolver !== null`.
// A task is only enqueued for a non-cache-hit pending with at least
// one specifier, which by phase 1's logic implies the file went
// through the lex+resolve branch, which only runs when at least one
// file in the batch lacks `dependencies` — i.e., `needsResolver` was
// `true` and a resolver was picked. Convert this unreachable-by-
// invariant into unreachable-by-throw so the `resolver!` below has
// an explicit runtime defense rather than relying on the chain.
if (tasks.length > 0 && resolver === null) {
throw new Error(
'svelte-docinfo: phase-2 invariant violated — tasks pending without a resolver',
);
}
const taskResults = await map_concurrent(tasks, MAX_RESOLVE_CONCURRENCY, async (t) => {
const pending = pendings[t.pendingIdx]!;
try {
const resolved = await resolver!.resolve(t.specifier, pending.file.id);
// Posixify resolver output — custom resolvers (Vite/Rollup,
// user-supplied) may emit native paths on Windows. The TS
// default resolver already returns POSIX, so this is a no-op
// there. Storing POSIX keeps unfilteredDeps consistent with
// owned-set keys in `query()`'s ownedIds filter.
return {
...t,
resolved: resolved === null ? null : toPosixPath(resolved),
error: undefined,
};
} catch (err) {
return {
...t,
resolved: null,
error: err instanceof Error ? err.message : String(err),
};
}
});
// Group resolved results by pending index for phase 3. Resolver errors
// land on the importing file's ingest diagnostics so per-file grouping
// (LSP publish, etc.) keeps them attached to the right source.
// Dedup on (pendingIdx, specifier) — duplicate imports of the same path
// throw N times, but the diagnostic carries no per-import-site info, so
// emitting once per specifier keeps the output non-redundant.
const resolvedByPending: Array<Array<string | null>> = pendings.map((p) =>
p.cacheHit ? [] : new Array<string | null>(p.specifiers.length).fill(null),
);
const seenFailures = new Map<number, Set<string>>();
for (const r of taskResults) {
resolvedByPending[r.pendingIdx]![r.specIdx] = r.resolved;
if (r.error === undefined) continue;
let seen = seenFailures.get(r.pendingIdx);
if (!seen) {
seen = new Set();
seenFailures.set(r.pendingIdx, seen);
}
if (seen.has(r.specifier)) continue;
seen.add(r.specifier);
const pending = pendings[r.pendingIdx]!;
pending.ingestDiagnostics.push({
kind: 'resolver_failed',
file: pending.file.id,
message: `Import resolver threw for "${r.specifier}": ${r.error}`,
severity: 'warning',
specifier: r.specifier,
});
}
// Phase 3: serial per-file LS push + entry write. Single LS mutator
// across the batch — no interleaved updates from concurrent tasks.
const perFile = new Map<string, SetFileResult>();
const changedIds = new Set<string>();
const aggregateDiagnostics: Array<Diagnostic> = [];
for (let i = 0; i < pendings.length; i++) {
const pending = pendings[i]!;
const result = phase3(pending, resolvedByPending[i]!);
perFile.set(pending.file.id, result);
if (result.changed) changedIds.add(pending.file.id);
for (const d of result.diagnostics) aggregateDiagnostics.push(d);
}
return {changedIds, diagnostics: aggregateDiagnostics, perFile};
};
const setFile = async (file: SourceFileInfo, opts?: SetFileOptions): Promise<SetFileResult> => {
const batch = await setFiles([file], opts);
return batch.perFile.get(toPosixPath(file.id))!;
};
// `deleteFile` returns `Promise<void>` for symmetry with `setFile`/`setFiles`
// (the spec calls this "async-by-convention"). The body is purely sync —
// `ls.deleteFile` and `Map.delete` are sync — so we wrap the return rather
// than declaring `async` (which would trip eslint's require-await).
const deleteFile = (id: string): Promise<void> => {
const posixId = toPosixPath(id);
const entry = owned.get(posixId);
if (!entry) return Promise.resolve();
if (entry.virtual) {
ls.deleteFile(entry.virtual.virtualPath);
} else {
ls.deleteFile(posixId);
}
owned.delete(posixId);
return Promise.resolve();
};
const has = (id: string): boolean => owned.has(toPosixPath(id));
const list = (): ReadonlyArray<string> => [...owned.keys()];
const allIngestDiagnostics = (): Array<Diagnostic> => {
const out: Array<Diagnostic> = [];
for (const entry of owned.values()) {
for (const d of entry.ingestDiagnostics) out.push(d);
}
return out;
};
const query = (opts?: QueryOptions): AnalyzeResultJson => {
// Build query inputs from owned entries. Filter unfilteredDeps to the
// current owned set per cache strategy A.
const ownedIds = new Set(owned.keys());
const sourceFiles: Array<SourceFileInfo> = [];
const svelteVirtualFiles = new Map<string, SvelteVirtualFile>();
const transformFailedIds = new Set<string>();
for (const [id, entry] of owned) {
const filteredDeps = entry.unfilteredDeps.filter((d) => ownedIds.has(d));
sourceFiles.push({id, content: entry.content, dependencies: filteredDeps});
if (entry.virtual) svelteVirtualFiles.set(id, entry.virtual);
if (entry.transformFailed) transformFailedIds.add(id);
}
// Compute bidirectional dependents from the filtered forward edges.
const filesWithDeps = computeDependents(sourceFiles);
// `getProgram()` returns the same `ts.Program` reference as the prior
// call when no version bumped, or a fresh program reusing unchanged
// ASTs via the document registry.
const program = ls.getProgram();
const result = analyzeCore({
sourceFiles: filesWithDeps,
sourceOptions,
program,
svelteVirtualFiles,
transformFailedIds,
onDuplicates: opts?.onDuplicates,
log: opts?.log ?? options.log,
});
return result;
};
const dispose = (): void => {
ls.dispose();
owned.clear();
};
return {setFile, setFiles, deleteFile, has, list, query, allIngestDiagnostics, dispose};
};
{
"path": "session.ts",
"declarations": [
{
"name": "SetFileOptions",
"kind": "interface",
"docComment": "Options for a per-file or per-batch resolver override.\n\nIdentity is required (not optional) — silently coalescing missing identities\nto a function reference would destroy cache reuse when the same logical\nresolver is wrapped in fresh closures across calls.",
"typeSignature": "SetFileOptions",
"sourceLine": 69,
"alsoExportedFrom": [
"index.ts"
],
"members": [
{
"name": "resolveImport",
"kind": "variable",
"docComment": "Per-call override of the session-default resolver — a bare\n`ResolveImportFn` or a token-paired `ImportResolver` (see `ResolveImport`).\n\nA bare function is normalized with a fresh identity on each call, so the\nfiles touched by this call re-resolve rather than cache-hitting — the\nexpected behavior for a deliberate one-off override. To reuse the resolve\ncache across calls, pass an `ImportResolver` with a stable `identity`.",
"typeSignature": "ResolveImport",
"optional": true
}
]
},
{
"name": "SetFileResult",
"kind": "interface",
"docComment": "Result of `setFile` (single-file ingest).\n\n`changed` is `true` when content or the mode-specific cache key (resolver\nidentity for lex+resolve; dependency snapshot for pre-resolved) differed\nfrom the cached entry — the owned entry was rewritten. An LS push\naccompanies the entry write only when the file is TS/JS or has a\nsuccessful Svelte virtual; CSS/JSON and transform-failed Svelte rewrite\nthe entry without touching the LS. `false` indicates a cache-hit no-op:\nthe cached `ingestDiagnostics` are returned but no work ran.",
"typeSignature": "SetFileResult",
"sourceLine": 93,
"alsoExportedFrom": [
"index.ts"
],
"members": [
{
"name": "changed",
"kind": "variable",
"docComment": "Whether content or the mode-specific cache key differed from the cached entry.",
"typeSignature": "boolean"
},
{
"name": "diagnostics",
"kind": "variable",
"docComment": "Ingest-time diagnostics for this file (durable on the entry).",
"typeSignature": "Array<Diagnostic>"
}
]
},
{
"name": "SetFilesResult",
"kind": "interface",
"docComment": "Result of `setFiles` (batch ingest).\n\nCarries both aggregate views (`changedIds`, pre-flattened `diagnostics`)\nand a structured `perFile` map. HMR-style consumers want\n`changedIds.size > 0` as the hot check; LSP-style consumers want per-file\ndiagnostic association via `perFile`. Both are populated in the same\nsingle-pass walk over the batch — no extra cost.",
"typeSignature": "SetFilesResult",
"sourceLine": 109,
"alsoExportedFrom": [
"index.ts"
],
"members": [
{
"name": "changedIds",
"kind": "variable",
"docComment": "IDs whose content or mode-specific cache key differed from the cached\nentry — the subset of input file IDs that actually triggered work.\nEmpty when every file was a cache-hit no-op.",
"typeSignature": "ReadonlySet<string>"
},
{
"name": "diagnostics",
"kind": "variable",
"docComment": "Pre-flattened union of every file's `ingestDiagnostics`. Consumers\ncan group by `Diagnostic.file` for per-file publish.",
"typeSignature": "Array<Diagnostic>"
},
{
"name": "perFile",
"kind": "variable",
"docComment": "Per-file `SetFileResult` keyed by input file ID. Use this when the\ngrouping `Diagnostic.file` would do isn't enough — e.g., LSP wanting\nto publish empty-diagnostic-list updates for files that ingested\ncleanly.",
"typeSignature": "ReadonlyMap<string, SetFileResult>"
}
]
},
{
"name": "QueryOptions",
"kind": "interface",
"docComment": "Per-call input to `query`.",
"typeSignature": "QueryOptions",
"sourceLine": 133,
"alsoExportedFrom": [
"index.ts"
],
"members": [
{
"name": "onDuplicates",
"kind": "variable",
"docComment": "Behavior when duplicate declaration names are found across modules.",
"typeSignature": "OnDuplicates",
"optional": true
},
{
"name": "log",
"kind": "variable",
"docComment": "Per-call logger override (defaults to the session-level logger).",
"typeSignature": "AnalysisLog",
"optional": true
}
]
},
{
"name": "AnalysisSession",
"kind": "interface",
"docComment": "Persistent analysis handle.\n\n**Concurrency**: not safe across overlapping calls. Serialize externally\n(each caller awaits the previous `setFile`/`setFiles` before starting the\nnext). The LS underneath is sync, but the resolver phase awaits I/O for\nasync resolvers (Vite/Rollup), so the session does cross await boundaries.\n\n**Cache-hit semantics**: per-entry, all-or-nothing. The implementation\nmust not split the guarantee across separate caches (e.g. transform-cache\nhit + lex re-run). The match criterion is mode-discriminated:\n\n- lex+resolve mode: `existing.content === incoming.content` AND\n `existing.resolverIdentity === incoming.resolverIdentity`.\n- pre-resolved mode: `existing.content === incoming.content` AND\n `arraysShallowEqual(existing.preResolvedDepsSnapshot, incoming.dependencies)`.\n\nMode flips (an entry previously ingested as lex+resolve now arrives with\n`dependencies`, or vice versa) always cache-miss.\n\n**Promise resolution**: `setFile` / `setFiles` resolve only after the\nserial LS push (phase 3) completes for every file in the batch. Awaiting\nthe returned promise is sufficient — no separate flush step.",
"typeSignature": "AnalysisSession",
"sourceLine": 164,
"alsoExportedFrom": [
"index.ts"
],
"members": [
{
"name": "setFile",
"kind": "function",
"docComment": "Ingest one file's content into the session. Idempotent on cache hit.",
"typeSignature": "(file: SourceFileInfo, opts?: SetFileOptions | undefined): Promise<SetFileResult>",
"parameters": [
{
"name": "file",
"type": "SourceFileInfo"
},
{
"name": "opts",
"type": "SetFileOptions | undefined",
"optional": true
}
],
"returnType": "Promise<SetFileResult>",
"returnDescription": "`{changed, diagnostics}` — `changed: false` indicates a\ncache-hit no-op where the cached ingest diagnostics are returned."
},
{
"name": "setFiles",
"kind": "function",
"docComment": "Ingest a batch of files. Additive — never removes; use `deleteFile` for\nremoval. Cache hits are folded into the result with `changed: false`.",
"typeSignature": "(files: readonly SourceFileInfo[], opts?: SetFileOptions | undefined): Promise<SetFilesResult>",
"parameters": [
{
"name": "files",
"type": "readonly SourceFileInfo[]"
},
{
"name": "opts",
"type": "SetFileOptions | undefined",
"optional": true
}
],
"returnType": "Promise<SetFilesResult>"
},
{
"name": "deleteFile",
"kind": "function",
"docComment": "Drop a file from the session and evict from the LS.",
"typeSignature": "(id: string): Promise<void>",
"parameters": [
{
"name": "id",
"type": "string"
}
],
"returnType": "Promise<void>"
},
{
"name": "has",
"kind": "function",
"docComment": "Whether the given file ID is currently owned by the session.",
"typeSignature": "(id: string): boolean",
"parameters": [
{
"name": "id",
"type": "string"
}
],
"returnType": "boolean"
},
{
"name": "list",
"kind": "function",
"docComment": "Snapshot of currently-owned file IDs (sort order is insertion order).",
"typeSignature": "(): readonly string[]",
"returnType": "readonly string[]"
},
{
"name": "query",
"kind": "function",
"docComment": "Run a two-phase analysis pass against the current owned set.",
"typeSignature": "(opts?: QueryOptions | undefined): { modules: { path: string; declarations: ({ kind: \"function\"; parameters: { name: string; type: string; optional: boolean; rest: boolean; description?: string | undefined; defaultValue?: string | undefined; propertyDescriptions?: Record<...> | undefined; }[]; ... 17 more ...; sourceLine?: number | undefined; } | ... 7 more ... | { ...; })[]; ... 7 more ...; moduleComment?: string | undefined; }[]; diagnostics: ({ ...; } | ... 12 more ... | { ...; })[]; }",
"throws": [
{
"type": "Error",
"description": "if `onDuplicates: 'throw'` and duplicates exist"
}
],
"parameters": [
{
"name": "opts",
"type": "QueryOptions | undefined",
"optional": true
}
],
"returnType": "{ modules: { path: string; declarations: ({ kind: \"function\"; parameters: { name: string; type: string; optional: boolean; rest: boolean; description?: string | undefined; defaultValue?: string | undefined; propertyDescriptions?: Record<...> | undefined; }[]; ... 17 more ...; sourceLine?: number | undefined; } | ......",
"returnDescription": "analyzed modules and analysis-pass diagnostics. Ingest\ndiagnostics from prior `setFile`/`setFiles` calls are NOT included\nhere — concat with those returns for the full picture."
},
{
"name": "allIngestDiagnostics",
"kind": "function",
"docComment": "Concatenated ingest-time diagnostics across every owned entry — the\ncumulative view of every `setFile`/`setFiles` return, kept current as\nentries are added/replaced/deleted.\n\nLets long-lived consumers (Vite plugin, LSP) publish the full ingest\npicture without tracking per-batch returns themselves. Cheap: walks\nthe owned map.",
"typeSignature": "(): ({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | { functionName: string; ... 5 more ...; column?: number | undefined; } | ... 11 more ... | { ...; })[]",
"returnType": "({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | { functionName: string; ... 5 more ...; column?: number | undefined; } | ... 11 more ... | { ...; })[]"
},
{
"name": "dispose",
"kind": "function",
"docComment": "Release LS resources and clear the owned set. The session must not be\nused after disposal.",
"typeSignature": "(): void",
"returnType": "void"
}
]
},
{
"name": "AnalysisSessionOptions",
"kind": "interface",
"docComment": "Options for `createAnalysisSession`.\n\n`documentRegistry` flows through to the underlying `LanguageService` only.\n`tsconfig` and `compilerOptions` flow to both the LS *and* the lazy default\n`ImportResolver` (`getDefaultResolver` re-invokes `loadTsconfig` with them\nto produce a merged `ts.CompilerOptions` for module resolution). The two\npaths share the same merge semantics — user-supplied `compilerOptions`\noverride parsed tsconfig keys, but never bypass the tsconfig.json file\nrequirement.\n\n`projectRoot` and `virtualFiles` from the LS options shape are excluded —\nthe session derives `projectRoot` from `sourceOptions` and manages\nsvelte2tsx virtuals internally per file.",
"typeSignature": "AnalysisSessionOptions",
"sourceLine": 224,
"alsoExportedFrom": [
"index.ts"
],
"extends": [
"Omit<\n\tAnalysisLanguageServiceOptions,\n\t'projectRoot' | 'virtualFiles'\n>"
],
"members": [
{
"name": "sourceOptions",
"kind": "variable",
"docComment": "Module source options for path extraction and source filtering.\n\nMust be a fully-constructed `ModuleSourceOptions` — the session re-runs\n`normalizeSourceOptions` (idempotent) but does not apply any defaults.\nPass through `createSourceOptions(projectRoot, overrides?)` to merge with\n`DEFAULT_SOURCE_OPTIONS`. (The `Partial<SourceOptionsDefaults>` ergonomic\nshape exists only on `AnalyzeFromFilesOptions.sourceOptions`, where the\ndefaults merge happens inside `analyzeFromFiles`.)",
"typeSignature": "ModuleSourceOptions"
},
{
"name": "resolveImport",
"kind": "variable",
"docComment": "Session-default custom import resolver used when no per-call override is\nsupplied — a bare `ResolveImportFn` or a token-paired `ImportResolver`\n(see `ResolveImport`). A bare function is normalized once at construction,\nso its synthesized identity is stable for the session's lifetime (cache\nreuse works). When omitted, the session lazily constructs the TS+tsconfig\ndefault on first use.",
"typeSignature": "ResolveImport",
"optional": true
},
{
"name": "log",
"kind": "variable",
"docComment": "Optional logger for session-level messages.",
"typeSignature": "AnalysisLog",
"optional": true
}
]
},
{
"name": "createAnalysisSession",
"kind": "function",
"docComment": "Create a persistent analysis session.",
"typeSignature": "(options: AnalysisSessionOptions): AnalysisSession",
"sourceLine": 373,
"examples": [
"Vite plugin integration\n```ts\nconst session = createAnalysisSession({sourceOptions, resolveImport, log});\nawait session.setFiles(initialFiles);\nconst result = session.query();\n// on watcher events:\nawait session.setFile({id, content});\nawait session.deleteFile(removedId);\nconst next = session.query();\n// on shutdown:\nsession.dispose();\n```",
"One-shot via the public wrapper\n```ts\n// Equivalent to `analyze(...)` — the wrapper goes through a session internally.\nconst session = createAnalysisSession({sourceOptions});\ntry {\nawait session.setFiles(sourceFiles);\nreturn session.query({onDuplicates: 'throw'});\n} finally {\nsession.dispose();\n}\n```"
],
"alsoExportedFrom": [
"index.ts"
],
"parameters": [
{
"name": "options",
"type": "AnalysisSessionOptions"
}
],
"returnType": "AnalysisSession"
}
],
"moduleComment": "Persistent analysis session — δ-shaped API over a `ts.LanguageService`.\n\nMaps cleanly onto LSP and Vite/HMR consumers:\n\n- `setFile` / `setFiles` — additive ingest; transform-if-Svelte, lex\n specifiers, resolve imports (parallel), push content/virtual to the LS.\n Returns ingest-time diagnostics + a `changed` flag. Cache-hit no-op\n when content matches AND the mode-specific cache key matches (resolver\n identity for lex+resolve, dependency-snapshot equality for pre-resolved).\n- `deleteFile` — drop owned entry, evict from LS.\n- `has` / `list` — owned-set introspection (covers what consumers used to\n get from their own mirror caches).\n- `query` — sync analysis pass against the current owned set; returns\n analysis-pass diagnostics only (ingest diagnostics surface via the\n `setFile`/`setFiles` returns).\n- `dispose` — release LS resources.\n\nThe session owns a single `Map<id, OwnedEntry>` covering content, svelte\nvirtuals, unfiltered deps, the mode-specific cache key (resolver identity\nor pre-resolved snapshot), and ingest-time diagnostics. svelte2tsx runs\nat most once per content change. Resolver work parallelizes across the\nbatch in phase 2 of the three-phase setFiles pipeline; fully pre-resolved\nbatches skip phase 2 (and the default-resolver construction) entirely.\n\n@see `analyze-core.ts` for the two-phase analysis orchestrator\n@see `dep-resolver.ts` for the `ImportResolver` token contract",
"dependencies": [
"analyze-core.ts",
"concurrency.ts",
"dep-resolver.ts",
"diagnostics.ts",
"log.ts",
"paths.ts",
"postprocess.ts",
"source-config.ts",
"source.ts",
"svelte.ts",
"typescript-program.ts"
],
"dependents": [
"analyze.ts",
"index.ts",
"vite.ts"
]
}