/* AI prompt for writing Effect public API JSDoc: This package owns Effect public API JSDoc parsing, extraction, and model generation. */ import * as Effect from "effect/Effect" import { globSync } from "glob" import * as crypto from "node:crypto" import * as fs from "node:fs" import * as path from "node:path" import * as ts from "typescript" type ExportBucket = "value" | "type" type DocScope = "module" | "declaration" | "namespace" | "namespace-declaration" | "member" type Result = | { readonly _tag: "Success"; readonly value: A } | { readonly _tag: "Failure"; readonly error: E } interface SourceFileWithParseDiagnostics extends ts.SourceFile { readonly parseDiagnostics: ReadonlyArray } /** * Result type returned by the JSDoc parser helpers. * * @category utility types * @since 4.0.0 */ export type JSDocResult = Result /** * Diagnostic emitted when a JSDoc block does not follow the standard shape. * * @category models * @since 4.0.0 */ export interface JSDocDiagnostic { readonly code: string readonly message: string } /** * Parse error containing all diagnostics collected for a JSDoc block or file. * * @category models * @since 4.0.0 */ export interface JSDocParseError { readonly diagnostics: ReadonlyArray } /** * Parsed `@see` tag text and any inline links it contains. * * @category models * @since 4.0.0 */ export interface ParsedSeeTag { readonly text: string readonly links: ReadonlyArray } /** * TypeScript symbol target resolved for a parsed inline JSDoc link. * * @category models * @since 4.0.0 */ export interface ParsedInlineLinkSymbol { readonly file: string readonly range: readonly [number, number] } /** * Parsed inline JSDoc link target and optional display text. * * @category models * @since 4.0.0 */ export interface ParsedInlineLink { readonly raw: string readonly target: string readonly text: string | null readonly range?: readonly [number, number] readonly symbol?: ParsedInlineLinkSymbol } /** * Parsed JSDoc description sections. * * @category models * @since 4.0.0 */ export interface ParsedDescription { readonly short: string readonly whenToUse: string | null readonly details: string | null readonly gotchas: string | null } /** * Parsed example section from a JSDoc block. * * @category models * @since 4.0.0 */ export interface ParsedExample { readonly title: string readonly body: string | null readonly code: string } /** * Raw module-level JSDoc block collected from the top of a checked file. * * @category models * @since 4.0.0 */ export interface ParsedModuleJSDoc { readonly raw: string readonly range: readonly [number, number] } interface ParsedModuleTags { readonly since: string readonly deprecated: string | null readonly see: ReadonlyArray } /** * Parsed tags required for a root public declaration. * * @category models * @since 4.0.0 */ export interface ParsedDeclarationTags { readonly category: string readonly since: string readonly deprecated: string | null readonly see: ReadonlyArray } /** * Parsed tags allowed for namespace JSDoc. * * @category models * @since 4.0.0 */ export interface ParsedNamespaceTags { readonly category: string | null readonly since: string readonly deprecated: string | null readonly see: ReadonlyArray } /** * Parsed tags allowed for documented members. * * @category models * @since 4.0.0 */ export interface ParsedMemberTags { readonly since: string | null readonly default: string | null readonly deprecated: string | null readonly see: ReadonlyArray } /** * Parsed documented member within a declaration or another member. * * @category models * @since 4.0.0 */ export interface ParsedMember { readonly name: string readonly signature: string | null readonly range: readonly [number, number] readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: ParsedMemberTags readonly members: ReadonlyArray } /** * Parsed root declaration exported from a checked file. * * @category models * @since 4.0.0 */ export interface ParsedRootDeclaration { readonly name: string readonly bucket: ExportBucket readonly signature: string | null readonly range: readonly [number, number] readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: ParsedDeclarationTags readonly members: ReadonlyArray } /** * Parsed type declaration exported from inside a namespace. * * @category models * @since 4.0.0 */ export interface ParsedNamespaceDeclaration { readonly name: string readonly signature: string | null readonly range: readonly [number, number] readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: ParsedNamespaceTags readonly members: ReadonlyArray } /** * Parsed namespace and its documented exported type declarations. * * @category models * @since 4.0.0 */ export interface ParsedNamespace { readonly name: string readonly signature: string | null readonly range: readonly [number, number] readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: ParsedNamespaceTags readonly declarations: ReadonlyArray readonly namespaces: ReadonlyArray } /** * Parsed public JSDoc data collected from one checked file. * * @category models * @since 4.0.0 */ export interface ParsedJSDocFile { readonly moduleJSDoc?: ParsedModuleJSDoc readonly declarations: ReadonlyArray readonly namespaces: ReadonlyArray } /** * Barrel import metadata for a public module included in the JSDoc dump. * * @category models * @since 4.0.0 */ export type ParsedJSDocBarrelImport = | { readonly type: "namespace" readonly module: string readonly name: string } | { readonly type: "flat" readonly module: string } /** * Import specifiers for a public module included in the JSDoc dump. * * @category models * @since 4.0.0 */ export interface ParsedJSDocImports { readonly module: string readonly barrel: ParsedJSDocBarrelImport | null readonly flatNames: ReadonlyArray } export type JSDocApiKind = "root-declaration" | "namespace" | "namespace-declaration" | "member" export type JSDocApiImportGuidance = | { readonly style: "namespace-barrel" readonly importDeclaration: string readonly usage: string } | { readonly style: "named-barrel" readonly importDeclaration: string readonly usage: string } | { readonly style: "named-module" readonly importDeclaration: string readonly usage: string } export type JSDocApiSeeLinkResolution = | { readonly _tag: "Resolved" readonly apiId: string readonly apiFqn: string } | { readonly _tag: "Unresolved" readonly reason: "not-found" | "ambiguous" readonly candidates: ReadonlyArray } export interface JSDocApiSeeLink extends ParsedInlineLink { readonly resolution: JSDocApiSeeLinkResolution } export interface JSDocApiSeeTag { readonly text: string readonly links: ReadonlyArray } export interface JSDocApiTags { readonly category: string | null readonly since: string | null readonly deprecated: string | null readonly default: string | null } export interface JSDocApi { readonly id: string readonly kind: JSDocApiKind readonly moduleName: string readonly apiName: string readonly localName: string readonly signature: string | null readonly parentApiName: string | null readonly apiFqn: string readonly hasFqnCollision: boolean readonly bucket: ExportBucket | null readonly importable: boolean readonly namespacePath: ReadonlyArray readonly memberPath: ReadonlyArray readonly source: { readonly file: string readonly path: ReadonlyArray readonly range: readonly [number, number] } readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: JSDocApiTags readonly see: ReadonlyArray readonly importGuidance: JSDocApiImportGuidance | null } interface ParsedBarrelExports { readonly namespace: ReadonlyMap readonly flat: ReadonlySet readonly named: ReadonlyMap> } /** * Parsed public JSDoc data paired with the normalized source file path. * * @category models * @since 4.0.0 */ export interface ParsedJSDocFileDumpEntry extends ParsedJSDocFile { readonly file: string readonly imports: ParsedJSDocImports } interface AstNode { readonly type: string readonly range: [number, number] readonly [key: string]: any } interface JSDocTag { readonly name: string value: string readonly line: number } interface JSDocBlock { readonly range: [number, number] readonly raw: string readonly lines: Array readonly tags: Array readonly parsed?: ParsedCoreJSDoc readonly diagnostics: Array readonly internal: boolean } interface ParsedCoreJSDoc { readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: ReadonlyArray } interface ProgramCacheEntry { readonly program?: ts.Program readonly error?: string reported: boolean } interface PackageMetadata { readonly root: string readonly sourceRoot: string readonly name: string readonly exports: unknown } interface PackageExportMatch { readonly key: string readonly value: unknown readonly star: string | null readonly specificity: number readonly prefixLength: number } interface ParsedConfigResult { readonly parsed?: ts.ParsedCommandLine readonly error?: string } const programCache = new Map() const packageMetadataCache = new Map>() const barrelExportCache = new Map>() const tagOrder = new Map([ ["deprecated", 0], ["default", 1], ["see", 2], ["category", 3], ["since", 4] ]) const signatureTypeFormatFlags = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType const stableSemverRegex = /^\d+\.\d+\.\d+$/ const urlRegex = /^https?:\/\// function diagnostic(code: string, message: string): JSDocDiagnostic { return { code, message } } function normalizePathName(filePath: string): string { return filePath.replaceAll(path.sep, "/") } function normalizePackagePath(filePath: string): string { return normalizePathName(filePath).replace(/^\.\//, "./") } function escapeRegExp(value: string): string { return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&") } function globToRegExp(glob: string): RegExp { let source = "^" for (let index = 0; index < glob.length; index++) { const char = glob[index] const next = glob[index + 1] if (char === "*") { if (next === "*") { source += ".*" index++ } else { source += "[^/]*" } } else if (char === "?") { source += "[^/]" } else { source += escapeRegExp(char) } } return new RegExp(`${source}$`) } /** * Creates a predicate that checks whether a filename is included by the configured JSDoc globs. * * @category constructors * @since 4.0.0 */ export function createJSDocFileMatcher(options: { readonly cwd: string readonly include?: ReadonlyArray readonly exclude?: ReadonlyArray }): (filename: string) => boolean { const include = options.include?.map(globToRegExp) const exclude = options.exclude?.map(globToRegExp) return (filename) => { const normalizedFilename = normalizePathName(filename) const relativeFilename = normalizePathName(path.relative(options.cwd, filename)) const matches = (regexp: RegExp) => regexp.test(normalizedFilename) || regexp.test(relativeFilename) if (include !== undefined && !include.some(matches)) { return false } if (exclude !== undefined && exclude.some(matches)) { return false } return true } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } function findPackageRoot(filename: string): string | undefined { let directory = path.dirname(path.resolve(filename)) while (true) { const packageJsonPath = path.join(directory, "package.json") if (fs.existsSync(packageJsonPath)) { return directory } const parent = path.dirname(directory) if (parent === directory) { return undefined } directory = parent } } function readPackageMetadata(root: string): Result { const normalizedRoot = path.resolve(root) const cached = packageMetadataCache.get(normalizedRoot) if (cached !== undefined) { return cached } const packageJsonPath = path.join(normalizedRoot, "package.json") let parsed: unknown try { parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) } catch (error) { const result = { _tag: "Failure", error: `unable to read package.json at ${packageJsonPath}: ${String(error)}` } as const packageMetadataCache.set(normalizedRoot, result) return result } if (!isRecord(parsed)) { const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must be an object` } as const packageMetadataCache.set(normalizedRoot, result) return result } if (typeof parsed.name !== "string" || parsed.name.length === 0) { const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must include a non-empty name` } as const packageMetadataCache.set(normalizedRoot, result) return result } if (parsed.exports === undefined) { const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must include exports` } as const packageMetadataCache.set(normalizedRoot, result) return result } const result = { _tag: "Success", value: { root: normalizedRoot, sourceRoot: path.join(normalizedRoot, "src"), name: parsed.name, exports: parsed.exports } } as const packageMetadataCache.set(normalizedRoot, result) return result } function parseBarrelExports(indexPath: string): Result { const normalizedIndexPath = path.resolve(indexPath) const cached = barrelExportCache.get(normalizedIndexPath) if (cached !== undefined) { return cached } let source: string try { source = fs.readFileSync(normalizedIndexPath, "utf8") } catch (error) { const result = { _tag: "Failure", error: `unable to read barrel file ${normalizedIndexPath}: ${String(error)}` } as const barrelExportCache.set(normalizedIndexPath, result) return result } const sourceFile = ts.createSourceFile(normalizedIndexPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) const namespace = new Map() const flat = new Set() const named = new Map>() for (const statement of sourceFile.statements) { if ( ts.isExportDeclaration(statement) && statement.moduleSpecifier !== undefined && ts.isStringLiteralLike(statement.moduleSpecifier) ) { const moduleSpecifier = normalizePathName(statement.moduleSpecifier.text) if (statement.exportClause === undefined) { flat.add(moduleSpecifier) } else if (ts.isNamespaceExport(statement.exportClause)) { namespace.set(moduleSpecifier, statement.exportClause.name.text) } else if (ts.isNamedExports(statement.exportClause)) { const names = named.get(moduleSpecifier) ?? new Set() for (const specifier of statement.exportClause.elements) { names.add(specifier.name.text) } named.set(moduleSpecifier, names) } } } const result = { _tag: "Success", value: { namespace, flat, named } } as const barrelExportCache.set(normalizedIndexPath, result) return result } function getPackageExportMatch(exports: unknown, subpath: string): PackageExportMatch | undefined { if (!isRecord(exports)) { return subpath === "." ? { key: ".", value: exports, star: null, specificity: 1, prefixLength: 1 } : undefined } if (Object.hasOwn(exports, subpath)) { return { key: subpath, value: exports[subpath], star: null, specificity: Number.MAX_SAFE_INTEGER, prefixLength: subpath.length } } const matches: Array = [] for (const [key, value] of Object.entries(exports)) { const starIndex = key.indexOf("*") if (!key.startsWith(".") || starIndex === -1) { continue } const prefix = key.slice(0, starIndex) const suffix = key.slice(starIndex + 1) if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) { continue } matches.push({ key, value, star: subpath.slice(prefix.length, subpath.length - suffix.length), specificity: prefix.length + suffix.length, prefixLength: prefix.length }) } return matches.sort((a, b) => b.specificity - a.specificity || b.prefixLength - a.prefixLength || b.key.length - a.key.length )[0] } function packageExportTargetMatches(value: unknown, star: string | null, expectedTarget: string): boolean { if (typeof value === "string") { const target = normalizePackagePath(star === null ? value : value.replaceAll("*", star)) return target === expectedTarget } if (value === null || value === undefined) { return false } if (Array.isArray(value)) { return value.some((item) => packageExportTargetMatches(item, star, expectedTarget)) } if (isRecord(value)) { return Object.values(value).some((item) => packageExportTargetMatches(item, star, expectedTarget)) } return false } function isPackageSubpathExported( packageExports: unknown, subpath: string, expectedTarget: string ): boolean { const match = getPackageExportMatch(packageExports, subpath) return match !== undefined && packageExportTargetMatches(match.value, match.star, expectedTarget) } function isPathInside(parent: string, child: string): boolean { const relative = path.relative(parent, child) return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) } function packageSpecifier(packageName: string, subpath: string): string { return subpath === "" ? packageName : `${packageName}/${subpath}` } function resolveBarrelImport( metadata: PackageMetadata, filename: string ): Result<{ readonly barrel: ParsedJSDocBarrelImport | null; readonly flatNames: ReadonlyArray }, string> { let directory = path.dirname(filename) while (isPathInside(metadata.sourceRoot, directory)) { const indexPath = path.join(directory, "index.ts") if (indexPath !== filename && fs.existsSync(indexPath)) { const expectedExport = `./${normalizePathName(path.relative(directory, filename))}` const parsed = parseBarrelExports(indexPath) if (parsed._tag === "Failure") { return parsed } const barrelSubpath = normalizePathName(path.relative(metadata.sourceRoot, directory)) const exportSubpath = barrelSubpath === "" ? "." : `./${barrelSubpath}` const exportTarget = barrelSubpath === "" ? "./src/index.ts" : `./src/${barrelSubpath}/index.ts` if (isPackageSubpathExported(metadata.exports, exportSubpath, exportTarget)) { const moduleSpecifier = packageSpecifier(metadata.name, barrelSubpath) const flatNames = Array.from(parsed.value.named.get(expectedExport) ?? []) const namespace = parsed.value.namespace.get(expectedExport) if (namespace !== undefined) { return { _tag: "Success", value: { barrel: { type: "namespace", module: moduleSpecifier, name: namespace }, flatNames } } } if (parsed.value.flat.has(expectedExport)) { return { _tag: "Success", value: { barrel: { type: "flat", module: moduleSpecifier }, flatNames } } } if (flatNames.length > 0) { return { _tag: "Success", value: { barrel: { type: "flat", module: moduleSpecifier }, flatNames } } } } } if (directory === metadata.sourceRoot) { break } directory = path.dirname(directory) } return { _tag: "Success", value: { barrel: null, flatNames: [] } } } function resolveJSDocImports( cwd: string, filename: string ): Result { const absoluteFilename = path.resolve(filename) const packageRoot = findPackageRoot(absoluteFilename) const relativeFilename = normalizePathName(path.relative(cwd, absoluteFilename)) if (packageRoot === undefined) { return { _tag: "Failure", error: `no package.json found for ${relativeFilename}` } } const metadata = readPackageMetadata(packageRoot) if (metadata._tag === "Failure") { return metadata } const relativeToSourceRoot = normalizePathName(path.relative(metadata.value.sourceRoot, absoluteFilename)) if (!isPathInside(metadata.value.sourceRoot, absoluteFilename)) { return { _tag: "Failure", error: `${relativeFilename} is not under ${normalizePathName(path.relative(cwd, metadata.value.sourceRoot))}` } } if (path.basename(absoluteFilename) === "index.ts") { return { _tag: "Failure", error: `${relativeFilename} is a barrel file; exclude it from jsdocs` } } if (path.extname(absoluteFilename) !== ".ts" || absoluteFilename.endsWith(".d.ts")) { return { _tag: "Failure", error: `${relativeFilename} is not a TypeScript source module` } } const moduleSubpath = relativeToSourceRoot.slice(0, -".ts".length) const moduleExportSubpath = `./${moduleSubpath}` const moduleExportTarget = `./src/${relativeToSourceRoot}` if (!isPackageSubpathExported(metadata.value.exports, moduleExportSubpath, moduleExportTarget)) { return { _tag: "Failure", error: `package.json exports do not expose ${ packageSpecifier(metadata.value.name, moduleSubpath) } for ${relativeFilename}` } } const barrel = resolveBarrelImport(metadata.value, absoluteFilename) if (barrel._tag === "Failure") { return barrel } return { _tag: "Success", value: { module: packageSpecifier(metadata.value.name, moduleSubpath), barrel: barrel.value.barrel, flatNames: barrel.value.flatNames } } } export function getSourceText(context: { readonly sourceCode: { readonly text?: string; getText(node?: unknown): string } }): string { return context.sourceCode.text ?? context.sourceCode.getText() } export function getCwd(context: { readonly cwd?: string; getCwd?: () => string }): string { return context.cwd ?? context.getCwd?.() ?? process.cwd() } function skipWhitespace(source: string, end: number): number { while (end > 0 && /\s/.test(source[end - 1])) { end-- } return end } function isSkippableDirectiveComment(line: string): boolean { const trimmed = line.trim() return trimmed.startsWith("// @ts-expect-error") || trimmed.startsWith("// @ts-ignore") || trimmed.startsWith("// @effect-diagnostics") || trimmed.startsWith("// eslint-disable-next-line") || trimmed.startsWith("// oxlint-disable-next-line") } function skipDirectiveComments(source: string, end: number): number { while (end > 0) { const lineStart = source.lastIndexOf("\n", end - 1) + 1 if (!isSkippableDirectiveComment(source.slice(lineStart, end))) { return end } end = skipWhitespace(source, lineStart) } return end } export function findLeadingJSDoc( source: string, node: AstNode, ignoredRange?: [number, number] ): JSDocBlock | undefined { const end = skipDirectiveComments(source, skipWhitespace(source, node.range[0])) if (!source.slice(0, end).endsWith("*/")) { return undefined } const start = source.lastIndexOf("/*", end) if (start === -1 || source[start + 2] !== "*") { return undefined } const range: [number, number] = [start, end] if (ignoredRange !== undefined && range[0] === ignoredRange[0] && range[1] === ignoredRange[1]) { return undefined } return parseJSDocBlock(source.slice(start, end), range) } /** * Parses one raw JSDoc block into the standard description, example, and tag structure. * * **Details** * * The block must have one short description paragraph, optional `When to use`, `Details`, and `Gotchas` sections in that order, then any example sections before tags. Example titles must be unique after trimming and lowercasing, each example must contain exactly one non-empty TypeScript code fence, and boundaries between sections, examples, and tags must use exactly one blank line. * * **Example** (Parsing a block) * * ```ts * const rawBlock = [ * "/" + "**", * " * A value.", * " *", * " * @category models", * " * @since 4.0.0", * " *" + "/" * ].join("\n") * const result = parseJSDoc(rawBlock) * ``` * * @category parsing * @since 4.0.0 */ export function parseJSDoc(raw: string): Result { const block = parseJSDocBlock(raw, [0, raw.length]) if (block.internal) { return { _tag: "Failure", error: { diagnostics: [diagnostic("internal", "Internal JSDoc blocks are ignored")] } } } if (block.diagnostics.length > 0 || block.parsed === undefined) { return { _tag: "Failure", error: { diagnostics: block.diagnostics } } } return { _tag: "Success", value: block.parsed } } function parseLooseJSDocBlock(raw: string, range: [number, number]): JSDocBlock { const body = raw.replace(/^\/\*\*/, "").replace(/\*\/$/, "") const rawLines = body.split(/\r\n|\r|\n/) const lines = rawLines.map((line) => line.replace(/^\s*\* ?/, "").trimEnd()) const normalized = trimStructuralBlankLines(lines) const tags = parseTags(normalized) const internal = tags.some((tag) => tag.name === "internal") return { range, raw, lines: normalized, tags, diagnostics: [], internal } } function parseJSDocBlock(raw: string, range: [number, number]): JSDocBlock { const base = parseLooseJSDocBlock(raw, range) const { lines: normalized, tags, internal } = base if (internal) { return base } const result = parseCoreJSDoc(normalized, tags) const block: JSDocBlock = { ...base, range, raw, lines: normalized, tags, diagnostics: result._tag === "Failure" ? Array.from(result.error.diagnostics) : [], internal } return result._tag === "Success" ? { ...block, parsed: result.value } : block } function trimStructuralBlankLines(lines: Array): Array { let start = 0 let end = lines.length if (lines[start]?.trim() === "") { start++ } if (end > start && lines[end - 1]?.trim() === "") { end-- } return lines.slice(start, end) } function parseTags(lines: Array): Array { const tags: Array = [] let current: JSDocTag | undefined let inFence = false for (let index = 0; index < lines.length; index++) { const trimmed = lines[index].trim() if (trimmed.startsWith("```")) { inFence = !inFence current = undefined continue } if (inFence) { continue } const match = /^@([A-Za-z][\w-]*)(?:\s+(.*))?$/.exec(trimmed) if (match) { current = { name: match[1], value: match[2]?.trim() ?? "", line: index } tags.push(current) continue } if (current !== undefined && trimmed !== "") { current.value = current.value === "" ? trimmed : `${current.value}\n${trimmed}` } else if (trimmed === "") { current = undefined } } return tags } function parseCoreJSDoc(lines: Array, tags: Array): Result { const diagnostics: Array = [] const firstTagLine = tags[0]?.line ?? lines.length const content = lines.slice(0, firstTagLine) if (lines.length === 0) { diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description")) } if (content.at(-1)?.trim() === "" && tags.length > 0) { // exactly one blank before tags is validated by ensuring the last content line is blank and the previous is not blank if (content.length < 2 || content[content.length - 2]?.trim() === "") { diagnostics.push( diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line") ) } } else if (tags.length > 0 && content.length > 0) { diagnostics.push( diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line") ) } if (content[0]?.trim() === "") { diagnostics.push(diagnostic("leading-blank", "JSDoc must not start with a blank line")) } const contentWithoutTagBlank = tags.length > 0 && content.at(-1)?.trim() === "" ? content.slice(0, -1) : content if (contentWithoutTagBlank.at(-1)?.trim() === "") { diagnostics.push(diagnostic("trailing-blank", "JSDoc description must not end with a blank line")) } const parsedContent = parseDescriptionContent(contentWithoutTagBlank) diagnostics.push(...parsedContent.diagnostics) if (diagnostics.length > 0 || parsedContent.value === undefined) { return { _tag: "Failure", error: { diagnostics: uniqueDiagnostics(diagnostics) } } } return { _tag: "Success", value: { description: parsedContent.value.description, examples: parsedContent.value.examples, tags } } } function uniqueDiagnostics( diagnostics: ReadonlyArray ): ReadonlyArray { const seen = new Set() return diagnostics.filter((item) => { const key = `${item.code}:${item.message}` if (seen.has(key)) { return false } seen.add(key) return true }) } const standardHeadings = ["**When to use**", "**Details**", "**Gotchas**"] as const type StandardHeading = typeof standardHeadings[number] const whenToUsePrefixes = ["Use to", "Use when", "Use as", "Use with"] as const interface ParsedContentResult { readonly value?: { readonly description: ParsedDescription readonly examples: ReadonlyArray } readonly diagnostics: ReadonlyArray } function parseDescriptionContent(lines: Array): ParsedContentResult { const diagnostics: Array = [] const sections: Record = { "**When to use**": null, "**Details**": null, "**Gotchas**": null } const examples: Array = [] const exampleTitles = new Set() let index = 0 let currentSectionOrder = -1 let examplesStarted = false while (index < lines.length && lines[index].trim() !== "" && !isHeadingLine(lines[index])) { if (isForbiddenMarkdownHeading(lines[index], false)) { diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions")) } index++ } const shortLines = lines.slice(0, index) if (shortLines.length === 0 || joinBody(shortLines).trim() === "") { diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description")) } if (index < lines.length && lines[index].trim() === "") { const next = lines[index + 1] if (next !== undefined && !isHeadingLine(next)) { diagnostics.push(diagnostic("multiple-description-paragraphs", "JSDoc short description must be one paragraph")) } } while (index < lines.length) { if (lines[index].trim() !== "") { diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line")) break } if (lines[index + 1]?.trim() === "") { diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line")) while (lines[index + 1]?.trim() === "") index++ } index++ if (index >= lines.length) { break } const line = lines[index].trim() if (line.startsWith("**Example**")) { examplesStarted = true const parsed = parseExample(lines, index) diagnostics.push(...parsed.diagnostics) if (parsed.example !== undefined) { const key = parsed.example.title.trim().toLowerCase() if (exampleTitles.has(key)) { diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${parsed.example.title.trim()}`)) } exampleTitles.add(key) examples.push(parsed.example) } index = parsed.nextIndex continue } const heading = standardHeadings.find((candidate) => candidate === line) if (heading !== undefined) { if (examplesStarted) { diagnostics.push(diagnostic("section-after-example", `${heading} must appear before examples`)) } const order = standardHeadings.indexOf(heading) if (order <= currentSectionOrder) { diagnostics.push(diagnostic("section-out-of-order", `${heading} is out of order or duplicated`)) } currentSectionOrder = Math.max(currentSectionOrder, order) if (sections[heading] !== null) { diagnostics.push(diagnostic("duplicate-section", `${heading} may appear at most once`)) } const parsed = parseSection(lines, index) diagnostics.push(...parsed.diagnostics) sections[heading] = parsed.body index = parsed.nextIndex continue } if (isNearMissHeading(line)) { diagnostics.push(diagnostic("invalid-heading", `Invalid JSDoc section heading: ${line}`)) index++ continue } if (isBoldOnlyLine(line)) { diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${line}`)) index++ continue } if (isForbiddenMarkdownHeading(line, false)) { diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions")) index++ continue } diagnostics.push( diagnostic("invalid-description", "JSDoc description content must appear under a standard section heading") ) index++ } const whenToUse = sections["**When to use**"] if (whenToUse !== null && !hasWhenToUsePrefix(whenToUse)) { diagnostics.push( diagnostic( "when-to-use-format", `**When to use** must start with one of: ${whenToUsePrefixes.map((prefix) => `\`${prefix}\``).join(", ")}` ) ) } if (diagnostics.length > 0) { return { diagnostics } } return { value: { description: { short: joinBody(shortLines), whenToUse: sections["**When to use**"], details: sections["**Details**"], gotchas: sections["**Gotchas**"] }, examples }, diagnostics } } function hasWhenToUsePrefix(text: string): boolean { const trimmed = text.trimStart() return whenToUsePrefixes.some((prefix) => trimmed === prefix || trimmed.startsWith(`${prefix} `)) } function parseSection(lines: Array, headingIndex: number): { readonly body: string | null readonly nextIndex: number readonly diagnostics: ReadonlyArray } { const diagnostics: Array = [] if (lines[headingIndex + 1]?.trim() !== "") { diagnostics.push( diagnostic("invalid-spacing", `${lines[headingIndex].trim()} must be followed by exactly one blank line`) ) } let index = headingIndex + 2 const bodyStart = index let inFence = false while (index < lines.length) { const trimmed = lines[index].trim() if (trimmed.startsWith("```")) { if (trimmed === "```ts") { diagnostics.push(diagnostic("loose-ts-fence", "TypeScript examples must use **Example** (Title) sections")) } inFence = !inFence } if (!inFence && trimmed === "" && isHeadingLine(lines[index + 1])) { break } if (!inFence && trimmed !== "" && isForbiddenMarkdownHeading(trimmed, false)) { diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions")) } if ( !inFence && isBoldOnlyLine(trimmed) && !standardHeadings.includes(trimmed as StandardHeading) && !trimmed.startsWith("**Example**") ) { diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${trimmed}`)) } index++ } const bodyLines = lines.slice(bodyStart, index) if (joinBody(bodyLines).trim() === "") { diagnostics.push(diagnostic("empty-section", `${lines[headingIndex].trim()} must have a non-empty body`)) } if (bodyLines.at(-1)?.trim() === "") { diagnostics.push(diagnostic("invalid-spacing", "Section bodies must not end with extra blank lines")) } return { body: joinBody(bodyLines), nextIndex: index, diagnostics } } function parseExample(lines: Array, headingIndex: number): { readonly example?: ParsedExample readonly nextIndex: number readonly diagnostics: ReadonlyArray } { const diagnostics: Array = [] const heading = lines[headingIndex].trim() const match = /^\*\*Example\*\* \((.+)\)$/.exec(heading) if (!match || match[1].trim() === "") { diagnostics.push(diagnostic("malformed-example", "TypeScript examples must use **Example** (Title)")) } if (lines[headingIndex + 1]?.trim() !== "") { diagnostics.push(diagnostic("invalid-spacing", "Example headings must be followed by exactly one blank line")) } let index = headingIndex + 2 const bodyStart = index let fenceIndex = -1 while (index < lines.length) { const trimmed = lines[index].trim() if (trimmed === "```ts") { fenceIndex = index break } if (trimmed.startsWith("```")) { diagnostics.push(diagnostic("malformed-example", "Examples may only contain one TypeScript code fence")) } if (trimmed === "" && isHeadingLine(lines[index + 1])) { break } if (trimmed.startsWith("@")) { break } index++ } if (fenceIndex === -1) { diagnostics.push(diagnostic("malformed-example", "Examples must include a non-empty ```ts fence")) return { nextIndex: index, diagnostics } } const bodyLines = lines.slice(bodyStart, fenceIndex) const nonEmptyBody = joinBody(bodyLines).trim() !== "" if (nonEmptyBody && bodyLines.at(-1)?.trim() !== "") { diagnostics.push( diagnostic("invalid-spacing", "Example prose must be separated from code by exactly one blank line") ) } const prose = nonEmptyBody ? joinBody(bodyLines.slice(0, bodyLines.at(-1)?.trim() === "" ? -1 : undefined)) : null index = fenceIndex + 1 const codeStart = index while (index < lines.length && lines[index].trim() !== "```") { if (lines[index].trim() === "```ts") { diagnostics.push(diagnostic("malformed-example", "Examples must contain exactly one TypeScript code fence")) } index++ } const codeLines = lines.slice(codeStart, index) if (index >= lines.length) { diagnostics.push(diagnostic("malformed-example", "Examples must close the TypeScript code fence")) } if (joinBody(codeLines).trim() === "") { diagnostics.push(diagnostic("malformed-example", "Examples must include non-empty TypeScript code")) } index++ if (index < lines.length && lines[index].trim() !== "" && !lines[index].trim().startsWith("@")) { diagnostics.push( diagnostic("invalid-spacing", "Examples must be separated from following content by exactly one blank line") ) } const result = { nextIndex: index, diagnostics } return match ? { ...result, example: { title: match[1].trim(), body: prose, code: joinBody(codeLines) } } : result } function parseModuleExamples(block: JSDocBlock): { readonly examples: ReadonlyArray readonly diagnostics: ReadonlyArray } { const diagnostics: Array = [] const examples: Array = [] const exampleTitles = new Set() const firstTagLine = block.tags[0]?.line ?? block.lines.length const lines = block.lines.slice(0, firstTagLine) let index = 0 while (index < lines.length) { const trimmed = lines[index].trim() if (trimmed.startsWith("**Example**")) { const parsed = parseExample(lines, index) diagnostics.push(...parsed.diagnostics) if (parsed.example !== undefined) { const key = parsed.example.title.trim().toLowerCase() if (exampleTitles.has(key)) { diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${parsed.example.title.trim()}`)) } exampleTitles.add(key) examples.push(parsed.example) } index = Math.max(parsed.nextIndex, index + 1) continue } if (trimmed.startsWith("```")) { diagnostics.push( diagnostic("loose-ts-fence", "Code fences in module JSDoc must use **Example** (Title) sections") ) index++ while (index < lines.length && !lines[index].trim().startsWith("```")) index++ if (index < lines.length) index++ continue } index++ } return { examples, diagnostics } } function joinBody(lines: ReadonlyArray): string { let start = 0 let end = lines.length while (start < end && lines[start]?.trim() === "") start++ while (end > start && lines[end - 1]?.trim() === "") end-- return lines.slice(start, end).join("\n") } function isHeadingLine(line: string | undefined): boolean { if (line === undefined) return false const trimmed = line.trim() return standardHeadings.includes(trimmed as StandardHeading) || trimmed.startsWith("**Example**") || isNearMissHeading(trimmed) || isBoldOnlyLine(trimmed) || isForbiddenMarkdownHeading(trimmed, false) } function isNearMissHeading(line: string): boolean { return /^\*\*(When to use|When To Use|Details|Gotchas).*\*\*/.test(line) && !standardHeadings.includes(line as StandardHeading) } function isBoldOnlyLine(line: string): boolean { return /^\*\*[^*]+\*\*(?:\s*)$/.test(line) } function isForbiddenMarkdownHeading(line: string, inFence: boolean): boolean { return !inFence && /^#{1,6}\s+/.test(line.trim()) } function parseSeeTag(value: string): ParsedSeeTag { return { text: value, links: extractParsedInlineLinks(value) } } function extractParsedInlineLinks(source: string): ReadonlyArray { const links: Array = [] let index = source.indexOf("{@link") while (index !== -1) { if (!isInlineLinkStart(source, index)) { index = source.indexOf("{@link", index + 1) continue } const end = source.indexOf("}", index + "{@link".length) if (end === -1) { break } const raw = source.slice(index, end + 1) const content = raw.slice("{@link".length, -1).trim() const pipeIndex = content.indexOf("|") const beforeLabel = pipeIndex === -1 ? content : content.slice(0, pipeIndex).trim() const target = beforeLabel.split(/\s+/)[0] ?? "" const text = pipeIndex === -1 ? beforeLabel.slice(target.length).trim() || null : content.slice(pipeIndex + 1).trim() || null links.push({ raw, target, text }) index = source.indexOf("{@link", end + 1) } return links } function buildTags( scope: DocScope, tags: ReadonlyArray ): Result { const diagnostics: Array = [] const allowed = scope === "declaration" ? new Set(["deprecated", "see", "category", "since"]) : scope === "member" ? new Set(["deprecated", "default", "see", "since"]) : scope === "module" ? new Set(["deprecated", "see", "since"]) : new Set(["deprecated", "see", "category", "since"]) let previousOrder = -1 const values = new Map>() for (const tag of tags) { if (tag.name === "internal") { continue } if (tag.name === "example") { diagnostics.push( diagnostic("forbidden-tag", "@example is not allowed; use a canonical **Example** (Title) section") ) continue } if (!allowed.has(tag.name)) { diagnostics.push( diagnostic( "forbidden-tag", `@${tag.name} is not allowed in ${scope === "namespace-declaration" ? "namespace" : scope} JSDoc` ) ) continue } const order = tagOrder.get(tag.name) ?? previousOrder if (order < previousOrder) { diagnostics.push(diagnostic("tag-out-of-order", `@${tag.name} is out of order in JSDoc`)) } previousOrder = Math.max(previousOrder, order) values.set(tag.name, [...values.get(tag.name) ?? [], tag.value.trim()]) } const singletonTags = scope === "member" ? ["deprecated", "default", "since"] : ["deprecated", "category", "since"] for (const tag of singletonTags) { if ((values.get(tag)?.length ?? 0) > 1) { diagnostics.push(diagnostic("duplicate-tag", `JSDoc blocks may contain at most one @${tag} tag`)) } } const see = values.get("see") ?? [] for (const value of see) { if (value === "") { diagnostics.push(diagnostic("empty-tag", "@see must include a value")) } } const deprecated = values.get("deprecated")?.[0] ?? null if (deprecated === "") diagnostics.push(diagnostic("empty-tag", "@deprecated must include a message")) const since = values.get("since")?.[0] ?? null if ((scope === "declaration" || scope === "namespace" || scope === "namespace-declaration") && since === null) { diagnostics.push( diagnostic( "missing-tag", scope === "declaration" ? "Public JSDoc must include @since" : "Namespace JSDoc must include @since" ) ) } if (scope === "module" && since === null) { diagnostics.push(diagnostic("missing-tag", "Module JSDoc must include @since")) } if (since !== null && !stableSemverRegex.test(since)) { diagnostics.push(diagnostic("invalid-since", "@since must be a stable semver version like 1.2.3")) } if (scope === "declaration") { const category = values.get("category")?.[0] ?? null if (category === null) diagnostics.push(diagnostic("missing-tag", "Public JSDoc must include @category")) if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value")) if (diagnostics.length > 0 || category === null || since === null) { return { _tag: "Failure", error: { diagnostics } } } return { _tag: "Success", value: { category, since, deprecated, see: see.map(parseSeeTag) } } } if (scope === "member") { const defaultValue = values.get("default")?.[0] ?? null if (defaultValue === "") diagnostics.push(diagnostic("empty-tag", "@default must include a value")) if (diagnostics.length > 0) return { _tag: "Failure", error: { diagnostics } } return { _tag: "Success", value: { since, default: defaultValue, deprecated, see: see.map(parseSeeTag) } } } if (scope === "module") { if (diagnostics.length > 0 || since === null) return { _tag: "Failure", error: { diagnostics } } return { _tag: "Success", value: { since, deprecated, see: see.map(parseSeeTag) } } } const category = values.get("category")?.[0] ?? null if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value")) if (diagnostics.length > 0 || since === null) return { _tag: "Failure", error: { diagnostics } } return { _tag: "Success", value: { category, since, deprecated, see: see.map(parseSeeTag) } } } function formatDiagnostic(diagnostic: ts.Diagnostic): string { return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n") } function parseTsConfig(tsconfigPath: string): ParsedConfigResult { const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile) if (config.error !== undefined) { return { error: `Unable to read TypeScript config ${tsconfigPath}: ${formatDiagnostic(config.error)}` } } const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(tsconfigPath)) if (parsed.errors.length > 0) { return { error: `Unable to parse TypeScript config ${tsconfigPath}: ${parsed.errors.map(formatDiagnostic).join("; ")}` } } return { parsed } } function collectTsConfigFiles(tsconfigPath: string, seen: Set, fileNames: Set): ParsedConfigResult { if (seen.has(tsconfigPath)) return {} seen.add(tsconfigPath) const result = parseTsConfig(tsconfigPath) if (result.error !== undefined || result.parsed === undefined) return result for (const fileName of result.parsed.fileNames) fileNames.add(fileName) for (const reference of result.parsed.projectReferences ?? []) { const referenceResult = collectTsConfigFiles(ts.resolveProjectReferencePath(reference), seen, fileNames) if (referenceResult.error !== undefined) return referenceResult } return result } export function getProgram(tsconfigPath: string): ProgramCacheEntry { const cached = programCache.get(tsconfigPath) if (cached !== undefined) return cached const fileNames = new Set() const result = collectTsConfigFiles(tsconfigPath, new Set(), fileNames) if (result.error !== undefined || result.parsed === undefined) { const entry = { error: result.error ?? `Unable to parse TypeScript config ${tsconfigPath}`, reported: false } programCache.set(tsconfigPath, entry) return entry } const program = ts.createProgram(Array.from(fileNames), result.parsed.options) const entry = { program, reported: false } programCache.set(tsconfigPath, entry) return entry } function resolveJSDocLinkSymbol( link: ts.JSDocLink, checker: ts.TypeChecker ): ts.Symbol | undefined { return link.name === undefined ? undefined : checker.getSymbolAtLocation(link.name) } function isInlineLinkStart(source: string, index: number): boolean { if (!source.startsWith("{@link", index)) return false const next = source[index + "{@link".length] return next === undefined || /\s|}/.test(next) } function hasInlineLink(block: JSDocBlock): boolean { return block.lines.some((line) => { let index = line.indexOf("{@link") while (index !== -1) { if (isInlineLinkStart(line, index)) return true index = line.indexOf("{@link", index + 1) } return false }) } function extractInlineLinkTarget(linkText: string): string { const content = linkText.slice("{@link".length, linkText.endsWith("}") ? -1 : undefined).trim() const pipeIndex = content.indexOf("|") const beforeLabel = pipeIndex === -1 ? content : content.slice(0, pipeIndex).trim() return beforeLabel.split(/\s+/)[0] ?? "" } function malformedInlineLinks(block: JSDocBlock): Array { const source = block.lines.join("\n") const malformed: Array = [] let index = source.indexOf("{@link") while (index !== -1) { if (!isInlineLinkStart(source, index)) { index = source.indexOf("{@link", index + 1) continue } const end = source.indexOf("}", index + "{@link".length) const text = end === -1 ? source.slice(index).split(/\r?\n/, 1)[0] : source.slice(index, end + 1) if (end === -1 || extractInlineLinkTarget(text) === "") malformed.push(text) index = source.indexOf("{@link", end === -1 ? index + 1 : end + 1) } return malformed } function collectJSDocLinks(sourceFile: ts.SourceFile, block: JSDocBlock): Array { const links: Array = [] function inspectJSDoc(node: ts.Node) { for (const jsdoc of (node as { readonly jsDoc?: ReadonlyArray }).jsDoc ?? []) { if (jsdoc.pos !== block.range[0] || jsdoc.end !== block.range[1]) continue ts.forEachChild(jsdoc, function visit(child) { if (ts.isJSDocLink(child)) links.push(child) ts.forEachChild(child, visit) }) } } function visit(node: ts.Node) { inspectJSDoc(node) ts.forEachChild(node, visit) } visit(sourceFile) return links } function collectJSDocSeeLinks(sourceFile: ts.SourceFile, block: JSDocBlock): Array { const links: Array = [] function inspectJSDoc(node: ts.Node) { for (const jsdoc of (node as { readonly jsDoc?: ReadonlyArray }).jsDoc ?? []) { if (jsdoc.pos !== block.range[0] || jsdoc.end !== block.range[1]) continue ts.forEachChild(jsdoc, function visit(child) { if (ts.isJSDocSeeTag(child)) { ts.forEachChild(child, function visitSeeTag(seeTagChild) { if (ts.isJSDocLink(seeTagChild)) links.push(seeTagChild) ts.forEachChild(seeTagChild, visitSeeTag) }) return } ts.forEachChild(child, visit) }) } } function visit(node: ts.Node) { inspectJSDoc(node) ts.forEachChild(node, visit) } visit(sourceFile) return links } function resolvedSymbolTarget(symbol: ts.Symbol, checker: ts.TypeChecker): ts.Symbol { return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol) } function isLocalSourceFile(cwd: string, sourceFile: ts.SourceFile): boolean { if (sourceFile.isDeclarationFile) return false const relative = path.relative(cwd, sourceFile.fileName) return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) } function symbolDeclarations(symbol: ts.Symbol, checker: ts.TypeChecker): ReadonlyArray { const target = resolvedSymbolTarget(symbol, checker) const declarations = new Set() if (target.valueDeclaration !== undefined) declarations.add(target.valueDeclaration) for (const declaration of target.declarations ?? []) declarations.add(declaration) return Array.from(declarations) } function symbolHasPublicJSDoc(symbol: ts.Symbol, checker: ts.TypeChecker): boolean { return symbolDeclarations(symbol, checker).some((declaration) => { const block = getNodeJSDoc(declaration) return block !== undefined && !block.internal && block.parsed !== undefined }) } function isMemberDeclaration(declaration: ts.Declaration): boolean { return ts.isPropertySignature(declaration) || ts.isMethodSignature(declaration) || ts.isPropertyDeclaration(declaration) || ts.isMethodDeclaration(declaration) || ts.isGetAccessorDeclaration(declaration) || ts.isSetAccessorDeclaration(declaration) } function undocumentedSeeLinkTarget( symbol: ts.Symbol, checker: ts.TypeChecker, cwd: string ): ts.Declaration | undefined { const localDeclarations = symbolDeclarations(symbol, checker).filter((declaration) => isMemberDeclaration(declaration) && isLocalSourceFile(cwd, declaration.getSourceFile()) ) if (localDeclarations.length === 0 || symbolHasPublicJSDoc(symbol, checker)) return undefined return localDeclarations[0] } function inlineLinkSymbol( symbol: ts.Symbol, cwd: string, checker: ts.TypeChecker ): ParsedInlineLinkSymbol | undefined { const target = resolvedSymbolTarget(symbol, checker) const declaration = target.valueDeclaration ?? target.declarations?.[0] if (declaration === undefined) return undefined return { file: normalizeFile(cwd, declaration.getSourceFile().fileName), range: nodeRange(declaration) } } function attachSeeLinkSymbolsFromSourceFile< T extends ParsedModuleTags | ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags >( tags: T, sourceFile: ts.SourceFile, block: JSDocBlock, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): T { const resolved = new Map< string, Array<{ readonly range: readonly [number, number]; readonly symbol?: ParsedInlineLinkSymbol }> >() for (const link of collectJSDocLinks(sourceFile, block)) { const text = link.getText(sourceFile) const target = extractInlineLinkTarget(text) if (target === "" || urlRegex.test(target)) continue const symbol = resolveJSDocLinkSymbol(link, linkContext.checker) const info = symbol === undefined ? undefined : inlineLinkSymbol(symbol, linkContext.cwd, linkContext.checker) const items = resolved.get(text) ?? [] items.push(info === undefined ? { range: [link.pos, link.end] } : { range: [link.pos, link.end], symbol: info }) resolved.set(text, items) } return { ...tags, see: tags.see.map((tag) => ({ ...tag, links: tag.links.map((link) => { const items = resolved.get(link.raw) const info = items?.shift() if (info === undefined) return link return info.symbol === undefined ? { ...link, range: info.range } : { ...link, range: info.range, symbol: info.symbol } }) })) } } function attachSeeLinkSymbols( tags: T, node: ts.Node, block: JSDocBlock, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): T { return attachSeeLinkSymbolsFromSourceFile(tags, node.getSourceFile(), block, linkContext) } export interface JSDocModelDiagnostic extends JSDocDiagnostic { readonly range: readonly [number, number] } export interface JSDocModelFile extends ParsedJSDocFile { readonly file: string readonly hash: string readonly diagnostics: ReadonlyArray readonly imports?: ParsedJSDocImports } export interface JSDocModel { readonly version: 2 readonly generatedBy: "@effect/jsdocs" readonly generatedAt: string readonly inputHash?: string readonly files: ReadonlyArray readonly apis: ReadonlyArray } export interface JSDocConfig { readonly tsconfig: string readonly include: ReadonlyArray readonly exclude?: ReadonlyArray readonly output: string } export interface ExtractJSDocsOptions extends JSDocConfig { readonly cwd?: string } function addInputFile(files: Set, filename: string) { const normalized = path.resolve(filename) if (fs.existsSync(normalized) && fs.statSync(normalized).isFile()) { files.add(normalized) } } export function computeJSDocInputHash(options: ExtractJSDocsOptions): string { const cwd = path.resolve(options.cwd ?? process.cwd()) const hash = crypto.createHash("sha256") const files = new Set() hash.update(JSON.stringify({ tsconfig: options.tsconfig, include: options.include, exclude: options.exclude ?? [], output: options.output })) addInputFile(files, path.join(cwd, "jsdocs.config.json")) addInputFile(files, path.resolve(cwd, options.tsconfig)) for ( const filename of globSync([...options.include], { cwd, absolute: true, nodir: true, ignore: ["**/node_modules/**"] }) ) { addInputFile(files, filename) } for ( const filename of globSync([ "package.json", "packages/**/package.json", "tsconfig*.json", "packages/**/tsconfig*.json" ], { cwd, absolute: true, nodir: true, ignore: ["**/node_modules/**"] }) ) { addInputFile(files, filename) } for (const filename of Array.from(files).sort()) { hash.update("\0") hash.update(normalizeFile(cwd, filename)) hash.update("\0") hash.update(hashSource(fs.readFileSync(filename, "utf8"))) } return hash.digest("hex") } function isIdentifierName(value: string): boolean { return /^[$A-Z_a-z][$\w]*$/.test(value) } function renderInlineLinks(text: string): string { return text.replace(/\{@link\s+([^}]+)\}/g, (_match, content: string) => { const pipeIndex = content.indexOf("|") const beforeLabel = pipeIndex === -1 ? content.trim() : content.slice(0, pipeIndex).trim() const target = beforeLabel.split(/\s+/)[0] ?? "" const label = pipeIndex === -1 ? beforeLabel.slice(target.length).trim() || target : content.slice(pipeIndex + 1).trim() || target return `\`${label.replace(/:var$/, "")}\`` }) } function renderDescription(description: ParsedDescription): ParsedDescription { return { short: renderInlineLinks(description.short), whenToUse: description.whenToUse === null ? null : renderInlineLinks(description.whenToUse), details: description.details === null ? null : renderInlineLinks(description.details), gotchas: description.gotchas === null ? null : renderInlineLinks(description.gotchas) } } function renderExamples(examples: ReadonlyArray): ReadonlyArray { return examples.map((example) => ({ ...example, body: example.body === null ? null : renderInlineLinks(example.body) })) } function apiTags( tags: ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags ): JSDocApiTags { return { category: "category" in tags ? tags.category : null, since: tags.since, deprecated: tags.deprecated, default: "default" in tags ? tags.default : null } } function unresolvedSeeTag(tag: ParsedSeeTag): JSDocApiSeeTag { return { text: renderInlineLinks(tag.text), links: tag.links.map((link) => ({ ...link, resolution: { _tag: "Unresolved", reason: "not-found", candidates: [] } })) } } function unresolvedSee(tags: ReadonlyArray): ReadonlyArray { return tags.map(unresolvedSeeTag) } function apiId(kind: JSDocApiKind, bucket: ExportBucket | null, apiFqn: string): string { return `${kind}:${bucket ?? "none"}:${apiFqn}` } function importGuidance( imports: ParsedJSDocImports, kind: JSDocApiKind, bucket: ExportBucket | null, apiName: string ): JSDocApiImportGuidance | null { if (kind !== "root-declaration" || bucket !== "value" || !isIdentifierName(apiName)) { return null } if (imports.flatNames.includes(apiName) && imports.barrel !== null) { return { style: "named-barrel", importDeclaration: `import { ${apiName} } from "${imports.barrel.module}"`, usage: apiName } } if (imports.barrel?.type === "namespace") { if (!isIdentifierName(imports.barrel.name)) return null return { style: "namespace-barrel", importDeclaration: `import { ${imports.barrel.name} } from "${imports.barrel.module}"`, usage: `${imports.barrel.name}.${apiName}` } } if (imports.barrel?.type === "flat") { return { style: "named-barrel", importDeclaration: `import { ${apiName} } from "${imports.barrel.module}"`, usage: apiName } } return { style: "named-module", importDeclaration: `import { ${apiName} } from "${imports.module}"`, usage: apiName } } function makeApi(input: { readonly file: JSDocModelFile readonly imports: ParsedJSDocImports readonly kind: JSDocApiKind readonly bucket: ExportBucket | null readonly apiName: string readonly localName: string readonly signature: string | null readonly parentApiName: string | null readonly namespacePath: ReadonlyArray readonly memberPath: ReadonlyArray readonly sourcePath: ReadonlyArray readonly sourceRange: readonly [number, number] readonly description: ParsedDescription readonly examples: ReadonlyArray readonly tags: ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags }): JSDocApi { const apiFqn = `${input.imports.module}.${input.apiName}` return { id: apiId(input.kind, input.bucket, apiFqn), kind: input.kind, moduleName: input.imports.module, apiName: input.apiName, localName: input.localName, signature: input.signature, parentApiName: input.parentApiName, apiFqn, hasFqnCollision: false, bucket: input.bucket, importable: input.kind === "root-declaration", namespacePath: input.namespacePath, memberPath: input.memberPath, source: { file: input.file.file, path: input.sourcePath, range: input.sourceRange }, description: renderDescription(input.description), examples: renderExamples(input.examples), tags: apiTags(input.tags), see: unresolvedSee(input.tags.see), importGuidance: importGuidance(input.imports, input.kind, input.bucket, input.apiName) } } function buildJSDocApis(files: ReadonlyArray): ReadonlyArray { const apis: Array = [] const addMembers = ( file: JSDocModelFile, imports: ParsedJSDocImports, members: ReadonlyArray, parentApiName: string, namespacePath: ReadonlyArray, memberPath: ReadonlyArray, sourcePath: ReadonlyArray ) => { members.forEach((member, index) => { const nextMemberPath = [...memberPath, member.name] const apiName = `${parentApiName}.${nextMemberPath.join(".")}` apis.push(makeApi({ file, imports, kind: "member", bucket: null, apiName, localName: member.name, signature: member.signature, parentApiName, namespacePath, memberPath: nextMemberPath, sourcePath: [...sourcePath, "members", index], sourceRange: member.range, description: member.description, examples: member.examples, tags: member.tags })) addMembers(file, imports, member.members, parentApiName, namespacePath, nextMemberPath, [ ...sourcePath, "members", index ]) }) } const addNamespaces = ( file: JSDocModelFile, imports: ParsedJSDocImports, namespaces: ReadonlyArray, namespacePath: ReadonlyArray, sourcePath: ReadonlyArray ) => { namespaces.forEach((namespace, index) => { const nextNamespacePath = [...namespacePath, namespace.name] const namespaceApiName = nextNamespacePath.join(".") apis.push(makeApi({ file, imports, kind: "namespace", bucket: null, apiName: namespaceApiName, localName: namespace.name, signature: namespace.signature, parentApiName: namespacePath.length === 0 ? null : namespacePath.join("."), namespacePath: nextNamespacePath, memberPath: [], sourcePath: [...sourcePath, index], sourceRange: namespace.range, description: namespace.description, examples: namespace.examples, tags: namespace.tags })) namespace.declarations.forEach((declaration, declarationIndex) => { const apiName = `${namespaceApiName}.${declaration.name}` apis.push(makeApi({ file, imports, kind: "namespace-declaration", bucket: "type", apiName, localName: declaration.name, signature: declaration.signature, parentApiName: namespaceApiName, namespacePath: nextNamespacePath, memberPath: [], sourcePath: [...sourcePath, index, "declarations", declarationIndex], sourceRange: declaration.range, description: declaration.description, examples: declaration.examples, tags: declaration.tags })) addMembers(file, imports, declaration.members, apiName, nextNamespacePath, [], [ ...sourcePath, index, "declarations", declarationIndex ]) }) addNamespaces(file, imports, namespace.namespaces, nextNamespacePath, [...sourcePath, index, "namespaces"]) }) } for (const file of files) { if (file.imports === undefined) continue const imports = file.imports file.declarations.forEach((declaration, index) => { apis.push(makeApi({ file, imports, kind: "root-declaration", bucket: declaration.bucket, apiName: declaration.name, localName: declaration.name, signature: declaration.signature, parentApiName: null, namespacePath: [], memberPath: [], sourcePath: ["declarations", index], sourceRange: declaration.range, description: declaration.description, examples: declaration.examples, tags: declaration.tags })) addMembers(file, imports, declaration.members, declaration.name, [], [], ["declarations", index]) }) addNamespaces(file, imports, file.namespaces, [], ["namespaces"]) } return resolveJSDocApiSeeLinks(markFqnCollisions(apis)) } function markFqnCollisions(apis: ReadonlyArray): ReadonlyArray { const counts = new Map() for (const api of apis) counts.set(api.apiFqn, (counts.get(api.apiFqn) ?? 0) + 1) return apis.map((api) => ({ ...api, hasFqnCollision: (counts.get(api.apiFqn) ?? 0) > 1 })) } function normalizeSeeTarget(target: string): ReadonlyArray { const withoutKind = target.replace(/:[A-Za-z]+$/, "") const out = [withoutKind] if (withoutKind.endsWith("_")) out.push(withoutKind.slice(0, -1)) return Array.from(new Set(out.filter((item) => item.length > 0))) } function sourceRangeKey(file: string, range: readonly [number, number]): string { return `${file}:${range[0]}:${range[1]}` } function rangeContains(outer: readonly [number, number], inner: readonly [number, number]): boolean { return outer[0] <= inner[0] && inner[1] <= outer[1] } function rangeOverlaps(self: readonly [number, number], that: readonly [number, number]): boolean { return self[0] <= that[1] && that[0] <= self[1] } function rangeWidth(range: readonly [number, number]): number { return range[1] - range[0] } function seeTargetNames(link: ParsedInlineLink): ReadonlySet { const names = new Set() const add = (target: string) => { for (const normalized of normalizeSeeTarget(target)) { const parts = normalized.split(".").filter((part) => part.length > 0) for (const part of parts) names.add(part) if (parts.length > 0) names.add(parts.join(".")) } } add(link.target) if (link.text !== null) add(link.text) return names } function apiMatchesSeeTarget(api: JSDocApi, names: ReadonlySet): boolean { if (names.size === 0) return true if (names.has(api.localName) || names.has(api.apiName)) return true for (const name of names) { if (api.apiName.endsWith(`.${name}`)) return true } return false } function createJSDocApiSeeLinkResolver(apis: ReadonlyArray) { const byModuleAndName = new Map() const byLocalName = new Map>() const bySourceRange = new Map() const bySourceFile = new Map>() for (const api of apis) { byModuleAndName.set(`${api.moduleName}:${api.apiName}`, api) bySourceRange.set(sourceRangeKey(api.source.file, api.source.range), api) const sourceApis = bySourceFile.get(api.source.file) ?? [] sourceApis.push(api) bySourceFile.set(api.source.file, sourceApis) for (const name of new Set([api.apiName, api.localName])) { const existing = byLocalName.get(name) ?? [] existing.push(api) byLocalName.set(name, existing) } } const resolveSymbol = (link: ParsedInlineLink): JSDocApiSeeLinkResolution | undefined => { if (link.symbol === undefined) return undefined const symbol = link.symbol const exact = bySourceRange.get(sourceRangeKey(symbol.file, symbol.range)) if (exact !== undefined) return { _tag: "Resolved", apiId: exact.id, apiFqn: exact.apiFqn } const names = seeTargetNames(link) const candidates = (bySourceFile.get(symbol.file) ?? []).filter((api) => apiMatchesSeeTarget(api, names) && (rangeContains(symbol.range, api.source.range) || rangeContains(api.source.range, symbol.range) || rangeOverlaps(symbol.range, api.source.range)) ).sort((self, that) => rangeWidth(self.source.range) - rangeWidth(that.source.range)) const unique = Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values()) if (unique.length === 1) return { _tag: "Resolved", apiId: unique[0].id, apiFqn: unique[0].apiFqn } if (unique.length > 1) { return { _tag: "Unresolved", reason: "ambiguous", candidates: unique.map((candidate) => candidate.id) } } const sameFileByName = (bySourceFile.get(symbol.file) ?? []).filter((api) => apiMatchesSeeTarget(api, names)) const uniqueByName = Array.from(new Map(sameFileByName.map((candidate) => [candidate.id, candidate])).values()) if (uniqueByName.length === 1) { return { _tag: "Resolved", apiId: uniqueByName[0].id, apiFqn: uniqueByName[0].apiFqn } } if (uniqueByName.length > 1) { return { _tag: "Unresolved", reason: "ambiguous", candidates: uniqueByName.map((candidate) => candidate.id) } } return undefined } return ( context: { readonly moduleName: string; readonly namespacePath: ReadonlyArray }, link: ParsedInlineLink ): JSDocApiSeeLinkResolution => { const symbolResolution = resolveSymbol(link) if (symbolResolution !== undefined) return symbolResolution const targets = new Set() for (const target of normalizeSeeTarget(link.target)) targets.add(target) if (link.text !== null) { for (const target of normalizeSeeTarget(link.text)) targets.add(target) } for (const target of targets) { const sameModule = byModuleAndName.get(`${context.moduleName}:${target}`) if (sameModule !== undefined) return { _tag: "Resolved", apiId: sameModule.id, apiFqn: sameModule.apiFqn } if (!target.includes(".") && context.namespacePath.length > 0) { const sameNamespace = byModuleAndName.get( `${context.moduleName}:${[...context.namespacePath, target].join(".")}` ) if (sameNamespace !== undefined) { return { _tag: "Resolved", apiId: sameNamespace.id, apiFqn: sameNamespace.apiFqn } } } } const candidates = Array.from(targets).flatMap((target) => byLocalName.get(target) ?? []) const unique = Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values()) if (unique.length === 1) return { _tag: "Resolved", apiId: unique[0].id, apiFqn: unique[0].apiFqn } if (unique.length > 1) { return { _tag: "Unresolved", reason: "ambiguous", candidates: unique.map((candidate) => candidate.id) } } return { _tag: "Unresolved", reason: "not-found", candidates: [] } } } function resolveJSDocApiSeeLinks(apis: ReadonlyArray): ReadonlyArray { const resolve = createJSDocApiSeeLinkResolver(apis) return apis.map((api) => ({ ...api, see: api.see.map((tag) => ({ ...tag, links: tag.links.map((link) => ({ ...link, resolution: resolve(api, link) })) })) })) } function sameDiagnostic(self: JSDocModelDiagnostic, that: JSDocModelDiagnostic): boolean { return self.code === that.code && self.message === that.message && self.range[0] === that.range[0] && self.range[1] === that.range[1] } function unresolvedPublicSeeDiagnostic(link: JSDocApiSeeLink): JSDocModelDiagnostic | undefined { if (link.resolution._tag === "Resolved" || link.range === undefined) return undefined const message = link.resolution.reason === "ambiguous" ? `@see link ${link.raw} is ambiguous; it must resolve to exactly one public JSDoc API. Candidates: ${ link.resolution.candidates.join(", ") }` : `@see link ${link.raw} does not resolve to a public JSDoc API. Check that the target is exported and has valid public JSDoc.` return { ...diagnostic("public-see-target", message), range: link.range } } function appendPublicSeeDiagnostics( files: ReadonlyArray, apis: ReadonlyArray ): { readonly files: ReadonlyArray; readonly added: boolean } { const diagnosticsByFile = new Map(files.map((file) => [file.file, [...file.diagnostics]])) let added = false for (const api of apis) { const diagnostics = diagnosticsByFile.get(api.source.file) if (diagnostics === undefined) continue for (const tag of api.see) { for (const link of tag.links) { const item = unresolvedPublicSeeDiagnostic(link) if (item === undefined || diagnostics.some((diagnostic) => sameDiagnostic(diagnostic, item))) continue diagnostics.push(item) added = true } } } if (!added) return { files, added } return { added, files: files.map((file) => ({ ...file, diagnostics: diagnosticsByFile.get(file.file) ?? file.diagnostics })) } } function moduleSeeTags( file: JSDocModelFile, sourceFile: ts.SourceFile, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): ReadonlyArray { if (file.moduleJSDoc === undefined) return [] const block = parseLooseJSDocBlock(file.moduleJSDoc.raw, [file.moduleJSDoc.range[0], file.moduleJSDoc.range[1]]) if (block.internal) return [] const tags = buildTags("module", block.tags) const moduleTags: ParsedModuleTags = tags._tag === "Success" ? tags.value as ParsedModuleTags : { since: "0.0.0", deprecated: null, see: block.tags.filter((tag) => tag.name === "see" && tag.value.trim() !== "").map((tag) => parseSeeTag(tag.value.trim()) ) } const withSymbols = attachSeeLinkSymbolsFromSourceFile(moduleTags, sourceFile, block, linkContext) return unresolvedSee(withSymbols.see) } function appendModulePublicSeeDiagnostics( files: ReadonlyArray, apis: ReadonlyArray, context: { readonly sourceFilesByFile: ReadonlyMap readonly checker: ts.TypeChecker readonly entry: ProgramCacheEntry readonly cwd: string } ): { readonly files: ReadonlyArray; readonly added: boolean } { const diagnosticsByFile = new Map(files.map((file) => [file.file, [...file.diagnostics]])) const resolve = createJSDocApiSeeLinkResolver(apis) let added = false for (const file of files) { if (file.imports === undefined || file.moduleJSDoc === undefined) continue const sourceFile = context.sourceFilesByFile.get(file.file) const diagnostics = diagnosticsByFile.get(file.file) if (sourceFile === undefined || diagnostics === undefined) continue for (const tag of moduleSeeTags(file, sourceFile, context)) { for (const link of tag.links) { const item = unresolvedPublicSeeDiagnostic({ ...link, resolution: resolve({ moduleName: file.imports.module, namespacePath: [] }, link) }) if (item === undefined || diagnostics.some((diagnostic) => sameDiagnostic(diagnostic, item))) continue diagnostics.push(item) added = true } } } if (!added) return { files, added } return { added, files: files.map((file) => ({ ...file, diagnostics: diagnosticsByFile.get(file.file) ?? file.diagnostics })) } } function buildJSDocApisWithPublicSeeDiagnostics( files: ReadonlyArray, context?: { readonly sourceFilesByFile: ReadonlyMap readonly checker: ts.TypeChecker readonly entry: ProgramCacheEntry readonly cwd: string } ): { readonly files: ReadonlyArray readonly apis: ReadonlyArray } { let current = files for (let index = 0; index < 5; index++) { const apis = buildJSDocApis(current) const publicSee = appendPublicSeeDiagnostics(current, apis) const moduleSee = context === undefined ? { files: publicSee.files, added: false } : appendModulePublicSeeDiagnostics(publicSee.files, apis, context) const next = { files: moduleSee.files, added: publicSee.added || moduleSee.added } if (!next.added) return { files: current, apis } current = next.files } return { files: current, apis: buildJSDocApis(current) } } interface ExampleImportPolicy { readonly allowedNamedBySource: ReadonlyMap> readonly namespaceModuleBySource: ReadonlyMap readonly flatModuleBySource: ReadonlyMap< string, { readonly barrelModule: string; readonly names: ReadonlySet } > readonly namedModuleBySource: ReadonlyMap> } function createExampleImportPolicy(files: ReadonlyArray): ExampleImportPolicy { const allowedNamed = new Map>() const namespaceModuleBySource = new Map() const flatModuleBySource = new Map }>() const namedModuleBySource = new Map>() const addAllowed = (source: string, name: string) => { const names = allowedNamed.get(source) ?? new Set() names.add(name) allowedNamed.set(source, names) } for (const file of files) { if (file.imports === undefined) continue const rootValues = file.declarations.filter((declaration) => declaration.bucket === "value" && isIdentifierName(declaration.name) ).map((declaration) => declaration.name) if (file.imports.barrel?.type === "namespace") { addAllowed(file.imports.barrel.module, file.imports.barrel.name) for (const name of file.imports.flatNames) addAllowed(file.imports.barrel.module, name) namespaceModuleBySource.set(file.imports.module, { barrelModule: file.imports.barrel.module, name: file.imports.barrel.name }) continue } if (file.imports.barrel?.type === "flat") { for (const name of new Set([...rootValues, ...file.imports.flatNames])) { addAllowed(file.imports.barrel.module, name) } flatModuleBySource.set(file.imports.module, { barrelModule: file.imports.barrel.module, names: new Set([...rootValues, ...file.imports.flatNames]) }) continue } for (const name of rootValues) addAllowed(file.imports.module, name) namedModuleBySource.set(file.imports.module, new Set(rootValues)) } return { allowedNamedBySource: allowedNamed, namespaceModuleBySource, flatModuleBySource, namedModuleBySource } } function importSpecifierName(specifier: ts.ImportSpecifier): string { return specifier.propertyName?.text ?? specifier.name.text } function validateExampleImports( code: string, policy: ExampleImportPolicy ): ReadonlyArray { const diagnostics: Array = [] const sourceFile = ts.createSourceFile("example.ts", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) for (const statement of sourceFile.statements) { if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue if (statement.importClause?.isTypeOnly) continue const source = statement.moduleSpecifier.text const namedAllowed = policy.allowedNamedBySource.get(source) const namespaceModule = policy.namespaceModuleBySource.get(source) const flatModule = policy.flatModuleBySource.get(source) const namedModule = policy.namedModuleBySource.get(source) const clause = statement.importClause if (clause === undefined) continue if (namespaceModule !== undefined) { diagnostics.push(diagnostic( "example-import-style", `JSDoc examples should use \`import { ${namespaceModule.name} } from "${namespaceModule.barrelModule}"\` instead of importing from "${source}"` )) continue } if (flatModule !== undefined) { diagnostics.push(diagnostic( "example-import-style", `JSDoc examples should import ${ Array.from(flatModule.names).map((name) => `\`${name}\``).join(", ") } from "${flatModule.barrelModule}" instead of "${source}"` )) continue } if (clause.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) { if (namedAllowed !== undefined || namedModule !== undefined) { diagnostics.push(diagnostic("example-import-style", `JSDoc examples should use named imports from "${source}"`)) } continue } if (clause.name !== undefined && (namedAllowed !== undefined || namedModule !== undefined)) { diagnostics.push( diagnostic("example-import-style", `JSDoc examples should not use default imports from "${source}"`) ) } if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings) && namedAllowed !== undefined) { for (const specifier of clause.namedBindings.elements) { if (specifier.isTypeOnly) continue const imported = importSpecifierName(specifier) if (!namedAllowed.has(imported)) { diagnostics.push(diagnostic( "example-import-style", `JSDoc example import \`${imported}\` from "${source}" is not a canonical public API import` )) } } } } return diagnostics } function appendExampleImportDiagnostics(files: ReadonlyArray): ReadonlyArray { const policy = createExampleImportPolicy(files) const checkExamples = ( examples: ReadonlyArray, range: readonly [number, number] = [0, 0] as const ): ReadonlyArray => examples.flatMap((example) => validateExampleImports(example.code, policy).map((item) => ({ ...item, range }))) const checkModuleExamples = (moduleJSDoc: ParsedModuleJSDoc | undefined): ReadonlyArray => { if (moduleJSDoc === undefined) return [] const block = parseLooseJSDocBlock(moduleJSDoc.raw, [moduleJSDoc.range[0], moduleJSDoc.range[1]]) if (block.internal) return [] return checkExamples(parseModuleExamples(block).examples, moduleJSDoc.range) } const checkMembers = (members: ReadonlyArray): ReadonlyArray => members.flatMap((member) => [ ...checkExamples(member.examples), ...checkMembers(member.members) ]) const checkNamespaces = (namespaces: ReadonlyArray): ReadonlyArray => namespaces.flatMap((namespace) => [ ...checkExamples(namespace.examples), ...namespace.declarations.flatMap((declaration) => [ ...checkExamples(declaration.examples), ...checkMembers(declaration.members) ]), ...checkNamespaces(namespace.namespaces) ]) return files.map((file) => { const diagnostics = [ ...file.diagnostics, ...checkModuleExamples(file.moduleJSDoc), ...file.declarations.flatMap((declaration) => [ ...checkExamples(declaration.examples), ...checkMembers(declaration.members) ]), ...checkNamespaces(file.namespaces) ] return diagnostics.length === file.diagnostics.length ? file : { ...file, diagnostics: uniqueModelDiagnostics(diagnostics) } }) } function uniqueModelDiagnostics( diagnostics: ReadonlyArray ): ReadonlyArray { const seen = new Set() return diagnostics.filter((item) => { const key = `${item.code}:${item.message}:${item.range[0]}:${item.range[1]}` if (seen.has(key)) return false seen.add(key) return true }) } function nodeRange(node: ts.Node): [number, number] { return [node.getStart(), node.getEnd()] } function normalizeFile(cwd: string, filename: string): string { return normalizePathName(path.relative(cwd, filename)) } function hashSource(source: string): string { return crypto.createHash("sha256").update(source).digest("hex") } function getNodeJSDoc(node: ts.Node): JSDocBlock | undefined { const jsDocs = (node as { readonly jsDoc?: ReadonlyArray }).jsDoc const jsDoc = jsDocs?.[jsDocs.length - 1] if (jsDoc === undefined) return undefined const source = node.getSourceFile().text return parseJSDocBlock(source.slice(jsDoc.pos, jsDoc.end), [jsDoc.pos, jsDoc.end]) } function addModelDiagnostics( out: Array, range: [number, number], diagnostics: ReadonlyArray ) { for (const item of diagnostics) out.push({ ...item, range }) } function addJSDocLinkDiagnostics( sourceFile: ts.SourceFile, block: JSDocBlock, diagnostics: Array, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ) { if (!hasInlineLink(block)) return for (const link of malformedInlineLinks(block)) { diagnostics.push({ ...diagnostic("malformed-link", `Malformed JSDoc inline link: ${link}`), range: block.range }) } for (const link of collectJSDocLinks(sourceFile, block)) { const text = link.getText(sourceFile) const target = extractInlineLinkTarget(text) if (target === "") continue if (urlRegex.test(target)) { diagnostics.push({ ...diagnostic("url-link", `JSDoc inline link must target a TypeScript symbol: ${text}`), range: [link.pos, link.end] }) } else if (resolveJSDocLinkSymbol(link, linkContext.checker) === undefined) { diagnostics.push({ ...diagnostic("unresolved-link", `Unresolved JSDoc inline link: ${text}`), range: [link.pos, link.end] }) } } for (const link of collectJSDocSeeLinks(sourceFile, block)) { const text = link.getText(sourceFile) const target = extractInlineLinkTarget(text) if (target === "" || urlRegex.test(target)) continue const symbol = resolveJSDocLinkSymbol(link, linkContext.checker) if (symbol === undefined) continue if (undocumentedSeeLinkTarget(symbol, linkContext.checker, linkContext.cwd) !== undefined) { diagnostics.push({ ...diagnostic("undocumented-see-target", `@see link target must have public JSDoc: ${text}`), range: [link.pos, link.end] }) } } } function parseDocumentedTs( node: ts.Node, scope: DocScope, diagnostics: Array, required = true, linkContext?: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ) { const block = getNodeJSDoc(node) if (block?.internal) return undefined if (block === undefined) { if (required) { diagnostics.push({ ...diagnostic("missing-jsdoc", scope === "member" ? "Member JSDoc is required" : "Public JSDoc is required"), range: nodeRange(node) }) } return undefined } addModelDiagnostics(diagnostics, block.range, block.diagnostics) if (block.parsed === undefined) return undefined const tags = buildTags(scope, block.parsed.tags) if (tags._tag === "Failure") { addModelDiagnostics(diagnostics, block.range, tags.error.diagnostics) return undefined } const parsedTags = tags.value as ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags if (linkContext !== undefined) addJSDocLinkDiagnostics(node.getSourceFile(), block, diagnostics, linkContext) return { core: block.parsed, tags: linkContext === undefined ? parsedTags : attachSeeLinkSymbols(parsedTags, node, block, linkContext) } } function hasExportModifier(node: ts.Node): boolean { return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false) } function hasDefaultModifier(node: ts.Node): boolean { return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false) } function declarationName(node: ts.Node): string | undefined { if ( (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isModuleDeclaration(node)) && node.name !== undefined ) return node.name.text if (ts.isVariableStatement(node)) { return node.declarationList.declarations.map((declaration) => { if (ts.isIdentifier(declaration.name)) return declaration.name.text if (ts.isObjectBindingPattern(declaration.name)) { return declaration.name.elements.map((element) => element.propertyName?.getText(element.getSourceFile()) ?? element.name.getText(element.getSourceFile()) ).join(", ") } return "" }).filter(Boolean).join(", ") } return undefined } function publicDeclarationRange(node: ts.Node, name: string): readonly [number, number] { if (ts.isVariableStatement(node)) { const declaration = node.declarationList.declarations.find((item) => ts.isIdentifier(item.name) && item.name.text === name ) if (declaration !== undefined) return nodeRange(declaration) } return nodeRange(node) } function bucketOfTs(node: ts.Node): ExportBucket | undefined { if (ts.isVariableStatement(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return "value" if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) return "type" return undefined } function normalizeSignature(cwd: string, text: string): string | null { const normalizedCwd = normalizePathName(cwd) const signature = text.replaceAll(normalizedCwd, ".").replaceAll(cwd, ".").replace(/\r\n?/g, "\n").replace( /[ \t]+$/gm, "" ).trim() return signature === "" ? null : signature } function displaySignature(text: string): string { return text.replace(/import\((?:"[^"]+"|'[^']+')\)\.([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)/g, "$1") } function parseableSignature(cwd: string, text: string): string | null { const signature = normalizeSignature(cwd, displaySignature(text)) if (signature === null) return null const source = ts.createSourceFile( "signature.ts", signature, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ) as SourceFileWithParseDiagnostics return source.parseDiagnostics.length === 0 ? signature : null } function signatureLocalName(name: string): string { const sanitized = name.replace(/[^A-Za-z0-9_$]/g, "_") return `_${sanitized === "" ? "api" : sanitized}` } function signatureExportName(name: string): string { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name) } function signatureAlias(name: string, text: string): string { const localName = signatureLocalName(name) return `declare const ${localName}: ${text} export { ${localName} as ${signatureExportName(name)} }` } function signatureParameterDeclaration(parameter: ts.Symbol): ts.ParameterDeclaration | undefined { const declaration = parameter.valueDeclaration ?? parameter.declarations?.[0] return declaration !== undefined && ts.isParameter(declaration) ? declaration : undefined } function signatureParameterName(parameter: ts.Symbol): string { const declaration = signatureParameterDeclaration(parameter) if (declaration !== undefined && ts.isIdentifier(declaration.name)) return declaration.name.text return parameter.getName() } function signatureFunctionThisKind(type: ts.Type, checker: ts.TypeChecker, location: ts.Node): string { const thisParameter = checker.getSignaturesOfType(type, ts.SignatureKind.Call)[0]?.thisParameter if (thisParameter === undefined) return "none" const thisType = checker.typeToString( checker.getTypeOfSymbolAtLocation(thisParameter, location), location, signatureTypeFormatFlags ) return thisType === "unassigned" ? "unassigned" : "self" } function isSignatureFunctionKind(kind: string): boolean { return kind.startsWith("body:") || kind.startsWith("function:") } function signatureTransformCountKind(count: number): string { if (count <= 3) return String(count) if (count <= 7) return "4" if (count <= 15) return "8" return "16" } function signatureParameterKind( parameter: ts.Symbol | undefined, checker: ts.TypeChecker, location: ts.Node ): string { if (parameter === undefined) return "none" const declaration = signatureParameterDeclaration(parameter) if (declaration?.dotDotDotToken !== undefined) return "rest" const name = signatureParameterName(parameter).replace(/_$/, "").toLowerCase() const type = checker.getTypeOfSymbolAtLocation(parameter, declaration ?? location) if (name === "self" || name === "that") return "self" if (name === "body") return `body:${signatureFunctionThisKind(type, checker, location)}` if (name === "name" || name === "spanname") return "name" if (name.includes("refinement")) return "refinement" if (name.includes("predicate")) return "predicate" if (name === "options" || name === "option" || name === "opts" || name === "config" || name === "args") { return "options" } if (name === "f" || name === "fn" || name === "callback") { return `function:${signatureFunctionThisKind(type, checker, location)}` } if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) { return `function:${signatureFunctionThisKind(type, checker, location)}` } if ((type.flags & ts.TypeFlags.StringLike) !== 0) return "primitive:string" if ((type.flags & ts.TypeFlags.NumberLike) !== 0) return "primitive:number" if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return "primitive:boolean" if ((type.flags & ts.TypeFlags.BigIntLike) !== 0) return "primitive:bigint" if ((type.flags & ts.TypeFlags.ESSymbolLike) !== 0) return "primitive:symbol" if ((type.flags & ts.TypeFlags.Object) !== 0 || checker.getPropertiesOfType(type).length > 0) return "object" return "other" } function signatureTailKind(signature: ts.Signature, checker: ts.TypeChecker, location: ts.Node): string { if (signature.parameters.length <= 1) return "none" const firstKind = signatureParameterKind(signature.parameters[0], checker, location) const tailKinds = signature.parameters.slice(1).map((parameter) => signatureParameterKind(parameter, checker, location) ) if (firstKind === "options") { const hasTransformTail = tailKinds.length > 1 && tailKinds.slice(1).every(isSignatureFunctionKind) return `${tailKinds[0]}:${hasTransformTail ? "transform" : "none"}` } if (tailKinds.every(isSignatureFunctionKind)) { return firstKind.startsWith("body:") ? "transform" : `transform:${signatureTransformCountKind(tailKinds.length)}` } return tailKinds[0] } function signatureShapeKey(signature: ts.Signature, checker: ts.TypeChecker, location: ts.Node): string { const hasRest = signature.parameters.some((parameter) => signatureParameterDeclaration(parameter)?.dotDotDotToken !== undefined ) const returnType = checker.getReturnTypeOfSignature(signature) const returnMode = checker.getSignaturesOfType(returnType, ts.SignatureKind.Call).length > 0 ? "curried" : "direct" return [ hasRest ? "rest" : "fixed", signatureParameterKind(signature.parameters[0], checker, location), signatureTailKind(signature, checker, location), returnMode ].join(":") } function representativeSignatures( signatures: ReadonlyArray, checker: ts.TypeChecker, location: ts.Node ): ReadonlyArray { const seen = new Set() const out: Array = [] for (const signature of signatures) { const key = signatureShapeKey(signature, checker, location) if (seen.has(key)) continue seen.add(key) out.push(signature) } return out } function functionSignature( name: string, symbol: ts.Symbol, location: ts.Node, checker: ts.TypeChecker, cwd: string ): string | null { const target = resolvedSymbolTarget(symbol, checker) const type = checker.getTypeOfSymbolAtLocation(target, location) const signatures = checker.getSignaturesOfType(type, ts.SignatureKind.Call) if (signatures.length === 0) return null const callSignatures: Array = [] for (const signature of representativeSignatures(signatures, checker, location)) { const text = normalizeSignature( cwd, checker.signatureToString(signature, location, signatureTypeFormatFlags, ts.SignatureKind.Call) ) if (text === null) return null callSignatures.push(text) } const declaration = parseableSignature( cwd, callSignatures.map((text) => `declare function ${name}${text}`).join("\n") ) if (declaration !== null) return declaration const callSignatureAlias = parseableSignature( cwd, signatureAlias( name, `{ ${callSignatures.map((text) => ` ${text};`).join("\n")} }` ) ) if (callSignatureAlias !== null) return callSignatureAlias const typeText = normalizeSignature(cwd, checker.typeToString(type, location, signatureTypeFormatFlags)) if (typeText === null) return null return parseableSignature(cwd, `declare const ${name}: ${typeText}`) ?? parseableSignature(cwd, signatureAlias(name, typeText)) } function symbolSignature( name: string, bucket: ExportBucket, symbol: ts.Symbol, location: ts.Node, checker: ts.TypeChecker, cwd: string ): string | null { if (bucket !== "value") return null const target = resolvedSymbolTarget(symbol, checker) const type = checker.getTypeOfSymbolAtLocation(target, location) if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length === 0) return null return functionSignature(name, symbol, location, checker, cwd) } function declarationSignature( name: string, bucket: ExportBucket, node: ts.Node, checker: ts.TypeChecker, cwd: string ): string | null { if (ts.isVariableStatement(node)) { const declaration = node.declarationList.declarations.find((item) => ts.isIdentifier(item.name) && item.name.text === name ) if (declaration === undefined) return null const symbol = checker.getSymbolAtLocation(declaration.name) return symbol === undefined ? null : symbolSignature(name, bucket, symbol, declaration.name, checker, cwd) } const nameNode = ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) ? node.name : undefined if (nameNode === undefined) return null const symbol = checker.getSymbolAtLocation(nameNode) return symbol === undefined ? null : symbolSignature(name, bucket, symbol, nameNode, checker, cwd) } function exportSpecifierSignature( name: string, bucket: ExportBucket, specifier: ts.ExportSpecifier, checker: ts.TypeChecker, cwd: string ): string | null { const symbol = checker.getSymbolAtLocation(specifier.name) ?? (specifier.propertyName === undefined ? undefined : checker.getSymbolAtLocation(specifier.propertyName)) return symbol === undefined ? null : symbolSignature(name, bucket, symbol, specifier.name, checker, cwd) } function parseMembersFromTsType( type: ts.TypeNode | undefined, diagnostics: Array, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): Array { if (!type) return [] if (ts.isTypeLiteralNode(type)) return parseTsMembers(type.members, diagnostics, linkContext) if (ts.isUnionTypeNode(type) || ts.isIntersectionTypeNode(type)) { return type.types.flatMap((item) => parseMembersFromTsType(item, diagnostics, linkContext)) } if (ts.isParenthesizedTypeNode(type) || ts.isTypeOperatorNode(type)) { return parseMembersFromTsType(type.type, diagnostics, linkContext) } if (ts.isArrayTypeNode(type)) return parseMembersFromTsType(type.elementType, diagnostics, linkContext) if (ts.isConditionalTypeNode(type)) { return [ ...parseMembersFromTsType(type.checkType, diagnostics, linkContext), ...parseMembersFromTsType(type.extendsType, diagnostics, linkContext), ...parseMembersFromTsType(type.trueType, diagnostics, linkContext), ...parseMembersFromTsType(type.falseType, diagnostics, linkContext) ] } if (ts.isTypeReferenceNode(type)) { return type.typeArguments?.flatMap((item) => parseMembersFromTsType(item, diagnostics, linkContext)) ?? [] } if (ts.isMappedTypeNode(type)) return parseMembersFromTsType(type.type, diagnostics, linkContext) if (ts.isIndexedAccessTypeNode(type)) { return [ ...parseMembersFromTsType(type.objectType, diagnostics, linkContext), ...parseMembersFromTsType(type.indexType, diagnostics, linkContext) ] } if (ts.isFunctionTypeNode(type) || ts.isConstructorTypeNode(type)) { return parseMembersFromTsType(type.type, diagnostics, linkContext) } return [] } function memberName(member: ts.TypeElement | ts.ClassElement): string { if ( ts.isPropertySignature(member) || ts.isMethodSignature(member) || ts.isPropertyDeclaration(member) || ts.isMethodDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member) ) { const name = member.name if (ts.isComputedPropertyName(name)) return name.expression.getText(member.getSourceFile()) return name.getText(member.getSourceFile()).replace(/^[']|[']$/g, "") } return "" } function memberType(member: ts.TypeElement | ts.ClassElement): ts.TypeNode | undefined { if ( ts.isPropertySignature(member) || ts.isMethodSignature(member) || ts.isPropertyDeclaration(member) || ts.isMethodDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member) ) return member.type return undefined } function parseTsMembers( members: ts.NodeArray, diagnostics: Array, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): Array { const out: Array = [] for (const member of members) { if ( ts.isCallSignatureDeclaration(member) || ts.isConstructSignatureDeclaration(member) || ts.isIndexSignatureDeclaration(member) || member.kind === ts.SyntaxKind.Constructor ) continue const documented = parseDocumentedTs(member, "member", diagnostics, false, linkContext) if (documented === undefined) continue const name = memberName(member) if (name === "") continue const nested = parseMembersFromTsType(memberType(member), diagnostics, linkContext) out.push({ name, signature: null, range: nodeRange(member), description: documented.core.description, examples: documented.core.examples, tags: documented.tags as ParsedMemberTags, members: nested }) } return out } function parseDeclarationMembersTs( node: ts.Node, diagnostics: Array, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): Array { if (ts.isInterfaceDeclaration(node)) return parseTsMembers(node.members, diagnostics, linkContext) if (ts.isClassDeclaration(node)) return parseTsMembers(node.members, diagnostics, linkContext) if (ts.isTypeAliasDeclaration(node)) return parseMembersFromTsType(node.type, diagnostics, linkContext) if (ts.isVariableStatement(node)) { return node.declarationList.declarations.flatMap((d) => parseMembersFromTsType(d.type, diagnostics, linkContext)) } if (ts.isFunctionDeclaration(node)) return parseMembersFromTsType(node.type, diagnostics, linkContext) return [] } function hasDeclareModifier(node: ts.Node): boolean { return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) ?? false) } function parseNamespaceTs( node: ts.ModuleDeclaration, diagnostics: Array, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }, isInsideDeclare = false ): ParsedNamespace | undefined { if (!ts.isIdentifier(node.name)) return undefined if (!isInsideDeclare && !hasDeclareModifier(node)) { diagnostics.push({ ...diagnostic("namespace-declare", "Namespaces must be declared with declare namespace"), range: nodeRange(node) }) return undefined } const documented = parseDocumentedTs(node, "namespace", diagnostics, true, linkContext) if (documented === undefined) return undefined const declarations: Array = [] const namespaces: Array = [] if (node.body !== undefined && ts.isModuleBlock(node.body)) { for (const statement of node.body.statements) { if (!hasExportModifier(statement)) continue if (ts.isModuleDeclaration(statement)) { const nested = parseNamespaceTs(statement, diagnostics, linkContext, true) if (nested !== undefined) namespaces.push(nested) continue } if (ts.isEnumDeclaration(statement)) { diagnostics.push({ ...diagnostic("enum", "Enums are not allowed"), range: nodeRange(statement) }) continue } const bucket = bucketOfTs(statement) if (bucket !== "type") { diagnostics.push({ ...diagnostic("namespace-value", "Namespace exports must be type declarations"), range: nodeRange(statement) }) continue } const nestedDocumented = parseDocumentedTs(statement, "namespace-declaration", diagnostics, true, linkContext) if (nestedDocumented === undefined) continue const name = declarationName(statement) if (name === undefined || name.length === 0) { diagnostics.push({ ...diagnostic("missing-name", "Namespace declaration name could not be determined"), range: nodeRange(statement) }) continue } declarations.push({ name, signature: null, range: publicDeclarationRange(statement, name), description: nestedDocumented.core.description, examples: nestedDocumented.core.examples, tags: nestedDocumented.tags as ParsedNamespaceTags, members: parseDeclarationMembersTs(statement, diagnostics, linkContext) }) } } return { name: node.name.text, signature: null, range: nodeRange(node), description: documented.core.description, examples: documented.core.examples, tags: documented.tags as ParsedNamespaceTags, declarations, namespaces } } function skipLeadingIgnoredTrivia(source: string): number { let index = source.charCodeAt(0) === 0xfeff ? 1 : 0 while (index < source.length) { while (index < source.length && /\s/.test(source[index])) index++ if (!source.startsWith("//", index)) return index const lineEnd = source.indexOf("\n", index) const end = lineEnd === -1 ? source.length : lineEnd + 1 if (!isSkippableDirectiveComment(source.slice(index, end))) return index index = end } return index } function sourceFileTopLevelJSDoc(sourceFile: ts.SourceFile): ParsedModuleJSDoc | undefined { const source = sourceFile.text const start = skipLeadingIgnoredTrivia(source) if (!source.startsWith("/**", start)) return undefined const end = source.indexOf("*/", start + 3) if (end === -1) return undefined const range = [start, end + 2] as const const firstStatement = sourceFile.statements[0] if (firstStatement !== undefined) { const jsDocs = (firstStatement as { readonly jsDoc?: ReadonlyArray }).jsDoc ?? [] if ( jsDocs.some((jsDoc) => jsDoc.pos === range[0] && jsDoc.end === range[1]) && !ts.isImportDeclaration(firstStatement) && !ts.isImportEqualsDeclaration(firstStatement) ) return undefined } return { raw: source.slice(range[0], range[1]), range } } function parseModuleJSDocTs( sourceFile: ts.SourceFile, moduleJSDoc: ParsedModuleJSDoc, diagnostics: Array, linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string } ): ParsedModuleJSDoc | undefined { const block = parseLooseJSDocBlock(moduleJSDoc.raw, [moduleJSDoc.range[0], moduleJSDoc.range[1]]) if (block.internal) return undefined const tags = buildTags("module", block.tags) if (tags._tag === "Failure") addModelDiagnostics(diagnostics, block.range, tags.error.diagnostics) const examples = parseModuleExamples(block) addModelDiagnostics(diagnostics, block.range, examples.diagnostics) addJSDocLinkDiagnostics(sourceFile, block, diagnostics, linkContext) return moduleJSDoc } function parseSourceFileDocs( cwd: string, sourceFile: ts.SourceFile, checker: ts.TypeChecker, entry: ProgramCacheEntry ): { readonly parsed: ParsedJSDocFile; readonly diagnostics: ReadonlyArray } { const diagnostics: Array = [] const declarations: Array = [] const namespaces: Array = [] const linkContext = { checker, entry, cwd } const checkedFunctionOverloads = new Set() const moduleJSDoc = sourceFileTopLevelJSDoc(sourceFile) const parsedModuleJSDoc = moduleJSDoc === undefined ? undefined : parseModuleJSDocTs(sourceFile, moduleJSDoc, diagnostics, linkContext) for (const statement of sourceFile.statements) { if (ts.isExportAssignment(statement)) continue if (ts.isExportDeclaration(statement)) { if (statement.exportClause === undefined) continue if (ts.isNamedExports(statement.exportClause) && statement.exportClause.elements.length === 0) { diagnostics.push({ ...diagnostic("empty-export", "Empty export declarations are not allowed"), range: nodeRange(statement) }) } if (ts.isNamedExports(statement.exportClause)) { for (const specifier of statement.exportClause.elements) { const documented = parseDocumentedTs(specifier, "declaration", diagnostics, true, linkContext) if (documented === undefined) continue declarations.push({ name: specifier.name.text, bucket: statement.isTypeOnly || specifier.isTypeOnly ? "type" : "value", signature: exportSpecifierSignature( specifier.name.text, statement.isTypeOnly || specifier.isTypeOnly ? "type" : "value", specifier, checker, cwd ), range: nodeRange(specifier), description: documented.core.description, examples: documented.core.examples, tags: documented.tags as ParsedDeclarationTags, members: [] }) } } continue } if (!hasExportModifier(statement) || hasDefaultModifier(statement)) continue if (ts.isModuleDeclaration(statement)) { const namespace = parseNamespaceTs(statement, diagnostics, linkContext) if (namespace !== undefined) namespaces.push(namespace) continue } if (ts.isEnumDeclaration(statement)) { diagnostics.push({ ...diagnostic("enum", "Enums are not allowed"), range: nodeRange(statement) }) continue } const bucket = bucketOfTs(statement) if (bucket === undefined) continue if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) { const name = statement.name.text if (statement.body === undefined) { if (checkedFunctionOverloads.has(name)) continue checkedFunctionOverloads.add(name) } else if (checkedFunctionOverloads.has(name)) { continue } } const documented = parseDocumentedTs(statement, "declaration", diagnostics, true, linkContext) if (documented === undefined) continue const name = declarationName(statement) if (name === undefined || name.length === 0) { diagnostics.push({ ...diagnostic("missing-name", "Root declaration name could not be determined"), range: nodeRange(statement) }) continue } declarations.push({ name, bucket, signature: declarationSignature(name, bucket, statement, checker, cwd), range: publicDeclarationRange(statement, name), description: documented.core.description, examples: documented.core.examples, tags: documented.tags as ParsedDeclarationTags, members: parseDeclarationMembersTs(statement, diagnostics, linkContext) }) } return { parsed: { ...(parsedModuleJSDoc === undefined ? {} : { moduleJSDoc: parsedModuleJSDoc }), declarations, namespaces }, diagnostics: uniqueDiagnostics(diagnostics) as Array } } export function loadJSDocConfig(cwd = process.cwd(), configPath = "jsdocs.config.json"): JSDocConfig { const absolute = path.resolve(cwd, configPath) const parsed = JSON.parse(fs.readFileSync(absolute, "utf8")) as JSDocConfig return parsed } export function extractJSDocsSync(options: ExtractJSDocsOptions): JSDocModel { const cwd = path.resolve(options.cwd ?? process.cwd()) const tsconfigPath = path.resolve(cwd, options.tsconfig) const entry = getProgram(tsconfigPath) if (entry.error !== undefined || entry.program === undefined) { throw new Error(entry.error ?? `Unable to read ${tsconfigPath}`) } const program = entry.program const checker = program.getTypeChecker() const sourceByPath = new Map(program.getSourceFiles().map((file) => [path.resolve(file.fileName), file])) const sourceFilesByFile = new Map(program.getSourceFiles().map((file) => [normalizeFile(cwd, file.fileName), file])) const files = globSync([...options.include], { cwd, absolute: true, nodir: true, ignore: [...options.exclude ?? []] }).sort() const modelFiles: Array = [] for (const filename of files) { const source = fs.readFileSync(filename, "utf8") const file = normalizeFile(cwd, filename) const hash = hashSource(source) const sourceFile = sourceByPath.get(path.resolve(filename)) if (sourceFile === undefined) { modelFiles.push({ file, hash, diagnostics: [{ ...diagnostic("tsconfig", `${file} is not included in ${options.tsconfig}`), range: [0, 0] }], declarations: [], namespaces: [] }) continue } const result = parseSourceFileDocs(cwd, sourceFile, checker, entry) if ( result.diagnostics.length === 0 && result.parsed.declarations.length === 0 && result.parsed.namespaces.length === 0 && result.parsed.moduleJSDoc === undefined ) continue const fileDiagnostics = [...result.diagnostics] let importsValue: ParsedJSDocImports | undefined if ( result.parsed.declarations.length > 0 || result.parsed.namespaces.length > 0 || result.parsed.moduleJSDoc !== undefined ) { const imports = resolveJSDocImports(cwd, filename) if (imports._tag === "Success") importsValue = imports.value else { fileDiagnostics.push({ ...diagnostic( "imports", `Unable to resolve jsdocs imports: ${imports.error}. Add the missing barrel/package export or exclude this file from jsdocs.` ), range: [0, 0] }) } } modelFiles.push({ file, hash, diagnostics: fileDiagnostics, ...(importsValue === undefined ? {} : { imports: importsValue }), ...result.parsed }) } const filesWithExampleDiagnostics = appendExampleImportDiagnostics(modelFiles) const withPublicSeeDiagnostics = buildJSDocApisWithPublicSeeDiagnostics(filesWithExampleDiagnostics, { sourceFilesByFile, checker, entry, cwd }) return { version: 2, generatedBy: "@effect/jsdocs", generatedAt: new Date().toISOString(), inputHash: computeJSDocInputHash(options), files: withPublicSeeDiagnostics.files, apis: withPublicSeeDiagnostics.apis } } export const extractJSDocs = (options: ExtractJSDocsOptions): Effect.Effect => Effect.sync(() => extractJSDocsSync(options)) export function writeJSDocModel(cwd: string, output: string, model: JSDocModel) { const filename = path.resolve(cwd, output) fs.mkdirSync(path.dirname(filename), { recursive: true }) fs.writeFileSync(filename, `${JSON.stringify(model, null, 2)}\n`) } export function readJSDocModel(filename: string): Result { if (!fs.existsSync(filename)) return { _tag: "Failure", error: "missing" } try { const parsed = JSON.parse(fs.readFileSync(filename, "utf8")) as JSDocModel if (parsed.version !== 2) return { _tag: "Failure", error: "Unsupported jsdocs model version" } if (!Array.isArray(parsed.files)) return { _tag: "Failure", error: "Invalid jsdocs model: files must be an array" } if (!Array.isArray(parsed.apis)) return { _tag: "Failure", error: "Invalid jsdocs model: apis must be an array" } return { _tag: "Success", value: parsed } } catch (error) { return { _tag: "Failure", error: `Invalid jsdocs model: ${error instanceof Error ? error.message : String(error)}` } } } export function sourceHash(source: string): string { return hashSource(source) }