/**
* Module-level export analysis: resolve every export of a TypeScript source
* file into a `DeclarationAnalysis`, including re-export classification.
*
* Builds on the per-declaration extractors in `typescript-extract-*.ts` by
* adding the orchestration layer — alias chain walking, namespace
* classification, JSDoc routing for re-exports, default-slot handling.
*
* @see `typescript-program.ts` for `IsExternalFile` and program construction
* @see `typescript-extract-*.ts` for the per-declaration extractors
*
* @module
*/
import ts from 'typescript';
import type {
DeclarationAnalysis,
ModuleAnalysis,
ModuleExportsAnalysis,
DeclarationJsonBuild,
} from './declaration-build.js';
import type {ReExportJsonInput, ExternalReExportJsonInput} from './types.js';
import type {Diagnostic} from './diagnostics.js';
import {parseComment, applyToDeclaration, cleanComment} from './tsdoc.js';
import {
type SourceFileInfo,
stripVirtualSuffix,
SVELTE_VIRTUAL_SUFFIX,
getComponentName,
} from './source.js';
import {
type ModuleSourceOptions,
extractDependencies,
extractPath,
isSource,
} from './source-config.js';
import {type IsExternalFile, createIsExternalFile} from './typescript-program.js';
import {inferDeclarationKind} from './typescript-extract-shared.js';
import {extractFunctionInfo, extractVariableInfo} from './typescript-extract-function.js';
import {extractTypeInfo, extractEnumInfo} from './typescript-extract-type.js';
import {extractClassInfo} from './typescript-extract-class.js';
/**
* Analyze a TypeScript file and extract module metadata.
*
* Wraps `analyzeExports` and adds dependency information via `extractDependencies`
* from the source file info if available.
*
* This is a high-level function suitable for building documentation or library metadata.
* For lower-level analysis, use `analyzeExports` directly.
*
* @param sourceFileInfo - the source file info (from file system, build pipeline, or other source)
* @param tsSourceFile - TypeScript source file from the program
* @param modulePath - the module path (relative to source root)
* @param checker - TypeScript type checker
* @param options - module source options for path extraction
* @param diagnostics - diagnostics collector for non-fatal issues
* @returns module metadata and re-export information
*/
export const analyzeTypescriptModule = (
sourceFileInfo: SourceFileInfo & {dependents?: ReadonlyArray<string>},
tsSourceFile: ts.SourceFile,
modulePath: string,
checker: ts.TypeChecker,
options: ModuleSourceOptions,
diagnostics: Array<Diagnostic>,
): ModuleAnalysis => {
// Use the mid-level helper for core analysis
const {
moduleComment,
declarations,
reExports,
starExports,
externalReExports,
externalStarExports,
} = analyzeExports(tsSourceFile, checker, options, diagnostics);
// Extract dependencies and dependents if provided
const {dependencies, dependents} = extractDependencies(sourceFileInfo, options);
return {
path: modulePath,
moduleComment,
declarations,
dependencies,
dependents,
starExports,
reExports,
externalReExports,
externalStarExports,
};
};
/**
* Walk the immediate-alias chain while names match, returning the deepest
* symbol whose name still equals `exportSymbol.name`. Used by both the
* namespace classifier and the standard alias path in `analyzeExports` to
* locate the canonical-for-this-name (which may be an upstream alias
* declaration, not the deeply-resolved root — relevant when a chain renames
* partway through).
*
* `getImmediateAliasedSymbol` asserts on non-alias symbols, so the walk also
* stops when the chain reaches a real declaration.
*/
const walkSameNameCanonical = (
exportSymbol: ts.Symbol,
immediateAlias: ts.Symbol | undefined,
checker: ts.TypeChecker,
): ts.Symbol => {
let canonical: ts.Symbol = exportSymbol;
let next: ts.Symbol | undefined = immediateAlias;
while (next && next.name === exportSymbol.name) {
canonical = next;
next =
(next.flags & ts.SymbolFlags.Alias) !== 0
? checker.getImmediateAliasedSymbol(next)
: undefined;
}
return canonical;
};
/**
* Classification of a namespace re-export — `export * as ns from './x'` and
* forwarding re-exports of such bindings.
*
* Three shapes:
* - **origination** — this file declares the namespace via `export * as ns from './x'`.
* - **same-name** — this file forwards an existing namespace by the same name
* (`export {ns} from './has-namespace'`, or N-hop chains of such specifiers
* where names match). Linked via `alsoExportedFrom`.
* - **renamed** — this file forwards a namespace under a different name
* (`export {ns as foo} from './has-namespace'`). Synthesized alias declaration
* with `aliasOf` pointing at the canonical.
*
* Star-projected namespace bindings (`export * from` a module whose export
* table contains `ns`) never reach classification — the caller's locality
* skip filters them first; `starExports` is their sole encoding like every
* other star-projected binding.
*/
type NamespaceClassification =
| {kind: 'origination'; sourceModule: string}
| {kind: 'same-name'; canonicalModule: string; sourceModule: string}
| {
kind: 'renamed';
namespaceDefiningFile: string;
sourceModule: string;
canonicalName: string;
};
/**
* Classify a namespace re-export, robust to arbitrary alias-chain depth.
*
* Detection uses the `ValueModule` flag on the deeply-resolved alias —
* `getImmediateAliasedSymbol` is fragile because intermediate hops are
* `ExportSpecifier` nodes, not `NamespaceExport`, so a chain like
* `c.ts: export {ns as foo} from './b'` → `b.ts: export {ns} from './a'` →
* `a.ts: export * as ns from './x'` defeats immediate-alias detection.
*
* Returns `null` for non-namespace re-exports (regular declarations and
* external-module re-exports), letting the caller fall through to the
* standard alias-handling path.
*/
const classifyNamespaceReExport = (
exportSymbol: ts.Symbol,
checker: ts.TypeChecker,
currentFileName: string,
options: ModuleSourceOptions,
): NamespaceClassification | null => {
const deeplyAliased = checker.getAliasedSymbol(exportSymbol);
if ((deeplyAliased.flags & ts.SymbolFlags.ValueModule) === 0) return null;
// Source module = where the deeply-resolved module symbol lives.
const sourceModuleFile = getPrimaryDeclarationFile(deeplyAliased);
if (!sourceModuleFile || !isSource(sourceModuleFile, options)) return null;
// Origination: export's first declaration is itself a NamespaceExport in
// this file. The caller's locality skip filters star-projected bindings
// before classification, but merged symbols could put a foreign
// declaration first — bail rather than misclassify.
const exportDecl = exportSymbol.declarations?.[0];
if (exportDecl && ts.isNamespaceExport(exportDecl)) {
const definingFile = stripVirtualSuffix(exportDecl.getSourceFile().fileName);
if (definingFile !== currentFileName) return null;
return {kind: 'origination', sourceModule: extractPath(sourceModuleFile, options)};
}
// Re-export specifier (`export {ns ...} from`). Use immediate-alias name
// comparison for rename detection — this matches the existing non-namespace
// rename semantics and stays correct for chains.
const immediateAlias = checker.getImmediateAliasedSymbol(exportSymbol);
if (!immediateAlias) return null;
if (exportSymbol.name !== immediateAlias.name) {
// Renamed: walk forward until we hit the canonical NamespaceExport.
// That's the namespace-defining file (where the binding originates).
let cursor: ts.Symbol | undefined = immediateAlias;
let namespaceDefiningFile: string | undefined;
let canonicalName: string | undefined;
while (cursor) {
const decl = cursor.declarations?.[0];
if (decl && ts.isNamespaceExport(decl)) {
namespaceDefiningFile = stripVirtualSuffix(decl.getSourceFile().fileName);
canonicalName = cursor.name;
break;
}
cursor =
(cursor.flags & ts.SymbolFlags.Alias) !== 0
? checker.getImmediateAliasedSymbol(cursor)
: undefined;
}
if (!namespaceDefiningFile || !canonicalName) return null;
if (!isSource(namespaceDefiningFile, options)) return null;
return {
kind: 'renamed',
namespaceDefiningFile: extractPath(namespaceDefiningFile, options),
sourceModule: extractPath(sourceModuleFile, options),
canonicalName,
};
}
// Same-name: the canonical-for-this-name may be an upstream renamed alias
// declaration, not the original NamespaceExport.
const canonical = walkSameNameCanonical(exportSymbol, immediateAlias, checker);
const canonicalDecl = canonical.declarations?.[0];
if (!canonicalDecl) return null;
const canonicalFile = stripVirtualSuffix(canonicalDecl.getSourceFile().fileName);
if (canonicalFile === currentFileName) return null;
if (!isSource(canonicalFile, options)) return null;
return {
kind: 'same-name',
canonicalModule: extractPath(canonicalFile, options),
sourceModule: extractPath(sourceModuleFile, options),
};
};
/**
* Whether any of the symbol's declarations lives in `fileName`
* (virtual-suffix-normalized).
*
* Merged symbols (module augmentation, declaration merging) can have
* declarations in several files — a symbol counts as declared in the file
* when at least one declaration is, so checking a single declaration node
* would drop locally-declared exports depending on bind order. Symbols
* without declarations are treated as declared in the file (permissive).
*/
const isDeclaredInFile = (symbol: ts.Symbol, fileName: string): boolean => {
const decls = symbol.declarations;
if (!decls?.length) return true;
return decls.some((d) => stripVirtualSuffix(d.getSourceFile().fileName) === fileName);
};
/**
* The source file of a symbol's primary declaration (`valueDeclaration`,
* else the first declaration), or `undefined` for declaration-less symbols.
*
* Resolves which file "owns" a symbol for canonical-module attribution.
* Distinct from `isDeclaredInFile`, which asks whether *any* declaration
* lives in a given file — the right question for ownership tests on
* potentially-merged symbols.
*/
const getPrimaryDeclarationSourceFile = (symbol: ts.Symbol): ts.SourceFile | undefined =>
(symbol.valueDeclaration ?? symbol.declarations?.[0])?.getSourceFile();
/**
* The virtual-suffix-normalized file name of a symbol's primary declaration,
* or `undefined` for declaration-less symbols. String form of
* `getPrimaryDeclarationSourceFile` for callers that only compare paths.
*/
const getPrimaryDeclarationFile = (symbol: ts.Symbol): string | undefined => {
const source = getPrimaryDeclarationSourceFile(symbol);
return source && stripVirtualSuffix(source.fileName);
};
/**
* The local export statement and binding node for an alias export symbol —
* `{node, statement}` where `node` is the `ExportSpecifier` (or, for
* `export * as ns`, the `NamespaceExport`) and `statement` its
* `ExportDeclaration`.
*
* Returns `undefined` when the statement isn't in `sourceFile`: merged
* symbols can put a foreign declaration first, and parsing JSDoc or
* positions there would attribute another module's content here.
*/
const getLocalExportStatement = (
exportSymbol: ts.Symbol,
sourceFile: ts.SourceFile,
): {node: ts.ExportSpecifier | ts.NamespaceExport; statement: ts.ExportDeclaration} | undefined => {
const node = exportSymbol.declarations?.[0];
if (!node) return undefined;
if (ts.isExportSpecifier(node)) {
const statement = node.parent.parent;
if (statement.getSourceFile() !== sourceFile) return undefined;
return {node, statement};
}
if (ts.isNamespaceExport(node)) {
const statement = node.parent;
if (statement.getSourceFile() !== sourceFile) return undefined;
return {node, statement};
}
return undefined;
};
/**
* Whether a local export statement/specifier pair is type-only — either
* statement-level (`export type {…} from`) or specifier-level
* (`export {type A} from`). Type-only names are erased at runtime.
*/
const isTypeOnlyLocalExport = (local: {
node: ts.ExportSpecifier | ts.NamespaceExport;
statement: ts.ExportDeclaration;
}): boolean =>
local.statement.isTypeOnly || (ts.isExportSpecifier(local.node) && local.node.isTypeOnly);
/**
* Synthesize a cross-file alias declaration for a renamed or documented
* same-name re-export.
*
* Svelte canonicals get a `kind: 'component'` placeholder — running
* `analyzeDeclaration` on svelte2tsx's `__SvelteComponent_` type alias would
* leak internal names; phase-2 `resolveComponentAliases` copies
* props/acceptsChildren/lang/etc. from the canonical (fill-gaps-only, so
* local JSDoc applied by the caller sticks). Everything else is analyzed in
* its own source file so the alias inherits `typeSignature`, `reactivity`,
* `docComment`, `parameters`, etc.
*
* `aliasOf.name` is the canonical's own symbol name — `'default'` for
* default-slot canonicals (renames into and out of the slot flow through
* uniformly), the filename-derived component name for Svelte. `sourceLine`
* is the local export specifier's line, not the canonical's location.
*/
const synthesizeCrossFileAlias = (
publicName: string,
aliasedSymbol: ts.Symbol,
originalSource: ts.SourceFile,
originalModule: string,
specifierLine: number | undefined,
checker: ts.TypeChecker,
diagnostics: Array<Diagnostic>,
isExternalFile: IsExternalFile,
): DeclarationJsonBuild => {
if (originalSource.fileName.endsWith(SVELTE_VIRTUAL_SUFFIX)) {
return {
name: publicName,
kind: 'component',
aliasOf: {module: originalModule, name: getComponentName(originalModule)},
sourceLine: specifierLine,
};
}
const {declaration: analyzed} = analyzeDeclaration(
aliasedSymbol,
originalSource,
checker,
diagnostics,
isExternalFile,
);
const canonicalName = analyzed.name!;
analyzed.name = publicName;
analyzed.aliasOf = {module: originalModule, name: canonicalName};
analyzed.sourceLine = specifierLine;
return analyzed;
};
/**
* Analyze all exports from a TypeScript source file.
*
* Extracts the module-level comment via `extractModuleComment`, star exports via
* `extractStarExports`, and all exported declarations with complete metadata.
* Handles re-exports by:
* - Same-name re-exports: tracked in `reExports` for `alsoExportedFrom` building
* - Renamed re-exports: included as new declarations with `aliasOf` metadata
* - Star exports (`export * from`): tracked in `starExports` for namespace-level info
* - Direct external re-exports: tracked in `externalReExports`/`externalStarExports`
* (specifier as written; import-then-export and source-chained forms stay silent)
*
* This is a mid-level function (above the individual `extract*` helpers, below `analyze`)
* suitable for building documentation, API explorers, or analysis tools.
* For standard SvelteKit library layouts, use `createSourceOptions(process.cwd())`.
*
* @param sourceFile - the TypeScript source file to analyze
* @param checker - the TypeScript type checker
* @param options - module source options for path extraction in re-exports
* @param diagnostics - diagnostics collector for non-fatal issues
* @returns module comment, declarations, re-exports (source + external), and star exports (source + external)
*/
export const analyzeExports = (
sourceFile: ts.SourceFile,
checker: ts.TypeChecker,
options: ModuleSourceOptions,
diagnostics: Array<Diagnostic>,
): ModuleExportsAnalysis => {
const declarations: Array<DeclarationAnalysis> = [];
const reExports: Array<ReExportJsonInput> = [];
const externalReExports: Array<ExternalReExportJsonInput> = [];
const isExternalFile = createIsExternalFile(options);
// Extract module-level comment
const moduleComment = extractModuleComment(sourceFile);
// Extract star exports (export * from './module' / 'pkg')
const {starExports, externalStarExports} = extractStarExports(sourceFile, checker, options);
// Normalize virtual paths once (e.g., Foo.svelte.__svelte2tsx__.ts → Foo.svelte)
// so re-export tracking matches real module paths
const currentFileName = stripVirtualSuffix(sourceFile.fileName);
warnModuleCommentNodocs(moduleComment, currentFileName, diagnostics);
// 1-based line of a node in this file. Virtual coordinates for Svelte
// `<script module>` sources — remapped in `analyzeSvelteModule`.
const lineOf = (node: ts.Node): number =>
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
// Get all exported symbols
const symbol = checker.getSymbolAtLocation(sourceFile);
if (symbol) {
const exports = checker.getExportsOfModule(symbol);
for (const exportSymbol of exports) {
// Check if this is an alias (potential re-export) using the Alias flag
const isAlias = (exportSymbol.flags & ts.SymbolFlags.Alias) !== 0;
if (isAlias) {
// Star-projected alias bindings: `export * from './b'` projects
// b.ts's own re-export bindings into this module's export table,
// sharing the foreign declaration node (an `ExportSpecifier` or,
// for `export * as ns`, a `NamespaceExport`). Same encoding rule
// as star-projected value symbols below — `starExports` is the
// sole encoding; processing the binding here would publish
// re-export edges for statements this module's source doesn't
// contain and read the foreign statement's JSDoc as if local
// (synthesizing duplicate declarations with mis-attributed docs).
// Runs before namespace classification so star-projected
// namespace bindings are silenced uniformly.
if (!isDeclaredInFile(exportSymbol, currentFileName)) continue;
// Namespace re-exports (`export * as ns from './x'` and re-exports
// of such bindings) need special handling: their deeply-resolved
// canonical is a module symbol, and `analyzeDeclaration` would fall
// through to `kind: 'variable'` and produce a `typeof import("/abs/path")`
// signature that leaks the publisher's filesystem path. Detection
// uses the `ValueModule` flag on the deeply-resolved alias, which
// is robust to arbitrary re-export chain depth (the immediate-alias
// shape is fragile — intermediate hops are `ExportSpecifier` nodes,
// not `NamespaceExport`).
const nsClass = classifyNamespaceReExport(exportSymbol, checker, currentFileName, options);
if (nsClass) {
// The locality skip above filters star-projected bindings before
// classification, so origination/renamed/same-name statements are
// local; `getLocalExportStatement`'s identity check stays as
// defense against merged symbols whose first declaration could be
// a foreign node.
const local = getLocalExportStatement(exportSymbol, sourceFile);
const localTsdoc = local ? parseComment(local.statement, sourceFile) : undefined;
const nsSpecifierLine = local ? lineOf(local.node) : undefined;
if (nsClass.kind === 'origination') {
const decl: DeclarationJsonBuild = {
name: exportSymbol.name,
kind: 'namespace',
module: nsClass.sourceModule,
sourceLine: nsSpecifierLine,
};
if (localTsdoc) {
applyToDeclaration(decl, localTsdoc);
}
declarations.push({declaration: decl, nodocs: !!localTsdoc?.nodocs});
} else if (nsClass.kind === 'renamed') {
const decl: DeclarationJsonBuild = {
name: exportSymbol.name,
kind: 'namespace',
module: nsClass.sourceModule,
aliasOf: {
module: nsClass.namespaceDefiningFile,
name: nsClass.canonicalName,
},
// Synthesized alias — the local export specifier's line,
// not the canonical's location
sourceLine: nsSpecifierLine,
};
if (localTsdoc) {
applyToDeclaration(decl, localTsdoc);
}
declarations.push({declaration: decl, nodocs: !!localTsdoc?.nodocs});
} else {
// Same-name re-export — link via alsoExportedFrom on the canonical.
// Position 3 (content-conditional synthesis): when the local
// statement carries JSDoc or `@nodocs`, also synthesize a
// `kind: 'namespace'` alias declaration so the local content
// has somewhere to live (mirrors non-namespace same-name semantics
// at the standard alias path). `@nodocs` suppresses both the
// alias and the link.
if (localTsdoc) {
const decl: DeclarationJsonBuild = {
name: exportSymbol.name,
kind: 'namespace',
module: nsClass.sourceModule,
aliasOf: {
module: nsClass.canonicalModule,
name: exportSymbol.name,
},
sourceLine: nsSpecifierLine,
};
applyToDeclaration(decl, localTsdoc);
declarations.push({declaration: decl, nodocs: !!localTsdoc.nodocs});
}
if (!localTsdoc?.nodocs) {
reExports.push({
name: exportSymbol.name,
module: nsClass.canonicalModule,
...(local && isTypeOnlyLocalExport(local) ? {typeOnly: true} : {}),
...(nsSpecifierLine !== undefined ? {sourceLine: nsSpecifierLine} : {}),
});
}
}
continue;
}
// This might be a re-export - use getAliasedSymbol to find the original
const aliasedSymbol = checker.getAliasedSymbol(exportSymbol);
const originalSource = getPrimaryDeclarationSourceFile(aliasedSymbol);
if (originalSource) {
const originalFileName = stripVirtualSuffix(originalSource.fileName);
// Check if this is a CROSS-FILE re-export (original in different file)
if (originalFileName !== currentFileName) {
// The local export statement, shared by the source and external
// arms. JSDoc on `/** Doc */ export {...} from './x'` lives on
// the ExportDeclaration, not on the canonical's declaration in
// the foreign file.
const local = getLocalExportStatement(exportSymbol, sourceFile);
const specifierTypeOnly = local ? isTypeOnlyLocalExport(local) : false;
const specifierLine = local ? lineOf(local.node) : undefined;
// Only track if the original is from a source module (not node_modules)
if (isSource(originalFileName, options)) {
const originalModule = extractPath(originalFileName, options);
// Use the IMMEDIATE alias (one hop) for rename detection so a
// same-name re-export of an intermediate alias is not mistaken
// for a rename relative to the deeply-resolved canonical (whose
// name may differ from this hop's name).
const immediateAlias = checker.getImmediateAliasedSymbol(exportSymbol);
const immediateName = immediateAlias?.name ?? aliasedSymbol.name;
const isRenamed = exportSymbol.name !== immediateName;
const localTsdoc = local ? parseComment(local.statement, sourceFile) : undefined;
if (isRenamed) {
// Renamed re-export (`export {foo as bar}`, `export {default as
// Foo} from './X.svelte'`) — synthesize the alias declaration.
const decl = synthesizeCrossFileAlias(
exportSymbol.name,
aliasedSymbol,
originalSource,
originalModule,
specifierLine,
checker,
diagnostics,
isExternalFile,
);
// Local JSDoc on the export statement overrides the canonical's
// (mirrors within-file branch semantics). `applyToDeclaration` only
// overwrites fields the local tsdoc actually populates, so canonical
// fields without a local override are preserved.
if (localTsdoc) {
applyToDeclaration(decl, localTsdoc);
}
declarations.push({declaration: decl, nodocs: !!localTsdoc?.nodocs});
} else {
// Same-name re-export — track for alsoExportedFrom on the
// canonical-for-this-name. The walk lands on the deepest
// same-named symbol so a same-name re-export of an intermediate
// alias points at that alias's module, not the deeply-resolved
// canonical (whose declaration uses the pre-rename name and
// wouldn't match in `mergeReExports`).
const canonical = walkSameNameCanonical(exportSymbol, immediateAlias, checker);
const canonicalSource = getPrimaryDeclarationSourceFile(canonical);
const canonicalFile = canonicalSource
? stripVirtualSuffix(canonicalSource.fileName)
: originalFileName;
// `export {default} from './x'` is a same-name re-export of the
// default slot. For Svelte components the canonical declaration
// in `analyzeSvelteModule` is named after the file, not `'default'`
// — so re-key the link by component name to match. For non-Svelte
// defaults the canonical's name is `'default'` (the actual symbol
// name); pass it through. Other same-name re-exports
// (`export {foo}`) match the canonical by name as before.
const isSvelteCanonical =
canonicalSource?.fileName.endsWith(SVELTE_VIRTUAL_SUFFIX) ?? false;
const reExportName =
exportSymbol.name === 'default' && isSvelteCanonical
? getComponentName(canonicalFile)
: exportSymbol.name;
// Position 3 (content-conditional synthesis): if the local export
// statement carries JSDoc or @nodocs, synthesize an alias declaration
// in the re-exporting module so the local content has a place to live.
// Without local content, fall through to the alsoExportedFrom link only.
if (localTsdoc) {
const decl = synthesizeCrossFileAlias(
reExportName,
aliasedSymbol,
originalSource,
originalModule,
specifierLine,
checker,
diagnostics,
isExternalFile,
);
applyToDeclaration(decl, localTsdoc);
declarations.push({declaration: decl, nodocs: !!localTsdoc.nodocs});
}
// `@nodocs` on a same-name re-export suppresses both the synthesized
// alias (filtered via nodocs flag) and the alsoExportedFrom link.
// Without `@nodocs`, the link is preserved so canonical declarations
// continue to surface every module that re-exports them.
if (
!localTsdoc?.nodocs &&
canonicalFile !== currentFileName &&
isSource(canonicalFile, options)
) {
reExports.push({
name: reExportName,
module: extractPath(canonicalFile, options),
...(specifierTypeOnly ? {typeOnly: true} : {}),
...(specifierLine !== undefined ? {sourceLine: specifierLine} : {}),
});
}
}
continue;
}
// Re-export from an external module. Direct forms
// (`export {x} from 'pkg'`, `export * as ns from 'pkg'`) are
// captured as externalReExports — but only when the statement's
// *immediate* target is itself external: chains that reach a
// package through another source module stay silent (that module
// owns the entry), as do import-then-export forms (their
// specifier lives on an import statement, and their immediate
// alias is the local import binding).
const immediateExternal = checker.getImmediateAliasedSymbol(exportSymbol);
const immediateExternalFile =
immediateExternal && getPrimaryDeclarationFile(immediateExternal);
if (!immediateExternalFile || isSource(immediateExternalFile, options)) continue;
if (!local?.statement.moduleSpecifier) continue;
if (!ts.isStringLiteral(local.statement.moduleSpecifier)) continue;
if (parseComment(local.statement, sourceFile)?.nodocs) continue;
const originalName = ts.isExportSpecifier(local.node)
? local.node.propertyName?.text
: undefined;
externalReExports.push({
name: exportSymbol.name,
specifier: local.statement.moduleSpecifier.text,
...(originalName !== undefined ? {originalName} : {}),
...(specifierTypeOnly ? {typeOnly: true} : {}),
sourceLine: specifierLine,
});
continue;
}
// Within-file alias (export { x as y }) - fall through to normal analysis
}
}
// Star-projected exports surface as the target module's own symbols —
// no Alias flag, declarations in a foreign file (`export * from './a'`
// merges a.ts's export table; there is no per-name alias node). Their
// encoding is `starExports`; analyzing them here would duplicate the
// canonical declaration into this module (triggering spurious
// duplicate_declaration diagnostics, with sourceLine pointing into
// the foreign file).
if (!isAlias && !isDeclaredInFile(exportSymbol, currentFileName)) continue;
// Normal export or within-file alias - declared in this file.
// For within-file aliases (export { x } or export { x as y }), resolve to
// the aliased symbol so that inferDeclarationKind sees the actual declaration
// node (e.g., VariableDeclaration with ArrowFunction) instead of the ExportSpecifier.
const symbolToAnalyze = isAlias ? checker.getAliasedSymbol(exportSymbol) : exportSymbol;
const analysisResult = analyzeDeclaration(
symbolToAnalyze,
sourceFile,
checker,
diagnostics,
isExternalFile,
);
const {declaration} = analysisResult;
let {nodocs} = analysisResult;
// Preserve the export name for within-file renames (export { x as y }).
// Renaming TO `default` (`export {x as default}`) lands in the default
// slot — `exportSymbol.name === 'default'` and the assignment carries
// it through. The default slot is just another name in the export
// object; no special-casing needed.
if (isAlias && declaration.name !== exportSymbol.name) {
declaration.name = exportSymbol.name;
}
// For within-file aliases, check the export statement for JSDoc.
// The aliased symbol's declaration (e.g., svelte2tsx-generated const) may lack JSDoc,
// but the export statement (e.g., /** Doc */ export { greet }) may have it.
if (isAlias) {
const local = getLocalExportStatement(exportSymbol, sourceFile);
const exportTsdoc = local ? parseComment(local.statement, sourceFile) : undefined;
if (exportTsdoc) {
applyToDeclaration(declaration, exportTsdoc);
if (exportTsdoc.nodocs) {
nodocs = true;
}
}
}
// Include all declarations with nodocs flag - consumer decides filtering policy
declarations.push({declaration, nodocs});
}
}
return {
moduleComment,
declarations,
reExports,
starExports,
externalReExports,
externalStarExports,
};
};
/**
* Analyze a TypeScript symbol and extract rich metadata.
*
* This is a high-level function that combines TSDoc parsing with TypeScript
* type analysis to produce complete declaration metadata. Suitable for use
* in documentation generators, IDE integrations, and other tooling.
*
* @param symbol - the TypeScript symbol to analyze
* @param sourceFile - the source file containing the symbol
* @param checker - the TypeScript type checker
* @param diagnostics - diagnostics collector for non-fatal issues
* @param isExternalFile - predicate for determining whether a source file is external to the project
* @returns complete declaration metadata including docs, types, and parameters, plus nodocs flag
*/
export const analyzeDeclaration = (
symbol: ts.Symbol,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker,
diagnostics: Array<Diagnostic>,
isExternalFile: IsExternalFile,
): DeclarationAnalysis => {
const declNode = symbol.valueDeclaration || symbol.declarations?.[0];
// Pass the symbol's name through verbatim. Default-slot symbols
// (`export default ...`, `export {x as default}`) carry `symbol.name === 'default'`
// — that's the actual export-object key in JS (`ns.default`,
// `import {default as X}`), not a sentinel for "no name." Consumers that need
// to render `import X from 'mod'` form branch on `name === 'default'`.
const name = symbol.name;
// Determine kind (fallback to 'variable' if no declaration node)
const kind = declNode ? inferDeclarationKind(symbol, declNode) : 'variable';
const result: DeclarationJsonBuild = {
name,
kind,
};
if (!declNode) {
return {declaration: result, nodocs: false};
}
// Extract TSDoc — `parseComment` filters `@module` blocks (handled by
// `extractModuleComment`), so a first declaration under a module comment
// keeps its own JSDoc
const tsdoc = parseComment(declNode, sourceFile);
const nodocs = tsdoc?.nodocs ?? false;
applyToDeclaration(result, tsdoc);
// Extract source line
const start = declNode.getStart(sourceFile);
const startPos = sourceFile.getLineAndCharacterOfPosition(start);
result.sourceLine = startPos.line + 1;
// Extract type-specific info
if (result.kind === 'function') {
extractFunctionInfo(declNode, symbol, checker, result, tsdoc, diagnostics);
} else if (result.kind === 'type' || result.kind === 'interface') {
extractTypeInfo(declNode, checker, result, diagnostics, isExternalFile);
} else if (result.kind === 'enum') {
extractEnumInfo(declNode, checker, result, diagnostics);
} else if (result.kind === 'class') {
extractClassInfo(declNode, checker, result, diagnostics);
} else if (result.kind === 'variable') {
extractVariableInfo(declNode, symbol, checker, result, diagnostics);
}
return {declaration: result, nodocs};
};
/**
* Extract module-level comment.
*
* @internal Used by `analyzeTypescriptModule` and `analyzeSvelteModule`'s
* `<script module>` handling. Exposed via the `svelte-docinfo/typescript-exports.js`
* subpath so the Svelte analyzer can reuse it without circular imports, but
* **not part of the stable barrel export**.
*
* Requires `@module` tag to identify module comments. The tag line is stripped
* from the output. Supports optional module renaming: `@module custom-name`.
*
* @returns cleaned module comment text (with `@module` line removed), or `undefined` if no `@module` comment found
* @see {@link https://typedoc.org/documents/Tags._module.html|TypeDoc @module documentation}
*/
export const extractModuleComment = (sourceFile: ts.SourceFile): string | undefined => {
const fullText = sourceFile.getFullText();
// Collect all JSDoc comments in the file
const allComments: Array<{pos: number; end: number}> = [];
// Check for comments at the start of the file (before any statements)
const leadingComments = ts.getLeadingCommentRanges(fullText, 0);
if (leadingComments?.length) {
allComments.push(...leadingComments);
}
// Check for comments before each statement
for (const statement of sourceFile.statements) {
const comments = ts.getLeadingCommentRanges(fullText, statement.getFullStart());
if (comments?.length) {
allComments.push(...comments);
}
}
// Find the first comment with `@module` tag
for (const comment of allComments) {
const commentText = fullText.substring(comment.pos, comment.end);
if (!commentText.trimStart().startsWith('/**')) continue;
// Clean the comment first, then check for tag at start of line
const cleaned = cleanComment(commentText);
if (!cleaned) continue;
// Check for `@module` as a proper tag (at start of line, not mentioned in prose)
if (/(?:^|\n)@module\b/.test(cleaned)) {
const stripped = stripModuleTag(cleaned);
return stripped || undefined;
}
}
return undefined;
};
/**
* Warn when a module comment carries `@nodocs`.
*
* The tag has no module-level meaning — it applies to declarations and export
* statements — so its presence in a `@module` comment is always author
* confusion: it does nothing except remain verbatim in `moduleComment` text.
* Same line-start detection as `extractModuleComment`'s `@module` test, so a
* backticked or mid-prose mention doesn't trigger.
*
* @internal Shared by `analyzeExports` (TS files and Svelte `<script module>`
* virtuals) and `analyzeSvelteModule` (instance-script and HTML module
* comments, which `analyzeExports` never sees).
*/
export const warnModuleCommentNodocs = (
moduleComment: string | undefined,
file: string,
diagnostics: Array<Diagnostic>,
): void => {
if (!moduleComment || !/(?:^|\n)@nodocs\b/.test(moduleComment)) return;
diagnostics.push({
kind: 'misplaced_tag',
file,
message:
'@nodocs in a module comment has no effect — it applies to declarations and export statements; to omit a module from analysis, use exclude patterns',
severity: 'warning',
tagName: 'nodocs',
});
};
/**
* Strip `@module` tag line from comment text.
*
* Handles formats:
* - `@module` (standalone)
* - `@module module-name` (with rename)
*/
const stripModuleTag = (text: string): string => {
// Remove lines that START with `@module` (not mentioned in prose)
const lines = text.split('\n');
const filtered = lines.filter((line) => !/^\s*@module\b/.test(line));
return filtered.join('\n').trim();
};
/**
* Extract star exports (`export * from './module'` / `'pkg'`) from a source file.
*
* Uses the type checker to resolve module specifiers: source modules land in
* `starExports` (as `sourceRoot`-relative paths), external modules in
* `externalStarExports` (specifier as written). Unresolvable specifiers
* (missing package, typo) are silently skipped.
*
* Statement-level `@nodocs` suppresses the entry — the same rule as the other
* re-export encodings (same-name edges and renamed aliases).
*/
const extractStarExports = (
sourceFile: ts.SourceFile,
checker: ts.TypeChecker,
options: ModuleSourceOptions,
): {starExports: Array<string>; externalStarExports: Array<string>} => {
const starExports: Array<string> = [];
const externalStarExports: Array<string> = [];
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
!statement.exportClause && // No exportClause means `export *`
statement.moduleSpecifier &&
ts.isStringLiteral(statement.moduleSpecifier)
) {
if (parseComment(statement, sourceFile)?.nodocs) continue;
// Use the type checker to resolve the module - it has already resolved all imports
// during program creation, so this leverages TypeScript's full module resolution
const moduleSymbol = checker.getSymbolAtLocation(statement.moduleSpecifier);
// Virtual paths for Svelte files are normalized by getPrimaryDeclarationFile
const resolvedPath = moduleSymbol && getPrimaryDeclarationFile(moduleSymbol);
if (resolvedPath) {
if (isSource(resolvedPath, options)) {
starExports.push(extractPath(resolvedPath, options));
} else {
// External package — record the specifier as written
externalStarExports.push(statement.moduleSpecifier.text);
}
}
// If the module couldn't be resolved (missing package, typo), skip it
}
}
return {starExports, externalStarExports};
};
{
"path": "typescript-exports.ts",
"declarations": [
{
"name": "analyzeTypescriptModule",
"kind": "function",
"docComment": "Analyze a TypeScript file and extract module metadata.\n\nWraps `analyzeExports` and adds dependency information via `extractDependencies`\nfrom the source file info if available.\n\nThis is a high-level function suitable for building documentation or library metadata.\nFor lower-level analysis, use `analyzeExports` directly.",
"typeSignature": "(sourceFileInfo: SourceFileInfo & { dependents?: readonly string[] | undefined; }, tsSourceFile: SourceFile, modulePath: string, checker: TypeChecker, options: ModuleSourceOptions, diagnostics: ({ ...; } | ... 12 more ... | { ...; })[]): ModuleAnalysis",
"sourceLine": 61,
"parameters": [
{
"name": "sourceFileInfo",
"type": "SourceFileInfo & { dependents?: readonly string[] | undefined; }",
"description": "the source file info (from file system, build pipeline, or other source)"
},
{
"name": "tsSourceFile",
"type": "SourceFile",
"description": "TypeScript source file from the program"
},
{
"name": "modulePath",
"type": "string",
"description": "the module path (relative to source root)"
},
{
"name": "checker",
"type": "TypeChecker",
"description": "TypeScript type checker"
},
{
"name": "options",
"type": "ModuleSourceOptions",
"description": "module source options for path extraction"
},
{
"name": "diagnostics",
"type": "({ 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 ... | { ...; })[]",
"description": "diagnostics collector for non-fatal issues"
}
],
"returnType": "ModuleAnalysis",
"returnDescription": "module metadata and re-export information"
},
{
"name": "analyzeExports",
"kind": "function",
"docComment": "Analyze all exports from a TypeScript source file.\n\nExtracts the module-level comment via `extractModuleComment`, star exports via\n`extractStarExports`, and all exported declarations with complete metadata.\nHandles re-exports by:\n- Same-name re-exports: tracked in `reExports` for `alsoExportedFrom` building\n- Renamed re-exports: included as new declarations with `aliasOf` metadata\n- Star exports (`export * from`): tracked in `starExports` for namespace-level info\n- Direct external re-exports: tracked in `externalReExports`/`externalStarExports`\n (specifier as written; import-then-export and source-chained forms stay silent)\n\nThis is a mid-level function (above the individual `extract*` helpers, below `analyze`)\nsuitable for building documentation, API explorers, or analysis tools.\nFor standard SvelteKit library layouts, use `createSourceOptions(process.cwd())`.",
"typeSignature": "(sourceFile: SourceFile, checker: TypeChecker, options: ModuleSourceOptions, diagnostics: ({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | ... 12 more ... | { ...; })[]): ModuleExportsAnalysis",
"sourceLine": 386,
"parameters": [
{
"name": "sourceFile",
"type": "SourceFile",
"description": "the TypeScript source file to analyze"
},
{
"name": "checker",
"type": "TypeChecker",
"description": "the TypeScript type checker"
},
{
"name": "options",
"type": "ModuleSourceOptions",
"description": "module source options for path extraction in re-exports"
},
{
"name": "diagnostics",
"type": "({ 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 ... | { ...; })[]",
"description": "diagnostics collector for non-fatal issues"
}
],
"returnType": "ModuleExportsAnalysis",
"returnDescription": "module comment, declarations, re-exports (source + external), and star exports (source + external)"
},
{
"name": "analyzeDeclaration",
"kind": "function",
"docComment": "Analyze a TypeScript symbol and extract rich metadata.\n\nThis is a high-level function that combines TSDoc parsing with TypeScript\ntype analysis to produce complete declaration metadata. Suitable for use\nin documentation generators, IDE integrations, and other tooling.",
"typeSignature": "(symbol: Symbol, sourceFile: SourceFile, checker: TypeChecker, diagnostics: ({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | ... 12 more ... | { ...; })[], isExternalFile: IsExternalFile): DeclarationAnalysis",
"sourceLine": 739,
"parameters": [
{
"name": "symbol",
"type": "Symbol",
"description": "the TypeScript symbol to analyze"
},
{
"name": "sourceFile",
"type": "SourceFile",
"description": "the source file containing the symbol"
},
{
"name": "checker",
"type": "TypeChecker",
"description": "the TypeScript type checker"
},
{
"name": "diagnostics",
"type": "({ 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 ... | { ...; })[]",
"description": "diagnostics collector for non-fatal issues"
},
{
"name": "isExternalFile",
"type": "IsExternalFile",
"description": "predicate for determining whether a source file is external to the project"
}
],
"returnType": "DeclarationAnalysis",
"returnDescription": "complete declaration metadata including docs, types, and parameters, plus nodocs flag"
},
{
"name": "extractModuleComment",
"kind": "function",
"docComment": "Extract module-level comment.",
"typeSignature": "(sourceFile: SourceFile): string | undefined",
"sourceLine": 808,
"seeAlso": [
"{@link https://typedoc.org/documents/Tags._module.html|TypeDoc @module documentation}"
],
"parameters": [
{
"name": "sourceFile",
"type": "SourceFile"
}
],
"returnType": "string | undefined",
"returnDescription": "cleaned module comment text (with `@module` line removed), or `undefined` if no `@module` comment found"
},
{
"name": "warnModuleCommentNodocs",
"kind": "function",
"docComment": "Warn when a module comment carries `@nodocs`.\n\nThe tag has no module-level meaning — it applies to declarations and export\nstatements — so its presence in a `@module` comment is always author\nconfusion: it does nothing except remain verbatim in `moduleComment` text.\nSame line-start detection as `extractModuleComment`'s `@module` test, so a\nbackticked or mid-prose mention doesn't trigger.",
"typeSignature": "(moduleComment: string | undefined, file: string, diagnostics: ({ symbolName: string; file: string; message: string; severity: \"error\" | \"warning\"; kind: \"type_extraction_failed\"; line?: number | undefined; column?: number | undefined; } | ... 12 more ... | { ...; })[]): void",
"sourceLine": 860,
"parameters": [
{
"name": "moduleComment",
"type": "string | undefined"
},
{
"name": "file",
"type": "string"
},
{
"name": "diagnostics",
"type": "({ 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": "void"
}
],
"moduleComment": "Module-level export analysis: resolve every export of a TypeScript source\nfile into a `DeclarationAnalysis`, including re-export classification.\n\nBuilds on the per-declaration extractors in `typescript-extract-*.ts` by\nadding the orchestration layer — alias chain walking, namespace\nclassification, JSDoc routing for re-exports, default-slot handling.\n\n@see `typescript-program.ts` for `IsExternalFile` and program construction\n@see `typescript-extract-*.ts` for the per-declaration extractors",
"dependencies": [
"declaration-build.ts",
"diagnostics.ts",
"source-config.ts",
"source.ts",
"tsdoc.ts",
"types.ts",
"typescript-extract-class.ts",
"typescript-extract-function.ts",
"typescript-extract-shared.ts",
"typescript-extract-type.ts",
"typescript-program.ts"
],
"dependents": [
"analyze-core.ts",
"svelte.ts"
]
}