1/*
2AI prompt for writing Effect public API JSDoc:
3
4This package owns Effect public API JSDoc parsing, extraction, and model generation.
5*/
6
7import * as Effect from "effect/Effect"
8import { globSync } from "glob"
9import * as crypto from "node:crypto"
10import * as fs from "node:fs"
11import * as path from "node:path"
12import * as ts from "typescript"
13
14type ExportBucket = "value" | "type"
15type DocScope = "module" | "declaration" | "namespace" | "namespace-declaration" | "member"
16
17type Result<A, E> =
18 | { readonly _tag: "Success"; readonly value: A }
19 | { readonly _tag: "Failure"; readonly error: E }
20
21interface SourceFileWithParseDiagnostics extends ts.SourceFile {
22 readonly parseDiagnostics: ReadonlyArray<ts.Diagnostic>
23}
24
25/**
26 * Result type returned by the JSDoc parser helpers.
27 *
28 * @category utility types
29 * @since 4.0.0
30 */
31export type JSDocResult<A, E> = Result<A, E>
32
33/**
34 * Diagnostic emitted when a JSDoc block does not follow the standard shape.
35 *
36 * @category models
37 * @since 4.0.0
38 */
39export interface JSDocDiagnostic {
40 readonly code: string
41 readonly message: string
42}
43
44/**
45 * Parse error containing all diagnostics collected for a JSDoc block or file.
46 *
47 * @category models
48 * @since 4.0.0
49 */
50export interface JSDocParseError {
51 readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
52}
53
54/**
55 * Parsed `@see` tag text and any inline links it contains.
56 *
57 * @category models
58 * @since 4.0.0
59 */
60export interface ParsedSeeTag {
61 readonly text: string
62 readonly links: ReadonlyArray<ParsedInlineLink>
63}
64
65/**
66 * TypeScript symbol target resolved for a parsed inline JSDoc link.
67 *
68 * @category models
69 * @since 4.0.0
70 */
71export interface ParsedInlineLinkSymbol {
72 readonly file: string
73 readonly range: readonly [number, number]
74}
75
76/**
77 * Parsed inline JSDoc link target and optional display text.
78 *
79 * @category models
80 * @since 4.0.0
81 */
82export interface ParsedInlineLink {
83 readonly raw: string
84 readonly target: string
85 readonly text: string | null
86 readonly range?: readonly [number, number]
87 readonly symbol?: ParsedInlineLinkSymbol
88}
89
90/**
91 * Parsed JSDoc description sections.
92 *
93 * @category models
94 * @since 4.0.0
95 */
96export interface ParsedDescription {
97 readonly short: string
98 readonly whenToUse: string | null
99 readonly details: string | null
100 readonly gotchas: string | null
101}
102
103/**
104 * Parsed example section from a JSDoc block.
105 *
106 * @category models
107 * @since 4.0.0
108 */
109export interface ParsedExample {
110 readonly title: string
111 readonly body: string | null
112 readonly code: string
113}
114
115/**
116 * Raw module-level JSDoc block collected from the top of a checked file.
117 *
118 * @category models
119 * @since 4.0.0
120 */
121export interface ParsedModuleJSDoc {
122 readonly raw: string
123 readonly range: readonly [number, number]
124}
125
126/**
127 * Stability of a documented public API. Untagged APIs are stable.
128 *
129 * @category models
130 * @since 4.0.0
131 */
132export type JSDocStability = "stable" | "unstable" | "experimental"
133
134interface ParsedModuleTags {
135 readonly since: string
136 readonly stability: JSDocStability
137 readonly deprecated: string | null
138 readonly see: ReadonlyArray<ParsedSeeTag>
139}
140
141/**
142 * Parsed tags required for a root public declaration.
143 *
144 * @category models
145 * @since 4.0.0
146 */
147export interface ParsedDeclarationTags {
148 readonly category: string
149 readonly since: string
150 readonly stability: JSDocStability
151 readonly deprecated: string | null
152 readonly see: ReadonlyArray<ParsedSeeTag>
153}
154
155/**
156 * Parsed tags allowed for namespace JSDoc.
157 *
158 * @category models
159 * @since 4.0.0
160 */
161export interface ParsedNamespaceTags {
162 readonly category: string | null
163 readonly since: string
164 readonly stability: JSDocStability
165 readonly deprecated: string | null
166 readonly see: ReadonlyArray<ParsedSeeTag>
167}
168
169/**
170 * Parsed tags allowed for documented members.
171 *
172 * @category models
173 * @since 4.0.0
174 */
175export interface ParsedMemberTags {
176 readonly since: string | null
177 readonly stability: JSDocStability
178 readonly default: string | null
179 readonly deprecated: string | null
180 readonly see: ReadonlyArray<ParsedSeeTag>
181}
182
183/**
184 * Parsed documented member within a declaration or another member.
185 *
186 * @category models
187 * @since 4.0.0
188 */
189export interface ParsedMember {
190 readonly name: string
191 readonly signature: string | null
192 readonly range: readonly [number, number]
193 readonly description: ParsedDescription
194 readonly examples: ReadonlyArray<ParsedExample>
195 readonly tags: ParsedMemberTags
196 readonly members: ReadonlyArray<ParsedMember>
197}
198
199/**
200 * Parsed root declaration exported from a checked file.
201 *
202 * @category models
203 * @since 4.0.0
204 */
205export interface ParsedRootDeclaration {
206 readonly name: string
207 readonly bucket: ExportBucket
208 readonly signature: string | null
209 readonly range: readonly [number, number]
210 readonly description: ParsedDescription
211 readonly examples: ReadonlyArray<ParsedExample>
212 readonly tags: ParsedDeclarationTags
213 readonly members: ReadonlyArray<ParsedMember>
214}
215
216/**
217 * Parsed type declaration exported from inside a namespace.
218 *
219 * @category models
220 * @since 4.0.0
221 */
222export interface ParsedNamespaceDeclaration {
223 readonly name: string
224 readonly signature: string | null
225 readonly range: readonly [number, number]
226 readonly description: ParsedDescription
227 readonly examples: ReadonlyArray<ParsedExample>
228 readonly tags: ParsedNamespaceTags
229 readonly members: ReadonlyArray<ParsedMember>
230}
231
232/**
233 * Parsed namespace and its documented exported type declarations.
234 *
235 * @category models
236 * @since 4.0.0
237 */
238export interface ParsedNamespace {
239 readonly name: string
240 readonly signature: string | null
241 readonly range: readonly [number, number]
242 readonly description: ParsedDescription
243 readonly examples: ReadonlyArray<ParsedExample>
244 readonly tags: ParsedNamespaceTags
245 readonly declarations: ReadonlyArray<ParsedNamespaceDeclaration>
246 readonly namespaces: ReadonlyArray<ParsedNamespace>
247}
248
249/**
250 * Parsed public JSDoc data collected from one checked file.
251 *
252 * @category models
253 * @since 4.0.0
254 */
255export interface ParsedJSDocFile {
256 readonly moduleJSDoc?: ParsedModuleJSDoc
257 readonly declarations: ReadonlyArray<ParsedRootDeclaration>
258 readonly namespaces: ReadonlyArray<ParsedNamespace>
259}
260
261/**
262 * Barrel import metadata for a public module included in the JSDoc dump.
263 *
264 * @category models
265 * @since 4.0.0
266 */
267export type ParsedJSDocBarrelImport =
268 | {
269 readonly type: "namespace"
270 readonly module: string
271 readonly name: string
272 }
273 | {
274 readonly type: "flat"
275 readonly module: string
276 }
277
278/**
279 * Import specifiers for a public module included in the JSDoc dump.
280 *
281 * @category models
282 * @since 4.0.0
283 */
284export interface ParsedJSDocImports {
285 readonly module: string
286 readonly barrel: ParsedJSDocBarrelImport | null
287 readonly flatNames: ReadonlyArray<string>
288}
289
290/**
291 * Kinds of public API records represented in a JSDoc model.
292 *
293 * @category models
294 * @since 4.0.0
295 */
296export type JSDocApiKind = "root-declaration" | "namespace" | "namespace-declaration" | "member"
297
298/**
299 * Recommended import declaration and usage for an importable API.
300 *
301 * @category models
302 * @since 4.0.0
303 */
304export type JSDocApiImportGuidance =
305 | {
306 readonly style: "namespace-barrel"
307 readonly importDeclaration: string
308 readonly usage: string
309 }
310 | {
311 readonly style: "named-barrel"
312 readonly importDeclaration: string
313 readonly usage: string
314 }
315 | {
316 readonly style: "named-module"
317 readonly importDeclaration: string
318 readonly usage: string
319 }
320
321/**
322 * Resolution result for a link in an API's `@see` tags.
323 *
324 * @category models
325 * @since 4.0.0
326 */
327export type JSDocApiSeeLinkResolution =
328 | {
329 readonly _tag: "Resolved"
330 readonly apiId: string
331 readonly apiFqn: string
332 }
333 | {
334 readonly _tag: "Unresolved"
335 readonly reason: "not-found" | "ambiguous"
336 readonly candidates: ReadonlyArray<string>
337 }
338
339/**
340 * Parsed `@see` link paired with its public API resolution.
341 *
342 * @category models
343 * @since 4.0.0
344 */
345export interface JSDocApiSeeLink extends ParsedInlineLink {
346 readonly resolution: JSDocApiSeeLinkResolution
347}
348
349/**
350 * Parsed `@see` tag and its resolved links.
351 *
352 * @category models
353 * @since 4.0.0
354 */
355export interface JSDocApiSeeTag {
356 readonly text: string
357 readonly links: ReadonlyArray<JSDocApiSeeLink>
358}
359
360/**
361 * Standard tags attached to a public API record.
362 *
363 * @category models
364 * @since 4.0.0
365 */
366export interface JSDocApiTags {
367 readonly category: string | null
368 readonly since: string | null
369 readonly stability: JSDocStability
370 readonly deprecated: string | null
371 readonly default: string | null
372}
373
374/**
375 * One public API record in an extracted JSDoc model.
376 *
377 * @category models
378 * @since 4.0.0
379 */
380export interface JSDocApi {
381 readonly id: string
382 readonly kind: JSDocApiKind
383 readonly moduleName: string
384 readonly apiName: string
385 readonly localName: string
386 readonly signature: string | null
387 readonly parentApiName: string | null
388 readonly apiFqn: string
389 readonly hasFqnCollision: boolean
390 readonly bucket: ExportBucket | null
391 readonly importable: boolean
392 readonly namespacePath: ReadonlyArray<string>
393 readonly memberPath: ReadonlyArray<string>
394 readonly source: {
395 readonly file: string
396 readonly path: ReadonlyArray<string | number>
397 readonly range: readonly [number, number]
398 }
399 readonly description: ParsedDescription
400 readonly examples: ReadonlyArray<ParsedExample>
401 readonly tags: JSDocApiTags
402 readonly see: ReadonlyArray<JSDocApiSeeTag>
403 readonly importGuidance: JSDocApiImportGuidance | null
404}
405
406interface ParsedBarrelExports {
407 readonly namespace: ReadonlyMap<string, string>
408 readonly flat: ReadonlySet<string>
409 readonly named: ReadonlyMap<string, ReadonlySet<string>>
410}
411
412/**
413 * Parsed public JSDoc data paired with the normalized source file path.
414 *
415 * @category models
416 * @since 4.0.0
417 */
418export interface ParsedJSDocFileDumpEntry extends ParsedJSDocFile {
419 readonly file: string
420 readonly imports: ParsedJSDocImports
421}
422
423interface AstNode {
424 readonly type: string
425 readonly range: [number, number]
426 readonly [key: string]: any
427}
428
429interface JSDocTag {
430 readonly name: string
431 value: string
432 readonly line: number
433}
434
435interface JSDocBlock {
436 readonly range: [number, number]
437 readonly raw: string
438 readonly lines: Array<string>
439 readonly tags: Array<JSDocTag>
440 readonly parsed?: ParsedCoreJSDoc
441 readonly diagnostics: Array<JSDocDiagnostic>
442 readonly internal: boolean
443}
444
445interface ParsedCoreJSDoc {
446 readonly description: ParsedDescription
447 readonly examples: ReadonlyArray<ParsedExample>
448 readonly tags: ReadonlyArray<JSDocTag>
449}
450
451interface ProgramCacheEntry {
452 readonly program?: ts.Program
453 readonly error?: string
454 reported: boolean
455}
456
457interface PackageMetadata {
458 readonly root: string
459 readonly sourceRoot: string
460 readonly name: string
461 readonly exports: unknown
462}
463
464interface PackageExportMatch {
465 readonly key: string
466 readonly value: unknown
467 readonly star: string | null
468 readonly specificity: number
469 readonly prefixLength: number
470}
471
472interface ParsedConfigResult {
473 readonly parsed?: ts.ParsedCommandLine
474 readonly error?: string
475}
476
477const programCache = new Map<string, ProgramCacheEntry>()
478const packageMetadataCache = new Map<string, Result<PackageMetadata, string>>()
479const barrelExportCache = new Map<string, Result<ParsedBarrelExports, string>>()
480const tagOrder = new Map([
481 ["deprecated", 0],
482 ["default", 1],
483 ["see", 2],
484 ["stability", 3],
485 ["category", 4],
486 ["since", 5]
487])
488const signatureTypeFormatFlags = ts.TypeFormatFlags.NoTruncation |
489 ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType
490
491const stableSemverRegex = /^\d+\.\d+\.\d+$/
492const urlRegex = /^https?:\/\//
493
494function diagnostic(code: string, message: string): JSDocDiagnostic {
495 return { code, message }
496}
497
498function normalizePathName(filePath: string): string {
499 return filePath.replaceAll(path.sep, "/")
500}
501
502function normalizePackagePath(filePath: string): string {
503 return normalizePathName(filePath).replace(/^\.\//, "./")
504}
505
506function escapeRegExp(value: string): string {
507 return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
508}
509
510function globToRegExp(glob: string): RegExp {
511 let source = "^"
512 for (let index = 0; index < glob.length; index++) {
513 const char = glob[index]
514 const next = glob[index + 1]
515 if (char === "*") {
516 if (next === "*") {
517 source += ".*"
518 index++
519 } else {
520 source += "[^/]*"
521 }
522 } else if (char === "?") {
523 source += "[^/]"
524 } else {
525 source += escapeRegExp(char)
526 }
527 }
528 return new RegExp(`${source}$`)
529}
530
531/**
532 * Creates a predicate that checks whether a filename is included by the configured JSDoc globs.
533 *
534 * @category constructors
535 * @since 4.0.0
536 */
537export function createJSDocFileMatcher(options: {
538 readonly cwd: string
539 readonly include?: ReadonlyArray<string>
540 readonly exclude?: ReadonlyArray<string>
541}): (filename: string) => boolean {
542 const include = options.include?.map(globToRegExp)
543 const exclude = options.exclude?.map(globToRegExp)
544 return (filename) => {
545 const normalizedFilename = normalizePathName(filename)
546 const relativeFilename = normalizePathName(path.relative(options.cwd, filename))
547 const matches = (regexp: RegExp) => regexp.test(normalizedFilename) || regexp.test(relativeFilename)
548 if (include !== undefined && !include.some(matches)) {
549 return false
550 }
551 if (exclude !== undefined && exclude.some(matches)) {
552 return false
553 }
554 return true
555 }
556}
557
558function isRecord(value: unknown): value is Record<string, unknown> {
559 return typeof value === "object" && value !== null && !Array.isArray(value)
560}
561
562function findPackageRoot(filename: string): string | undefined {
563 let directory = path.dirname(path.resolve(filename))
564 while (true) {
565 const packageJsonPath = path.join(directory, "package.json")
566 if (fs.existsSync(packageJsonPath)) {
567 return directory
568 }
569 const parent = path.dirname(directory)
570 if (parent === directory) {
571 return undefined
572 }
573 directory = parent
574 }
575}
576
577function readPackageMetadata(root: string): Result<PackageMetadata, string> {
578 const normalizedRoot = path.resolve(root)
579 const cached = packageMetadataCache.get(normalizedRoot)
580 if (cached !== undefined) {
581 return cached
582 }
583 const packageJsonPath = path.join(normalizedRoot, "package.json")
584 let parsed: unknown
585 try {
586 parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
587 } catch (error) {
588 const result = {
589 _tag: "Failure",
590 error: `unable to read package.json at ${packageJsonPath}: ${String(error)}`
591 } as const
592 packageMetadataCache.set(normalizedRoot, result)
593 return result
594 }
595 if (!isRecord(parsed)) {
596 const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must be an object` } as const
597 packageMetadataCache.set(normalizedRoot, result)
598 return result
599 }
600 if (typeof parsed.name !== "string" || parsed.name.length === 0) {
601 const result = {
602 _tag: "Failure",
603 error: `package.json at ${packageJsonPath} must include a non-empty name`
604 } as const
605 packageMetadataCache.set(normalizedRoot, result)
606 return result
607 }
608 if (parsed.exports === undefined) {
609 const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must include exports` } as const
610 packageMetadataCache.set(normalizedRoot, result)
611 return result
612 }
613 const result = {
614 _tag: "Success",
615 value: {
616 root: normalizedRoot,
617 sourceRoot: path.join(normalizedRoot, "src"),
618 name: parsed.name,
619 exports: parsed.exports
620 }
621 } as const
622 packageMetadataCache.set(normalizedRoot, result)
623 return result
624}
625
626function parseBarrelExports(indexPath: string): Result<ParsedBarrelExports, string> {
627 const normalizedIndexPath = path.resolve(indexPath)
628 const cached = barrelExportCache.get(normalizedIndexPath)
629 if (cached !== undefined) {
630 return cached
631 }
632 let source: string
633 try {
634 source = fs.readFileSync(normalizedIndexPath, "utf8")
635 } catch (error) {
636 const result = {
637 _tag: "Failure",
638 error: `unable to read barrel file ${normalizedIndexPath}: ${String(error)}`
639 } as const
640 barrelExportCache.set(normalizedIndexPath, result)
641 return result
642 }
643 const sourceFile = ts.createSourceFile(normalizedIndexPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
644 const namespace = new Map<string, string>()
645 const flat = new Set<string>()
646 const named = new Map<string, Set<string>>()
647 for (const statement of sourceFile.statements) {
648 if (
649 ts.isExportDeclaration(statement) &&
650 statement.moduleSpecifier !== undefined &&
651 ts.isStringLiteralLike(statement.moduleSpecifier)
652 ) {
653 const moduleSpecifier = normalizePathName(statement.moduleSpecifier.text)
654 if (statement.exportClause === undefined) {
655 flat.add(moduleSpecifier)
656 } else if (ts.isNamespaceExport(statement.exportClause)) {
657 namespace.set(moduleSpecifier, statement.exportClause.name.text)
658 } else if (ts.isNamedExports(statement.exportClause)) {
659 const names = named.get(moduleSpecifier) ?? new Set<string>()
660 for (const specifier of statement.exportClause.elements) {
661 names.add(specifier.name.text)
662 }
663 named.set(moduleSpecifier, names)
664 }
665 }
666 }
667 const result = { _tag: "Success", value: { namespace, flat, named } } as const
668 barrelExportCache.set(normalizedIndexPath, result)
669 return result
670}
671
672function getPackageExportMatch(exports: unknown, subpath: string): PackageExportMatch | undefined {
673 if (!isRecord(exports)) {
674 return subpath === "." ? { key: ".", value: exports, star: null, specificity: 1, prefixLength: 1 } : undefined
675 }
676 if (Object.hasOwn(exports, subpath)) {
677 return {
678 key: subpath,
679 value: exports[subpath],
680 star: null,
681 specificity: Number.MAX_SAFE_INTEGER,
682 prefixLength: subpath.length
683 }
684 }
685 const matches: Array<PackageExportMatch> = []
686 for (const [key, value] of Object.entries(exports)) {
687 const starIndex = key.indexOf("*")
688 if (!key.startsWith(".") || starIndex === -1) {
689 continue
690 }
691 const prefix = key.slice(0, starIndex)
692 const suffix = key.slice(starIndex + 1)
693 if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
694 continue
695 }
696 matches.push({
697 key,
698 value,
699 star: subpath.slice(prefix.length, subpath.length - suffix.length),
700 specificity: prefix.length + suffix.length,
701 prefixLength: prefix.length
702 })
703 }
704 return matches.sort((a, b) =>
705 b.specificity - a.specificity || b.prefixLength - a.prefixLength || b.key.length - a.key.length
706 )[0]
707}
708
709function packageExportTargetMatches(value: unknown, star: string | null, expectedTarget: string): boolean {
710 if (typeof value === "string") {
711 const target = normalizePackagePath(star === null ? value : value.replaceAll("*", star))
712 return target === expectedTarget
713 }
714 if (value === null || value === undefined) {
715 return false
716 }
717 if (Array.isArray(value)) {
718 return value.some((item) => packageExportTargetMatches(item, star, expectedTarget))
719 }
720 if (isRecord(value)) {
721 return Object.values(value).some((item) => packageExportTargetMatches(item, star, expectedTarget))
722 }
723 return false
724}
725
726function isPackageSubpathExported(
727 packageExports: unknown,
728 subpath: string,
729 expectedTarget: string
730): boolean {
731 const match = getPackageExportMatch(packageExports, subpath)
732 return match !== undefined && packageExportTargetMatches(match.value, match.star, expectedTarget)
733}
734
735function isPathInside(parent: string, child: string): boolean {
736 const relative = path.relative(parent, child)
737 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
738}
739
740function packageSpecifier(packageName: string, subpath: string): string {
741 return subpath === "" ? packageName : `${packageName}/${subpath}`
742}
743
744function resolveBarrelImport(
745 metadata: PackageMetadata,
746 filename: string
747): Result<{ readonly barrel: ParsedJSDocBarrelImport | null; readonly flatNames: ReadonlyArray<string> }, string> {
748 let directory = path.dirname(filename)
749 while (isPathInside(metadata.sourceRoot, directory)) {
750 const indexPath = path.join(directory, "index.ts")
751 if (indexPath !== filename && fs.existsSync(indexPath)) {
752 const expectedExport = `./${normalizePathName(path.relative(directory, filename))}`
753 const parsed = parseBarrelExports(indexPath)
754 if (parsed._tag === "Failure") {
755 return parsed
756 }
757 const barrelSubpath = normalizePathName(path.relative(metadata.sourceRoot, directory))
758 const exportSubpath = barrelSubpath === "" ? "." : `./${barrelSubpath}`
759 const exportTarget = barrelSubpath === "" ? "./src/index.ts" : `./src/${barrelSubpath}/index.ts`
760 if (isPackageSubpathExported(metadata.exports, exportSubpath, exportTarget)) {
761 const moduleSpecifier = packageSpecifier(metadata.name, barrelSubpath)
762 const flatNames = Array.from(parsed.value.named.get(expectedExport) ?? [])
763 const namespace = parsed.value.namespace.get(expectedExport)
764 if (namespace !== undefined) {
765 return {
766 _tag: "Success",
767 value: { barrel: { type: "namespace", module: moduleSpecifier, name: namespace }, flatNames }
768 }
769 }
770 if (parsed.value.flat.has(expectedExport)) {
771 return {
772 _tag: "Success",
773 value: { barrel: { type: "flat", module: moduleSpecifier }, flatNames }
774 }
775 }
776 if (flatNames.length > 0) {
777 return {
778 _tag: "Success",
779 value: { barrel: { type: "flat", module: moduleSpecifier }, flatNames }
780 }
781 }
782 }
783 }
784 if (directory === metadata.sourceRoot) {
785 break
786 }
787 directory = path.dirname(directory)
788 }
789 return { _tag: "Success", value: { barrel: null, flatNames: [] } }
790}
791
792function resolveJSDocImports(
793 cwd: string,
794 filename: string
795): Result<ParsedJSDocImports, string> {
796 const absoluteFilename = path.resolve(filename)
797 const packageRoot = findPackageRoot(absoluteFilename)
798 const relativeFilename = normalizePathName(path.relative(cwd, absoluteFilename))
799 if (packageRoot === undefined) {
800 return { _tag: "Failure", error: `no package.json found for ${relativeFilename}` }
801 }
802 const metadata = readPackageMetadata(packageRoot)
803 if (metadata._tag === "Failure") {
804 return metadata
805 }
806 const relativeToSourceRoot = normalizePathName(path.relative(metadata.value.sourceRoot, absoluteFilename))
807 if (!isPathInside(metadata.value.sourceRoot, absoluteFilename)) {
808 return {
809 _tag: "Failure",
810 error: `${relativeFilename} is not under ${normalizePathName(path.relative(cwd, metadata.value.sourceRoot))}`
811 }
812 }
813 if (path.basename(absoluteFilename) === "index.ts") {
814 return {
815 _tag: "Failure",
816 error: `${relativeFilename} is a barrel file; exclude it from jsdocs`
817 }
818 }
819 if (path.extname(absoluteFilename) !== ".ts" || absoluteFilename.endsWith(".d.ts")) {
820 return {
821 _tag: "Failure",
822 error: `${relativeFilename} is not a TypeScript source module`
823 }
824 }
825 const moduleSubpath = relativeToSourceRoot.slice(0, -".ts".length)
826 const moduleExportSubpath = `./${moduleSubpath}`
827 const moduleExportTarget = `./src/${relativeToSourceRoot}`
828 if (!isPackageSubpathExported(metadata.value.exports, moduleExportSubpath, moduleExportTarget)) {
829 return {
830 _tag: "Failure",
831 error: `package.json exports do not expose ${
832 packageSpecifier(metadata.value.name, moduleSubpath)
833 } for ${relativeFilename}`
834 }
835 }
836 const barrel = resolveBarrelImport(metadata.value, absoluteFilename)
837 if (barrel._tag === "Failure") {
838 return barrel
839 }
840 return {
841 _tag: "Success",
842 value: {
843 module: packageSpecifier(metadata.value.name, moduleSubpath),
844 barrel: barrel.value.barrel,
845 flatNames: barrel.value.flatNames
846 }
847 }
848}
849
850/**
851 * Reads source text from an Oxlint-compatible rule context.
852 *
853 * @category getters
854 * @since 4.0.0
855 */
856export function getSourceText(context: {
857 readonly sourceCode: { readonly text?: string; getText(node?: unknown): string }
858}): string {
859 return context.sourceCode.text ?? context.sourceCode.getText()
860}
861
862/**
863 * Resolves the working directory from an Oxlint-compatible rule context.
864 *
865 * @category getters
866 * @since 4.0.0
867 */
868export function getCwd(context: { readonly cwd?: string; getCwd?: () => string }): string {
869 return context.cwd ?? context.getCwd?.() ?? process.cwd()
870}
871
872function skipWhitespace(source: string, end: number): number {
873 while (end > 0 && /\s/.test(source[end - 1])) {
874 end--
875 }
876 return end
877}
878
879function isSkippableDirectiveComment(line: string): boolean {
880 const trimmed = line.trim()
881 return trimmed.startsWith("// @ts-expect-error") ||
882 trimmed.startsWith("// @ts-ignore") ||
883 trimmed.startsWith("// @effect-diagnostics") ||
884 trimmed.startsWith("// eslint-disable-next-line") ||
885 trimmed.startsWith("// oxlint-disable-next-line")
886}
887
888function skipDirectiveComments(source: string, end: number): number {
889 while (end > 0) {
890 const lineStart = source.lastIndexOf("\n", end - 1) + 1
891 if (!isSkippableDirectiveComment(source.slice(lineStart, end))) {
892 return end
893 }
894 end = skipWhitespace(source, lineStart)
895 }
896 return end
897}
898
899/**
900 * Finds the JSDoc block immediately preceding an AST node.
901 *
902 * @category parsing
903 * @since 4.0.0
904 */
905export function findLeadingJSDoc(
906 source: string,
907 node: AstNode,
908 ignoredRange?: [number, number]
909): JSDocBlock | undefined {
910 const end = skipDirectiveComments(source, skipWhitespace(source, node.range[0]))
911 if (!source.slice(0, end).endsWith("*/")) {
912 return undefined
913 }
914
915 const start = source.lastIndexOf("/*", end)
916 if (start === -1 || source[start + 2] !== "*") {
917 return undefined
918 }
919 const range: [number, number] = [start, end]
920 if (ignoredRange !== undefined && range[0] === ignoredRange[0] && range[1] === ignoredRange[1]) {
921 return undefined
922 }
923 return parseJSDocBlock(source.slice(start, end), range)
924}
925
926/**
927 * Parses one raw JSDoc block into the standard description, example, and tag structure.
928 *
929 * **Details**
930 *
931 * 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.
932 *
933 * **Example** (Parsing a block)
934 *
935 * ```ts import.meta.vitest
936 * import { parseJSDoc } from "@effect/jsdocs"
937 *
938 * const rawBlock = [
939 * "/" + "**",
940 * " * A value.",
941 * " *",
942 * " * @category models",
943 * " * @since 4.0.0",
944 * " *" + "/"
945 * ].join("\n")
946 * const result = parseJSDoc(rawBlock)
947 *
948 * result._tag // => "Success"
949 * ```
950 *
951 * @category parsing
952 * @since 4.0.0
953 */
954export function parseJSDoc(raw: string): Result<ParsedCoreJSDoc, JSDocParseError> {
955 const block = parseJSDocBlock(raw, [0, raw.length])
956 if (block.internal) {
957 return { _tag: "Failure", error: { diagnostics: [diagnostic("internal", "Internal JSDoc blocks are ignored")] } }
958 }
959 if (block.diagnostics.length > 0 || block.parsed === undefined) {
960 return { _tag: "Failure", error: { diagnostics: block.diagnostics } }
961 }
962 return { _tag: "Success", value: block.parsed }
963}
964
965function parseLooseJSDocBlock(raw: string, range: [number, number]): JSDocBlock {
966 const body = raw.replace(/^\/\*\*/, "").replace(/\*\/$/, "")
967 const rawLines = body.split(/\r\n|\r|\n/)
968 const lines = rawLines.map((line) => line.replace(/^\s*\* ?/, "").trimEnd())
969 const normalized = trimStructuralBlankLines(lines)
970 const tags = parseTags(normalized)
971 const internal = tags.some((tag) => tag.name === "internal")
972 return { range, raw, lines: normalized, tags, diagnostics: [], internal }
973}
974
975function parseJSDocBlock(raw: string, range: [number, number]): JSDocBlock {
976 const base = parseLooseJSDocBlock(raw, range)
977 const { lines: normalized, tags, internal } = base
978 if (internal) {
979 return base
980 }
981 const result = parseCoreJSDoc(normalized, tags)
982 const block: JSDocBlock = {
983 ...base,
984 range,
985 raw,
986 lines: normalized,
987 tags,
988 diagnostics: result._tag === "Failure" ? Array.from(result.error.diagnostics) : [],
989 internal
990 }
991 return result._tag === "Success" ? { ...block, parsed: result.value } : block
992}
993
994function trimStructuralBlankLines(lines: Array<string>): Array<string> {
995 let start = 0
996 let end = lines.length
997 if (lines[start]?.trim() === "") {
998 start++
999 }
1000 if (end > start && lines[end - 1]?.trim() === "") {
1001 end--
1002 }
1003 return lines.slice(start, end)
1004}
1005
1006function parseTags(lines: Array<string>): Array<JSDocTag> {
1007 const tags: Array<JSDocTag> = []
1008 let current: JSDocTag | undefined
1009 let inFence = false
1010
1011 for (let index = 0; index < lines.length; index++) {
1012 const trimmed = lines[index].trim()
1013 if (trimmed.startsWith("```")) {
1014 inFence = !inFence
1015 current = undefined
1016 continue
1017 }
1018 if (inFence) {
1019 continue
1020 }
1021
1022 const match = /^@([A-Za-z][\w-]*)(?:\s+(.*))?$/.exec(trimmed)
1023 if (match) {
1024 current = { name: match[1], value: match[2]?.trim() ?? "", line: index }
1025 tags.push(current)
1026 continue
1027 }
1028
1029 if (current !== undefined && trimmed !== "") {
1030 current.value = current.value === "" ? trimmed : `${current.value}\n${trimmed}`
1031 } else if (trimmed === "") {
1032 current = undefined
1033 }
1034 }
1035
1036 return tags
1037}
1038
1039function parseCoreJSDoc(lines: Array<string>, tags: Array<JSDocTag>): Result<ParsedCoreJSDoc, JSDocParseError> {
1040 const diagnostics: Array<JSDocDiagnostic> = []
1041 const firstTagLine = tags[0]?.line ?? lines.length
1042 const content = lines.slice(0, firstTagLine)
1043
1044 if (lines.length === 0) {
1045 diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description"))
1046 }
1047 if (content.at(-1)?.trim() === "" && tags.length > 0) {
1048 // exactly one blank before tags is validated by ensuring the last content line is blank and the previous is not blank
1049 if (content.length < 2 || content[content.length - 2]?.trim() === "") {
1050 diagnostics.push(
1051 diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line")
1052 )
1053 }
1054 } else if (tags.length > 0 && content.length > 0) {
1055 diagnostics.push(
1056 diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line")
1057 )
1058 }
1059
1060 if (content[0]?.trim() === "") {
1061 diagnostics.push(diagnostic("leading-blank", "JSDoc must not start with a blank line"))
1062 }
1063 const contentWithoutTagBlank = tags.length > 0 && content.at(-1)?.trim() === "" ? content.slice(0, -1) : content
1064 if (contentWithoutTagBlank.at(-1)?.trim() === "") {
1065 diagnostics.push(diagnostic("trailing-blank", "JSDoc description must not end with a blank line"))
1066 }
1067
1068 const parsedContent = parseDescriptionContent(contentWithoutTagBlank)
1069 diagnostics.push(...parsedContent.diagnostics)
1070
1071 if (diagnostics.length > 0 || parsedContent.value === undefined) {
1072 return { _tag: "Failure", error: { diagnostics: uniqueDiagnostics(diagnostics) } }
1073 }
1074 return {
1075 _tag: "Success",
1076 value: {
1077 description: parsedContent.value.description,
1078 examples: parsedContent.value.examples,
1079 tags
1080 }
1081 }
1082}
1083
1084function uniqueDiagnostics(
1085 diagnostics: ReadonlyArray<JSDocDiagnostic>
1086): ReadonlyArray<JSDocDiagnostic> {
1087 const seen = new Set<string>()
1088 return diagnostics.filter((item) => {
1089 const key = `${item.code}:${item.message}`
1090 if (seen.has(key)) {
1091 return false
1092 }
1093 seen.add(key)
1094 return true
1095 })
1096}
1097
1098const standardHeadings = ["**When to use**", "**Details**", "**Gotchas**"] as const
1099
1100type StandardHeading = typeof standardHeadings[number]
1101
1102const whenToUsePrefixes = ["Use to", "Use when", "Use as", "Use with"] as const
1103
1104interface ParsedContentResult {
1105 readonly value?: {
1106 readonly description: ParsedDescription
1107 readonly examples: ReadonlyArray<ParsedExample>
1108 }
1109 readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
1110}
1111
1112function parseDescriptionContent(lines: Array<string>): ParsedContentResult {
1113 const diagnostics: Array<JSDocDiagnostic> = []
1114 const sections: Record<StandardHeading, string | null> = {
1115 "**When to use**": null,
1116 "**Details**": null,
1117 "**Gotchas**": null
1118 }
1119 const examples: Array<ParsedExample> = []
1120 const exampleTitles = new Set<string>()
1121 let index = 0
1122 let currentSectionOrder = -1
1123 let examplesStarted = false
1124
1125 while (index < lines.length && lines[index].trim() !== "" && !isHeadingLine(lines[index])) {
1126 if (isForbiddenMarkdownHeading(lines[index], false)) {
1127 diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"))
1128 }
1129 index++
1130 }
1131
1132 const shortLines = lines.slice(0, index)
1133 if (shortLines.length === 0 || joinBody(shortLines).trim() === "") {
1134 diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description"))
1135 }
1136 if (index < lines.length && lines[index].trim() === "") {
1137 const next = lines[index + 1]
1138 if (next !== undefined && !isHeadingLine(next)) {
1139 diagnostics.push(diagnostic("multiple-description-paragraphs", "JSDoc short description must be one paragraph"))
1140 }
1141 }
1142
1143 while (index < lines.length) {
1144 if (lines[index].trim() !== "") {
1145 diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line"))
1146 break
1147 }
1148 if (lines[index + 1]?.trim() === "") {
1149 diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line"))
1150 while (lines[index + 1]?.trim() === "") index++
1151 }
1152 index++
1153 if (index >= lines.length) {
1154 break
1155 }
1156
1157 const line = lines[index].trim()
1158 if (line.startsWith("**Example**")) {
1159 examplesStarted = true
1160 const parsed = parseExample(lines, index)
1161 diagnostics.push(...parsed.diagnostics)
1162 if (parsed.example !== undefined) {
1163 const key = parsed.example.title.trim().toLowerCase()
1164 if (exampleTitles.has(key)) {
1165 diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${parsed.example.title.trim()}`))
1166 }
1167 exampleTitles.add(key)
1168 examples.push(parsed.example)
1169 }
1170 index = parsed.nextIndex
1171 continue
1172 }
1173
1174 const heading = standardHeadings.find((candidate) => candidate === line)
1175 if (heading !== undefined) {
1176 if (examplesStarted) {
1177 diagnostics.push(diagnostic("section-after-example", `${heading} must appear before examples`))
1178 }
1179 const order = standardHeadings.indexOf(heading)
1180 if (order <= currentSectionOrder) {
1181 diagnostics.push(diagnostic("section-out-of-order", `${heading} is out of order or duplicated`))
1182 }
1183 currentSectionOrder = Math.max(currentSectionOrder, order)
1184 if (sections[heading] !== null) {
1185 diagnostics.push(diagnostic("duplicate-section", `${heading} may appear at most once`))
1186 }
1187 const parsed = parseSection(lines, index)
1188 diagnostics.push(...parsed.diagnostics)
1189 sections[heading] = parsed.body
1190 index = parsed.nextIndex
1191 continue
1192 }
1193
1194 if (isNearMissHeading(line)) {
1195 diagnostics.push(diagnostic("invalid-heading", `Invalid JSDoc section heading: ${line}`))
1196 index++
1197 continue
1198 }
1199 if (isBoldOnlyLine(line)) {
1200 diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${line}`))
1201 index++
1202 continue
1203 }
1204 if (isForbiddenMarkdownHeading(line, false)) {
1205 diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"))
1206 index++
1207 continue
1208 }
1209 diagnostics.push(
1210 diagnostic("invalid-description", "JSDoc description content must appear under a standard section heading")
1211 )
1212 index++
1213 }
1214
1215 const whenToUse = sections["**When to use**"]
1216 if (whenToUse !== null && !hasWhenToUsePrefix(whenToUse)) {
1217 diagnostics.push(
1218 diagnostic(
1219 "when-to-use-format",
1220 `**When to use** must start with one of: ${whenToUsePrefixes.map((prefix) => `\`${prefix}\``).join(", ")}`
1221 )
1222 )
1223 }
1224
1225 if (diagnostics.length > 0) {
1226 return { diagnostics }
1227 }
1228 return {
1229 value: {
1230 description: {
1231 short: joinBody(shortLines),
1232 whenToUse: sections["**When to use**"],
1233 details: sections["**Details**"],
1234 gotchas: sections["**Gotchas**"]
1235 },
1236 examples
1237 },
1238 diagnostics
1239 }
1240}
1241
1242function hasWhenToUsePrefix(text: string): boolean {
1243 const trimmed = text.trimStart()
1244 return whenToUsePrefixes.some((prefix) => trimmed === prefix || trimmed.startsWith(`${prefix} `))
1245}
1246
1247function parseSection(lines: Array<string>, headingIndex: number): {
1248 readonly body: string | null
1249 readonly nextIndex: number
1250 readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
1251} {
1252 const diagnostics: Array<JSDocDiagnostic> = []
1253 if (lines[headingIndex + 1]?.trim() !== "") {
1254 diagnostics.push(
1255 diagnostic("invalid-spacing", `${lines[headingIndex].trim()} must be followed by exactly one blank line`)
1256 )
1257 }
1258 let index = headingIndex + 2
1259 const bodyStart = index
1260 let inFence = false
1261 while (index < lines.length) {
1262 const trimmed = lines[index].trim()
1263 if (trimmed.startsWith("```")) {
1264 if (isTypeScriptFence(trimmed)) {
1265 diagnostics.push(diagnostic("loose-ts-fence", "TypeScript examples must use **Example** (Title) sections"))
1266 }
1267 inFence = !inFence
1268 }
1269 if (!inFence && trimmed === "" && isHeadingLine(lines[index + 1])) {
1270 break
1271 }
1272 if (!inFence && trimmed !== "" && isForbiddenMarkdownHeading(trimmed, false)) {
1273 diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"))
1274 }
1275 if (
1276 !inFence && isBoldOnlyLine(trimmed) && !standardHeadings.includes(trimmed as StandardHeading) &&
1277 !trimmed.startsWith("**Example**")
1278 ) {
1279 diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${trimmed}`))
1280 }
1281 index++
1282 }
1283 const bodyLines = lines.slice(bodyStart, index)
1284 if (joinBody(bodyLines).trim() === "") {
1285 diagnostics.push(diagnostic("empty-section", `${lines[headingIndex].trim()} must have a non-empty body`))
1286 }
1287 if (bodyLines.at(-1)?.trim() === "") {
1288 diagnostics.push(diagnostic("invalid-spacing", "Section bodies must not end with extra blank lines"))
1289 }
1290 return { body: joinBody(bodyLines), nextIndex: index, diagnostics }
1291}
1292
1293const isTypeScriptFence = (line: string): boolean => /^```ts(?:\s.*)?$/.test(line)
1294
1295function parseExample(lines: Array<string>, headingIndex: number): {
1296 readonly example?: ParsedExample
1297 readonly nextIndex: number
1298 readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
1299} {
1300 const diagnostics: Array<JSDocDiagnostic> = []
1301 const heading = lines[headingIndex].trim()
1302 const match = /^\*\*Example\*\* \((.+)\)$/.exec(heading)
1303 if (!match || match[1].trim() === "") {
1304 diagnostics.push(diagnostic("malformed-example", "TypeScript examples must use **Example** (Title)"))
1305 }
1306 if (lines[headingIndex + 1]?.trim() !== "") {
1307 diagnostics.push(diagnostic("invalid-spacing", "Example headings must be followed by exactly one blank line"))
1308 }
1309
1310 let index = headingIndex + 2
1311 const bodyStart = index
1312 let fenceIndex = -1
1313 while (index < lines.length) {
1314 const trimmed = lines[index].trim()
1315 if (isTypeScriptFence(trimmed)) {
1316 fenceIndex = index
1317 break
1318 }
1319 if (trimmed.startsWith("```")) {
1320 diagnostics.push(diagnostic("malformed-example", "Examples may only contain one TypeScript code fence"))
1321 }
1322 if (trimmed === "" && isHeadingLine(lines[index + 1])) {
1323 break
1324 }
1325 if (trimmed.startsWith("@")) {
1326 break
1327 }
1328 index++
1329 }
1330
1331 if (fenceIndex === -1) {
1332 diagnostics.push(diagnostic("malformed-example", "Examples must include a non-empty ```ts fence"))
1333 return { nextIndex: index, diagnostics }
1334 }
1335
1336 const bodyLines = lines.slice(bodyStart, fenceIndex)
1337 const nonEmptyBody = joinBody(bodyLines).trim() !== ""
1338 if (nonEmptyBody && bodyLines.at(-1)?.trim() !== "") {
1339 diagnostics.push(
1340 diagnostic("invalid-spacing", "Example prose must be separated from code by exactly one blank line")
1341 )
1342 }
1343 const prose = nonEmptyBody ? joinBody(bodyLines.slice(0, bodyLines.at(-1)?.trim() === "" ? -1 : undefined)) : null
1344
1345 index = fenceIndex + 1
1346 const codeStart = index
1347 while (index < lines.length && lines[index].trim() !== "```") {
1348 if (isTypeScriptFence(lines[index].trim())) {
1349 diagnostics.push(diagnostic("malformed-example", "Examples must contain exactly one TypeScript code fence"))
1350 }
1351 index++
1352 }
1353 const codeLines = lines.slice(codeStart, index)
1354 if (index >= lines.length) {
1355 diagnostics.push(diagnostic("malformed-example", "Examples must close the TypeScript code fence"))
1356 }
1357 if (joinBody(codeLines).trim() === "") {
1358 diagnostics.push(diagnostic("malformed-example", "Examples must include non-empty TypeScript code"))
1359 }
1360 index++
1361 if (index < lines.length && lines[index].trim() !== "" && !lines[index].trim().startsWith("@")) {
1362 diagnostics.push(
1363 diagnostic("invalid-spacing", "Examples must be separated from following content by exactly one blank line")
1364 )
1365 }
1366
1367 const result = { nextIndex: index, diagnostics }
1368 return match
1369 ? { ...result, example: { title: match[1].trim(), body: prose, code: joinBody(codeLines) } }
1370 : result
1371}
1372
1373function parseModuleExamples(block: JSDocBlock): {
1374 readonly examples: ReadonlyArray<ParsedExample>
1375 readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
1376} {
1377 const diagnostics: Array<JSDocDiagnostic> = []
1378 const examples: Array<ParsedExample> = []
1379 const exampleTitles = new Set<string>()
1380 const firstTagLine = block.tags[0]?.line ?? block.lines.length
1381 const lines = block.lines.slice(0, firstTagLine)
1382 let index = 0
1383 while (index < lines.length) {
1384 const trimmed = lines[index].trim()
1385 if (trimmed.startsWith("**Example**")) {
1386 const parsed = parseExample(lines, index)
1387 diagnostics.push(...parsed.diagnostics)
1388 if (parsed.example !== undefined) {
1389 const key = parsed.example.title.trim().toLowerCase()
1390 if (exampleTitles.has(key)) {
1391 diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${parsed.example.title.trim()}`))
1392 }
1393 exampleTitles.add(key)
1394 examples.push(parsed.example)
1395 }
1396 index = Math.max(parsed.nextIndex, index + 1)
1397 continue
1398 }
1399 if (trimmed.startsWith("```")) {
1400 diagnostics.push(
1401 diagnostic("loose-ts-fence", "Code fences in module JSDoc must use **Example** (Title) sections")
1402 )
1403 index++
1404 while (index < lines.length && !lines[index].trim().startsWith("```")) index++
1405 if (index < lines.length) index++
1406 continue
1407 }
1408 index++
1409 }
1410 return { examples, diagnostics }
1411}
1412
1413function joinBody(lines: ReadonlyArray<string>): string {
1414 let start = 0
1415 let end = lines.length
1416 while (start < end && lines[start]?.trim() === "") start++
1417 while (end > start && lines[end - 1]?.trim() === "") end--
1418 return lines.slice(start, end).join("\n")
1419}
1420
1421function isHeadingLine(line: string | undefined): boolean {
1422 if (line === undefined) return false
1423 const trimmed = line.trim()
1424 return standardHeadings.includes(trimmed as StandardHeading) || trimmed.startsWith("**Example**") ||
1425 isNearMissHeading(trimmed) || isBoldOnlyLine(trimmed) || isForbiddenMarkdownHeading(trimmed, false)
1426}
1427
1428function isNearMissHeading(line: string): boolean {
1429 return /^\*\*(When to use|When To Use|Details|Gotchas).*\*\*/.test(line) &&
1430 !standardHeadings.includes(line as StandardHeading)
1431}
1432
1433function isBoldOnlyLine(line: string): boolean {
1434 return /^\*\*[^*]+\*\*(?:\s*)$/.test(line)
1435}
1436
1437function isForbiddenMarkdownHeading(line: string, inFence: boolean): boolean {
1438 return !inFence && /^#{1,6}\s+/.test(line.trim())
1439}
1440
1441function parseSeeTag(value: string): ParsedSeeTag {
1442 return { text: value, links: extractParsedInlineLinks(value) }
1443}
1444
1445function extractParsedInlineLinks(source: string): ReadonlyArray<ParsedInlineLink> {
1446 const links: Array<ParsedInlineLink> = []
1447 let index = source.indexOf("{@link")
1448 while (index !== -1) {
1449 if (!isInlineLinkStart(source, index)) {
1450 index = source.indexOf("{@link", index + 1)
1451 continue
1452 }
1453 const end = source.indexOf("}", index + "{@link".length)
1454 if (end === -1) {
1455 break
1456 }
1457 const raw = source.slice(index, end + 1)
1458 const content = raw.slice("{@link".length, -1).trim()
1459 const pipeIndex = content.indexOf("|")
1460 const beforeLabel = pipeIndex === -1 ? content : content.slice(0, pipeIndex).trim()
1461 const target = beforeLabel.split(/\s+/)[0] ?? ""
1462 const text = pipeIndex === -1
1463 ? beforeLabel.slice(target.length).trim() || null
1464 : content.slice(pipeIndex + 1).trim() || null
1465 links.push({ raw, target, text })
1466 index = source.indexOf("{@link", end + 1)
1467 }
1468 return links
1469}
1470
1471function buildTags(
1472 scope: DocScope,
1473 tags: ReadonlyArray<JSDocTag>
1474): Result<ParsedModuleTags | ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags, JSDocParseError> {
1475 const diagnostics: Array<JSDocDiagnostic> = []
1476 const allowed = scope === "declaration"
1477 ? new Set(["deprecated", "see", "stability", "category", "since"])
1478 : scope === "member"
1479 ? new Set(["deprecated", "default", "see", "stability", "since"])
1480 : scope === "module"
1481 ? new Set(["deprecated", "see", "stability", "since"])
1482 : new Set(["deprecated", "see", "stability", "category", "since"])
1483 let previousOrder = -1
1484 const values = new Map<string, Array<string>>()
1485
1486 for (const tag of tags) {
1487 if (tag.name === "internal") {
1488 continue
1489 }
1490 if (tag.name === "example") {
1491 diagnostics.push(
1492 diagnostic("forbidden-tag", "@example is not allowed; use a canonical **Example** (Title) section")
1493 )
1494 continue
1495 }
1496 if (!allowed.has(tag.name)) {
1497 diagnostics.push(
1498 diagnostic(
1499 "forbidden-tag",
1500 `@${tag.name} is not allowed in ${scope === "namespace-declaration" ? "namespace" : scope} JSDoc`
1501 )
1502 )
1503 continue
1504 }
1505 const order = tagOrder.get(tag.name) ?? previousOrder
1506 if (order < previousOrder) {
1507 diagnostics.push(diagnostic("tag-out-of-order", `@${tag.name} is out of order in JSDoc`))
1508 }
1509 previousOrder = Math.max(previousOrder, order)
1510 values.set(tag.name, [...values.get(tag.name) ?? [], tag.value.trim()])
1511 }
1512
1513 const singletonTags = scope === "member"
1514 ? ["deprecated", "default", "stability", "since"]
1515 : ["deprecated", "stability", "category", "since"]
1516 for (const tag of singletonTags) {
1517 if ((values.get(tag)?.length ?? 0) > 1) {
1518 diagnostics.push(diagnostic("duplicate-tag", `JSDoc blocks may contain at most one @${tag} tag`))
1519 }
1520 }
1521
1522 const see = values.get("see") ?? []
1523 for (const value of see) {
1524 if (value === "") {
1525 diagnostics.push(diagnostic("empty-tag", "@see must include a value"))
1526 }
1527 }
1528 const deprecated = values.get("deprecated")?.[0] ?? null
1529 const stability = values.get("stability")?.[0]
1530 if (stability !== undefined && stability !== "unstable" && stability !== "experimental") {
1531 diagnostics.push(diagnostic("invalid-stability", "@stability must have the value unstable or experimental"))
1532 }
1533 const resolvedStability: JSDocStability = stability === "unstable" || stability === "experimental"
1534 ? stability
1535 : "stable"
1536 if (deprecated === "") diagnostics.push(diagnostic("empty-tag", "@deprecated must include a message"))
1537 const since = values.get("since")?.[0] ?? null
1538 if ((scope === "declaration" || scope === "namespace" || scope === "namespace-declaration") && since === null) {
1539 diagnostics.push(
1540 diagnostic(
1541 "missing-tag",
1542 scope === "declaration" ? "Public JSDoc must include @since" : "Namespace JSDoc must include @since"
1543 )
1544 )
1545 }
1546 if (scope === "module" && since === null) {
1547 diagnostics.push(diagnostic("missing-tag", "Module JSDoc must include @since"))
1548 }
1549 if (since !== null && !stableSemverRegex.test(since)) {
1550 diagnostics.push(diagnostic("invalid-since", "@since must be a stable semver version like 1.2.3"))
1551 }
1552
1553 if (scope === "declaration") {
1554 const category = values.get("category")?.[0] ?? null
1555 if (category === null) diagnostics.push(diagnostic("missing-tag", "Public JSDoc must include @category"))
1556 if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value"))
1557 if (diagnostics.length > 0 || category === null || since === null) {
1558 return { _tag: "Failure", error: { diagnostics } }
1559 }
1560 return {
1561 _tag: "Success",
1562 value: { category, since, stability: resolvedStability, deprecated, see: see.map(parseSeeTag) }
1563 }
1564 }
1565
1566 if (scope === "member") {
1567 const defaultValue = values.get("default")?.[0] ?? null
1568 if (defaultValue === "") diagnostics.push(diagnostic("empty-tag", "@default must include a value"))
1569 if (diagnostics.length > 0) return { _tag: "Failure", error: { diagnostics } }
1570 return {
1571 _tag: "Success",
1572 value: { since, stability: resolvedStability, default: defaultValue, deprecated, see: see.map(parseSeeTag) }
1573 }
1574 }
1575
1576 if (scope === "module") {
1577 if (diagnostics.length > 0 || since === null) return { _tag: "Failure", error: { diagnostics } }
1578 return { _tag: "Success", value: { since, stability: resolvedStability, deprecated, see: see.map(parseSeeTag) } }
1579 }
1580
1581 const category = values.get("category")?.[0] ?? null
1582 if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value"))
1583 if (diagnostics.length > 0 || since === null) return { _tag: "Failure", error: { diagnostics } }
1584 return {
1585 _tag: "Success",
1586 value: { category, since, stability: resolvedStability, deprecated, see: see.map(parseSeeTag) }
1587 }
1588}
1589
1590function formatDiagnostic(diagnostic: ts.Diagnostic): string {
1591 return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
1592}
1593
1594function parseTsConfig(tsconfigPath: string): ParsedConfigResult {
1595 const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile)
1596 if (config.error !== undefined) {
1597 return { error: `Unable to read TypeScript config ${tsconfigPath}: ${formatDiagnostic(config.error)}` }
1598 }
1599
1600 const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(tsconfigPath))
1601 if (parsed.errors.length > 0) {
1602 return {
1603 error: `Unable to parse TypeScript config ${tsconfigPath}: ${parsed.errors.map(formatDiagnostic).join("; ")}`
1604 }
1605 }
1606 return { parsed }
1607}
1608
1609function collectTsConfigFiles(tsconfigPath: string, seen: Set<string>, fileNames: Set<string>): ParsedConfigResult {
1610 if (seen.has(tsconfigPath)) return {}
1611 seen.add(tsconfigPath)
1612 const result = parseTsConfig(tsconfigPath)
1613 if (result.error !== undefined || result.parsed === undefined) return result
1614 for (const fileName of result.parsed.fileNames) fileNames.add(fileName)
1615 for (const reference of result.parsed.projectReferences ?? []) {
1616 const referenceResult = collectTsConfigFiles(ts.resolveProjectReferencePath(reference), seen, fileNames)
1617 if (referenceResult.error !== undefined) return referenceResult
1618 }
1619 return result
1620}
1621
1622/**
1623 * Loads and caches the TypeScript program for a project configuration.
1624 *
1625 * @category constructors
1626 * @since 4.0.0
1627 */
1628export function getProgram(tsconfigPath: string): ProgramCacheEntry {
1629 const cached = programCache.get(tsconfigPath)
1630 if (cached !== undefined) return cached
1631 const fileNames = new Set<string>()
1632 const result = collectTsConfigFiles(tsconfigPath, new Set(), fileNames)
1633 if (result.error !== undefined || result.parsed === undefined) {
1634 const entry = { error: result.error ?? `Unable to parse TypeScript config ${tsconfigPath}`, reported: false }
1635 programCache.set(tsconfigPath, entry)
1636 return entry
1637 }
1638 const program = ts.createProgram(Array.from(fileNames), result.parsed.options)
1639 const entry = { program, reported: false }
1640 programCache.set(tsconfigPath, entry)
1641 return entry
1642}
1643
1644function resolveJSDocLinkSymbol(
1645 link: ts.JSDocLink,
1646 checker: ts.TypeChecker
1647): ts.Symbol | undefined {
1648 return link.name === undefined ? undefined : checker.getSymbolAtLocation(link.name)
1649}
1650
1651function isInlineLinkStart(source: string, index: number): boolean {
1652 if (!source.startsWith("{@link", index)) return false
1653 const next = source[index + "{@link".length]
1654 return next === undefined || /\s|}/.test(next)
1655}
1656
1657function hasInlineLink(block: JSDocBlock): boolean {
1658 return block.lines.some((line) => {
1659 let index = line.indexOf("{@link")
1660 while (index !== -1) {
1661 if (isInlineLinkStart(line, index)) return true
1662 index = line.indexOf("{@link", index + 1)
1663 }
1664 return false
1665 })
1666}
1667
1668function extractInlineLinkTarget(linkText: string): string {
1669 const content = linkText.slice("{@link".length, linkText.endsWith("}") ? -1 : undefined).trim()
1670 const pipeIndex = content.indexOf("|")
1671 const beforeLabel = pipeIndex === -1 ? content : content.slice(0, pipeIndex).trim()
1672 return beforeLabel.split(/\s+/)[0] ?? ""
1673}
1674
1675function malformedInlineLinks(block: JSDocBlock): Array<string> {
1676 const source = block.lines.join("\n")
1677 const malformed: Array<string> = []
1678 let index = source.indexOf("{@link")
1679 while (index !== -1) {
1680 if (!isInlineLinkStart(source, index)) {
1681 index = source.indexOf("{@link", index + 1)
1682 continue
1683 }
1684 const end = source.indexOf("}", index + "{@link".length)
1685 const text = end === -1 ? source.slice(index).split(/\r?\n/, 1)[0] : source.slice(index, end + 1)
1686 if (end === -1 || extractInlineLinkTarget(text) === "") malformed.push(text)
1687 index = source.indexOf("{@link", end === -1 ? index + 1 : end + 1)
1688 }
1689 return malformed
1690}
1691
1692function collectJSDocLinks(sourceFile: ts.SourceFile, block: JSDocBlock): Array<ts.JSDocLink> {
1693 const links: Array<ts.JSDocLink> = []
1694 function inspectJSDoc(node: ts.Node) {
1695 for (const jsdoc of (node as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc ?? []) {
1696 if (jsdoc.pos !== block.range[0] || jsdoc.end !== block.range[1]) continue
1697 ts.forEachChild(jsdoc, function visit(child) {
1698 if (ts.isJSDocLink(child)) links.push(child)
1699 ts.forEachChild(child, visit)
1700 })
1701 }
1702 }
1703 function visit(node: ts.Node) {
1704 inspectJSDoc(node)
1705 ts.forEachChild(node, visit)
1706 }
1707 visit(sourceFile)
1708 return links
1709}
1710
1711function collectJSDocSeeLinks(sourceFile: ts.SourceFile, block: JSDocBlock): Array<ts.JSDocLink> {
1712 const links: Array<ts.JSDocLink> = []
1713 function inspectJSDoc(node: ts.Node) {
1714 for (const jsdoc of (node as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc ?? []) {
1715 if (jsdoc.pos !== block.range[0] || jsdoc.end !== block.range[1]) continue
1716 ts.forEachChild(jsdoc, function visit(child) {
1717 if (ts.isJSDocSeeTag(child)) {
1718 ts.forEachChild(child, function visitSeeTag(seeTagChild) {
1719 if (ts.isJSDocLink(seeTagChild)) links.push(seeTagChild)
1720 ts.forEachChild(seeTagChild, visitSeeTag)
1721 })
1722 return
1723 }
1724 ts.forEachChild(child, visit)
1725 })
1726 }
1727 }
1728 function visit(node: ts.Node) {
1729 inspectJSDoc(node)
1730 ts.forEachChild(node, visit)
1731 }
1732 visit(sourceFile)
1733 return links
1734}
1735
1736function resolvedSymbolTarget(symbol: ts.Symbol, checker: ts.TypeChecker): ts.Symbol {
1737 return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol)
1738}
1739
1740function isLocalSourceFile(cwd: string, sourceFile: ts.SourceFile): boolean {
1741 if (sourceFile.isDeclarationFile) return false
1742 const relative = path.relative(cwd, sourceFile.fileName)
1743 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
1744}
1745
1746function symbolDeclarations(symbol: ts.Symbol, checker: ts.TypeChecker): ReadonlyArray<ts.Declaration> {
1747 const target = resolvedSymbolTarget(symbol, checker)
1748 const declarations = new Set<ts.Declaration>()
1749 if (target.valueDeclaration !== undefined) declarations.add(target.valueDeclaration)
1750 for (const declaration of target.declarations ?? []) declarations.add(declaration)
1751 return Array.from(declarations)
1752}
1753
1754function symbolHasPublicJSDoc(symbol: ts.Symbol, checker: ts.TypeChecker): boolean {
1755 return symbolDeclarations(symbol, checker).some((declaration) => {
1756 const block = getNodeJSDoc(declaration)
1757 return block !== undefined && !block.internal && block.parsed !== undefined
1758 })
1759}
1760
1761function isMemberDeclaration(declaration: ts.Declaration): boolean {
1762 return ts.isPropertySignature(declaration) || ts.isMethodSignature(declaration) ||
1763 ts.isPropertyDeclaration(declaration) || ts.isMethodDeclaration(declaration) ||
1764 ts.isGetAccessorDeclaration(declaration) || ts.isSetAccessorDeclaration(declaration)
1765}
1766
1767function undocumentedSeeLinkTarget(
1768 symbol: ts.Symbol,
1769 checker: ts.TypeChecker,
1770 cwd: string
1771): ts.Declaration | undefined {
1772 const localDeclarations = symbolDeclarations(symbol, checker).filter((declaration) =>
1773 isMemberDeclaration(declaration) && isLocalSourceFile(cwd, declaration.getSourceFile())
1774 )
1775 if (localDeclarations.length === 0 || symbolHasPublicJSDoc(symbol, checker)) return undefined
1776 return localDeclarations[0]
1777}
1778
1779function inlineLinkSymbol(
1780 symbol: ts.Symbol,
1781 cwd: string,
1782 checker: ts.TypeChecker
1783): ParsedInlineLinkSymbol | undefined {
1784 const target = resolvedSymbolTarget(symbol, checker)
1785 const declaration = target.valueDeclaration ?? target.declarations?.[0]
1786 if (declaration === undefined) return undefined
1787 return {
1788 file: normalizeFile(cwd, declaration.getSourceFile().fileName),
1789 range: nodeRange(declaration)
1790 }
1791}
1792
1793function attachSeeLinkSymbolsFromSourceFile<
1794 T extends ParsedModuleTags | ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
1795>(
1796 tags: T,
1797 sourceFile: ts.SourceFile,
1798 block: JSDocBlock,
1799 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
1800): T {
1801 const resolved = new Map<
1802 string,
1803 Array<{ readonly range: readonly [number, number]; readonly symbol?: ParsedInlineLinkSymbol }>
1804 >()
1805 for (const link of collectJSDocLinks(sourceFile, block)) {
1806 const text = link.getText(sourceFile)
1807 const target = extractInlineLinkTarget(text)
1808 if (target === "" || urlRegex.test(target)) continue
1809 const symbol = resolveJSDocLinkSymbol(link, linkContext.checker)
1810 const info = symbol === undefined ? undefined : inlineLinkSymbol(symbol, linkContext.cwd, linkContext.checker)
1811 const items = resolved.get(text) ?? []
1812 items.push(info === undefined ? { range: [link.pos, link.end] } : { range: [link.pos, link.end], symbol: info })
1813 resolved.set(text, items)
1814 }
1815 return {
1816 ...tags,
1817 see: tags.see.map((tag) => ({
1818 ...tag,
1819 links: tag.links.map((link) => {
1820 const items = resolved.get(link.raw)
1821 const info = items?.shift()
1822 if (info === undefined) return link
1823 return info.symbol === undefined
1824 ? { ...link, range: info.range }
1825 : { ...link, range: info.range, symbol: info.symbol }
1826 })
1827 }))
1828 }
1829}
1830
1831function attachSeeLinkSymbols<T extends ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags>(
1832 tags: T,
1833 node: ts.Node,
1834 block: JSDocBlock,
1835 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
1836): T {
1837 return attachSeeLinkSymbolsFromSourceFile(tags, node.getSourceFile(), block, linkContext)
1838}
1839
1840/**
1841 * JSDoc diagnostic paired with its source range.
1842 *
1843 * @category models
1844 * @since 4.0.0
1845 */
1846export interface JSDocModelDiagnostic extends JSDocDiagnostic {
1847 readonly range: readonly [number, number]
1848}
1849
1850/**
1851 * Parsed JSDoc and diagnostics for one source file in a model.
1852 *
1853 * @category models
1854 * @since 4.0.0
1855 */
1856export interface JSDocModelFile extends ParsedJSDocFile {
1857 readonly file: string
1858 readonly hash: string
1859 readonly diagnostics: ReadonlyArray<JSDocModelDiagnostic>
1860 readonly imports?: ParsedJSDocImports
1861}
1862
1863/**
1864 * Versioned output of a repository JSDoc extraction.
1865 *
1866 * @category models
1867 * @since 4.0.0
1868 */
1869export interface JSDocModel {
1870 readonly version: 3
1871 readonly generatedBy: "@effect/jsdocs"
1872 readonly generatedAt: string
1873 readonly inputHash?: string
1874 readonly files: ReadonlyArray<JSDocModelFile>
1875 readonly apis: ReadonlyArray<JSDocApi>
1876}
1877
1878/**
1879 * File selection and output configuration for JSDoc extraction.
1880 *
1881 * @category configuration
1882 * @since 4.0.0
1883 */
1884export interface JSDocConfig {
1885 readonly tsconfig: string
1886 readonly include: ReadonlyArray<string>
1887 readonly exclude?: ReadonlyArray<string>
1888 readonly output: string
1889}
1890
1891/**
1892 * JSDoc extraction configuration with an optional working directory.
1893 *
1894 * @category configuration
1895 * @since 4.0.0
1896 */
1897export interface ExtractJSDocsOptions extends JSDocConfig {
1898 readonly cwd?: string
1899}
1900
1901function addInputFile(files: Set<string>, filename: string) {
1902 const normalized = path.resolve(filename)
1903 if (fs.existsSync(normalized) && fs.statSync(normalized).isFile()) {
1904 files.add(normalized)
1905 }
1906}
1907
1908/**
1909 * Computes the cache key for the configured JSDoc extraction inputs.
1910 *
1911 * @category hashing
1912 * @since 4.0.0
1913 */
1914export function computeJSDocInputHash(options: ExtractJSDocsOptions): string {
1915 const cwd = path.resolve(options.cwd ?? process.cwd())
1916 const hash = crypto.createHash("sha256")
1917 const files = new Set<string>()
1918
1919 hash.update(JSON.stringify({
1920 tsconfig: options.tsconfig,
1921 include: options.include,
1922 exclude: options.exclude ?? [],
1923 output: options.output
1924 }))
1925
1926 addInputFile(files, path.join(cwd, "jsdocs.config.json"))
1927 addInputFile(files, path.resolve(cwd, options.tsconfig))
1928
1929 for (
1930 const filename of globSync([...options.include], {
1931 cwd,
1932 absolute: true,
1933 nodir: true,
1934 ignore: ["**/node_modules/**"]
1935 })
1936 ) {
1937 addInputFile(files, filename)
1938 }
1939
1940 for (
1941 const filename of globSync([
1942 "package.json",
1943 "packages/**/package.json",
1944 "tsconfig*.json",
1945 "packages/**/tsconfig*.json"
1946 ], {
1947 cwd,
1948 absolute: true,
1949 nodir: true,
1950 ignore: ["**/node_modules/**"]
1951 })
1952 ) {
1953 addInputFile(files, filename)
1954 }
1955
1956 for (const filename of Array.from(files).sort()) {
1957 hash.update("\0")
1958 hash.update(normalizeFile(cwd, filename))
1959 hash.update("\0")
1960 hash.update(hashSource(fs.readFileSync(filename, "utf8")))
1961 }
1962
1963 return hash.digest("hex")
1964}
1965
1966function isIdentifierName(value: string): boolean {
1967 return /^[$A-Z_a-z][$\w]*$/.test(value)
1968}
1969
1970function renderInlineLinks(text: string): string {
1971 return text.replace(/\{@link\s+([^}]+)\}/g, (_match, content: string) => {
1972 const pipeIndex = content.indexOf("|")
1973 const beforeLabel = pipeIndex === -1 ? content.trim() : content.slice(0, pipeIndex).trim()
1974 const target = beforeLabel.split(/\s+/)[0] ?? ""
1975 const label = pipeIndex === -1
1976 ? beforeLabel.slice(target.length).trim() || target
1977 : content.slice(pipeIndex + 1).trim() || target
1978 return `\`${label.replace(/:var$/, "")}\``
1979 })
1980}
1981
1982function renderDescription(description: ParsedDescription): ParsedDescription {
1983 return {
1984 short: renderInlineLinks(description.short),
1985 whenToUse: description.whenToUse === null ? null : renderInlineLinks(description.whenToUse),
1986 details: description.details === null ? null : renderInlineLinks(description.details),
1987 gotchas: description.gotchas === null ? null : renderInlineLinks(description.gotchas)
1988 }
1989}
1990
1991function renderExamples(examples: ReadonlyArray<ParsedExample>): ReadonlyArray<ParsedExample> {
1992 return examples.map((example) => ({
1993 ...example,
1994 body: example.body === null ? null : renderInlineLinks(example.body)
1995 }))
1996}
1997
1998function apiTags(
1999 tags: ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
2000): JSDocApiTags {
2001 return {
2002 category: "category" in tags ? tags.category : null,
2003 since: tags.since,
2004 stability: tags.stability,
2005 deprecated: tags.deprecated,
2006 default: "default" in tags ? tags.default : null
2007 }
2008}
2009
2010function unresolvedSeeTag(tag: ParsedSeeTag): JSDocApiSeeTag {
2011 return {
2012 text: renderInlineLinks(tag.text),
2013 links: tag.links.map((link) => ({
2014 ...link,
2015 resolution: { _tag: "Unresolved", reason: "not-found", candidates: [] }
2016 }))
2017 }
2018}
2019
2020function unresolvedSee(tags: ReadonlyArray<ParsedSeeTag>): ReadonlyArray<JSDocApiSeeTag> {
2021 return tags.map(unresolvedSeeTag)
2022}
2023
2024function apiId(kind: JSDocApiKind, bucket: ExportBucket | null, apiFqn: string): string {
2025 return `${kind}:${bucket ?? "none"}:${apiFqn}`
2026}
2027
2028function importGuidance(
2029 imports: ParsedJSDocImports,
2030 kind: JSDocApiKind,
2031 bucket: ExportBucket | null,
2032 apiName: string
2033): JSDocApiImportGuidance | null {
2034 if (kind !== "root-declaration" || bucket !== "value" || !isIdentifierName(apiName)) {
2035 return null
2036 }
2037 if (imports.flatNames.includes(apiName) && imports.barrel !== null) {
2038 return {
2039 style: "named-barrel",
2040 importDeclaration: `import { ${apiName} } from "${imports.barrel.module}"`,
2041 usage: apiName
2042 }
2043 }
2044 if (imports.barrel?.type === "namespace") {
2045 if (!isIdentifierName(imports.barrel.name)) return null
2046 return {
2047 style: "namespace-barrel",
2048 importDeclaration: `import { ${imports.barrel.name} } from "${imports.barrel.module}"`,
2049 usage: `${imports.barrel.name}.${apiName}`
2050 }
2051 }
2052 if (imports.barrel?.type === "flat") {
2053 return {
2054 style: "named-barrel",
2055 importDeclaration: `import { ${apiName} } from "${imports.barrel.module}"`,
2056 usage: apiName
2057 }
2058 }
2059 return {
2060 style: "named-module",
2061 importDeclaration: `import { ${apiName} } from "${imports.module}"`,
2062 usage: apiName
2063 }
2064}
2065
2066function makeApi(input: {
2067 readonly file: JSDocModelFile
2068 readonly imports: ParsedJSDocImports
2069 readonly kind: JSDocApiKind
2070 readonly bucket: ExportBucket | null
2071 readonly apiName: string
2072 readonly localName: string
2073 readonly signature: string | null
2074 readonly parentApiName: string | null
2075 readonly namespacePath: ReadonlyArray<string>
2076 readonly memberPath: ReadonlyArray<string>
2077 readonly sourcePath: ReadonlyArray<string | number>
2078 readonly sourceRange: readonly [number, number]
2079 readonly description: ParsedDescription
2080 readonly examples: ReadonlyArray<ParsedExample>
2081 readonly tags: ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
2082}): JSDocApi {
2083 const apiFqn = `${input.imports.module}.${input.apiName}`
2084 return {
2085 id: apiId(input.kind, input.bucket, apiFqn),
2086 kind: input.kind,
2087 moduleName: input.imports.module,
2088 apiName: input.apiName,
2089 localName: input.localName,
2090 signature: input.signature,
2091 parentApiName: input.parentApiName,
2092 apiFqn,
2093 hasFqnCollision: false,
2094 bucket: input.bucket,
2095 importable: input.kind === "root-declaration",
2096 namespacePath: input.namespacePath,
2097 memberPath: input.memberPath,
2098 source: {
2099 file: input.file.file,
2100 path: input.sourcePath,
2101 range: input.sourceRange
2102 },
2103 description: renderDescription(input.description),
2104 examples: renderExamples(input.examples),
2105 tags: apiTags(input.tags),
2106 see: unresolvedSee(input.tags.see),
2107 importGuidance: importGuidance(input.imports, input.kind, input.bucket, input.apiName)
2108 }
2109}
2110
2111function buildJSDocApis(files: ReadonlyArray<JSDocModelFile>): ReadonlyArray<JSDocApi> {
2112 const apis: Array<JSDocApi> = []
2113 const addMembers = (
2114 file: JSDocModelFile,
2115 imports: ParsedJSDocImports,
2116 members: ReadonlyArray<ParsedMember>,
2117 parentApiName: string,
2118 namespacePath: ReadonlyArray<string>,
2119 memberPath: ReadonlyArray<string>,
2120 sourcePath: ReadonlyArray<string | number>
2121 ) => {
2122 members.forEach((member, index) => {
2123 const nextMemberPath = [...memberPath, member.name]
2124 const apiName = `${parentApiName}.${nextMemberPath.join(".")}`
2125 apis.push(makeApi({
2126 file,
2127 imports,
2128 kind: "member",
2129 bucket: null,
2130 apiName,
2131 localName: member.name,
2132 signature: member.signature,
2133 parentApiName,
2134 namespacePath,
2135 memberPath: nextMemberPath,
2136 sourcePath: [...sourcePath, "members", index],
2137 sourceRange: member.range,
2138 description: member.description,
2139 examples: member.examples,
2140 tags: member.tags
2141 }))
2142 addMembers(file, imports, member.members, parentApiName, namespacePath, nextMemberPath, [
2143 ...sourcePath,
2144 "members",
2145 index
2146 ])
2147 })
2148 }
2149 const addNamespaces = (
2150 file: JSDocModelFile,
2151 imports: ParsedJSDocImports,
2152 namespaces: ReadonlyArray<ParsedNamespace>,
2153 namespacePath: ReadonlyArray<string>,
2154 sourcePath: ReadonlyArray<string | number>
2155 ) => {
2156 namespaces.forEach((namespace, index) => {
2157 const nextNamespacePath = [...namespacePath, namespace.name]
2158 const namespaceApiName = nextNamespacePath.join(".")
2159 apis.push(makeApi({
2160 file,
2161 imports,
2162 kind: "namespace",
2163 bucket: null,
2164 apiName: namespaceApiName,
2165 localName: namespace.name,
2166 signature: namespace.signature,
2167 parentApiName: namespacePath.length === 0 ? null : namespacePath.join("."),
2168 namespacePath: nextNamespacePath,
2169 memberPath: [],
2170 sourcePath: [...sourcePath, index],
2171 sourceRange: namespace.range,
2172 description: namespace.description,
2173 examples: namespace.examples,
2174 tags: namespace.tags
2175 }))
2176 namespace.declarations.forEach((declaration, declarationIndex) => {
2177 const apiName = `${namespaceApiName}.${declaration.name}`
2178 apis.push(makeApi({
2179 file,
2180 imports,
2181 kind: "namespace-declaration",
2182 bucket: "type",
2183 apiName,
2184 localName: declaration.name,
2185 signature: declaration.signature,
2186 parentApiName: namespaceApiName,
2187 namespacePath: nextNamespacePath,
2188 memberPath: [],
2189 sourcePath: [...sourcePath, index, "declarations", declarationIndex],
2190 sourceRange: declaration.range,
2191 description: declaration.description,
2192 examples: declaration.examples,
2193 tags: declaration.tags
2194 }))
2195 addMembers(file, imports, declaration.members, apiName, nextNamespacePath, [], [
2196 ...sourcePath,
2197 index,
2198 "declarations",
2199 declarationIndex
2200 ])
2201 })
2202 addNamespaces(file, imports, namespace.namespaces, nextNamespacePath, [...sourcePath, index, "namespaces"])
2203 })
2204 }
2205 for (const file of files) {
2206 if (file.imports === undefined) continue
2207 const imports = file.imports
2208 file.declarations.forEach((declaration, index) => {
2209 apis.push(makeApi({
2210 file,
2211 imports,
2212 kind: "root-declaration",
2213 bucket: declaration.bucket,
2214 apiName: declaration.name,
2215 localName: declaration.name,
2216 signature: declaration.signature,
2217 parentApiName: null,
2218 namespacePath: [],
2219 memberPath: [],
2220 sourcePath: ["declarations", index],
2221 sourceRange: declaration.range,
2222 description: declaration.description,
2223 examples: declaration.examples,
2224 tags: declaration.tags
2225 }))
2226 addMembers(file, imports, declaration.members, declaration.name, [], [], ["declarations", index])
2227 })
2228 addNamespaces(file, imports, file.namespaces, [], ["namespaces"])
2229 }
2230 return resolveJSDocApiSeeLinks(markFqnCollisions(apis))
2231}
2232
2233function markFqnCollisions(apis: ReadonlyArray<JSDocApi>): ReadonlyArray<JSDocApi> {
2234 const counts = new Map<string, number>()
2235 for (const api of apis) counts.set(api.apiFqn, (counts.get(api.apiFqn) ?? 0) + 1)
2236 return apis.map((api) => ({ ...api, hasFqnCollision: (counts.get(api.apiFqn) ?? 0) > 1 }))
2237}
2238
2239function normalizeSeeTarget(target: string): ReadonlyArray<string> {
2240 const withoutKind = target.replace(/:[A-Za-z]+$/, "")
2241 const out = [withoutKind]
2242 if (withoutKind.endsWith("_")) out.push(withoutKind.slice(0, -1))
2243 return Array.from(new Set(out.filter((item) => item.length > 0)))
2244}
2245
2246function sourceRangeKey(file: string, range: readonly [number, number]): string {
2247 return `${file}:${range[0]}:${range[1]}`
2248}
2249
2250function rangeContains(outer: readonly [number, number], inner: readonly [number, number]): boolean {
2251 return outer[0] <= inner[0] && inner[1] <= outer[1]
2252}
2253
2254function rangeOverlaps(self: readonly [number, number], that: readonly [number, number]): boolean {
2255 return self[0] <= that[1] && that[0] <= self[1]
2256}
2257
2258function rangeWidth(range: readonly [number, number]): number {
2259 return range[1] - range[0]
2260}
2261
2262function seeTargetNames(link: ParsedInlineLink): ReadonlySet<string> {
2263 const names = new Set<string>()
2264 const add = (target: string) => {
2265 for (const normalized of normalizeSeeTarget(target)) {
2266 const parts = normalized.split(".").filter((part) => part.length > 0)
2267 for (const part of parts) names.add(part)
2268 if (parts.length > 0) names.add(parts.join("."))
2269 }
2270 }
2271 add(link.target)
2272 if (link.text !== null) add(link.text)
2273 return names
2274}
2275
2276function apiMatchesSeeTarget(api: JSDocApi, names: ReadonlySet<string>): boolean {
2277 if (names.size === 0) return true
2278 if (names.has(api.localName) || names.has(api.apiName)) return true
2279 for (const name of names) {
2280 if (api.apiName.endsWith(`.${name}`)) return true
2281 }
2282 return false
2283}
2284
2285function createJSDocApiSeeLinkResolver(apis: ReadonlyArray<JSDocApi>) {
2286 const byModuleAndName = new Map<string, JSDocApi>()
2287 const byLocalName = new Map<string, Array<JSDocApi>>()
2288 const bySourceRange = new Map<string, JSDocApi>()
2289 const bySourceFile = new Map<string, Array<JSDocApi>>()
2290 for (const api of apis) {
2291 byModuleAndName.set(`${api.moduleName}:${api.apiName}`, api)
2292 bySourceRange.set(sourceRangeKey(api.source.file, api.source.range), api)
2293 const sourceApis = bySourceFile.get(api.source.file) ?? []
2294 sourceApis.push(api)
2295 bySourceFile.set(api.source.file, sourceApis)
2296 for (const name of new Set([api.apiName, api.localName])) {
2297 const existing = byLocalName.get(name) ?? []
2298 existing.push(api)
2299 byLocalName.set(name, existing)
2300 }
2301 }
2302 const resolveSymbol = (link: ParsedInlineLink): JSDocApiSeeLinkResolution | undefined => {
2303 if (link.symbol === undefined) return undefined
2304 const symbol = link.symbol
2305 const exact = bySourceRange.get(sourceRangeKey(symbol.file, symbol.range))
2306 if (exact !== undefined) return { _tag: "Resolved", apiId: exact.id, apiFqn: exact.apiFqn }
2307 const names = seeTargetNames(link)
2308 const candidates = (bySourceFile.get(symbol.file) ?? []).filter((api) =>
2309 apiMatchesSeeTarget(api, names) &&
2310 (rangeContains(symbol.range, api.source.range) ||
2311 rangeContains(api.source.range, symbol.range) ||
2312 rangeOverlaps(symbol.range, api.source.range))
2313 ).sort((self, that) => rangeWidth(self.source.range) - rangeWidth(that.source.range))
2314 const unique = Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values())
2315 if (unique.length === 1) return { _tag: "Resolved", apiId: unique[0].id, apiFqn: unique[0].apiFqn }
2316 if (unique.length > 1) {
2317 return { _tag: "Unresolved", reason: "ambiguous", candidates: unique.map((candidate) => candidate.id) }
2318 }
2319 const sameFileByName = (bySourceFile.get(symbol.file) ?? []).filter((api) => apiMatchesSeeTarget(api, names))
2320 const uniqueByName = Array.from(new Map(sameFileByName.map((candidate) => [candidate.id, candidate])).values())
2321 if (uniqueByName.length === 1) {
2322 return { _tag: "Resolved", apiId: uniqueByName[0].id, apiFqn: uniqueByName[0].apiFqn }
2323 }
2324 if (uniqueByName.length > 1) {
2325 return { _tag: "Unresolved", reason: "ambiguous", candidates: uniqueByName.map((candidate) => candidate.id) }
2326 }
2327 return undefined
2328 }
2329 return (
2330 context: { readonly moduleName: string; readonly namespacePath: ReadonlyArray<string> },
2331 link: ParsedInlineLink
2332 ): JSDocApiSeeLinkResolution => {
2333 const symbolResolution = resolveSymbol(link)
2334 if (symbolResolution !== undefined) return symbolResolution
2335 const targets = new Set<string>()
2336 for (const target of normalizeSeeTarget(link.target)) targets.add(target)
2337 if (link.text !== null) {
2338 for (const target of normalizeSeeTarget(link.text)) targets.add(target)
2339 }
2340 for (const target of targets) {
2341 const sameModule = byModuleAndName.get(`${context.moduleName}:${target}`)
2342 if (sameModule !== undefined) return { _tag: "Resolved", apiId: sameModule.id, apiFqn: sameModule.apiFqn }
2343 if (!target.includes(".") && context.namespacePath.length > 0) {
2344 const sameNamespace = byModuleAndName.get(
2345 `${context.moduleName}:${[...context.namespacePath, target].join(".")}`
2346 )
2347 if (sameNamespace !== undefined) {
2348 return { _tag: "Resolved", apiId: sameNamespace.id, apiFqn: sameNamespace.apiFqn }
2349 }
2350 }
2351 }
2352 const candidates = Array.from(targets).flatMap((target) => byLocalName.get(target) ?? [])
2353 const unique = Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values())
2354 if (unique.length === 1) return { _tag: "Resolved", apiId: unique[0].id, apiFqn: unique[0].apiFqn }
2355 if (unique.length > 1) {
2356 return { _tag: "Unresolved", reason: "ambiguous", candidates: unique.map((candidate) => candidate.id) }
2357 }
2358 return { _tag: "Unresolved", reason: "not-found", candidates: [] }
2359 }
2360}
2361
2362function resolveJSDocApiSeeLinks(apis: ReadonlyArray<JSDocApi>): ReadonlyArray<JSDocApi> {
2363 const resolve = createJSDocApiSeeLinkResolver(apis)
2364 return apis.map((api) => ({
2365 ...api,
2366 see: api.see.map((tag) => ({
2367 ...tag,
2368 links: tag.links.map((link) => ({ ...link, resolution: resolve(api, link) }))
2369 }))
2370 }))
2371}
2372
2373function sameDiagnostic(self: JSDocModelDiagnostic, that: JSDocModelDiagnostic): boolean {
2374 return self.code === that.code && self.message === that.message &&
2375 self.range[0] === that.range[0] && self.range[1] === that.range[1]
2376}
2377
2378function unresolvedPublicSeeDiagnostic(link: JSDocApiSeeLink): JSDocModelDiagnostic | undefined {
2379 if (link.resolution._tag === "Resolved" || link.range === undefined) return undefined
2380 const message = link.resolution.reason === "ambiguous"
2381 ? `@see link ${link.raw} is ambiguous; it must resolve to exactly one public JSDoc API. Candidates: ${
2382 link.resolution.candidates.join(", ")
2383 }`
2384 : `@see link ${link.raw} does not resolve to a public JSDoc API. Check that the target is exported and has valid public JSDoc.`
2385 return { ...diagnostic("public-see-target", message), range: link.range }
2386}
2387
2388function appendPublicSeeDiagnostics(
2389 files: ReadonlyArray<JSDocModelFile>,
2390 apis: ReadonlyArray<JSDocApi>
2391): { readonly files: ReadonlyArray<JSDocModelFile>; readonly added: boolean } {
2392 const diagnosticsByFile = new Map(files.map((file) => [file.file, [...file.diagnostics]]))
2393 let added = false
2394 for (const api of apis) {
2395 const diagnostics = diagnosticsByFile.get(api.source.file)
2396 if (diagnostics === undefined) continue
2397 for (const tag of api.see) {
2398 for (const link of tag.links) {
2399 const item = unresolvedPublicSeeDiagnostic(link)
2400 if (item === undefined || diagnostics.some((diagnostic) => sameDiagnostic(diagnostic, item))) continue
2401 diagnostics.push(item)
2402 added = true
2403 }
2404 }
2405 }
2406 if (!added) return { files, added }
2407 return {
2408 added,
2409 files: files.map((file) => ({
2410 ...file,
2411 diagnostics: diagnosticsByFile.get(file.file) ?? file.diagnostics
2412 }))
2413 }
2414}
2415
2416function moduleSeeTags(
2417 file: JSDocModelFile,
2418 sourceFile: ts.SourceFile,
2419 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
2420): ReadonlyArray<JSDocApiSeeTag> {
2421 if (file.moduleJSDoc === undefined) return []
2422 const block = parseLooseJSDocBlock(file.moduleJSDoc.raw, [file.moduleJSDoc.range[0], file.moduleJSDoc.range[1]])
2423 if (block.internal) return []
2424 const tags = buildTags("module", block.tags)
2425 const moduleTags: ParsedModuleTags = tags._tag === "Success"
2426 ? tags.value as ParsedModuleTags
2427 : {
2428 since: "0.0.0",
2429 stability: "stable",
2430 deprecated: null,
2431 see: block.tags.filter((tag) => tag.name === "see" && tag.value.trim() !== "").map((tag) =>
2432 parseSeeTag(tag.value.trim())
2433 )
2434 }
2435 const withSymbols = attachSeeLinkSymbolsFromSourceFile(moduleTags, sourceFile, block, linkContext)
2436 return unresolvedSee(withSymbols.see)
2437}
2438
2439function appendModulePublicSeeDiagnostics(
2440 files: ReadonlyArray<JSDocModelFile>,
2441 apis: ReadonlyArray<JSDocApi>,
2442 context: {
2443 readonly sourceFilesByFile: ReadonlyMap<string, ts.SourceFile>
2444 readonly checker: ts.TypeChecker
2445 readonly entry: ProgramCacheEntry
2446 readonly cwd: string
2447 }
2448): { readonly files: ReadonlyArray<JSDocModelFile>; readonly added: boolean } {
2449 const diagnosticsByFile = new Map(files.map((file) => [file.file, [...file.diagnostics]]))
2450 const resolve = createJSDocApiSeeLinkResolver(apis)
2451 let added = false
2452 for (const file of files) {
2453 if (file.imports === undefined || file.moduleJSDoc === undefined) continue
2454 const sourceFile = context.sourceFilesByFile.get(file.file)
2455 const diagnostics = diagnosticsByFile.get(file.file)
2456 if (sourceFile === undefined || diagnostics === undefined) continue
2457 for (const tag of moduleSeeTags(file, sourceFile, context)) {
2458 for (const link of tag.links) {
2459 const item = unresolvedPublicSeeDiagnostic({
2460 ...link,
2461 resolution: resolve({ moduleName: file.imports.module, namespacePath: [] }, link)
2462 })
2463 if (item === undefined || diagnostics.some((diagnostic) => sameDiagnostic(diagnostic, item))) continue
2464 diagnostics.push(item)
2465 added = true
2466 }
2467 }
2468 }
2469 if (!added) return { files, added }
2470 return {
2471 added,
2472 files: files.map((file) => ({
2473 ...file,
2474 diagnostics: diagnosticsByFile.get(file.file) ?? file.diagnostics
2475 }))
2476 }
2477}
2478
2479function buildJSDocApisWithPublicSeeDiagnostics(
2480 files: ReadonlyArray<JSDocModelFile>,
2481 context?: {
2482 readonly sourceFilesByFile: ReadonlyMap<string, ts.SourceFile>
2483 readonly checker: ts.TypeChecker
2484 readonly entry: ProgramCacheEntry
2485 readonly cwd: string
2486 }
2487): {
2488 readonly files: ReadonlyArray<JSDocModelFile>
2489 readonly apis: ReadonlyArray<JSDocApi>
2490} {
2491 let current = files
2492 for (let index = 0; index < 5; index++) {
2493 const apis = buildJSDocApis(current)
2494 const publicSee = appendPublicSeeDiagnostics(current, apis)
2495 const moduleSee = context === undefined
2496 ? { files: publicSee.files, added: false }
2497 : appendModulePublicSeeDiagnostics(publicSee.files, apis, context)
2498 const next = { files: moduleSee.files, added: publicSee.added || moduleSee.added }
2499 if (!next.added) return { files: current, apis }
2500 current = next.files
2501 }
2502 return { files: current, apis: buildJSDocApis(current) }
2503}
2504
2505interface ExampleImportPolicy {
2506 readonly allowedNamedBySource: ReadonlyMap<string, ReadonlySet<string>>
2507 readonly namespaceModuleBySource: ReadonlyMap<string, { readonly barrelModule: string; readonly name: string }>
2508 readonly flatModuleBySource: ReadonlyMap<
2509 string,
2510 { readonly barrelModule: string; readonly names: ReadonlySet<string> }
2511 >
2512 readonly namedModuleBySource: ReadonlyMap<string, ReadonlySet<string>>
2513}
2514
2515function createExampleImportPolicy(files: ReadonlyArray<JSDocModelFile>): ExampleImportPolicy {
2516 const allowedNamed = new Map<string, Set<string>>()
2517 const namespaceModuleBySource = new Map<string, { readonly barrelModule: string; readonly name: string }>()
2518 const flatModuleBySource = new Map<string, { readonly barrelModule: string; readonly names: ReadonlySet<string> }>()
2519 const namedModuleBySource = new Map<string, ReadonlySet<string>>()
2520 const addAllowed = (source: string, name: string) => {
2521 const names = allowedNamed.get(source) ?? new Set<string>()
2522 names.add(name)
2523 allowedNamed.set(source, names)
2524 }
2525 for (const file of files) {
2526 if (file.imports === undefined) continue
2527 const rootValues = file.declarations.filter((declaration) =>
2528 declaration.bucket === "value" && isIdentifierName(declaration.name)
2529 ).map((declaration) => declaration.name)
2530 if (file.imports.barrel?.type === "namespace") {
2531 addAllowed(file.imports.barrel.module, file.imports.barrel.name)
2532 for (const name of file.imports.flatNames) addAllowed(file.imports.barrel.module, name)
2533 namespaceModuleBySource.set(file.imports.module, {
2534 barrelModule: file.imports.barrel.module,
2535 name: file.imports.barrel.name
2536 })
2537 continue
2538 }
2539 if (file.imports.barrel?.type === "flat") {
2540 for (const name of new Set([...rootValues, ...file.imports.flatNames])) {
2541 addAllowed(file.imports.barrel.module, name)
2542 }
2543 flatModuleBySource.set(file.imports.module, {
2544 barrelModule: file.imports.barrel.module,
2545 names: new Set([...rootValues, ...file.imports.flatNames])
2546 })
2547 continue
2548 }
2549 for (const name of rootValues) addAllowed(file.imports.module, name)
2550 namedModuleBySource.set(file.imports.module, new Set(rootValues))
2551 }
2552 return { allowedNamedBySource: allowedNamed, namespaceModuleBySource, flatModuleBySource, namedModuleBySource }
2553}
2554
2555function importSpecifierName(specifier: ts.ImportSpecifier): string {
2556 return specifier.propertyName?.text ?? specifier.name.text
2557}
2558
2559function validateExampleImports(
2560 code: string,
2561 policy: ExampleImportPolicy
2562): ReadonlyArray<JSDocDiagnostic> {
2563 const diagnostics: Array<JSDocDiagnostic> = []
2564 const sourceFile = ts.createSourceFile("example.ts", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
2565 for (const statement of sourceFile.statements) {
2566 if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue
2567 if (statement.importClause?.isTypeOnly) continue
2568 const source = statement.moduleSpecifier.text
2569 const namedAllowed = policy.allowedNamedBySource.get(source)
2570 const namespaceModule = policy.namespaceModuleBySource.get(source)
2571 const flatModule = policy.flatModuleBySource.get(source)
2572 const namedModule = policy.namedModuleBySource.get(source)
2573 const clause = statement.importClause
2574 if (clause === undefined) continue
2575 if (namespaceModule !== undefined) {
2576 diagnostics.push(diagnostic(
2577 "example-import-style",
2578 `JSDoc examples should use \`import { ${namespaceModule.name} } from "${namespaceModule.barrelModule}"\` instead of importing from "${source}"`
2579 ))
2580 continue
2581 }
2582 if (flatModule !== undefined) {
2583 diagnostics.push(diagnostic(
2584 "example-import-style",
2585 `JSDoc examples should import ${
2586 Array.from(flatModule.names).map((name) => `\`${name}\``).join(", ")
2587 } from "${flatModule.barrelModule}" instead of "${source}"`
2588 ))
2589 continue
2590 }
2591 if (clause.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) {
2592 if (namedAllowed !== undefined || namedModule !== undefined) {
2593 diagnostics.push(diagnostic("example-import-style", `JSDoc examples should use named imports from "${source}"`))
2594 }
2595 continue
2596 }
2597 if (clause.name !== undefined && (namedAllowed !== undefined || namedModule !== undefined)) {
2598 diagnostics.push(
2599 diagnostic("example-import-style", `JSDoc examples should not use default imports from "${source}"`)
2600 )
2601 }
2602 if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings) && namedAllowed !== undefined) {
2603 for (const specifier of clause.namedBindings.elements) {
2604 if (specifier.isTypeOnly) continue
2605 const imported = importSpecifierName(specifier)
2606 if (!namedAllowed.has(imported)) {
2607 diagnostics.push(diagnostic(
2608 "example-import-style",
2609 `JSDoc example import \`${imported}\` from "${source}" is not a canonical public API import`
2610 ))
2611 }
2612 }
2613 }
2614 }
2615 return diagnostics
2616}
2617
2618function appendExampleImportDiagnostics(files: ReadonlyArray<JSDocModelFile>): ReadonlyArray<JSDocModelFile> {
2619 const policy = createExampleImportPolicy(files)
2620 const checkExamples = (
2621 examples: ReadonlyArray<ParsedExample>,
2622 range: readonly [number, number] = [0, 0] as const
2623 ): ReadonlyArray<JSDocModelDiagnostic> =>
2624 examples.flatMap((example) => validateExampleImports(example.code, policy).map((item) => ({ ...item, range })))
2625 const checkModuleExamples = (moduleJSDoc: ParsedModuleJSDoc | undefined): ReadonlyArray<JSDocModelDiagnostic> => {
2626 if (moduleJSDoc === undefined) return []
2627 const block = parseLooseJSDocBlock(moduleJSDoc.raw, [moduleJSDoc.range[0], moduleJSDoc.range[1]])
2628 if (block.internal) return []
2629 return checkExamples(parseModuleExamples(block).examples, moduleJSDoc.range)
2630 }
2631 const checkMembers = (members: ReadonlyArray<ParsedMember>): ReadonlyArray<JSDocModelDiagnostic> =>
2632 members.flatMap((member) => [
2633 ...checkExamples(member.examples),
2634 ...checkMembers(member.members)
2635 ])
2636 const checkNamespaces = (namespaces: ReadonlyArray<ParsedNamespace>): ReadonlyArray<JSDocModelDiagnostic> =>
2637 namespaces.flatMap((namespace) => [
2638 ...checkExamples(namespace.examples),
2639 ...namespace.declarations.flatMap((declaration) => [
2640 ...checkExamples(declaration.examples),
2641 ...checkMembers(declaration.members)
2642 ]),
2643 ...checkNamespaces(namespace.namespaces)
2644 ])
2645 return files.map((file) => {
2646 const diagnostics = [
2647 ...file.diagnostics,
2648 ...checkModuleExamples(file.moduleJSDoc),
2649 ...file.declarations.flatMap((declaration) => [
2650 ...checkExamples(declaration.examples),
2651 ...checkMembers(declaration.members)
2652 ]),
2653 ...checkNamespaces(file.namespaces)
2654 ]
2655 return diagnostics.length === file.diagnostics.length
2656 ? file
2657 : { ...file, diagnostics: uniqueModelDiagnostics(diagnostics) }
2658 })
2659}
2660
2661function uniqueModelDiagnostics(
2662 diagnostics: ReadonlyArray<JSDocModelDiagnostic>
2663): ReadonlyArray<JSDocModelDiagnostic> {
2664 const seen = new Set<string>()
2665 return diagnostics.filter((item) => {
2666 const key = `${item.code}:${item.message}:${item.range[0]}:${item.range[1]}`
2667 if (seen.has(key)) return false
2668 seen.add(key)
2669 return true
2670 })
2671}
2672
2673function nodeRange(node: ts.Node): [number, number] {
2674 return [node.getStart(), node.getEnd()]
2675}
2676
2677function normalizeFile(cwd: string, filename: string): string {
2678 return normalizePathName(path.relative(cwd, filename))
2679}
2680
2681function hashSource(source: string): string {
2682 return crypto.createHash("sha256").update(source).digest("hex")
2683}
2684
2685function getNodeJSDoc(node: ts.Node): JSDocBlock | undefined {
2686 const jsDocs = (node as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc
2687 const jsDoc = jsDocs?.[jsDocs.length - 1]
2688 if (jsDoc === undefined) return undefined
2689 const source = node.getSourceFile().text
2690 return parseJSDocBlock(source.slice(jsDoc.pos, jsDoc.end), [jsDoc.pos, jsDoc.end])
2691}
2692
2693function addModelDiagnostics(
2694 out: Array<JSDocModelDiagnostic>,
2695 range: [number, number],
2696 diagnostics: ReadonlyArray<JSDocDiagnostic>
2697) {
2698 for (const item of diagnostics) out.push({ ...item, range })
2699}
2700
2701function addJSDocLinkDiagnostics(
2702 sourceFile: ts.SourceFile,
2703 block: JSDocBlock,
2704 diagnostics: Array<JSDocModelDiagnostic>,
2705 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
2706) {
2707 if (!hasInlineLink(block)) return
2708 for (const link of malformedInlineLinks(block)) {
2709 diagnostics.push({ ...diagnostic("malformed-link", `Malformed JSDoc inline link: ${link}`), range: block.range })
2710 }
2711 for (const link of collectJSDocLinks(sourceFile, block)) {
2712 const text = link.getText(sourceFile)
2713 const target = extractInlineLinkTarget(text)
2714 if (target === "") continue
2715 if (urlRegex.test(target)) {
2716 diagnostics.push({
2717 ...diagnostic("url-link", `JSDoc inline link must target a TypeScript symbol: ${text}`),
2718 range: [link.pos, link.end]
2719 })
2720 } else if (resolveJSDocLinkSymbol(link, linkContext.checker) === undefined) {
2721 diagnostics.push({
2722 ...diagnostic("unresolved-link", `Unresolved JSDoc inline link: ${text}`),
2723 range: [link.pos, link.end]
2724 })
2725 }
2726 }
2727 for (const link of collectJSDocSeeLinks(sourceFile, block)) {
2728 const text = link.getText(sourceFile)
2729 const target = extractInlineLinkTarget(text)
2730 if (target === "" || urlRegex.test(target)) continue
2731 const symbol = resolveJSDocLinkSymbol(link, linkContext.checker)
2732 if (symbol === undefined) continue
2733 if (undocumentedSeeLinkTarget(symbol, linkContext.checker, linkContext.cwd) !== undefined) {
2734 diagnostics.push({
2735 ...diagnostic("undocumented-see-target", `@see link target must have public JSDoc: ${text}`),
2736 range: [link.pos, link.end]
2737 })
2738 }
2739 }
2740}
2741
2742function parseDocumentedTs(
2743 node: ts.Node,
2744 scope: DocScope,
2745 diagnostics: Array<JSDocModelDiagnostic>,
2746 required = true,
2747 linkContext?: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
2748) {
2749 const block = getNodeJSDoc(node)
2750 if (block?.internal) return undefined
2751 if (block === undefined) {
2752 if (required) {
2753 diagnostics.push({
2754 ...diagnostic("missing-jsdoc", scope === "member" ? "Member JSDoc is required" : "Public JSDoc is required"),
2755 range: nodeRange(node)
2756 })
2757 }
2758 return undefined
2759 }
2760 addModelDiagnostics(diagnostics, block.range, block.diagnostics)
2761 if (block.parsed === undefined) return undefined
2762 const tags = buildTags(scope, block.parsed.tags)
2763 if (tags._tag === "Failure") {
2764 addModelDiagnostics(diagnostics, block.range, tags.error.diagnostics)
2765 return undefined
2766 }
2767 const parsedTags = tags.value as ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
2768 if (linkContext !== undefined) addJSDocLinkDiagnostics(node.getSourceFile(), block, diagnostics, linkContext)
2769 return {
2770 core: block.parsed,
2771 tags: linkContext === undefined ? parsedTags : attachSeeLinkSymbols(parsedTags, node, block, linkContext)
2772 }
2773}
2774
2775function hasExportModifier(node: ts.Node): boolean {
2776 return ts.canHaveModifiers(node) &&
2777 (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false)
2778}
2779
2780function hasDefaultModifier(node: ts.Node): boolean {
2781 return ts.canHaveModifiers(node) &&
2782 (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false)
2783}
2784
2785function declarationName(node: ts.Node): string | undefined {
2786 if (
2787 (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node) ||
2788 ts.isFunctionDeclaration(node) || ts.isModuleDeclaration(node)) && node.name !== undefined
2789 ) return node.name.text
2790 if (ts.isVariableStatement(node)) {
2791 return node.declarationList.declarations.map((declaration) => {
2792 if (ts.isIdentifier(declaration.name)) return declaration.name.text
2793 if (ts.isObjectBindingPattern(declaration.name)) {
2794 return declaration.name.elements.map((element) =>
2795 element.propertyName?.getText(element.getSourceFile()) ?? element.name.getText(element.getSourceFile())
2796 ).join(", ")
2797 }
2798 return ""
2799 }).filter(Boolean).join(", ")
2800 }
2801 return undefined
2802}
2803
2804function publicDeclarationRange(node: ts.Node, name: string): readonly [number, number] {
2805 if (ts.isVariableStatement(node)) {
2806 const declaration = node.declarationList.declarations.find((item) =>
2807 ts.isIdentifier(item.name) && item.name.text === name
2808 )
2809 if (declaration !== undefined) return nodeRange(declaration)
2810 }
2811 return nodeRange(node)
2812}
2813
2814function bucketOfTs(node: ts.Node): ExportBucket | undefined {
2815 if (ts.isVariableStatement(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return "value"
2816 if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) return "type"
2817 return undefined
2818}
2819
2820function normalizeSignature(cwd: string, text: string): string | null {
2821 const normalizedCwd = normalizePathName(cwd)
2822 const signature = text.replaceAll(normalizedCwd, ".").replaceAll(cwd, ".").replace(/\r\n?/g, "\n").replace(
2823 /[ \t]+$/gm,
2824 ""
2825 ).trim()
2826 return signature === "" ? null : signature
2827}
2828
2829function displaySignature(text: string): string {
2830 return text.replace(/import\((?:"[^"]+"|'[^']+')\)\.([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)/g, "$1")
2831}
2832
2833function parseableSignature(cwd: string, text: string): string | null {
2834 const signature = normalizeSignature(cwd, displaySignature(text))
2835 if (signature === null) return null
2836 const source = ts.createSourceFile(
2837 "signature.ts",
2838 signature,
2839 ts.ScriptTarget.Latest,
2840 true,
2841 ts.ScriptKind.TS
2842 ) as SourceFileWithParseDiagnostics
2843 return source.parseDiagnostics.length === 0 ? signature : null
2844}
2845
2846function signatureLocalName(name: string): string {
2847 const sanitized = name.replace(/[^A-Za-z0-9_$]/g, "_")
2848 return `_${sanitized === "" ? "api" : sanitized}`
2849}
2850
2851function signatureExportName(name: string): string {
2852 return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name)
2853}
2854
2855function signatureAlias(name: string, text: string): string {
2856 const localName = signatureLocalName(name)
2857 return `declare const ${localName}: ${text}
2858export { ${localName} as ${signatureExportName(name)} }`
2859}
2860
2861function signatureParameterDeclaration(parameter: ts.Symbol): ts.ParameterDeclaration | undefined {
2862 const declaration = parameter.valueDeclaration ?? parameter.declarations?.[0]
2863 return declaration !== undefined && ts.isParameter(declaration) ? declaration : undefined
2864}
2865
2866function signatureParameterName(parameter: ts.Symbol): string {
2867 const declaration = signatureParameterDeclaration(parameter)
2868 if (declaration !== undefined && ts.isIdentifier(declaration.name)) return declaration.name.text
2869 return parameter.getName()
2870}
2871
2872function signatureFunctionThisKind(type: ts.Type, checker: ts.TypeChecker, location: ts.Node): string {
2873 const thisParameter = checker.getSignaturesOfType(type, ts.SignatureKind.Call)[0]?.thisParameter
2874 if (thisParameter === undefined) return "none"
2875 const thisType = checker.typeToString(
2876 checker.getTypeOfSymbolAtLocation(thisParameter, location),
2877 location,
2878 signatureTypeFormatFlags
2879 )
2880 return thisType === "unassigned" ? "unassigned" : "self"
2881}
2882
2883function isSignatureFunctionKind(kind: string): boolean {
2884 return kind.startsWith("body:") || kind.startsWith("function:")
2885}
2886
2887function signatureTransformCountKind(count: number): string {
2888 if (count <= 3) return String(count)
2889 if (count <= 7) return "4"
2890 if (count <= 15) return "8"
2891 return "16"
2892}
2893
2894function signatureParameterKind(
2895 parameter: ts.Symbol | undefined,
2896 checker: ts.TypeChecker,
2897 location: ts.Node
2898): string {
2899 if (parameter === undefined) return "none"
2900 const declaration = signatureParameterDeclaration(parameter)
2901 if (declaration?.dotDotDotToken !== undefined) return "rest"
2902 const name = signatureParameterName(parameter).replace(/_$/, "").toLowerCase()
2903 const type = checker.getTypeOfSymbolAtLocation(parameter, declaration ?? location)
2904 if (name === "self" || name === "that") return "self"
2905 if (name === "body") return `body:${signatureFunctionThisKind(type, checker, location)}`
2906 if (name === "name" || name === "spanname") return "name"
2907 if (name.includes("refinement")) return "refinement"
2908 if (name.includes("predicate")) return "predicate"
2909 if (name === "options" || name === "option" || name === "opts" || name === "config" || name === "args") {
2910 return "options"
2911 }
2912 if (name === "f" || name === "fn" || name === "callback") {
2913 return `function:${signatureFunctionThisKind(type, checker, location)}`
2914 }
2915 if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) {
2916 return `function:${signatureFunctionThisKind(type, checker, location)}`
2917 }
2918 if ((type.flags & ts.TypeFlags.StringLike) !== 0) return "primitive:string"
2919 if ((type.flags & ts.TypeFlags.NumberLike) !== 0) return "primitive:number"
2920 if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return "primitive:boolean"
2921 if ((type.flags & ts.TypeFlags.BigIntLike) !== 0) return "primitive:bigint"
2922 if ((type.flags & ts.TypeFlags.ESSymbolLike) !== 0) return "primitive:symbol"
2923 if ((type.flags & ts.TypeFlags.Object) !== 0 || checker.getPropertiesOfType(type).length > 0) return "object"
2924 return "other"
2925}
2926
2927function signatureTailKind(signature: ts.Signature, checker: ts.TypeChecker, location: ts.Node): string {
2928 if (signature.parameters.length <= 1) return "none"
2929 const firstKind = signatureParameterKind(signature.parameters[0], checker, location)
2930 const tailKinds = signature.parameters.slice(1).map((parameter) =>
2931 signatureParameterKind(parameter, checker, location)
2932 )
2933 if (firstKind === "options") {
2934 const hasTransformTail = tailKinds.length > 1 && tailKinds.slice(1).every(isSignatureFunctionKind)
2935 return `${tailKinds[0]}:${hasTransformTail ? "transform" : "none"}`
2936 }
2937 if (tailKinds.every(isSignatureFunctionKind)) {
2938 return firstKind.startsWith("body:")
2939 ? "transform"
2940 : `transform:${signatureTransformCountKind(tailKinds.length)}`
2941 }
2942 return tailKinds[0]
2943}
2944
2945function signatureShapeKey(signature: ts.Signature, checker: ts.TypeChecker, location: ts.Node): string {
2946 const hasRest = signature.parameters.some((parameter) =>
2947 signatureParameterDeclaration(parameter)?.dotDotDotToken !== undefined
2948 )
2949 const returnType = checker.getReturnTypeOfSignature(signature)
2950 const returnMode = checker.getSignaturesOfType(returnType, ts.SignatureKind.Call).length > 0 ? "curried" : "direct"
2951 return [
2952 hasRest ? "rest" : "fixed",
2953 signatureParameterKind(signature.parameters[0], checker, location),
2954 signatureTailKind(signature, checker, location),
2955 returnMode
2956 ].join(":")
2957}
2958
2959function representativeSignatures(
2960 signatures: ReadonlyArray<ts.Signature>,
2961 checker: ts.TypeChecker,
2962 location: ts.Node
2963): ReadonlyArray<ts.Signature> {
2964 const seen = new Set<string>()
2965 const out: Array<ts.Signature> = []
2966 for (const signature of signatures) {
2967 const key = signatureShapeKey(signature, checker, location)
2968 if (seen.has(key)) continue
2969 seen.add(key)
2970 out.push(signature)
2971 }
2972 return out
2973}
2974
2975function functionSignature(
2976 name: string,
2977 symbol: ts.Symbol,
2978 location: ts.Node,
2979 checker: ts.TypeChecker,
2980 cwd: string
2981): string | null {
2982 const target = resolvedSymbolTarget(symbol, checker)
2983 const type = checker.getTypeOfSymbolAtLocation(target, location)
2984 const signatures = checker.getSignaturesOfType(type, ts.SignatureKind.Call)
2985 if (signatures.length === 0) return null
2986 const callSignatures: Array<string> = []
2987 for (const signature of representativeSignatures(signatures, checker, location)) {
2988 const text = normalizeSignature(
2989 cwd,
2990 checker.signatureToString(signature, location, signatureTypeFormatFlags, ts.SignatureKind.Call)
2991 )
2992 if (text === null) return null
2993 callSignatures.push(text)
2994 }
2995 const declaration = parseableSignature(
2996 cwd,
2997 callSignatures.map((text) => `declare function ${name}${text}`).join("\n")
2998 )
2999 if (declaration !== null) return declaration
3000 const callSignatureAlias = parseableSignature(
3001 cwd,
3002 signatureAlias(
3003 name,
3004 `{
3005${callSignatures.map((text) => ` ${text};`).join("\n")}
3006}`
3007 )
3008 )
3009 if (callSignatureAlias !== null) return callSignatureAlias
3010 const typeText = normalizeSignature(cwd, checker.typeToString(type, location, signatureTypeFormatFlags))
3011 if (typeText === null) return null
3012 return parseableSignature(cwd, `declare const ${name}: ${typeText}`) ??
3013 parseableSignature(cwd, signatureAlias(name, typeText))
3014}
3015
3016function symbolSignature(
3017 name: string,
3018 bucket: ExportBucket,
3019 symbol: ts.Symbol,
3020 location: ts.Node,
3021 checker: ts.TypeChecker,
3022 cwd: string
3023): string | null {
3024 if (bucket !== "value") return null
3025 const target = resolvedSymbolTarget(symbol, checker)
3026 const type = checker.getTypeOfSymbolAtLocation(target, location)
3027 if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length === 0) return null
3028 return functionSignature(name, symbol, location, checker, cwd)
3029}
3030
3031function declarationSignature(
3032 name: string,
3033 bucket: ExportBucket,
3034 node: ts.Node,
3035 checker: ts.TypeChecker,
3036 cwd: string
3037): string | null {
3038 if (ts.isVariableStatement(node)) {
3039 const declaration = node.declarationList.declarations.find((item) =>
3040 ts.isIdentifier(item.name) && item.name.text === name
3041 )
3042 if (declaration === undefined) return null
3043 const symbol = checker.getSymbolAtLocation(declaration.name)
3044 return symbol === undefined ? null : symbolSignature(name, bucket, symbol, declaration.name, checker, cwd)
3045 }
3046 const nameNode = ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) ||
3047 ts.isTypeAliasDeclaration(node)
3048 ? node.name
3049 : undefined
3050 if (nameNode === undefined) return null
3051 const symbol = checker.getSymbolAtLocation(nameNode)
3052 return symbol === undefined ? null : symbolSignature(name, bucket, symbol, nameNode, checker, cwd)
3053}
3054
3055function exportSpecifierSignature(
3056 name: string,
3057 bucket: ExportBucket,
3058 specifier: ts.ExportSpecifier,
3059 checker: ts.TypeChecker,
3060 cwd: string
3061): string | null {
3062 const symbol = checker.getSymbolAtLocation(specifier.name) ??
3063 (specifier.propertyName === undefined ? undefined : checker.getSymbolAtLocation(specifier.propertyName))
3064 return symbol === undefined ? null : symbolSignature(name, bucket, symbol, specifier.name, checker, cwd)
3065}
3066
3067function parseMembersFromTsType(
3068 type: ts.TypeNode | undefined,
3069 diagnostics: Array<JSDocModelDiagnostic>,
3070 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
3071): Array<ParsedMember> {
3072 if (!type) return []
3073 if (ts.isTypeLiteralNode(type)) return parseTsMembers(type.members, diagnostics, linkContext)
3074 if (ts.isUnionTypeNode(type) || ts.isIntersectionTypeNode(type)) {
3075 return type.types.flatMap((item) => parseMembersFromTsType(item, diagnostics, linkContext))
3076 }
3077 if (ts.isParenthesizedTypeNode(type) || ts.isTypeOperatorNode(type)) {
3078 return parseMembersFromTsType(type.type, diagnostics, linkContext)
3079 }
3080 if (ts.isArrayTypeNode(type)) return parseMembersFromTsType(type.elementType, diagnostics, linkContext)
3081 if (ts.isConditionalTypeNode(type)) {
3082 return [
3083 ...parseMembersFromTsType(type.checkType, diagnostics, linkContext),
3084 ...parseMembersFromTsType(type.extendsType, diagnostics, linkContext),
3085 ...parseMembersFromTsType(type.trueType, diagnostics, linkContext),
3086 ...parseMembersFromTsType(type.falseType, diagnostics, linkContext)
3087 ]
3088 }
3089 if (ts.isTypeReferenceNode(type)) {
3090 return type.typeArguments?.flatMap((item) => parseMembersFromTsType(item, diagnostics, linkContext)) ?? []
3091 }
3092 if (ts.isMappedTypeNode(type)) return parseMembersFromTsType(type.type, diagnostics, linkContext)
3093 if (ts.isIndexedAccessTypeNode(type)) {
3094 return [
3095 ...parseMembersFromTsType(type.objectType, diagnostics, linkContext),
3096 ...parseMembersFromTsType(type.indexType, diagnostics, linkContext)
3097 ]
3098 }
3099 if (ts.isFunctionTypeNode(type) || ts.isConstructorTypeNode(type)) {
3100 return parseMembersFromTsType(type.type, diagnostics, linkContext)
3101 }
3102 return []
3103}
3104
3105function memberName(member: ts.TypeElement | ts.ClassElement): string {
3106 if (
3107 ts.isPropertySignature(member) || ts.isMethodSignature(member) || ts.isPropertyDeclaration(member) ||
3108 ts.isMethodDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)
3109 ) {
3110 const name = member.name
3111 if (ts.isComputedPropertyName(name)) return name.expression.getText(member.getSourceFile())
3112 return name.getText(member.getSourceFile()).replace(/^[']|[']$/g, "")
3113 }
3114 return ""
3115}
3116
3117function memberType(member: ts.TypeElement | ts.ClassElement): ts.TypeNode | undefined {
3118 if (
3119 ts.isPropertySignature(member) || ts.isMethodSignature(member) || ts.isPropertyDeclaration(member) ||
3120 ts.isMethodDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)
3121 ) return member.type
3122 return undefined
3123}
3124
3125function parseTsMembers(
3126 members: ts.NodeArray<ts.TypeElement | ts.ClassElement>,
3127 diagnostics: Array<JSDocModelDiagnostic>,
3128 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
3129): Array<ParsedMember> {
3130 const out: Array<ParsedMember> = []
3131 for (const member of members) {
3132 if (
3133 ts.isCallSignatureDeclaration(member) || ts.isConstructSignatureDeclaration(member) ||
3134 ts.isIndexSignatureDeclaration(member) || member.kind === ts.SyntaxKind.Constructor
3135 ) continue
3136 const documented = parseDocumentedTs(member, "member", diagnostics, false, linkContext)
3137 if (documented === undefined) continue
3138 const name = memberName(member)
3139 if (name === "") continue
3140 const nested = parseMembersFromTsType(memberType(member), diagnostics, linkContext)
3141 out.push({
3142 name,
3143 signature: null,
3144 range: nodeRange(member),
3145 description: documented.core.description,
3146 examples: documented.core.examples,
3147 tags: documented.tags as ParsedMemberTags,
3148 members: nested
3149 })
3150 }
3151 return out
3152}
3153
3154function parseDeclarationMembersTs(
3155 node: ts.Node,
3156 diagnostics: Array<JSDocModelDiagnostic>,
3157 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
3158): Array<ParsedMember> {
3159 if (ts.isInterfaceDeclaration(node)) return parseTsMembers(node.members, diagnostics, linkContext)
3160 if (ts.isClassDeclaration(node)) return parseTsMembers(node.members, diagnostics, linkContext)
3161 if (ts.isTypeAliasDeclaration(node)) return parseMembersFromTsType(node.type, diagnostics, linkContext)
3162 if (ts.isVariableStatement(node)) {
3163 return node.declarationList.declarations.flatMap((d) => parseMembersFromTsType(d.type, diagnostics, linkContext))
3164 }
3165 if (ts.isFunctionDeclaration(node)) return parseMembersFromTsType(node.type, diagnostics, linkContext)
3166 return []
3167}
3168
3169function hasDeclareModifier(node: ts.Node): boolean {
3170 return ts.canHaveModifiers(node) &&
3171 (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
3172}
3173
3174function parseNamespaceTs(
3175 node: ts.ModuleDeclaration,
3176 diagnostics: Array<JSDocModelDiagnostic>,
3177 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string },
3178 isInsideDeclare = false
3179): ParsedNamespace | undefined {
3180 if (!ts.isIdentifier(node.name)) return undefined
3181 if (!isInsideDeclare && !hasDeclareModifier(node)) {
3182 diagnostics.push({
3183 ...diagnostic("namespace-declare", "Namespaces must be declared with declare namespace"),
3184 range: nodeRange(node)
3185 })
3186 return undefined
3187 }
3188 const documented = parseDocumentedTs(node, "namespace", diagnostics, true, linkContext)
3189 if (documented === undefined) return undefined
3190 const declarations: Array<ParsedNamespaceDeclaration> = []
3191 const namespaces: Array<ParsedNamespace> = []
3192 if (node.body !== undefined && ts.isModuleBlock(node.body)) {
3193 for (const statement of node.body.statements) {
3194 if (!hasExportModifier(statement)) continue
3195 if (ts.isModuleDeclaration(statement)) {
3196 const nested = parseNamespaceTs(statement, diagnostics, linkContext, true)
3197 if (nested !== undefined) namespaces.push(nested)
3198 continue
3199 }
3200 if (ts.isEnumDeclaration(statement)) {
3201 diagnostics.push({ ...diagnostic("enum", "Enums are not allowed"), range: nodeRange(statement) })
3202 continue
3203 }
3204 const bucket = bucketOfTs(statement)
3205 if (bucket !== "type") {
3206 diagnostics.push({
3207 ...diagnostic("namespace-value", "Namespace exports must be type declarations"),
3208 range: nodeRange(statement)
3209 })
3210 continue
3211 }
3212 const nestedDocumented = parseDocumentedTs(statement, "namespace-declaration", diagnostics, true, linkContext)
3213 if (nestedDocumented === undefined) continue
3214 const name = declarationName(statement)
3215 if (name === undefined || name.length === 0) {
3216 diagnostics.push({
3217 ...diagnostic("missing-name", "Namespace declaration name could not be determined"),
3218 range: nodeRange(statement)
3219 })
3220 continue
3221 }
3222 declarations.push({
3223 name,
3224 signature: null,
3225 range: publicDeclarationRange(statement, name),
3226 description: nestedDocumented.core.description,
3227 examples: nestedDocumented.core.examples,
3228 tags: nestedDocumented.tags as ParsedNamespaceTags,
3229 members: parseDeclarationMembersTs(statement, diagnostics, linkContext)
3230 })
3231 }
3232 }
3233 return {
3234 name: node.name.text,
3235 signature: null,
3236 range: nodeRange(node),
3237 description: documented.core.description,
3238 examples: documented.core.examples,
3239 tags: documented.tags as ParsedNamespaceTags,
3240 declarations,
3241 namespaces
3242 }
3243}
3244
3245function skipLeadingIgnoredTrivia(source: string): number {
3246 let index = source.charCodeAt(0) === 0xfeff ? 1 : 0
3247 while (index < source.length) {
3248 while (index < source.length && /\s/.test(source[index])) index++
3249 if (!source.startsWith("//", index)) return index
3250 const lineEnd = source.indexOf("\n", index)
3251 const end = lineEnd === -1 ? source.length : lineEnd + 1
3252 if (!isSkippableDirectiveComment(source.slice(index, end))) return index
3253 index = end
3254 }
3255 return index
3256}
3257
3258function sourceFileTopLevelJSDoc(sourceFile: ts.SourceFile): ParsedModuleJSDoc | undefined {
3259 const source = sourceFile.text
3260 const start = skipLeadingIgnoredTrivia(source)
3261 if (!source.startsWith("/**", start)) return undefined
3262 const end = source.indexOf("*/", start + 3)
3263 if (end === -1) return undefined
3264 const range = [start, end + 2] as const
3265 const firstStatement = sourceFile.statements[0]
3266 if (firstStatement !== undefined) {
3267 const jsDocs = (firstStatement as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc ?? []
3268 if (
3269 jsDocs.some((jsDoc) => jsDoc.pos === range[0] && jsDoc.end === range[1]) &&
3270 !ts.isImportDeclaration(firstStatement) &&
3271 !ts.isImportEqualsDeclaration(firstStatement)
3272 ) return undefined
3273 }
3274 return { raw: source.slice(range[0], range[1]), range }
3275}
3276
3277function parseModuleJSDocTs(
3278 sourceFile: ts.SourceFile,
3279 moduleJSDoc: ParsedModuleJSDoc,
3280 diagnostics: Array<JSDocModelDiagnostic>,
3281 linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
3282): ParsedModuleJSDoc | undefined {
3283 const block = parseLooseJSDocBlock(moduleJSDoc.raw, [moduleJSDoc.range[0], moduleJSDoc.range[1]])
3284 if (block.internal) return undefined
3285 const tags = buildTags("module", block.tags)
3286 if (tags._tag === "Failure") addModelDiagnostics(diagnostics, block.range, tags.error.diagnostics)
3287 const examples = parseModuleExamples(block)
3288 addModelDiagnostics(diagnostics, block.range, examples.diagnostics)
3289 addJSDocLinkDiagnostics(sourceFile, block, diagnostics, linkContext)
3290 return moduleJSDoc
3291}
3292
3293function parseSourceFileDocs(
3294 cwd: string,
3295 sourceFile: ts.SourceFile,
3296 checker: ts.TypeChecker,
3297 entry: ProgramCacheEntry
3298): { readonly parsed: ParsedJSDocFile; readonly diagnostics: ReadonlyArray<JSDocModelDiagnostic> } {
3299 const diagnostics: Array<JSDocModelDiagnostic> = []
3300 const declarations: Array<ParsedRootDeclaration> = []
3301 const namespaces: Array<ParsedNamespace> = []
3302 const linkContext = { checker, entry, cwd }
3303 const checkedFunctionOverloads = new Set<string>()
3304 const moduleJSDoc = sourceFileTopLevelJSDoc(sourceFile)
3305 const parsedModuleJSDoc = moduleJSDoc === undefined
3306 ? undefined
3307 : parseModuleJSDocTs(sourceFile, moduleJSDoc, diagnostics, linkContext)
3308
3309 for (const statement of sourceFile.statements) {
3310 if (ts.isExportAssignment(statement)) continue
3311 if (ts.isExportDeclaration(statement)) {
3312 if (statement.exportClause === undefined) continue
3313 if (ts.isNamedExports(statement.exportClause) && statement.exportClause.elements.length === 0) {
3314 diagnostics.push({
3315 ...diagnostic("empty-export", "Empty export declarations are not allowed"),
3316 range: nodeRange(statement)
3317 })
3318 }
3319 if (ts.isNamedExports(statement.exportClause)) {
3320 for (const specifier of statement.exportClause.elements) {
3321 const documented = parseDocumentedTs(specifier, "declaration", diagnostics, true, linkContext)
3322 if (documented === undefined) continue
3323 declarations.push({
3324 name: specifier.name.text,
3325 bucket: statement.isTypeOnly || specifier.isTypeOnly ? "type" : "value",
3326 signature: exportSpecifierSignature(
3327 specifier.name.text,
3328 statement.isTypeOnly || specifier.isTypeOnly ? "type" : "value",
3329 specifier,
3330 checker,
3331 cwd
3332 ),
3333 range: nodeRange(specifier),
3334 description: documented.core.description,
3335 examples: documented.core.examples,
3336 tags: documented.tags as ParsedDeclarationTags,
3337 members: []
3338 })
3339 }
3340 }
3341 continue
3342 }
3343 if (!hasExportModifier(statement) || hasDefaultModifier(statement)) continue
3344 if (ts.isModuleDeclaration(statement)) {
3345 const namespace = parseNamespaceTs(statement, diagnostics, linkContext)
3346 if (namespace !== undefined) namespaces.push(namespace)
3347 continue
3348 }
3349 if (ts.isEnumDeclaration(statement)) {
3350 diagnostics.push({ ...diagnostic("enum", "Enums are not allowed"), range: nodeRange(statement) })
3351 continue
3352 }
3353 const bucket = bucketOfTs(statement)
3354 if (bucket === undefined) continue
3355 if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) {
3356 const name = statement.name.text
3357 if (statement.body === undefined) {
3358 if (checkedFunctionOverloads.has(name)) continue
3359 checkedFunctionOverloads.add(name)
3360 } else if (checkedFunctionOverloads.has(name)) {
3361 continue
3362 }
3363 }
3364 const documented = parseDocumentedTs(statement, "declaration", diagnostics, true, linkContext)
3365 if (documented === undefined) continue
3366 const name = declarationName(statement)
3367 if (name === undefined || name.length === 0) {
3368 diagnostics.push({
3369 ...diagnostic("missing-name", "Root declaration name could not be determined"),
3370 range: nodeRange(statement)
3371 })
3372 continue
3373 }
3374 declarations.push({
3375 name,
3376 bucket,
3377 signature: declarationSignature(name, bucket, statement, checker, cwd),
3378 range: publicDeclarationRange(statement, name),
3379 description: documented.core.description,
3380 examples: documented.core.examples,
3381 tags: documented.tags as ParsedDeclarationTags,
3382 members: parseDeclarationMembersTs(statement, diagnostics, linkContext)
3383 })
3384 }
3385 return {
3386 parsed: {
3387 ...(parsedModuleJSDoc === undefined ? {} : { moduleJSDoc: parsedModuleJSDoc }),
3388 declarations,
3389 namespaces
3390 },
3391 diagnostics: uniqueDiagnostics(diagnostics) as Array<JSDocModelDiagnostic>
3392 }
3393}
3394
3395/**
3396 * Loads a JSDoc extraction configuration from JSON.
3397 *
3398 * @category configuration
3399 * @since 4.0.0
3400 */
3401export function loadJSDocConfig(cwd = process.cwd(), configPath = "jsdocs.config.json"): JSDocConfig {
3402 const absolute = path.resolve(cwd, configPath)
3403 const parsed = JSON.parse(fs.readFileSync(absolute, "utf8")) as JSDocConfig
3404 return parsed
3405}
3406
3407/**
3408 * Extracts a complete JSDoc model synchronously.
3409 *
3410 * @category extraction
3411 * @since 4.0.0
3412 */
3413export function extractJSDocsSync(options: ExtractJSDocsOptions): JSDocModel {
3414 // Share caches within an extraction, not across source snapshots.
3415 programCache.clear()
3416 packageMetadataCache.clear()
3417 barrelExportCache.clear()
3418 const cwd = path.resolve(options.cwd ?? process.cwd())
3419 const tsconfigPath = path.resolve(cwd, options.tsconfig)
3420 const entry = getProgram(tsconfigPath)
3421 if (entry.error !== undefined || entry.program === undefined) {
3422 throw new Error(entry.error ?? `Unable to read ${tsconfigPath}`)
3423 }
3424 const program = entry.program
3425 const checker = program.getTypeChecker()
3426 const sourceByPath = new Map(program.getSourceFiles().map((file) => [path.resolve(file.fileName), file]))
3427 const sourceFilesByFile = new Map(program.getSourceFiles().map((file) => [normalizeFile(cwd, file.fileName), file]))
3428 const files = globSync([...options.include], {
3429 cwd,
3430 absolute: true,
3431 nodir: true,
3432 ignore: [...options.exclude ?? []]
3433 }).sort()
3434 const modelFiles: Array<JSDocModelFile> = []
3435 for (const filename of files) {
3436 const source = fs.readFileSync(filename, "utf8")
3437 const file = normalizeFile(cwd, filename)
3438 const hash = hashSource(source)
3439 const sourceFile = sourceByPath.get(path.resolve(filename))
3440 if (sourceFile === undefined) {
3441 modelFiles.push({
3442 file,
3443 hash,
3444 diagnostics: [{ ...diagnostic("tsconfig", `${file} is not included in ${options.tsconfig}`), range: [0, 0] }],
3445 declarations: [],
3446 namespaces: []
3447 })
3448 continue
3449 }
3450 const result = parseSourceFileDocs(cwd, sourceFile, checker, entry)
3451 if (
3452 result.diagnostics.length === 0 && result.parsed.declarations.length === 0 &&
3453 result.parsed.namespaces.length === 0 && result.parsed.moduleJSDoc === undefined
3454 ) continue
3455 const fileDiagnostics = [...result.diagnostics]
3456 let importsValue: ParsedJSDocImports | undefined
3457 if (
3458 result.parsed.declarations.length > 0 || result.parsed.namespaces.length > 0 ||
3459 result.parsed.moduleJSDoc !== undefined
3460 ) {
3461 const imports = resolveJSDocImports(cwd, filename)
3462 if (imports._tag === "Success") importsValue = imports.value
3463 else {
3464 fileDiagnostics.push({
3465 ...diagnostic(
3466 "imports",
3467 `Unable to resolve jsdocs imports: ${imports.error}. Add the missing barrel/package export or exclude this file from jsdocs.`
3468 ),
3469 range: [0, 0]
3470 })
3471 }
3472 }
3473 modelFiles.push({
3474 file,
3475 hash,
3476 diagnostics: fileDiagnostics,
3477 ...(importsValue === undefined ? {} : { imports: importsValue }),
3478 ...result.parsed
3479 })
3480 }
3481 const filesWithExampleDiagnostics = appendExampleImportDiagnostics(modelFiles)
3482 const withPublicSeeDiagnostics = buildJSDocApisWithPublicSeeDiagnostics(filesWithExampleDiagnostics, {
3483 sourceFilesByFile,
3484 checker,
3485 entry,
3486 cwd
3487 })
3488 return {
3489 version: 3,
3490 generatedBy: "@effect/jsdocs",
3491 generatedAt: new Date().toISOString(),
3492 inputHash: computeJSDocInputHash(options),
3493 files: withPublicSeeDiagnostics.files,
3494 apis: withPublicSeeDiagnostics.apis
3495 }
3496}
3497
3498/**
3499 * Extracts a complete JSDoc model in `Effect`.
3500 *
3501 * @category extraction
3502 * @since 4.0.0
3503 */
3504export const extractJSDocs = (options: ExtractJSDocsOptions): Effect.Effect<JSDocModel> =>
3505 Effect.sync(() => extractJSDocsSync(options))
3506
3507/**
3508 * Writes a JSDoc model as formatted JSON.
3509 *
3510 * @category persistence
3511 * @since 4.0.0
3512 */
3513export function writeJSDocModel(cwd: string, output: string, model: JSDocModel) {
3514 const filename = path.resolve(cwd, output)
3515 fs.mkdirSync(path.dirname(filename), { recursive: true })
3516 fs.writeFileSync(filename, `${JSON.stringify(model, null, 2)}\n`)
3517}
3518
3519/**
3520 * Reads and validates the outer structure of a persisted JSDoc model.
3521 *
3522 * @category persistence
3523 * @since 4.0.0
3524 */
3525export function readJSDocModel(filename: string): Result<JSDocModel, string> {
3526 if (!fs.existsSync(filename)) return { _tag: "Failure", error: "missing" }
3527 try {
3528 const parsed = JSON.parse(fs.readFileSync(filename, "utf8")) as JSDocModel
3529 if (parsed.version !== 3) return { _tag: "Failure", error: "Unsupported jsdocs model version" }
3530 if (!Array.isArray(parsed.files)) return { _tag: "Failure", error: "Invalid jsdocs model: files must be an array" }
3531 if (!Array.isArray(parsed.apis)) return { _tag: "Failure", error: "Invalid jsdocs model: apis must be an array" }
3532 return { _tag: "Success", value: parsed }
3533 } catch (error) {
3534 return { _tag: "Failure", error: `Invalid jsdocs model: ${error instanceof Error ? error.message : String(error)}` }
3535 }
3536}
3537
3538/**
3539 * Computes the content hash stored for a source file in a JSDoc model.
3540 *
3541 * @category hashing
3542 * @since 4.0.0
3543 */
3544export function sourceHash(source: string): string {
3545 return hashSource(source)
3546}