1import { computeJSDocInputHash, extractJSDocsSync, parseJSDoc } from "@effect/jsdocs"
2import { assert, describe, it } from "@effect/vitest"
3import * as fs from "node:fs"
4import * as os from "node:os"
5import * as path from "node:path"
6import * as ts from "typescript"
7
8interface SourceFileWithParseDiagnostics extends ts.SourceFile {
9 readonly parseDiagnostics: ReadonlyArray<ts.Diagnostic>
10}
11
12const stabilityResult = (tag: string) => {
13 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-stability-"))
14 try {
15 fs.mkdirSync(path.join(cwd, "src"))
16 fs.writeFileSync(
17 path.join(cwd, "tsconfig.json"),
18 JSON.stringify({
19 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
20 include: ["src/**/*.ts"]
21 })
22 )
23 fs.writeFileSync(
24 path.join(cwd, "package.json"),
25 JSON.stringify({
26 name: "@effect/sample",
27 type: "module",
28 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
29 })
30 )
31 fs.writeFileSync(path.join(cwd, "src/index.ts"), "export * as Foo from \"./Foo.ts\"\n")
32 fs.writeFileSync(
33 path.join(cwd, "src/Foo.ts"),
34 `/**
35 * Creates a value.
36 *
37 * ${tag}
38 * @category constructors
39 * @since 1.0.0
40 */
41export const makeValue = () => 1
42`
43 )
44 const model = extractJSDocsSync({
45 cwd,
46 tsconfig: "tsconfig.json",
47 include: ["src/**/*.ts"],
48 output: ".data/jsdocs.json"
49 })
50 return {
51 diagnostics: model.files.flatMap((file) => file.diagnostics.map((diagnostic) => diagnostic.code)),
52 stability: model.apis.find((api) => api.apiFqn === "@effect/sample/Foo.makeValue")?.tags.stability
53 }
54 } finally {
55 fs.rmSync(cwd, { recursive: true, force: true })
56 }
57}
58
59const signatureParseDiagnostics = (signature: string): ReadonlyArray<string> =>
60 (ts.createSourceFile(
61 "signature.ts",
62 signature,
63 ts.ScriptTarget.Latest,
64 true,
65 ts.ScriptKind.TS
66 ) as SourceFileWithParseDiagnostics)
67 .parseDiagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
68
69describe("jsdocs", () => {
70 it("parses a raw JSDoc block", () => {
71 const result = parseJSDoc(`/**
72 * Creates a value.
73 *
74 * @category constructors
75 * @since 1.0.0
76 */`)
77 assert.strictEqual(result._tag, "Success")
78 if (result._tag === "Success") {
79 assert.strictEqual(result.value.description.short, "Creates a value.")
80 }
81 })
82
83 it("accepts unstable declarations", () => {
84 const result = parseJSDoc(`/**
85 * Creates an unstable value.
86 *
87 * @stability unstable
88 * @category constructors
89 * @since 1.0.0
90 */`)
91 assert.strictEqual(result._tag, "Success")
92 })
93
94 it("accepts experimental declarations", () => {
95 assert.deepStrictEqual(stabilityResult("@stability experimental"), {
96 diagnostics: [],
97 stability: "experimental"
98 })
99 })
100
101 it("rejects unknown stability values", () => {
102 assert.deepStrictEqual(stabilityResult("@stability bogus").diagnostics, ["invalid-stability"])
103 assert.deepStrictEqual(stabilityResult("@stability stable").diagnostics, ["invalid-stability"])
104 })
105
106 it("rejects duplicate stability tags", () => {
107 assert.deepStrictEqual(stabilityResult("@stability unstable\n * @stability unstable").diagnostics, [
108 "duplicate-tag"
109 ])
110 })
111
112 it("rejects legacy unstable tags", () => {
113 assert.deepStrictEqual(stabilityResult("@unstable").diagnostics, ["forbidden-tag"])
114 })
115
116 it("accepts doctest metadata on TypeScript fences", () => {
117 const result = parseJSDoc(`/**
118 * Creates a value.
119 *
120 * **Example** (Creating a value)
121 *
122 * \`\`\`ts import.meta.vitest name="creates a value"
123 * const value = 1
124 * \`\`\`
125 *
126 * @category constructors
127 * @since 1.0.0
128 */`)
129 assert.strictEqual(result._tag, "Success")
130 })
131
132 it("accepts practical When to use forms", () => {
133 const result = parseJSDoc(`/**
134 * Creates a value.
135 *
136 * **When to use**
137 *
138 * Use to create a value when construction should be explicit.
139 *
140 * @category constructors
141 * @since 1.0.0
142 */`)
143 assert.strictEqual(result._tag, "Success")
144 if (result._tag === "Success") {
145 assert.strictEqual(
146 result.value.description.whenToUse,
147 "Use to create a value when construction should be explicit."
148 )
149 }
150 })
151
152 it("flags unsupported When to use forms", () => {
153 const result = parseJSDoc(`/**
154 * Creates a value.
155 *
156 * **When to use**
157 *
158 * - Creating a value when construction should be explicit.
159 *
160 * @category constructors
161 * @since 1.0.0
162 */`)
163 assert.strictEqual(result._tag, "Failure")
164 if (result._tag === "Failure") {
165 assert.deepStrictEqual(
166 result.error.diagnostics.map((diagnostic) => diagnostic.code),
167 ["when-to-use-format"]
168 )
169 }
170 })
171
172 it("refreshes extracted docs after a source change", () => {
173 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
174 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
175 fs.writeFileSync(
176 path.join(cwd, "tsconfig.json"),
177 JSON.stringify({
178 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
179 include: ["src/**/*.ts"]
180 })
181 )
182 fs.writeFileSync(
183 path.join(cwd, "package.json"),
184 JSON.stringify({
185 name: "@effect/sample",
186 type: "module",
187 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
188 })
189 )
190 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
191 fs.writeFileSync(
192 path.join(cwd, "src/Foo.ts"),
193 `/**
194 * Creates a value.
195 *
196 * @category constructors
197 * @since 1.0.0
198 */
199export const makeValue = () => 1
200`
201 )
202 const options = {
203 cwd,
204 tsconfig: "tsconfig.json",
205 include: ["src/**/*.ts"],
206 output: ".data/jsdocs.json"
207 }
208 const model = extractJSDocsSync(options)
209 assert.strictEqual(model.version, 3)
210 assert.strictEqual(model.files.length, 1)
211 assert.strictEqual(model.files[0]?.declarations[0]?.name, "makeValue")
212 assert.strictEqual(model.apis[0]?.apiFqn, "@effect/sample/Foo.makeValue")
213 assert.deepStrictEqual(model.apis[0]?.importGuidance, {
214 style: "namespace-barrel",
215 importDeclaration: "import { Foo } from \"@effect/sample\"",
216 usage: "Foo.makeValue"
217 })
218
219 const sourcePath = path.join(cwd, "src/Foo.ts")
220 fs.writeFileSync(sourcePath, fs.readFileSync(sourcePath, "utf8").replaceAll("makeValue", "makeUpdatedValue"))
221 const updated = extractJSDocsSync(options)
222 assert.strictEqual(updated.files[0]?.declarations[0]?.name, "makeUpdatedValue")
223 })
224
225 it("stores a stable input hash for cache checks", () => {
226 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
227 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
228 fs.writeFileSync(
229 path.join(cwd, "tsconfig.json"),
230 JSON.stringify({
231 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
232 include: ["src/**/*.ts"]
233 })
234 )
235 fs.writeFileSync(
236 path.join(cwd, "jsdocs.config.json"),
237 JSON.stringify({
238 tsconfig: "tsconfig.json",
239 include: ["src/**/*.ts"],
240 output: ".data/jsdocs.json"
241 })
242 )
243 fs.writeFileSync(
244 path.join(cwd, "package.json"),
245 JSON.stringify({
246 name: "@effect/sample",
247 type: "module",
248 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
249 })
250 )
251 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
252 fs.writeFileSync(
253 path.join(cwd, "src/Foo.ts"),
254 `/**
255 * Creates a value.
256 *
257 * @category constructors
258 * @since 1.0.0
259 */
260export const makeValue = () => 1
261`
262 )
263 const options = {
264 cwd,
265 tsconfig: "tsconfig.json",
266 include: ["src/**/*.ts"],
267 output: ".data/jsdocs.json"
268 }
269 const inputHash = computeJSDocInputHash(options)
270 const model = extractJSDocsSync(options)
271 assert.strictEqual(model.inputHash, inputHash)
272 assert.strictEqual(computeJSDocInputHash(options), inputHash)
273
274 fs.appendFileSync(path.join(cwd, "src/Foo.ts"), "\n")
275 assert.notStrictEqual(computeJSDocInputHash(options), inputHash)
276 })
277
278 it("extracts typechecker signatures for top-level functions", () => {
279 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
280 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
281 fs.writeFileSync(
282 path.join(cwd, "tsconfig.json"),
283 JSON.stringify({
284 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
285 include: ["src/**/*.ts"]
286 })
287 )
288 fs.writeFileSync(
289 path.join(cwd, "package.json"),
290 JSON.stringify({
291 name: "@effect/sample",
292 type: "module",
293 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
294 })
295 )
296 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
297 fs.writeFileSync(
298 path.join(cwd, "src/External.ts"),
299 `/**
300 * External value type.
301 *
302 * @category models
303 * @since 1.0.0
304 */
305export interface External {
306 readonly value: string
307}
308
309/**
310 * Makes an external value.
311 *
312 * @category constructors
313 * @since 1.0.0
314 */
315export const makeExternal = (value: string): External => ({ value })
316`
317 )
318 fs.writeFileSync(
319 path.join(cwd, "src/Foo.ts"),
320 `/**
321 * Parses a value.
322 *
323 * @category parsing
324 * @since 1.0.0
325 */
326export function parse(value: string): string
327export function parse(value: number): number
328export function parse(value: string | number) {
329 return value
330}
331
332/**
333 * Converts a value.
334 *
335 * @category constructors
336 * @since 1.0.0
337 */
338export const inferred = (value: number) => String(value)
339
340const aliased = (value: boolean) => !value
341
342export {
343 /**
344 * Negates a value.
345 *
346 * @category constructors
347 * @since 1.0.0
348 */
349 aliased as renamed
350}
351
352export {
353 /**
354 * Makes an external value.
355 *
356 * @category constructors
357 * @since 1.0.0
358 */
359 makeExternal as external
360} from "./External.ts"
361
362const attempt = (value: string) => value.length
363
364export {
365 /**
366 * Attempts a value.
367 *
368 * @category constructors
369 * @since 1.0.0
370 */
371 attempt as try
372}
373
374/**
375 * Handles many overloads.
376 *
377 * @category constructors
378 * @since 1.0.0
379 */
380export function many(value: 1): 1
381export function many(value: 2): 2
382export function many(value: 3): 3
383export function many(value: 4): 4
384export function many(value: 5): 5
385export function many(value: 6): 6
386export function many(value: 7): 7
387export function many(value: 8): 8
388export function many(value: 9): 9
389export function many(value: 10): 10
390export function many(value: 11): 11
391export function many(value: 12): 12
392export function many(value: 13): 13
393export function many(value: number) {
394 return value
395}
396
397/**
398 * Runs a value through a transform chain.
399 *
400 * @category constructors
401 * @since 1.0.0
402 */
403export function pipeline<A>(a: A): A
404export function pipeline<A, B>(a: A, ab: (a: A) => B): B
405export function pipeline<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C
406export function pipeline<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D
407export function pipeline<A, B, C, D, E>(
408 a: A,
409 ab: (a: A) => B,
410 bc: (b: B) => C,
411 cd: (c: C) => D,
412 de: (d: D) => E
413): E
414export function pipeline<A, B, C, D, E, F>(
415 a: A,
416 ab: (a: A) => B,
417 bc: (b: B) => C,
418 cd: (c: C) => D,
419 de: (d: D) => E,
420 ef: (e: E) => F
421): F
422export function pipeline<A, B, C, D, E, F, G>(
423 a: A,
424 ab: (a: A) => B,
425 bc: (b: B) => C,
426 cd: (c: C) => D,
427 de: (d: D) => E,
428 ef: (e: E) => F,
429 fg: (f: F) => G
430): G
431export function pipeline<A, B, C, D, E, F, G, H>(
432 a: A,
433 ab: (a: A) => B,
434 bc: (b: B) => C,
435 cd: (c: C) => D,
436 de: (d: D) => E,
437 ef: (e: E) => F,
438 fg: (f: F) => G,
439 gh: (g: G) => H
440): H
441export function pipeline<A, B, C, D, E, F, G, H, I>(
442 a: A,
443 ab: (a: A) => B,
444 bc: (b: B) => C,
445 cd: (c: C) => D,
446 de: (d: D) => E,
447 ef: (e: E) => F,
448 fg: (f: F) => G,
449 gh: (g: G) => H,
450 hi: (h: H) => I
451): I
452export function pipeline(...args: Array<any>) {
453 return args.length === 1 ? args[0] : args.slice(1).reduce((value, f) => f(value), args[0])
454}
455
456interface Box<in out A> {}
457
458declare namespace Box {
459 export type Inner<A extends Box<any>> = A extends Box<infer T> ? T : never
460}
461
462interface Check<A> {}
463
464interface Ctor<A extends Box<any>> {
465 (value: Box.Inner<A>): A
466}
467
468/**
469 * Builds a boxed constructor.
470 *
471 * @category constructors
472 * @since 1.0.0
473 */
474export function boxed<A extends Box<any>>(
475 ...value: readonly [Check<Box.Inner<A>>, ...Array<Check<Box.Inner<A>>>]
476): Ctor<A> {
477 return null as any
478}
479
480/**
481 * Handles distinct overload modes.
482 *
483 * @category constructors
484 * @since 1.0.0
485 */
486export function modes(body: () => string): string
487export function modes(body: (value: number) => string): string
488export function modes(body: (this: string) => string): string
489export function modes(body: () => string, map: (value: string) => number): number
490export function modes(body: () => string, map: (value: string) => number, next: (value: number) => boolean): boolean
491export function modes(options: { readonly self: string }, body: (this: string) => string): string
492export function modes(name: string): (body: () => string) => string
493export function modes(self: string, body: () => string): string
494export function modes(predicate: (value: number) => boolean, body: () => number): number
495export function modes(refinement: (value: string | number) => value is string, body: () => string): string
496export function modes(...args: Array<any>) {
497 return args[0]
498}
499
500/**
501 * Service for sample values.
502 *
503 * @category services
504 * @since 1.0.0
505 */
506export class Service {
507 readonly value: string
508 /**
509 * Runs the service.
510 */
511 run(input: number): string {
512 return \`\${this.value}:\${input}\`
513 }
514}
515
516/**
517 * Default count.
518 *
519 * @category constants
520 * @since 1.0.0
521 */
522export const count = 1
523
524/**
525 * Sample value type.
526 *
527 * @category models
528 * @since 1.0.0
529 */
530export interface Sample {
531 readonly value: string
532}
533`
534 )
535 const model = extractJSDocsSync({
536 cwd,
537 tsconfig: "tsconfig.json",
538 include: ["src/**/*.ts"],
539 output: ".data/jsdocs.json"
540 })
541 const signatureByName = new Map(model.apis.map((api) => [api.apiName, api.signature]))
542 assert.deepStrictEqual(model.files.flatMap((file) => file.diagnostics), [])
543 for (const api of model.apis) {
544 if (api.signature !== null) {
545 const diagnostics = signatureParseDiagnostics(api.signature)
546 if (diagnostics.length > 0) {
547 assert.fail(`${api.apiName}: ${diagnostics.join(", ")}`)
548 }
549 }
550 }
551 assert.strictEqual(
552 signatureByName.get("parse"),
553 "declare function parse(value: string): string\ndeclare function parse(value: number): number"
554 )
555 assert.strictEqual(signatureByName.get("inferred"), "declare function inferred(value: number): string")
556 assert.strictEqual(signatureByName.get("renamed"), "declare function renamed(value: boolean): boolean")
557 assert.strictEqual(
558 signatureByName.get("try"),
559 `declare const _try: {
560 (value: string): number;
561}
562export { _try as try }`
563 )
564 assert.strictEqual(signatureByName.get("external"), "declare function external(value: string): External")
565 const manyLines = signatureByName.get("many")?.split("\n")
566 assert.strictEqual(manyLines?.length, 1)
567 assert.strictEqual(manyLines?.[0], "declare function many(value: 1): 1")
568 const pipelineLines = signatureByName.get("pipeline")?.split("\n") ?? []
569 assert.strictEqual(pipelineLines.length, 6)
570 assert.deepStrictEqual(pipelineLines.map((line) => line.match(/=>/g)?.length ?? 0), [0, 1, 2, 3, 4, 8])
571 const boxedSignature = signatureByName.get("boxed")
572 assert.strictEqual(
573 boxedSignature,
574 "declare const boxed: <A extends Box<any>>(value_0: Check<Box.Inner<A>>, ...value: Check<Box.Inner<A>>[]) => Ctor<A>"
575 )
576 const modeLines = signatureByName.get("modes")?.split("\n")
577 assert.deepStrictEqual(modeLines, [
578 "declare function modes(body: () => string): string",
579 "declare function modes(body: (this: string) => string): string",
580 "declare function modes(body: () => string, map: (value: string) => number): number",
581 "declare function modes(options: { readonly self: string; }, body: (this: string) => string): string",
582 "declare function modes(name: string): (body: () => string) => string",
583 "declare function modes(self: string, body: () => string): string",
584 "declare function modes(predicate: (value: number) => boolean, body: () => number): number",
585 "declare function modes(refinement: (value: string | number) => value is string, body: () => string): string"
586 ])
587 assert.strictEqual(signatureByName.get("Service"), null)
588 assert.strictEqual(signatureByName.get("Service.run"), null)
589 assert.strictEqual(signatureByName.get("count"), null)
590 assert.strictEqual(signatureByName.get("Sample"), null)
591 })
592
593 it("stores valid top-of-file module JSDoc", () => {
594 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
595 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
596 fs.writeFileSync(
597 path.join(cwd, "tsconfig.json"),
598 JSON.stringify({
599 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
600 include: ["src/**/*.ts"]
601 })
602 )
603 fs.writeFileSync(
604 path.join(cwd, "package.json"),
605 JSON.stringify({
606 name: "@effect/sample",
607 type: "module",
608 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
609 })
610 )
611 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
612 fs.writeFileSync(
613 path.join(cwd, "src/Foo.ts"),
614 `/**
615 * The Foo module provides helpers for sample values. Use {@link makeValue} to
616 * create the default value.
617 *
618 * **Example** (Using the module)
619 *
620 * \`\`\`ts
621 * import { Foo } from "@effect/sample"
622 *
623 * Foo.makeValue()
624 * \`\`\`
625 *
626 * @see {@link makeValue}
627 * @since 1.0.0
628 */
629import type { Buffer } from "node:buffer"
630
631/**
632 * Creates a value.
633 *
634 * @category constructors
635 * @since 1.0.0
636 */
637export const makeValue = () => 1
638`
639 )
640 const model = extractJSDocsSync({
641 cwd,
642 tsconfig: "tsconfig.json",
643 include: ["src/**/*.ts"],
644 output: ".data/jsdocs.json"
645 })
646 assert.strictEqual(model.files[0]?.moduleJSDoc?.raw.includes("The Foo module provides helpers"), true)
647 assert.deepStrictEqual(model.files[0]?.diagnostics, [])
648 })
649
650 it("does not treat the first exported declaration JSDoc as module JSDoc", () => {
651 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
652 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
653 fs.writeFileSync(
654 path.join(cwd, "tsconfig.json"),
655 JSON.stringify({
656 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
657 include: ["src/**/*.ts"]
658 })
659 )
660 fs.writeFileSync(
661 path.join(cwd, "package.json"),
662 JSON.stringify({
663 name: "@effect/sample",
664 type: "module",
665 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
666 })
667 )
668 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
669 fs.writeFileSync(
670 path.join(cwd, "src/Foo.ts"),
671 `/**
672 * Creates a value.
673 *
674 * @category constructors
675 * @since 1.0.0
676 */
677export const makeValue = () => 1
678`
679 )
680 const model = extractJSDocsSync({
681 cwd,
682 tsconfig: "tsconfig.json",
683 include: ["src/**/*.ts"],
684 output: ".data/jsdocs.json"
685 })
686 assert.strictEqual(model.files[0]?.moduleJSDoc, undefined)
687 assert.deepStrictEqual(model.files[0]?.diagnostics, [])
688 })
689
690 it("flags inline links that TypeScript does not bind", () => {
691 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
692 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
693 fs.writeFileSync(
694 path.join(cwd, "tsconfig.json"),
695 JSON.stringify({
696 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
697 include: ["src/**/*.ts"]
698 })
699 )
700 fs.writeFileSync(
701 path.join(cwd, "package.json"),
702 JSON.stringify({
703 name: "@effect/sample",
704 type: "module",
705 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
706 })
707 )
708 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
709 fs.writeFileSync(path.join(cwd, "src/Schema.ts"), `export {}\n`)
710 fs.writeFileSync(
711 path.join(cwd, "src/Foo.ts"),
712 `/**
713 * Creates a value with the {@link Schema} module.
714 *
715 * @category constructors
716 * @since 1.0.0
717 */
718export const makeValue = () => 1
719`
720 )
721 const model = extractJSDocsSync({
722 cwd,
723 tsconfig: "tsconfig.json",
724 include: ["src/**/*.ts"],
725 output: ".data/jsdocs.json"
726 })
727 assert.deepStrictEqual(
728 model.files[0]?.diagnostics.map((diagnostic) => diagnostic.code),
729 ["unresolved-link"]
730 )
731 assert.deepStrictEqual(
732 model.files[0]?.diagnostics.map((diagnostic) => diagnostic.message),
733 ["Unresolved JSDoc inline link: {@link Schema}"]
734 )
735 })
736
737 it("flags module JSDoc tag, example, and public @see diagnostics", () => {
738 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
739 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
740 fs.writeFileSync(
741 path.join(cwd, "tsconfig.json"),
742 JSON.stringify({
743 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
744 include: ["src/**/*.ts"]
745 })
746 )
747 fs.writeFileSync(
748 path.join(cwd, "package.json"),
749 JSON.stringify({
750 name: "@effect/sample",
751 type: "module",
752 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
753 })
754 )
755 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
756 fs.writeFileSync(
757 path.join(cwd, "src/Foo.ts"),
758 `/**
759 * The Foo module provides helpers.
760 *
761 * \`\`\`ts
762 * const value = 1
763 * \`\`\`
764 *
765 * @see {@link Hidden}
766 */
767import type { Buffer } from "node:buffer"
768
769class Hidden {}
770
771/**
772 * Creates a value.
773 *
774 * @category constructors
775 * @since 1.0.0
776 */
777export const makeValue = () => 1
778`
779 )
780 const model = extractJSDocsSync({
781 cwd,
782 tsconfig: "tsconfig.json",
783 include: ["src/**/*.ts"],
784 output: ".data/jsdocs.json"
785 })
786 assert.deepStrictEqual(
787 model.files[0]?.diagnostics.map((diagnostic) => diagnostic.code).sort(),
788 ["loose-ts-fence", "missing-tag", "public-see-target"].sort()
789 )
790 assert.strictEqual(model.files[0]?.moduleJSDoc?.raw.includes("The Foo module provides helpers"), true)
791 })
792
793 it("flags @see links to targets without public JSDoc", () => {
794 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
795 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
796 fs.writeFileSync(
797 path.join(cwd, "tsconfig.json"),
798 JSON.stringify({
799 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
800 include: ["src/**/*.ts"]
801 })
802 )
803 fs.writeFileSync(
804 path.join(cwd, "package.json"),
805 JSON.stringify({
806 name: "@effect/sample",
807 type: "module",
808 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
809 })
810 )
811 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
812 fs.writeFileSync(
813 path.join(cwd, "src/Foo.ts"),
814 `/**
815 * A documented container.
816 *
817 * @category models
818 * @since 1.0.0
819 */
820export interface Box {
821 documented: string
822 hidden: string
823}
824
825/**
826 * Uses the hidden member.
827 *
828 * @see {@link Box.hidden}
829 * @category constructors
830 * @since 1.0.0
831 */
832export const useHidden = () => undefined
833`
834 )
835 const model = extractJSDocsSync({
836 cwd,
837 tsconfig: "tsconfig.json",
838 include: ["src/**/*.ts"],
839 output: ".data/jsdocs.json"
840 })
841 assert.deepStrictEqual(
842 model.files[0]?.diagnostics.map((diagnostic) => diagnostic.code),
843 ["undocumented-see-target"]
844 )
845 assert.deepStrictEqual(model.files[0]?.imports?.barrel, {
846 type: "namespace",
847 module: "@effect/sample",
848 name: "Foo"
849 })
850 })
851
852 it("resolves @see links through TypeScript symbols before local-name lookup", () => {
853 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
854 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
855 fs.writeFileSync(
856 path.join(cwd, "tsconfig.json"),
857 JSON.stringify({
858 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
859 include: ["src/**/*.ts"]
860 })
861 )
862 fs.writeFileSync(
863 path.join(cwd, "package.json"),
864 JSON.stringify({
865 name: "@effect/sample",
866 type: "module",
867 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
868 })
869 )
870 fs.writeFileSync(
871 path.join(cwd, "src/index.ts"),
872 `export * as Eq from "./Eq.ts"\nexport * as Ordering from "./Ordering.ts"\nexport * as Reducer from "./Reducer.ts"\n`
873 )
874 fs.writeFileSync(
875 path.join(cwd, "src/Reducer.ts"),
876 `/**
877 * A reusable reducer.
878 *
879 * @category models
880 * @since 1.0.0
881 */
882export interface Reducer {
883 /**
884 * Combines two values.
885 */
886 combine: string
887}
888`
889 )
890 fs.writeFileSync(
891 path.join(cwd, "src/Ordering.ts"),
892 `/**
893 * Ordering reducer value.
894 *
895 * @category constants
896 * @since 1.0.0
897 */
898export const Reducer = "ordering"
899`
900 )
901 fs.writeFileSync(
902 path.join(cwd, "src/Eq.ts"),
903 `import * as Reducer from "./Reducer.ts"
904
905/**
906 * Creates an equivalence reducer.
907 *
908 * @see {@link Reducer}
909 * @category constructors
910 * @since 1.0.0
911 */
912export const makeReducer = () => Reducer
913`
914 )
915 const model = extractJSDocsSync({
916 cwd,
917 tsconfig: "tsconfig.json",
918 include: ["src/**/*.ts"],
919 output: ".data/jsdocs.json"
920 })
921 const makeReducer = model.apis.find((api) => api.apiFqn === "@effect/sample/Eq.makeReducer")
922 const link = makeReducer?.see[0]?.links[0]
923 assert.deepStrictEqual(model.files.flatMap((file) => file.diagnostics), [])
924 assert.deepStrictEqual(link?.resolution, {
925 _tag: "Resolved",
926 apiId: "root-declaration:type:@effect/sample/Reducer.Reducer",
927 apiFqn: "@effect/sample/Reducer.Reducer"
928 })
929 })
930
931 it("flags @see links to targets outside the public JSDoc API model", () => {
932 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
933 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
934 fs.writeFileSync(
935 path.join(cwd, "tsconfig.json"),
936 JSON.stringify({
937 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
938 include: ["src/**/*.ts"]
939 })
940 )
941 fs.writeFileSync(
942 path.join(cwd, "package.json"),
943 JSON.stringify({
944 name: "@effect/sample",
945 type: "module",
946 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
947 })
948 )
949 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
950 fs.writeFileSync(
951 path.join(cwd, "src/Foo.ts"),
952 `/**
953 * Private helper.
954 *
955 * @category models
956 * @since 1.0.0
957 */
958class Hidden {}
959
960/**
961 * Uses a hidden helper.
962 *
963 * @see {@link Hidden}
964 * @category constructors
965 * @since 1.0.0
966 */
967export const useHidden = () => Hidden
968`
969 )
970 const model = extractJSDocsSync({
971 cwd,
972 tsconfig: "tsconfig.json",
973 include: ["src/**/*.ts"],
974 output: ".data/jsdocs.json"
975 })
976 assert.deepStrictEqual(
977 model.files[0]?.diagnostics.map((diagnostic) => diagnostic.code),
978 ["public-see-target"]
979 )
980 assert.deepStrictEqual(
981 model.files[0]?.diagnostics.map((diagnostic) => diagnostic.message),
982 [
983 "@see link {@link Hidden} does not resolve to a public JSDoc API. Check that the target is exported and has valid public JSDoc."
984 ]
985 )
986 })
987
988 it("keeps valid APIs from files with diagnostics for @see resolution", () => {
989 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
990 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
991 fs.writeFileSync(
992 path.join(cwd, "tsconfig.json"),
993 JSON.stringify({
994 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
995 include: ["src/**/*.ts"]
996 })
997 )
998 fs.writeFileSync(
999 path.join(cwd, "package.json"),
1000 JSON.stringify({
1001 name: "@effect/sample",
1002 type: "module",
1003 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
1004 })
1005 )
1006 fs.writeFileSync(
1007 path.join(cwd, "src/index.ts"),
1008 `export * as Bar from "./Bar.ts"\nexport * as Foo from "./Foo.ts"\n`
1009 )
1010 fs.writeFileSync(
1011 path.join(cwd, "src/Foo.ts"),
1012 `/**
1013 * Target value.
1014 *
1015 * @category constants
1016 * @since 1.0.0
1017 */
1018export const Target = "target"
1019
1020/**
1021 * Broken value.
1022 *
1023 * **Example**
1024 *
1025 * \`\`\`ts
1026 * const value = "broken"
1027 * \`\`\`
1028 *
1029 * @category constants
1030 * @since 1.0.0
1031 */
1032export const Broken = "broken"
1033`
1034 )
1035 fs.writeFileSync(
1036 path.join(cwd, "src/Bar.ts"),
1037 `import * as Foo from "./Foo.ts"
1038
1039/**
1040 * Uses the target value.
1041 *
1042 * @see {@link Foo.Target} for the target value
1043 * @category constants
1044 * @since 1.0.0
1045 */
1046export const useTarget = () => Foo.Target
1047`
1048 )
1049 const model = extractJSDocsSync({
1050 cwd,
1051 tsconfig: "tsconfig.json",
1052 include: ["src/**/*.ts"],
1053 output: ".data/jsdocs.json"
1054 })
1055 const foo = model.files.find((file) => file.file === "src/Foo.ts")
1056 const bar = model.files.find((file) => file.file === "src/Bar.ts")
1057 assert.deepStrictEqual(
1058 foo?.diagnostics.map((diagnostic) => diagnostic.code),
1059 ["malformed-example"]
1060 )
1061 assert.deepStrictEqual(bar?.diagnostics, [])
1062 assert.strictEqual(
1063 model.apis.some((api) => api.apiFqn === "@effect/sample/Foo.Target"),
1064 true
1065 )
1066 })
1067
1068 it("resolves @see links to aliased export specifiers", () => {
1069 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
1070 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
1071 fs.writeFileSync(
1072 path.join(cwd, "tsconfig.json"),
1073 JSON.stringify({
1074 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
1075 include: ["src/**/*.ts"]
1076 })
1077 )
1078 fs.writeFileSync(
1079 path.join(cwd, "package.json"),
1080 JSON.stringify({
1081 name: "@effect/sample",
1082 type: "module",
1083 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
1084 })
1085 )
1086 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
1087 fs.writeFileSync(
1088 path.join(cwd, "src/Foo.ts"),
1089 `/**
1090 * @since 1.0.0
1091 */
1092const Array_ = "array"
1093
1094export {
1095 /**
1096 * Public array helper.
1097 *
1098 * @category constructors
1099 * @since 1.0.0
1100 */
1101 Array_ as Array
1102}
1103
1104/**
1105 * Public tuple helper.
1106 *
1107 * @see {@link Array_}
1108 * @see {@link Array}
1109 * @category constructors
1110 * @since 1.0.0
1111 */
1112export const Tuple = Array
1113`
1114 )
1115 const model = extractJSDocsSync({
1116 cwd,
1117 tsconfig: "tsconfig.json",
1118 include: ["src/**/*.ts"],
1119 output: ".data/jsdocs.json"
1120 })
1121 const tuple = model.apis.find((api) => api.apiFqn === "@effect/sample/Foo.Tuple")
1122 const links = tuple?.see.flatMap((tag) => tag.links) ?? []
1123 assert.deepStrictEqual(model.files.flatMap((file) => file.diagnostics), [])
1124 assert.deepStrictEqual(links.map((link) => link.resolution), [{
1125 _tag: "Resolved",
1126 apiId: "root-declaration:value:@effect/sample/Foo.Array",
1127 apiFqn: "@effect/sample/Foo.Array"
1128 }, {
1129 _tag: "Resolved",
1130 apiId: "root-declaration:value:@effect/sample/Foo.Array",
1131 apiFqn: "@effect/sample/Foo.Array"
1132 }])
1133 })
1134
1135 it("accepts @stability unstable on module, member, and namespace docs", () => {
1136 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-"))
1137 fs.mkdirSync(path.join(cwd, "src"), { recursive: true })
1138 fs.writeFileSync(
1139 path.join(cwd, "tsconfig.json"),
1140 JSON.stringify({
1141 compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022" },
1142 include: ["src/**/*.ts"]
1143 })
1144 )
1145 fs.writeFileSync(
1146 path.join(cwd, "package.json"),
1147 JSON.stringify({
1148 name: "@effect/sample",
1149 type: "module",
1150 exports: { ".": "./src/index.ts", "./*": "./src/*.ts" }
1151 })
1152 )
1153 fs.writeFileSync(path.join(cwd, "src/index.ts"), `export * as Foo from "./Foo.ts"\n`)
1154 fs.writeFileSync(
1155 path.join(cwd, "src/Imported.ts"),
1156 `/**
1157 * Imported marker.
1158 *
1159 * @category models
1160 * @since 1.0.0
1161 */
1162export interface Marker {
1163 readonly id: string
1164}
1165`
1166 )
1167 fs.writeFileSync(
1168 path.join(cwd, "src/Foo.ts"),
1169 `/**
1170 * Sample module.
1171 *
1172 * @stability unstable
1173 * @since 1.0.0
1174 */
1175import type { Marker } from "./Imported.ts"
1176
1177/**
1178 * A boxed value.
1179 *
1180 * @category models
1181 * @since 1.0.0
1182 */
1183export interface Box {
1184 /**
1185 * The boxed text.
1186 *
1187 * @stability unstable
1188 * @since 1.0.0
1189 */
1190 readonly value: Marker["id"]
1191
1192 /**
1193 * The stable label.
1194 *
1195 * @since 1.0.0
1196 */
1197 readonly label: string
1198}
1199
1200/**
1201 * Groups boxed types.
1202 *
1203 * @stability unstable
1204 * @category models
1205 * @since 1.0.0
1206 */
1207export declare namespace Group {
1208 /**
1209 * A grouped item.
1210 *
1211 * @stability unstable
1212 * @category models
1213 * @since 1.0.0
1214 */
1215 export interface Item {
1216 readonly id: string
1217 }
1218
1219 /**
1220 * A stable grouped item.
1221 *
1222 * @category models
1223 * @since 1.0.0
1224 */
1225 export interface StableItem {
1226 readonly id: string
1227 }
1228}
1229
1230/**
1231 * Groups stable types.
1232 *
1233 * @category models
1234 * @since 1.0.0
1235 */
1236export declare namespace StableGroup {
1237 /**
1238 * A stable nested item.
1239 *
1240 * @category models
1241 * @since 1.0.0
1242 */
1243 export interface Item {
1244 readonly id: string
1245 }
1246}
1247`
1248 )
1249 const model = extractJSDocsSync({
1250 cwd,
1251 tsconfig: "tsconfig.json",
1252 include: ["src/**/*.ts"],
1253 output: ".data/jsdocs.json"
1254 })
1255 const foo = model.files.find((file) => file.file.endsWith("src/Foo.ts"))
1256 const stabilityByName = Object.fromEntries(
1257 model.apis
1258 .filter((api) => api.moduleName === "@effect/sample/Foo")
1259 .map((api) => [api.apiFqn, api.tags.stability])
1260 )
1261
1262 assert.deepStrictEqual(foo?.diagnostics ?? [], [])
1263 assert.match(foo?.moduleJSDoc?.raw ?? "", /@stability unstable/)
1264 assert.deepStrictEqual(stabilityByName, {
1265 "@effect/sample/Foo.Box": "stable",
1266 "@effect/sample/Foo.Box.value": "unstable",
1267 "@effect/sample/Foo.Box.label": "stable",
1268 "@effect/sample/Foo.Group": "unstable",
1269 "@effect/sample/Foo.Group.Item": "unstable",
1270 "@effect/sample/Foo.Group.StableItem": "stable",
1271 "@effect/sample/Foo.StableGroup": "stable",
1272 "@effect/sample/Foo.StableGroup.Item": "stable"
1273 })
1274 })
1275})