@reicek/neataptic-ts 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.github/agents/boundary-mapper.agent.md +29 -0
  2. package/.github/agents/docs-scout.agent.md +29 -0
  3. package/.github/agents/plan-scout.agent.md +29 -0
  4. package/.github/agents/solid-split.agent.md +138 -0
  5. package/.github/copilot-instructions.md +103 -0
  6. package/package.json +6 -3
  7. package/plans/ES2023 migration +13 -8
  8. package/plans/Evolution_Training_Interoperability_Contracts.md +1 -1
  9. package/plans/Interactive_Examples_and_Learning_Path.md +10 -2
  10. package/plans/Memory_Optimization.md +3 -3
  11. package/plans/README.md +63 -0
  12. package/plans/Roadmap.md +15 -3
  13. package/plans/asciiMaze_SOLID_split.done.md +130 -0
  14. package/plans/flappy_bird_SOLID_split.done.md +67 -0
  15. package/scripts/assets/theme.css +221 -34
  16. package/scripts/copy-examples.mjs +9 -5
  17. package/scripts/export-onnx.mjs +3 -3
  18. package/scripts/generate-bench-tables.mjs +10 -10
  19. package/scripts/generate-bench-tables.ts +10 -10
  20. package/scripts/generate-docs.ts +1415 -449
  21. package/scripts/render-docs-html.ts +15 -8
  22. package/src/README.md +101 -223
  23. package/src/architecture/README.md +57 -185
  24. package/src/architecture/layer/README.md +38 -38
  25. package/src/architecture/network/README.md +33 -31
  26. package/src/architecture/network/activate/README.md +77 -77
  27. package/src/architecture/network/connect/README.md +15 -13
  28. package/src/architecture/network/deterministic/README.md +7 -7
  29. package/src/architecture/network/evolve/README.md +44 -44
  30. package/src/architecture/network/gating/README.md +20 -20
  31. package/src/architecture/network/genetic/README.md +51 -51
  32. package/src/architecture/network/mutate/README.md +97 -97
  33. package/src/architecture/network/onnx/README.md +264 -264
  34. package/src/architecture/network/prune/README.md +39 -39
  35. package/src/architecture/network/remove/README.md +26 -26
  36. package/src/architecture/network/serialize/README.md +56 -56
  37. package/src/architecture/network/slab/README.md +61 -61
  38. package/src/architecture/network/standalone/README.md +24 -24
  39. package/src/architecture/network/stats/README.md +9 -9
  40. package/src/architecture/network/topology/README.md +46 -46
  41. package/src/architecture/network/training/README.md +21 -21
  42. package/src/methods/README.md +9 -87
  43. package/src/multithreading/README.md +8 -77
  44. package/src/multithreading/workers/README.md +2 -2
  45. package/src/multithreading/workers/browser/README.md +0 -6
  46. package/src/multithreading/workers/node/README.md +0 -3
  47. package/src/neat/README.md +562 -568
  48. package/src/utils/README.md +18 -18
  49. package/test/examples/asciiMaze/README.md +59 -59
  50. package/test/examples/asciiMaze/asciiMaze.e2e.test.ts +14 -9
  51. package/test/examples/asciiMaze/browser-entry/README.md +196 -0
  52. package/test/examples/asciiMaze/browser-entry/browser-entry.abort.services.ts +95 -0
  53. package/test/examples/asciiMaze/browser-entry/browser-entry.constants.ts +23 -0
  54. package/test/examples/asciiMaze/browser-entry/browser-entry.curriculum.services.ts +115 -0
  55. package/test/examples/asciiMaze/browser-entry/browser-entry.globals.services.ts +106 -0
  56. package/test/examples/asciiMaze/browser-entry/browser-entry.host.services.ts +157 -0
  57. package/test/examples/asciiMaze/browser-entry/browser-entry.services.ts +14 -0
  58. package/test/examples/asciiMaze/browser-entry/browser-entry.ts +129 -0
  59. package/test/examples/asciiMaze/browser-entry/browser-entry.types.ts +120 -0
  60. package/test/examples/asciiMaze/browser-entry/browser-entry.utils.ts +98 -0
  61. package/test/examples/asciiMaze/browser-entry.ts +10 -576
  62. package/test/examples/asciiMaze/dashboardManager/README.md +276 -0
  63. package/test/examples/asciiMaze/dashboardManager/archive/README.md +16 -0
  64. package/test/examples/asciiMaze/dashboardManager/archive/dashboardManager.archive.services.ts +267 -0
  65. package/test/examples/asciiMaze/dashboardManager/dashboardManager.constants.ts +35 -0
  66. package/test/examples/asciiMaze/dashboardManager/dashboardManager.services.ts +103 -0
  67. package/test/examples/asciiMaze/dashboardManager/dashboardManager.ts +181 -0
  68. package/test/examples/asciiMaze/dashboardManager/dashboardManager.types.ts +267 -0
  69. package/test/examples/asciiMaze/dashboardManager/dashboardManager.utils.ts +254 -0
  70. package/test/examples/asciiMaze/dashboardManager/live/README.md +14 -0
  71. package/test/examples/asciiMaze/dashboardManager/live/dashboardManager.live.services.ts +264 -0
  72. package/test/examples/asciiMaze/dashboardManager/telemetry/README.md +47 -0
  73. package/test/examples/asciiMaze/dashboardManager/telemetry/dashboardManager.telemetry.services.ts +513 -0
  74. package/test/examples/asciiMaze/dashboardManager.ts +13 -2335
  75. package/test/examples/asciiMaze/evolutionEngine/README.md +1058 -0
  76. package/test/examples/asciiMaze/evolutionEngine/curriculumPhase.ts +90 -0
  77. package/test/examples/asciiMaze/evolutionEngine/engineState.constants.ts +36 -0
  78. package/test/examples/asciiMaze/evolutionEngine/engineState.ts +58 -513
  79. package/test/examples/asciiMaze/evolutionEngine/engineState.types.ts +212 -0
  80. package/test/examples/asciiMaze/evolutionEngine/engineState.utils.ts +301 -0
  81. package/test/examples/asciiMaze/evolutionEngine/evolutionEngine.types.ts +445 -0
  82. package/test/examples/asciiMaze/evolutionEngine/evolutionLoop.ts +81 -50
  83. package/test/examples/asciiMaze/evolutionEngine/optionsAndSetup.ts +2 -4
  84. package/test/examples/asciiMaze/evolutionEngine/populationDynamics.ts +17 -33
  85. package/test/examples/asciiMaze/evolutionEngine/populationPruning.ts +1 -1
  86. package/test/examples/asciiMaze/evolutionEngine/rngAndTiming.ts +1 -2
  87. package/test/examples/asciiMaze/evolutionEngine/sampling.ts +1 -1
  88. package/test/examples/asciiMaze/evolutionEngine/scratchPools.ts +2 -5
  89. package/test/examples/asciiMaze/evolutionEngine/setupHelpers.ts +30 -37
  90. package/test/examples/asciiMaze/evolutionEngine/telemetryMetrics.ts +16 -58
  91. package/test/examples/asciiMaze/evolutionEngine/trainingWarmStart.ts +2 -2
  92. package/test/examples/asciiMaze/evolutionEngine.ts +55 -55
  93. package/test/examples/asciiMaze/fitness.ts +2 -2
  94. package/test/examples/asciiMaze/fitness.types.ts +65 -0
  95. package/test/examples/asciiMaze/interfaces.ts +64 -1352
  96. package/test/examples/asciiMaze/mazeMovement/README.md +356 -0
  97. package/test/examples/asciiMaze/mazeMovement/finalization/README.md +49 -0
  98. package/test/examples/asciiMaze/mazeMovement/finalization/mazeMovement.finalization.ts +138 -0
  99. package/test/examples/asciiMaze/mazeMovement/mazeMovement.constants.ts +101 -0
  100. package/test/examples/asciiMaze/mazeMovement/mazeMovement.services.ts +230 -0
  101. package/test/examples/asciiMaze/mazeMovement/mazeMovement.ts +299 -0
  102. package/test/examples/asciiMaze/mazeMovement/mazeMovement.types.ts +185 -0
  103. package/test/examples/asciiMaze/mazeMovement/mazeMovement.utils.ts +153 -0
  104. package/test/examples/asciiMaze/mazeMovement/policy/README.md +91 -0
  105. package/test/examples/asciiMaze/mazeMovement/policy/mazeMovement.policy.ts +467 -0
  106. package/test/examples/asciiMaze/mazeMovement/runtime/README.md +95 -0
  107. package/test/examples/asciiMaze/mazeMovement/runtime/mazeMovement.runtime.ts +354 -0
  108. package/test/examples/asciiMaze/mazeMovement/shaping/README.md +124 -0
  109. package/test/examples/asciiMaze/mazeMovement/shaping/mazeMovement.shaping.ts +459 -0
  110. package/test/examples/asciiMaze/mazeMovement.ts +12 -2978
  111. package/test/examples/flappy_bird/Trace-20260309T191949.json +24124 -0
  112. package/test/examples/flappy_bird/browser-entry/README.md +1129 -0
  113. package/test/examples/flappy_bird/browser-entry/browser-entry.host.utils.ts +4 -324
  114. package/test/examples/flappy_bird/browser-entry/browser-entry.network-view.utils.ts +6 -399
  115. package/test/examples/flappy_bird/browser-entry/browser-entry.playback.utils.ts +1 -717
  116. package/test/examples/flappy_bird/browser-entry/browser-entry.spawn.utils.ts +11 -31
  117. package/test/examples/flappy_bird/browser-entry/browser-entry.visualization.utils.ts +15 -893
  118. package/test/examples/flappy_bird/browser-entry/host/README.md +307 -0
  119. package/test/examples/flappy_bird/browser-entry/host/host.resize.service.ts +1 -295
  120. package/test/examples/flappy_bird/browser-entry/host/host.ts +562 -6
  121. package/test/examples/flappy_bird/browser-entry/host/resize/README.md +274 -0
  122. package/test/examples/flappy_bird/browser-entry/host/resize/host.resize.service.constants.ts +31 -0
  123. package/test/examples/flappy_bird/browser-entry/host/resize/host.resize.service.services.ts +360 -0
  124. package/test/examples/flappy_bird/browser-entry/host/resize/host.resize.service.ts +117 -0
  125. package/test/examples/flappy_bird/browser-entry/host/resize/host.resize.service.types.ts +63 -0
  126. package/test/examples/flappy_bird/browser-entry/host/resize/host.resize.service.utils.ts +250 -0
  127. package/test/examples/flappy_bird/browser-entry/network-view/README.md +399 -0
  128. package/test/examples/flappy_bird/browser-entry/network-view/network-view.topology.utils.ts +255 -0
  129. package/test/examples/flappy_bird/browser-entry/network-view/network-view.ts +802 -7
  130. package/test/examples/flappy_bird/browser-entry/playback/README.md +684 -0
  131. package/test/examples/flappy_bird/browser-entry/playback/background/README.md +277 -0
  132. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/README.md +770 -0
  133. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.cache.services.ts +178 -0
  134. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.constants.ts +107 -0
  135. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.geometry.utils.ts +518 -0
  136. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.math.utils.ts +117 -0
  137. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.pulse.utils.ts +233 -0
  138. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.services.ts +211 -0
  139. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.ts +48 -0
  140. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.types.ts +212 -0
  141. package/test/examples/flappy_bird/browser-entry/playback/background/ground-grid/playback.background.ground-grid.utils.ts +81 -0
  142. package/test/examples/flappy_bird/browser-entry/playback/background/playback.background.cache.services.ts +96 -0
  143. package/test/examples/flappy_bird/browser-entry/playback/background/playback.background.constants.ts +62 -0
  144. package/test/examples/flappy_bird/browser-entry/playback/background/playback.background.services.ts +244 -0
  145. package/test/examples/flappy_bird/browser-entry/playback/background/playback.background.ts +53 -0
  146. package/test/examples/flappy_bird/browser-entry/playback/background/playback.background.types.ts +68 -0
  147. package/test/examples/flappy_bird/browser-entry/playback/background/playback.background.utils.ts +100 -0
  148. package/test/examples/flappy_bird/browser-entry/playback/frame-render/README.md +310 -0
  149. package/test/examples/flappy_bird/browser-entry/playback/frame-render/playback.frame-render.service.ts +92 -0
  150. package/test/examples/flappy_bird/browser-entry/playback/frame-render/playback.frame-render.services.ts +272 -0
  151. package/test/examples/flappy_bird/browser-entry/playback/frame-render/playback.frame-render.types.ts +39 -0
  152. package/test/examples/flappy_bird/browser-entry/playback/frame-render/playback.frame-render.utils.ts +493 -0
  153. package/test/examples/flappy_bird/browser-entry/playback/playback.constants.ts +1 -1
  154. package/test/examples/flappy_bird/browser-entry/playback/playback.frame-render.service.ts +4 -0
  155. package/test/examples/flappy_bird/browser-entry/playback/playback.snapshot.utils.ts +44 -0
  156. package/test/examples/flappy_bird/browser-entry/playback/playback.starfield.service.ts +39 -122
  157. package/test/examples/flappy_bird/browser-entry/playback/playback.starfield.services.ts +272 -0
  158. package/test/examples/flappy_bird/browser-entry/playback/playback.starfield.types.ts +62 -0
  159. package/test/examples/flappy_bird/browser-entry/playback/playback.starfield.utils.ts +11 -4
  160. package/test/examples/flappy_bird/browser-entry/playback/playback.ts +409 -8
  161. package/test/examples/flappy_bird/browser-entry/playback/playback.types.ts +4 -12
  162. package/test/examples/flappy_bird/browser-entry/runtime/README.md +235 -0
  163. package/test/examples/flappy_bird/browser-entry/runtime/runtime.evolution-launch.service.ts +45 -0
  164. package/test/examples/flappy_bird/browser-entry/runtime/runtime.lifecycle.service.ts +81 -0
  165. package/test/examples/flappy_bird/browser-entry/runtime/runtime.startup.service.ts +74 -0
  166. package/test/examples/flappy_bird/browser-entry/runtime/runtime.ts +31 -121
  167. package/test/examples/flappy_bird/browser-entry/runtime/runtime.types.ts +36 -0
  168. package/test/examples/flappy_bird/browser-entry/visualization/README.md +557 -0
  169. package/test/examples/flappy_bird/browser-entry/visualization/visualization.constants.ts +110 -0
  170. package/test/examples/flappy_bird/browser-entry/visualization/visualization.draw.service.ts +957 -19
  171. package/test/examples/flappy_bird/browser-entry/visualization/visualization.legend.utils.ts +138 -3
  172. package/test/examples/flappy_bird/browser-entry/visualization/visualization.topology.utils.ts +3 -27
  173. package/test/examples/flappy_bird/browser-entry/visualization/visualization.ts +1 -23
  174. package/test/examples/flappy_bird/browser-entry/worker-channel/README.md +156 -0
  175. package/test/examples/flappy_bird/constants/README.md +1179 -0
  176. package/test/examples/flappy_bird/constants/constants.network-view.ts +24 -0
  177. package/test/examples/flappy_bird/constants/constants.palette.ts +7 -0
  178. package/test/examples/flappy_bird/constants/constants.starfield.ts +78 -3
  179. package/test/examples/flappy_bird/environment/README.md +143 -0
  180. package/test/examples/flappy_bird/environment/environment.observation.utils.ts +1 -19
  181. package/test/examples/flappy_bird/environment/environment.step.service.ts +3 -66
  182. package/test/examples/flappy_bird/evaluation/README.md +130 -0
  183. package/test/examples/flappy_bird/evaluation/evaluation.fitness.utils.ts +1 -1
  184. package/test/examples/flappy_bird/evaluation/evaluation.rollout.service.ts +5 -375
  185. package/test/examples/flappy_bird/evaluation/rollout/README.md +291 -0
  186. package/test/examples/flappy_bird/evaluation/rollout/evaluation.rollout.constants.ts +30 -0
  187. package/test/examples/flappy_bird/evaluation/rollout/evaluation.rollout.service.ts +58 -0
  188. package/test/examples/flappy_bird/evaluation/rollout/evaluation.rollout.services.ts +310 -0
  189. package/test/examples/flappy_bird/evaluation/rollout/evaluation.rollout.types.ts +56 -0
  190. package/test/examples/flappy_bird/evaluation/rollout/evaluation.rollout.utils.ts +368 -0
  191. package/test/examples/flappy_bird/flappy-evolution-worker/README.md +618 -0
  192. package/test/examples/flappy_bird/flappy-evolution-worker/flappy-evolution-worker.playback.service.ts +7 -7
  193. package/test/examples/flappy_bird/flappy-evolution-worker/flappy-evolution-worker.simulation.frame.service.ts +364 -0
  194. package/test/examples/flappy_bird/flappy-evolution-worker/flappy-evolution-worker.simulation.types.ts +14 -0
  195. package/test/examples/flappy_bird/flappy-evolution-worker/flappy-evolution-worker.simulation.utils.ts +4 -201
  196. package/test/examples/flappy_bird/flappy-evolution-worker/flappy-evolution-worker.ts +184 -345
  197. package/test/examples/flappy_bird/flappy-evolution-worker/flappy-evolution-worker.warm-start.service.ts +291 -0
  198. package/test/examples/flappy_bird/flappy.simulation.shared.utils.ts +5 -0
  199. package/test/examples/flappy_bird/simulation-shared/README.md +417 -0
  200. package/test/examples/flappy_bird/simulation-shared/observation/README.md +183 -0
  201. package/test/examples/flappy_bird/simulation-shared/observation/observation.features.utils.ts +301 -0
  202. package/test/examples/flappy_bird/simulation-shared/observation/observation.ts +9 -0
  203. package/test/examples/flappy_bird/simulation-shared/observation/observation.vector.utils.ts +59 -0
  204. package/test/examples/flappy_bird/simulation-shared/simulation-shared.observation.utils.ts +5 -403
  205. package/test/examples/flappy_bird/simulation-shared/simulation-shared.spawn.utils.ts +20 -6
  206. package/test/examples/flappy_bird/{evaluation/evaluation.statistics.utils.ts → simulation-shared/simulation-shared.statistics.utils.ts} +23 -8
  207. package/test/examples/flappy_bird/trainer/README.md +563 -0
  208. package/test/examples/flappy_bird/trainer/evaluation/README.md +199 -0
  209. package/test/examples/flappy_bird/trainer/evaluation/trainer.evaluation.service.constants.ts +9 -0
  210. package/test/examples/flappy_bird/trainer/evaluation/trainer.evaluation.service.services.ts +73 -0
  211. package/test/examples/flappy_bird/trainer/evaluation/trainer.evaluation.service.ts +165 -0
  212. package/test/examples/flappy_bird/trainer/evaluation/trainer.evaluation.service.types.ts +25 -0
  213. package/test/examples/flappy_bird/trainer/evaluation/trainer.evaluation.service.utils.ts +161 -0
  214. package/test/examples/flappy_bird/trainer/trainer.evaluation.service.ts +13 -0
  215. package/test/examples/flappy_bird/trainer/trainer.report.service.services.ts +181 -0
  216. package/test/examples/flappy_bird/trainer/trainer.report.service.ts +126 -0
  217. package/test/examples/flappy_bird/trainer/trainer.selection.utils.ts +89 -0
  218. package/test/examples/flappy_bird/trainer/trainer.ts +11 -553
  219. package/test/examples/flappy_bird/browser-entry/browser-entry.utils.ts +0 -12
  220. package/test/examples/flappy_bird/environment/environment.ts +0 -7
  221. package/test/examples/flappy_bird/evaluation/evaluation.ts +0 -7
  222. package/test/examples/flappy_bird/simulation-shared/simulation-shared.ts +0 -15
  223. package/test/examples/flappy_bird/trainer/trainer.statistics.utils.ts +0 -78
@@ -4,15 +4,42 @@
4
4
  * - Skips generating README for src root (leave top-level README manual)
5
5
  * Usage: npm run docs:folders
6
6
  */
7
- import { Project, type Symbol as MorphSymbol, type JSDocTag, type SourceFile } from 'ts-morph';
7
+ import {
8
+ Project,
9
+ type JSDocTag,
10
+ type SourceFile,
11
+ type Symbol as MorphSymbol,
12
+ } from 'ts-morph';
8
13
  import fg from 'fast-glob';
9
- import * as path from 'path';
10
14
  import fs from 'fs-extra';
15
+ import * as path from 'path';
11
16
 
12
- const SRC_DIR = path.resolve('src');
13
17
  const DOCS_DIR = path.resolve('docs');
14
- const ROOT_README_SRC = path.resolve('README.md');
15
- const ROOT_README_DEST = path.join(DOCS_DIR, 'README.md');
18
+ const DEFAULT_DOCS_TARGET = 'src';
19
+ const FILE_SUMMARY_SYMBOL_NAME = '__file_summary__';
20
+ const SOURCE_FILE_GLOBS = ['**/*.ts'];
21
+ const SOURCE_FILE_IGNORE_GLOBS = ['**/*.d.ts'];
22
+ const FOLDER_INDEX_FILE_NAME = 'FOLDERS.md';
23
+ const WORKSPACE_ROOT_DIR = path.resolve('.');
24
+
25
+ interface DocsTargetConfig {
26
+ name: string;
27
+ sourceDir: string;
28
+ docsDir: string;
29
+ rootDocsDir: string;
30
+ rootReadmeSource?: string;
31
+ rootReadmeDestination?: string;
32
+ excludeRootSourceFiles?: boolean;
33
+ includeFolderIndex?: boolean;
34
+ publishedRootDir?: string;
35
+ preservePublishedEntries?: string[];
36
+ }
37
+
38
+ interface RenderedParameter {
39
+ name: string;
40
+ type?: string;
41
+ doc?: string;
42
+ }
16
43
 
17
44
  interface RenderedSymbol {
18
45
  kind: string;
@@ -23,507 +50,1443 @@ interface RenderedSymbol {
23
50
  jsdoc: {
24
51
  summary?: string;
25
52
  description?: string;
26
- params?: { name: string; type?: string; doc?: string }[];
53
+ params?: RenderedParameter[];
27
54
  returns?: string;
28
55
  deprecated?: string;
29
56
  };
30
57
  }
31
58
 
59
+ interface FolderIndexNode {
60
+ name: string;
61
+ path: string;
62
+ children: Map<string, FolderIndexNode>;
63
+ fileCount?: number;
64
+ }
65
+
66
+ type DirectorySymbolMap = Map<string, Map<string, RenderedSymbol[]>>;
67
+
68
+ const DOCS_TARGETS: Record<string, DocsTargetConfig> = {
69
+ src: {
70
+ name: 'src',
71
+ sourceDir: path.resolve('src'),
72
+ docsDir: DOCS_DIR,
73
+ rootDocsDir: path.join(DOCS_DIR, 'src'),
74
+ rootReadmeSource: path.resolve('README.md'),
75
+ rootReadmeDestination: path.join(DOCS_DIR, 'README.md'),
76
+ includeFolderIndex: true,
77
+ },
78
+ asciiMaze: {
79
+ name: 'asciiMaze',
80
+ sourceDir: path.resolve('test', 'examples', 'asciiMaze'),
81
+ docsDir: path.join(DOCS_DIR, 'examples', 'asciiMaze', 'docs'),
82
+ rootDocsDir: path.join(DOCS_DIR, 'examples', 'asciiMaze', 'docs'),
83
+ rootReadmeSource: path.resolve('test', 'examples', 'asciiMaze', 'README.md'),
84
+ rootReadmeDestination: path.join(
85
+ DOCS_DIR,
86
+ 'examples',
87
+ 'asciiMaze',
88
+ 'docs',
89
+ 'README.md',
90
+ ),
91
+ excludeRootSourceFiles: true,
92
+ publishedRootDir: path.join(DOCS_DIR, 'examples', 'asciiMaze'),
93
+ preservePublishedEntries: ['index.html'],
94
+ },
95
+ 'flappy-bird': {
96
+ name: 'flappy-bird',
97
+ sourceDir: path.resolve('test', 'examples', 'flappy_bird'),
98
+ docsDir: path.join(DOCS_DIR, 'examples', 'flappy_bird', 'docs'),
99
+ rootDocsDir: path.join(DOCS_DIR, 'examples', 'flappy_bird', 'docs'),
100
+ rootReadmeSource: path.resolve(
101
+ 'test',
102
+ 'examples',
103
+ 'flappy_bird',
104
+ 'README.md',
105
+ ),
106
+ rootReadmeDestination: path.join(
107
+ DOCS_DIR,
108
+ 'examples',
109
+ 'flappy_bird',
110
+ 'docs',
111
+ 'README.md',
112
+ ),
113
+ excludeRootSourceFiles: true,
114
+ publishedRootDir: path.join(DOCS_DIR, 'examples', 'flappy_bird'),
115
+ preservePublishedEntries: ['index.html'],
116
+ },
117
+ };
118
+
32
119
  const project = new Project({
33
120
  tsConfigFilePath: path.resolve('tsconfig.json'),
34
- skipAddingFilesFromTsConfig: true
121
+ skipAddingFilesFromTsConfig: true,
35
122
  });
36
123
 
37
- function extractLeadingFileJsDocDescription(sf: SourceFile): string | undefined {
38
- // ts-morph doesn't consistently expose a top-of-file documentation block via
39
- // `SourceFile.getJsDocs()`.
40
- //
41
- // We treat the very first `/** ... */` block in the file as the module/file
42
- // overview, and emit it as a synthetic `__file_summary__` symbol so it can be
43
- // rendered before the file's declarations.
44
- const text = sf.getFullText();
45
- const match = text.match(/^\s*(?:\uFEFF)?\/\*\*([\s\S]*?)\*\//);
46
- if (!match) return undefined;
124
+ /**
125
+ * Generates docs for the requested target.
126
+ *
127
+ * The script runs in four passes:
128
+ * 1. Resolve and prepare the target output tree.
129
+ * 2. Load source files into the shared ts-morph project.
130
+ * 3. Collect and normalize rendered symbols.
131
+ * 4. Emit directory README files and the optional folder index.
132
+ */
133
+ async function main(): Promise<void> {
134
+ const target = resolveDocsTarget();
135
+ await initializeDocsTarget(target);
47
136
 
48
- const body = match[1];
49
- const cleaned = body
50
- .split(/\r?\n/)
51
- .map(line => line.replace(/^\s*\*\s?/, ''))
52
- .join('\n')
53
- .trim();
137
+ const sourceFiles = await loadTargetSourceFiles(target);
138
+ const directorySymbolMap = collectDirectorySymbols(sourceFiles);
139
+
140
+ dedupeDirectorySymbols(directorySymbolMap);
141
+ await emitDirectoryDocs(directorySymbolMap, target);
142
+
143
+ if (target.includeFolderIndex) {
144
+ await emitFolderIndex(directorySymbolMap, target);
145
+ }
54
146
 
55
- if (!cleaned) return undefined;
56
- if (cleaned.split('\n').some(line => line.trim().startsWith('@internal'))) return undefined;
57
- return cleaned;
147
+ console.log(`[docs:${target.name}] Per-folder README generation complete.`);
58
148
  }
59
149
 
60
- async function main() {
61
- await fs.ensureDir(DOCS_DIR);
62
- // Copy root README (manual) into docs
63
- if (await fs.pathExists(ROOT_README_SRC)) {
64
- await fs.copyFile(ROOT_README_SRC, ROOT_README_DEST);
150
+ /**
151
+ * Resolves the active docs target from CLI or environment input.
152
+ *
153
+ * Lookup order:
154
+ * 1. `--target=...`
155
+ * 2. `DOCS_TARGET`
156
+ * 3. default target
157
+ *
158
+ * @returns Concrete docs target configuration.
159
+ */
160
+ function resolveDocsTarget(): DocsTargetConfig {
161
+ const cliTarget = process.argv
162
+ .find((argument) => argument.startsWith('--target='))
163
+ ?.slice('--target='.length);
164
+ const requestedTarget =
165
+ cliTarget || process.env.DOCS_TARGET || DEFAULT_DOCS_TARGET;
166
+ const resolvedTarget = DOCS_TARGETS[requestedTarget];
167
+
168
+ if (!resolvedTarget) {
169
+ const supportedTargets = Object.keys(DOCS_TARGETS).toSorted().join(', ');
170
+ throw new Error(
171
+ `Unknown docs target "${requestedTarget}". Supported targets: ${supportedTargets}`,
172
+ );
65
173
  }
66
174
 
67
- const filePaths = await fg(['**/*.ts'], { cwd: SRC_DIR, absolute: true, ignore: ['**/*.d.ts'] });
68
- for (const p of filePaths) {
69
- if (/\.test\.ts$/i.test(p)) continue; // skip test specification files
70
- project.addSourceFileAtPath(p);
175
+ return resolvedTarget;
176
+ }
177
+
178
+ /**
179
+ * Prepares the output tree for the selected target.
180
+ *
181
+ * @param target - Target configuration.
182
+ * @returns Nothing.
183
+ */
184
+ async function initializeDocsTarget(target: DocsTargetConfig): Promise<void> {
185
+ await cleanupPublishedRoot(target);
186
+ await fs.ensureDir(target.docsDir);
187
+
188
+ if (
189
+ !target.rootReadmeSource ||
190
+ !target.rootReadmeDestination ||
191
+ !(await fs.pathExists(target.rootReadmeSource))
192
+ ) {
193
+ return;
71
194
  }
72
- const all = project.getSourceFiles();
73
- const sourceFiles: SourceFile[] = all.filter((sf: SourceFile) => {
74
- const fp = sf.getFilePath();
75
- return !fp.endsWith('.d.ts') && !/node_modules/.test(fp);
195
+
196
+ await fs.ensureDir(path.dirname(target.rootReadmeDestination));
197
+ await fs.copyFile(target.rootReadmeSource, target.rootReadmeDestination);
198
+ }
199
+
200
+ /**
201
+ * Loads target source files into the shared ts-morph project.
202
+ *
203
+ * @param target - Target configuration.
204
+ * @returns Filtered source files that belong to the target tree.
205
+ */
206
+ async function loadTargetSourceFiles(
207
+ target: DocsTargetConfig,
208
+ ): Promise<SourceFile[]> {
209
+ const filePaths = await fg(SOURCE_FILE_GLOBS, {
210
+ cwd: target.sourceDir,
211
+ absolute: true,
212
+ ignore: SOURCE_FILE_IGNORE_GLOBS,
76
213
  });
77
- console.log(`[docs] Loaded ${sourceFiles.length} source files (raw: ${all.length})`);
78
-
79
- const dirMap = new Map<string, Map<string, RenderedSymbol[]>>();
80
-
81
- for (const sf of sourceFiles) {
82
- const exported = sf.getExportedDeclarations();
83
- // capture file-level/module JSDoc if present
84
- const fileDocDesc =
85
- extractLeadingFileJsDocDescription(sf) ||
86
- ((sf as any).getJsDocs?.()?.[0]?.getDescription?.()?.trim() as string | undefined);
87
-
88
- // ensure fileMap exists early so we can add file-level doc
89
- const dir = path.dirname(sf.getFilePath());
90
- let fileMap = dirMap.get(dir);
91
- if (!fileMap) { fileMap = new Map(); dirMap.set(dir, fileMap); }
92
-
93
- if (fileDocDesc) {
94
- const fileSummarySymbol: RenderedSymbol = {
95
- kind: 'File',
96
- name: '__file_summary__',
97
- filePath: sf.getFilePath(),
98
- jsdoc: { description: fileDocDesc }
99
- };
100
- const arr0 = fileMap.get(sf.getFilePath()) || [];
101
- arr0.push(fileSummarySymbol);
102
- fileMap.set(sf.getFilePath(), arr0);
103
- console.log(`[docs] Added file summary for ${sf.getBaseName()}`);
214
+
215
+ for (const filePath of filePaths) {
216
+ if (!shouldIncludeSourceFile(filePath, target)) {
217
+ continue;
218
+ }
219
+
220
+ project.addSourceFileAtPath(filePath);
221
+ }
222
+
223
+ const rawSourceFiles = project.getSourceFiles();
224
+ const sourceFiles = rawSourceFiles.filter((sourceFile) => {
225
+ const filePath = sourceFile.getFilePath();
226
+ return !filePath.endsWith('.d.ts') && !/node_modules/.test(filePath);
227
+ });
228
+
229
+ console.log(
230
+ `[docs:${target.name}] Loaded ${sourceFiles.length} source files (raw: ${rawSourceFiles.length})`,
231
+ );
232
+
233
+ return sourceFiles;
234
+ }
235
+
236
+ /**
237
+ * Determines whether a discovered file should participate in docs generation.
238
+ *
239
+ * @param filePath - Absolute source file path.
240
+ * @param target - Target configuration.
241
+ * @returns True when the file belongs in the target set.
242
+ */
243
+ function shouldIncludeSourceFile(
244
+ filePath: string,
245
+ target: DocsTargetConfig,
246
+ ): boolean {
247
+ if (/\.test\.ts$/i.test(filePath)) {
248
+ return false;
249
+ }
250
+
251
+ if (!target.excludeRootSourceFiles) {
252
+ return true;
253
+ }
254
+
255
+ const relativeDirectory = path.dirname(
256
+ path.relative(target.sourceDir, filePath),
257
+ );
258
+ return relativeDirectory !== '' && relativeDirectory !== '.';
259
+ }
260
+
261
+ /**
262
+ * Removes previously published generated files for targets that mirror output
263
+ * into a browsable published directory.
264
+ *
265
+ * @param target - Target configuration.
266
+ * @returns Nothing.
267
+ */
268
+ async function cleanupPublishedRoot(target: DocsTargetConfig): Promise<void> {
269
+ if (!target.publishedRootDir) {
270
+ return;
271
+ }
272
+
273
+ await fs.ensureDir(target.publishedRootDir);
274
+ const preservedEntries = new Set(target.preservePublishedEntries ?? []);
275
+ const childEntries = await fs.readdir(target.publishedRootDir);
276
+
277
+ for (const childEntry of childEntries) {
278
+ if (preservedEntries.has(childEntry)) {
279
+ continue;
280
+ }
281
+
282
+ await fs.remove(path.join(target.publishedRootDir, childEntry));
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Collects renderable symbols grouped by directory and then by file.
288
+ *
289
+ * @param sourceFiles - Loaded source files to scan.
290
+ * @returns Nested symbol map keyed by directory and then source file path.
291
+ */
292
+ function collectDirectorySymbols(
293
+ sourceFiles: readonly SourceFile[],
294
+ ): DirectorySymbolMap {
295
+ const directorySymbolMap: DirectorySymbolMap = new Map();
296
+
297
+ for (const sourceFile of sourceFiles) {
298
+ collectSourceFileSymbols(sourceFile, directorySymbolMap);
299
+ }
300
+
301
+ return directorySymbolMap;
302
+ }
303
+
304
+ /**
305
+ * Collects every renderable symbol from a single source file.
306
+ *
307
+ * @param sourceFile - Source file being scanned.
308
+ * @param directorySymbolMap - Aggregate directory symbol map.
309
+ * @returns Nothing.
310
+ */
311
+ function collectSourceFileSymbols(
312
+ sourceFile: SourceFile,
313
+ directorySymbolMap: DirectorySymbolMap,
314
+ ): void {
315
+ const filePath = sourceFile.getFilePath();
316
+ const fileSymbolMap = resolveFileSymbolMap(directorySymbolMap, filePath);
317
+
318
+ addFileSummarySymbol(sourceFile, fileSymbolMap);
319
+ collectExportedDeclarations(sourceFile, fileSymbolMap);
320
+ collectDocumentedTopLevelDeclarations(sourceFile, fileSymbolMap);
321
+ }
322
+
323
+ /**
324
+ * Resolves the mutable file-symbol map for the file's directory.
325
+ *
326
+ * @param directorySymbolMap - Aggregate directory symbol map.
327
+ * @param filePath - Absolute source file path.
328
+ * @returns Per-directory file symbol map.
329
+ */
330
+ function resolveFileSymbolMap(
331
+ directorySymbolMap: DirectorySymbolMap,
332
+ filePath: string,
333
+ ): Map<string, RenderedSymbol[]> {
334
+ const directoryPath = path.dirname(filePath);
335
+ let fileSymbolMap = directorySymbolMap.get(directoryPath);
336
+
337
+ if (!fileSymbolMap) {
338
+ fileSymbolMap = new Map();
339
+ directorySymbolMap.set(directoryPath, fileSymbolMap);
340
+ }
341
+
342
+ return fileSymbolMap;
343
+ }
344
+
345
+ /**
346
+ * Adds a synthetic file-summary symbol when the file starts with a file-level
347
+ * JSDoc block.
348
+ *
349
+ * @param sourceFile - Source file being scanned.
350
+ * @param fileSymbolMap - Mutable symbol map for the file's directory.
351
+ * @returns Nothing.
352
+ */
353
+ function addFileSummarySymbol(
354
+ sourceFile: SourceFile,
355
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
356
+ ): void {
357
+ const fileSummaryDescription = resolveFileSummaryDescription(sourceFile);
358
+ if (!fileSummaryDescription) {
359
+ return;
360
+ }
361
+
362
+ appendRenderedSymbol(fileSymbolMap, sourceFile.getFilePath(), {
363
+ kind: 'File',
364
+ name: FILE_SUMMARY_SYMBOL_NAME,
365
+ filePath: sourceFile.getFilePath(),
366
+ jsdoc: { description: fileSummaryDescription },
367
+ });
368
+
369
+ console.log(`[docs] Added file summary for ${sourceFile.getBaseName()}`);
370
+ }
371
+
372
+ /**
373
+ * Resolves a file-level description for a source file.
374
+ *
375
+ * @param sourceFile - Source file to inspect.
376
+ * @returns File summary text when present.
377
+ */
378
+ function resolveFileSummaryDescription(
379
+ sourceFile: SourceFile,
380
+ ): string | undefined {
381
+ return (
382
+ extractLeadingFileJsDocDescription(sourceFile) ||
383
+ getFirstJsDocDescription(
384
+ sourceFile as unknown as { getJsDocs?: () => unknown[] },
385
+ )
386
+ );
387
+ }
388
+
389
+ /**
390
+ * Collects exported declarations and supported child members.
391
+ *
392
+ * @param sourceFile - Source file being scanned.
393
+ * @param fileSymbolMap - Mutable symbol map for the file's directory.
394
+ * @returns Nothing.
395
+ */
396
+ function collectExportedDeclarations(
397
+ sourceFile: SourceFile,
398
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
399
+ ): void {
400
+ const exportedDeclarations = sourceFile.getExportedDeclarations();
401
+ if (exportedDeclarations.size === 0) {
402
+ return;
403
+ }
404
+
405
+ console.log(
406
+ `[docs] ${sourceFile.getBaseName()} exports: ${exportedDeclarations.size}`,
407
+ );
408
+
409
+ for (const [, declarations] of exportedDeclarations) {
410
+ for (const declaration of declarations) {
411
+ const symbol = declaration.getSymbol();
412
+ if (!symbol) {
413
+ continue;
104
414
  }
105
415
 
106
- if (exported.size) {
107
- console.log(`[docs] ${sf.getBaseName()} exports: ${exported.size}`);
108
- for (const [, decls] of exported) {
109
- for (const decl of decls) {
110
- const sym = decl.getSymbol();
111
- if (!sym) continue;
112
- const rendered = renderSymbol(sym, decl.getKindName(), sf.getFilePath());
113
- if (rendered) {
114
- const arr = fileMap.get(sf.getFilePath()) || [];
115
- arr.push(rendered);
116
- fileMap.set(sf.getFilePath(), arr);
117
- }
118
-
119
- // special-case: exported variable with object literal initializer (e.g. Activation = { ... })
120
- try {
121
- if (decl.getKindName && decl.getKindName() === 'VariableDeclaration') {
122
- const init = (decl as any).getInitializer?.();
123
- if (init && init.getKindName && init.getKindName() === 'ObjectLiteralExpression') {
124
- const props = init.getProperties?.() || [];
125
- for (const p of props) {
126
- const propName = p.getName?.() || (p.getSymbol && p.getSymbol()?.getName?.());
127
- const renderedProp = renderDeclaration(p, String(propName), p.getKindName?.() || 'Property', sf.getFilePath(), sym.getName());
128
- if (renderedProp) {
129
- const arr2 = fileMap.get(sf.getFilePath()) || [];
130
- arr2.push(renderedProp);
131
- fileMap.set(sf.getFilePath(), arr2);
132
- }
133
- }
134
- }
135
- }
136
- } catch (e) { /* ignore introspection errors */ }
137
-
138
- // special-case: exported class -> include members
139
- try {
140
- if (decl.getKindName && decl.getKindName() === 'ClassDeclaration') {
141
- const members = (decl as any).getMembers?.() || [];
142
- for (const m of members) {
143
- const memberName = m.getName?.();
144
- if (!memberName) continue;
145
- const renderedMember = renderDeclaration(m, String(memberName), m.getKindName?.() || 'ClassMember', sf.getFilePath(), sym.getName());
146
- if (renderedMember) {
147
- const arr3 = fileMap.get(sf.getFilePath()) || [];
148
- arr3.push(renderedMember);
149
- fileMap.set(sf.getFilePath(), arr3);
150
- }
151
- }
152
- }
153
- } catch (e) { /* ignore */ }
154
- }
155
- }
416
+ const renderedSymbol = renderSymbol(
417
+ symbol,
418
+ declaration.getKindName(),
419
+ sourceFile.getFilePath(),
420
+ );
421
+ if (renderedSymbol) {
422
+ appendRenderedSymbol(
423
+ fileSymbolMap,
424
+ sourceFile.getFilePath(),
425
+ renderedSymbol,
426
+ );
427
+ }
428
+
429
+ collectExportedObjectLiteralMembers(
430
+ declaration as unknown as Record<string, unknown>,
431
+ symbol.getName(),
432
+ sourceFile.getFilePath(),
433
+ fileSymbolMap,
434
+ );
435
+ collectExportedClassMembers(
436
+ declaration as unknown as Record<string, unknown>,
437
+ symbol.getName(),
438
+ sourceFile.getFilePath(),
439
+ fileSymbolMap,
440
+ );
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Collects documented properties from exported object-literal variables.
447
+ *
448
+ * @param declaration - Candidate declaration.
449
+ * @param parentName - Parent exported symbol name.
450
+ * @param filePath - Absolute source file path.
451
+ * @param fileSymbolMap - Mutable symbol map for the file's directory.
452
+ * @returns Nothing.
453
+ */
454
+ function collectExportedObjectLiteralMembers(
455
+ declaration: Record<string, unknown>,
456
+ parentName: string,
457
+ filePath: string,
458
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
459
+ ): void {
460
+ try {
461
+ const declarationKindName = (
462
+ declaration.getKindName as (() => string) | undefined
463
+ )?.();
464
+ if (declarationKindName !== 'VariableDeclaration') {
465
+ return;
466
+ }
467
+
468
+ const initializer = (
469
+ declaration.getInitializer as (() => any) | undefined
470
+ )?.();
471
+ if (initializer?.getKindName?.() !== 'ObjectLiteralExpression') {
472
+ return;
473
+ }
474
+
475
+ const properties = (initializer.getProperties?.() as any[]) || [];
476
+ for (const property of properties) {
477
+ const propertyName =
478
+ property.getName?.() || property.getSymbol?.()?.getName?.();
479
+ const renderedProperty = renderDeclaration(
480
+ property,
481
+ String(propertyName),
482
+ property.getKindName?.() || 'Property',
483
+ filePath,
484
+ parentName,
485
+ );
486
+
487
+ if (renderedProperty) {
488
+ appendRenderedSymbol(fileSymbolMap, filePath, renderedProperty);
489
+ }
490
+ }
491
+ } catch {
492
+ // Best-effort introspection only.
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Collects documented members from exported classes.
498
+ *
499
+ * @param declaration - Candidate declaration.
500
+ * @param parentName - Parent exported symbol name.
501
+ * @param filePath - Absolute source file path.
502
+ * @param fileSymbolMap - Mutable symbol map for the file's directory.
503
+ * @returns Nothing.
504
+ */
505
+ function collectExportedClassMembers(
506
+ declaration: Record<string, unknown>,
507
+ parentName: string,
508
+ filePath: string,
509
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
510
+ ): void {
511
+ try {
512
+ const declarationKindName = (
513
+ declaration.getKindName as (() => string) | undefined
514
+ )?.();
515
+ if (declarationKindName !== 'ClassDeclaration') {
516
+ return;
517
+ }
518
+
519
+ const members =
520
+ (declaration.getMembers as (() => any[]) | undefined)?.() || [];
521
+ for (const member of members) {
522
+ const memberName = member.getName?.();
523
+ if (!memberName) {
524
+ continue;
156
525
  }
157
526
 
158
- // Additionally capture top-level, non-exported declarations if they have JSDoc
159
- try {
160
- const stmts = (sf as any).getStatements?.() || [];
161
- for (const st of stmts) {
162
- const decls = (st.getDeclarations && st.getDeclarations()) || [st];
163
- for (const d of decls) {
164
- const jsdocs = (d as any).getJsDocs?.() || [];
165
- const hasExport = (d.getSymbol && d.getSymbol()?.getDeclarations?.()?.some((x: any) => x.isExported && x.isExported())) || false;
166
- if (!jsdocs.length) continue; // only include documented non-exported declarations
167
- // skip if already added via exported processing
168
- const name = d.getName?.() || (d.getSymbol && d.getSymbol()?.getName?.());
169
- if (!name) continue;
170
- const already = (fileMap.get(sf.getFilePath()) || []).some(s => s.name === name || s.name === `${name}` || s.name === `${name}`);
171
- if (already) continue;
172
- const rendered = renderDeclaration(d, String(name), d.getKindName?.() || 'Declaration', sf.getFilePath());
173
- if (rendered) {
174
- const arr4 = fileMap.get(sf.getFilePath()) || [];
175
- arr4.push(rendered);
176
- fileMap.set(sf.getFilePath(), arr4);
177
- }
178
- }
527
+ const renderedMember = renderDeclaration(
528
+ member,
529
+ String(memberName),
530
+ member.getKindName?.() || 'ClassMember',
531
+ filePath,
532
+ parentName,
533
+ );
534
+
535
+ if (renderedMember) {
536
+ appendRenderedSymbol(fileSymbolMap, filePath, renderedMember);
537
+ }
538
+ }
539
+ } catch {
540
+ // Best-effort introspection only.
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Collects documented top-level non-exported declarations.
546
+ *
547
+ * @param sourceFile - Source file being scanned.
548
+ * @param fileSymbolMap - Mutable symbol map for the file's directory.
549
+ * @returns Nothing.
550
+ */
551
+ function collectDocumentedTopLevelDeclarations(
552
+ sourceFile: SourceFile,
553
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
554
+ ): void {
555
+ try {
556
+ const statements =
557
+ (
558
+ sourceFile as unknown as { getStatements?: () => any[] }
559
+ ).getStatements?.() || [];
560
+
561
+ for (const statement of statements) {
562
+ const declarations = statement.getDeclarations?.() || [statement];
563
+
564
+ for (const declaration of declarations) {
565
+ const jsDocs = declaration.getJsDocs?.() || [];
566
+ if (jsDocs.length === 0) {
567
+ continue;
179
568
  }
180
- } catch (e) { /* ignore */ }
181
- }
182
-
183
- for (const [dir, fileMap] of dirMap) {
184
- // Tidy & deduplicate symbols per file before rendering
185
- for (const [filePath, symbols] of fileMap) {
186
- const seen = new Map<string, RenderedSymbol>();
187
- const deduped: RenderedSymbol[] = [];
188
- for (const s of symbols) {
189
- // normalize name
190
- const normName = normalizeName(s, filePath);
191
- const key = `${s.parent || ''}::${normName}::${s.signature || ''}`;
192
- const existing = seen.get(key);
193
- if (existing) {
194
- // merge jsdoc fields conservatively
195
- existing.jsdoc.description = existing.jsdoc.description || s.jsdoc.description;
196
- existing.jsdoc.summary = existing.jsdoc.summary || s.jsdoc.summary;
197
- existing.jsdoc.deprecated = existing.jsdoc.deprecated || s.jsdoc.deprecated;
198
- if (s.jsdoc.params) {
199
- existing.jsdoc.params = (existing.jsdoc.params || []).slice();
200
- for (const p of s.jsdoc.params) {
201
- if (!existing.jsdoc.params.some(ep => ep.name === p.name)) existing.jsdoc.params.push(p);
202
- }
203
- }
204
- if (!existing.signature && s.signature) existing.signature = s.signature;
205
- } else {
206
- const clone = { ...s, name: normName, jsdoc: { ...s.jsdoc, params: s.jsdoc.params ? s.jsdoc.params.slice() : undefined } } as RenderedSymbol;
207
- seen.set(key, clone);
208
- deduped.push(clone);
569
+
570
+ const name =
571
+ declaration.getName?.() || declaration.getSymbol?.()?.getName?.();
572
+ if (!name) {
573
+ continue;
574
+ }
575
+
576
+ if (
577
+ hasRenderedSymbol(
578
+ fileSymbolMap,
579
+ sourceFile.getFilePath(),
580
+ String(name),
581
+ )
582
+ ) {
583
+ continue;
584
+ }
585
+
586
+ const renderedDeclaration = renderDeclaration(
587
+ declaration,
588
+ String(name),
589
+ declaration.getKindName?.() || 'Declaration',
590
+ sourceFile.getFilePath(),
591
+ );
592
+
593
+ if (renderedDeclaration) {
594
+ appendRenderedSymbol(
595
+ fileSymbolMap,
596
+ sourceFile.getFilePath(),
597
+ renderedDeclaration,
598
+ );
209
599
  }
210
600
  }
211
- fileMap.set(filePath, deduped);
212
601
  }
213
- const relDir = path.relative(SRC_DIR, dir); // '' means src root
214
- if (relDir.startsWith('..')) continue; // outside src
215
- const outDir = relDir === '' ? path.join(DOCS_DIR, 'src') : path.join(DOCS_DIR, relDir);
216
- await fs.ensureDir(outDir);
217
- const outFile = path.join(outDir, 'README.md');
218
- const md = buildDirectoryReadme(relDir, fileMap);
219
- await writeIfChanged(outFile, md);
220
-
221
- // Also emit directly into the src folder tree so GitHub shows it inline when browsing code.
222
- const srcTargetDir = path.join(SRC_DIR, relDir);
223
- const srcReadme = path.join(srcTargetDir, 'README.md');
224
- await emitSourceReadme(srcReadme, md);
225
- }
226
-
227
- // Build a friendly nested folder index at docs/FOLDERS.md
228
- type Node = { name: string; path: string; children: Map<string, Node>; fileCount?: number };
229
-
230
- const rootNode: Node = { name: 'src', path: 'src', children: new Map(), fileCount: 0 };
231
- const relDirs = [...dirMap.keys()].map(d => path.relative(SRC_DIR, d)).filter(d => !d.startsWith('..'));
232
-
233
- for (const rel of relDirs) {
234
- const clean = rel === '' ? 'src' : rel.replace(/\\/g, '/');
235
- const parts = clean.split('/');
236
- let node = rootNode;
237
- let acc = '';
238
- for (const part of parts) {
239
- acc = acc ? `${acc}/${part}` : part;
240
- if (!node.children.has(part)) {
241
- node.children.set(part, { name: part, path: acc, children: new Map() });
602
+ } catch {
603
+ // Best-effort introspection only.
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Checks whether a symbol name has already been collected for a file.
609
+ *
610
+ * @param fileSymbolMap - Mutable symbol map for the directory.
611
+ * @param filePath - Absolute source file path.
612
+ * @param name - Candidate symbol name.
613
+ * @returns True when the symbol already exists in the collected set.
614
+ */
615
+ function hasRenderedSymbol(
616
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
617
+ filePath: string,
618
+ name: string,
619
+ ): boolean {
620
+ return (fileSymbolMap.get(filePath) ?? []).some(
621
+ (symbol) => symbol.name === name,
622
+ );
623
+ }
624
+
625
+ /**
626
+ * Appends a rendered symbol to the per-file collection.
627
+ *
628
+ * @param fileSymbolMap - Mutable symbol map for the directory.
629
+ * @param filePath - Absolute source file path.
630
+ * @param renderedSymbol - Symbol to append.
631
+ * @returns Nothing.
632
+ */
633
+ function appendRenderedSymbol(
634
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
635
+ filePath: string,
636
+ renderedSymbol: RenderedSymbol,
637
+ ): void {
638
+ const renderedSymbols = fileSymbolMap.get(filePath) ?? [];
639
+ renderedSymbols.push(renderedSymbol);
640
+ fileSymbolMap.set(filePath, renderedSymbols);
641
+ }
642
+
643
+ /**
644
+ * Tidies and deduplicates collected symbols before README rendering.
645
+ *
646
+ * @param directorySymbolMap - Directory symbol map to normalize.
647
+ * @returns Nothing.
648
+ */
649
+ function dedupeDirectorySymbols(directorySymbolMap: DirectorySymbolMap): void {
650
+ for (const [, fileSymbolMap] of directorySymbolMap) {
651
+ for (const [filePath, renderedSymbols] of fileSymbolMap) {
652
+ fileSymbolMap.set(filePath, dedupeFileSymbols(renderedSymbols, filePath));
653
+ }
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Deduplicates symbols for a single file while conservatively merging JSDoc.
659
+ *
660
+ * @param renderedSymbols - Symbols collected for one file.
661
+ * @param filePath - Absolute source file path.
662
+ * @returns Deduplicated symbol list.
663
+ */
664
+ function dedupeFileSymbols(
665
+ renderedSymbols: readonly RenderedSymbol[],
666
+ filePath: string,
667
+ ): RenderedSymbol[] {
668
+ const seen = new Map<string, RenderedSymbol>();
669
+ const dedupedSymbols: RenderedSymbol[] = [];
670
+
671
+ for (const renderedSymbol of renderedSymbols) {
672
+ const normalizedName = normalizeName(renderedSymbol, filePath);
673
+ const dedupeKey = `${renderedSymbol.parent || ''}::${normalizedName}::${renderedSymbol.signature || ''}`;
674
+ const existingSymbol = seen.get(dedupeKey);
675
+
676
+ if (existingSymbol) {
677
+ mergeRenderedSymbols(existingSymbol, renderedSymbol);
678
+ continue;
679
+ }
680
+
681
+ const clonedSymbol: RenderedSymbol = {
682
+ ...renderedSymbol,
683
+ name: normalizedName,
684
+ jsdoc: {
685
+ ...renderedSymbol.jsdoc,
686
+ params: renderedSymbol.jsdoc.params?.slice(),
687
+ },
688
+ };
689
+
690
+ seen.set(dedupeKey, clonedSymbol);
691
+ dedupedSymbols.push(clonedSymbol);
692
+ }
693
+
694
+ return dedupedSymbols;
695
+ }
696
+
697
+ /**
698
+ * Merges missing JSDoc fields from a duplicate symbol into the retained one.
699
+ *
700
+ * @param target - Existing retained symbol.
701
+ * @param incoming - Duplicate symbol carrying possible extra data.
702
+ * @returns Nothing.
703
+ */
704
+ function mergeRenderedSymbols(
705
+ target: RenderedSymbol,
706
+ incoming: RenderedSymbol,
707
+ ): void {
708
+ target.jsdoc.description =
709
+ target.jsdoc.description || incoming.jsdoc.description;
710
+ target.jsdoc.summary = target.jsdoc.summary || incoming.jsdoc.summary;
711
+ target.jsdoc.deprecated =
712
+ target.jsdoc.deprecated || incoming.jsdoc.deprecated;
713
+
714
+ if (incoming.jsdoc.params?.length) {
715
+ target.jsdoc.params = (target.jsdoc.params || []).slice();
716
+
717
+ for (const parameter of incoming.jsdoc.params) {
718
+ const alreadyPresent = target.jsdoc.params.some(
719
+ (existingParameter) => existingParameter.name === parameter.name,
720
+ );
721
+ if (!alreadyPresent) {
722
+ target.jsdoc.params.push(parameter);
242
723
  }
243
- node = node.children.get(part)!;
244
724
  }
245
- // attach file count if available
246
- const abs = path.join(SRC_DIR, rel === '' ? '' : rel);
247
- const fm = dirMap.get(abs);
248
- if (fm) node.fileCount = fm.size;
249
- }
250
-
251
- const lines: string[] = ['# Docs Index', '', 'Auto-generated index of source folders (click to open folder README).', ''];
252
-
253
- function renderNode(n: Node, level: number) {
254
- const indent = ' '.repeat(Math.max(0, level));
255
- const label = (n.path === 'src' && level === 0) ? 'src (root)' : n.name;
256
- const link = `${n.path}/README.md`;
257
- const count = n.fileCount ? ` — ${n.fileCount} file${n.fileCount > 1 ? 's' : ''}` : '';
258
- lines.push(`${indent}- [${label}](${link})${count}`);
259
- const childNames = [...n.children.keys()].sort();
260
- for (const k of childNames) renderNode(n.children.get(k)!, level + 1);
261
- }
262
-
263
- // Render top-level root and its children (skip a duplicate 'src' nesting)
264
- renderNode(rootNode, 0);
265
- await fs.writeFile(path.join(DOCS_DIR, 'FOLDERS.md'), lines.join('\n') + '\n', 'utf8');
266
-
267
- console.log('Per-folder README generation complete.');
268
- }
269
-
270
- function renderSymbol(sym: MorphSymbol, fallbackKind: string, filePath: string): RenderedSymbol | null {
271
- const decl = sym.getDeclarations()[0];
272
- if (!decl) return null;
273
- const kind = decl.getKindName();
274
- const allow = /ClassDeclaration|FunctionDeclaration|InterfaceDeclaration|EnumDeclaration|TypeAliasDeclaration|VariableDeclaration/;
275
- if (!allow.test(kind)) return null;
276
- // ts-morph Declaration with JSDoc support; cast to any to access getJsDocs generically
277
- const jsDocs = (decl as any).getJsDocs?.() || [];
278
- if (jsDocs.some((j: any) => j.getTags().some((t: any) => t.getTagName() === 'internal'))) return null;
279
-
280
- const primary = jsDocs[0];
281
- const fullDesc = primary?.getDescription().trim();
282
- const summary = fullDesc?.split(/\r?\n\r?\n/)[0]?.trim();
283
- const tags: JSDocTag[] = primary?.getTags() || [];
284
- const paramsTags = tags.filter(t => t.getTagName() === 'param');
285
- const returnsTag = tags.find(t => t.getTagName() === 'returns' || t.getTagName() === 'return');
286
- const deprecatedTag = tags.find(t => t.getTagName() === 'deprecated');
287
-
288
- let signature: string | undefined;
289
- try {
290
- const sig = decl.getType().getCallSignatures()[0];
291
- if (sig) {
292
- const params = sig.getParameters().map((p: any) => {
293
- const decls = p.getDeclarations();
294
- const t = p.getTypeAtLocation(decls[0] || decl).getText();
295
- return `${p.getName()}: ${t}`;
296
- }).join(', ');
297
- const ret = sig.getReturnType().getText();
298
- signature = `(${params}) => ${ret}`;
725
+ }
726
+
727
+ if (!target.signature && incoming.signature) {
728
+ target.signature = incoming.signature;
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Emits directory README files to both docs output and the source tree.
734
+ *
735
+ * @param directorySymbolMap - Normalized directory symbol map.
736
+ * @param target - Target configuration.
737
+ * @returns Nothing.
738
+ */
739
+ async function emitDirectoryDocs(
740
+ directorySymbolMap: DirectorySymbolMap,
741
+ target: DocsTargetConfig,
742
+ ): Promise<void> {
743
+ for (const [directoryPath, fileSymbolMap] of directorySymbolMap) {
744
+ const relativeDirectory = path.relative(target.sourceDir, directoryPath);
745
+ if (relativeDirectory.startsWith('..')) {
746
+ continue;
299
747
  }
300
- } catch { /* ignore */ }
301
-
302
- const params = paramsTags.map(t => {
303
- const text = t.getText();
304
- const match = text.match(/@param\s+(\w+)/);
305
- const name = match?.[1] || '';
306
- const raw = t.getComment();
307
- const doc = Array.isArray(raw) ? raw.map(r => (r as any).getText ? (r as any).getText() : String(r)).join(' ').trim() : (raw || undefined);
308
- return { name, doc };
309
- });
748
+
749
+ const outputDirectory =
750
+ relativeDirectory === ''
751
+ ? target.rootDocsDir
752
+ : path.join(target.docsDir, relativeDirectory);
753
+ const sourceDirectory =
754
+ relativeDirectory === ''
755
+ ? target.sourceDir
756
+ : path.join(target.sourceDir, relativeDirectory);
757
+
758
+ const markdown = buildDirectoryReadme(
759
+ relativeDirectory,
760
+ fileSymbolMap,
761
+ target.sourceDir,
762
+ );
763
+
764
+ await fs.ensureDir(outputDirectory);
765
+ await writeFileIfChanged(path.join(outputDirectory, 'README.md'), markdown);
766
+ await writeFileIfChanged(path.join(sourceDirectory, 'README.md'), markdown);
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Emits the folder index for targets that want a docs landing page.
772
+ *
773
+ * @param directorySymbolMap - Normalized directory symbol map.
774
+ * @param target - Target configuration.
775
+ * @returns Nothing.
776
+ */
777
+ async function emitFolderIndex(
778
+ directorySymbolMap: DirectorySymbolMap,
779
+ target: DocsTargetConfig,
780
+ ): Promise<void> {
781
+ const rootNode: FolderIndexNode = {
782
+ name: 'src',
783
+ path: 'src',
784
+ children: new Map(),
785
+ fileCount: 0,
786
+ };
787
+
788
+ const relativeDirectories = [...directorySymbolMap.keys()]
789
+ .map((directoryPath) => path.relative(target.sourceDir, directoryPath))
790
+ .filter((relativeDirectory) => !relativeDirectory.startsWith('..'));
791
+
792
+ for (const relativeDirectory of relativeDirectories) {
793
+ insertFolderIndexNode(
794
+ rootNode,
795
+ relativeDirectory,
796
+ target,
797
+ directorySymbolMap,
798
+ );
799
+ }
800
+
801
+ const markdown = buildFolderIndexMarkdown(rootNode);
802
+ await writeFileIfChanged(
803
+ path.join(DOCS_DIR, FOLDER_INDEX_FILE_NAME),
804
+ markdown,
805
+ );
806
+ }
807
+
808
+ /**
809
+ * Inserts one relative directory into the folder index tree.
810
+ *
811
+ * @param rootNode - Root folder index node.
812
+ * @param relativeDirectory - Relative directory from the target source root.
813
+ * @param target - Target configuration.
814
+ * @param directorySymbolMap - Directory symbol map used for file counts.
815
+ * @returns Nothing.
816
+ */
817
+ function insertFolderIndexNode(
818
+ rootNode: FolderIndexNode,
819
+ relativeDirectory: string,
820
+ target: DocsTargetConfig,
821
+ directorySymbolMap: DirectorySymbolMap,
822
+ ): void {
823
+ const normalizedDirectory =
824
+ relativeDirectory === '' ? 'src' : relativeDirectory.replace(/\\/g, '/');
825
+ const pathParts = normalizedDirectory.split('/');
826
+
827
+ let currentNode = rootNode;
828
+ let accumulatedPath = '';
829
+
830
+ for (const pathPart of pathParts) {
831
+ accumulatedPath = accumulatedPath
832
+ ? `${accumulatedPath}/${pathPart}`
833
+ : pathPart;
834
+
835
+ if (!currentNode.children.has(pathPart)) {
836
+ currentNode.children.set(pathPart, {
837
+ name: pathPart,
838
+ path: accumulatedPath,
839
+ children: new Map(),
840
+ });
841
+ }
842
+
843
+ currentNode = currentNode.children.get(pathPart)!;
844
+ }
845
+
846
+ const absoluteDirectoryPath = path.join(
847
+ target.sourceDir,
848
+ relativeDirectory === '' ? '' : relativeDirectory,
849
+ );
850
+ const fileSymbolMap = directorySymbolMap.get(absoluteDirectoryPath);
851
+ if (fileSymbolMap) {
852
+ currentNode.fileCount = fileSymbolMap.size;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Builds markdown for the generated folder index.
858
+ *
859
+ * @param rootNode - Root folder index node.
860
+ * @returns Folder index markdown.
861
+ */
862
+ function buildFolderIndexMarkdown(rootNode: FolderIndexNode): string {
863
+ const lines = [
864
+ '# Docs Index',
865
+ '',
866
+ 'Auto-generated index of source folders (click to open folder README).',
867
+ '',
868
+ ];
869
+
870
+ renderFolderIndexNode(rootNode, 0, lines);
871
+ return `${lines.join('\n')}\n`;
872
+ }
873
+
874
+ /**
875
+ * Renders one folder index node and its children recursively.
876
+ *
877
+ * @param node - Current folder index node.
878
+ * @param level - Nesting level.
879
+ * @param lines - Output line buffer.
880
+ * @returns Nothing.
881
+ */
882
+ function renderFolderIndexNode(
883
+ node: FolderIndexNode,
884
+ level: number,
885
+ lines: string[],
886
+ ): void {
887
+ const indent = ' '.repeat(Math.max(0, level));
888
+ const label = node.path === 'src' && level === 0 ? 'src (root)' : node.name;
889
+ const countSuffix = node.fileCount
890
+ ? ` — ${node.fileCount} file${node.fileCount > 1 ? 's' : ''}`
891
+ : '';
892
+
893
+ lines.push(`${indent}- [${label}](${node.path}/README.md)${countSuffix}`);
894
+
895
+ const childNames = [...node.children.keys()].sort();
896
+ for (const childName of childNames) {
897
+ renderFolderIndexNode(node.children.get(childName)!, level + 1, lines);
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Extracts the leading file-level JSDoc block from raw source text.
903
+ *
904
+ * @param sourceFile - Source file to inspect.
905
+ * @returns Cleaned top-of-file description when present.
906
+ */
907
+ function extractLeadingFileJsDocDescription(
908
+ sourceFile: SourceFile,
909
+ ): string | undefined {
910
+ const text = sourceFile.getFullText();
911
+ const match = text.match(/^\s*(?:\uFEFF)?\/\*\*([\s\S]*?)\*\//);
912
+ if (!match) {
913
+ return undefined;
914
+ }
915
+
916
+ const cleanedText = match[1]
917
+ .split(/\r?\n/)
918
+ .map((line) => line.replace(/^\s*\*\s?/, ''))
919
+ .join('\n')
920
+ .trim();
921
+
922
+ if (!cleanedText) {
923
+ return undefined;
924
+ }
925
+
926
+ if (
927
+ cleanedText.split('\n').some((line) => line.trim().startsWith('@internal'))
928
+ ) {
929
+ return undefined;
930
+ }
931
+
932
+ return cleanedText;
933
+ }
934
+
935
+ /**
936
+ * Renders one exported symbol when it belongs to a supported declaration kind
937
+ * and is not marked internal.
938
+ *
939
+ * @param symbol - Exported symbol.
940
+ * @param fallbackKind - Declaration kind used as a fallback label.
941
+ * @param filePath - Absolute source file path.
942
+ * @returns Rendered symbol or null when not supported.
943
+ */
944
+ function renderSymbol(
945
+ symbol: MorphSymbol,
946
+ fallbackKind: string,
947
+ filePath: string,
948
+ ): RenderedSymbol | null {
949
+ const declaration = symbol.getDeclarations()[0];
950
+ if (!declaration) {
951
+ return null;
952
+ }
953
+
954
+ const declarationKind = declaration.getKindName();
955
+ const supportedKinds =
956
+ /ClassDeclaration|FunctionDeclaration|InterfaceDeclaration|EnumDeclaration|TypeAliasDeclaration|VariableDeclaration/;
957
+ if (!supportedKinds.test(declarationKind)) {
958
+ return null;
959
+ }
960
+
961
+ const jsDocs = getJsDocs(declaration);
962
+ if (hasInternalTag(jsDocs)) {
963
+ return null;
964
+ }
965
+
966
+ const primaryJsDoc = jsDocs[0];
967
+ const fullDescription = primaryJsDoc?.getDescription().trim();
310
968
 
311
969
  return {
312
- kind: fallbackKind || kind,
313
- name: sym.getName(),
314
- parent: undefined,
970
+ kind: fallbackKind || declarationKind,
971
+ name: symbol.getName(),
972
+ parent: undefined,
315
973
  filePath,
316
- signature,
974
+ signature: resolveCallSignature(declaration),
317
975
  jsdoc: {
318
- summary,
319
- description: fullDesc,
320
- params: params.length ? params : undefined,
321
- returns: (() => { const r = returnsTag?.getComment(); return typeof r === 'string' ? r.trim() : undefined; })(),
322
- deprecated: (() => { const r = deprecatedTag?.getComment(); return typeof r === 'string' ? r.trim() : undefined; })()
323
- }
976
+ summary: fullDescription?.split(/\r?\n\r?\n/)[0]?.trim(),
977
+ description: fullDescription,
978
+ params: extractParamDocs(primaryJsDoc?.getTags() || []),
979
+ returns: getTagCommentText(
980
+ primaryJsDoc
981
+ ?.getTags()
982
+ .find(
983
+ (tag: JSDocTag) =>
984
+ tag.getTagName() === 'returns' || tag.getTagName() === 'return',
985
+ ),
986
+ ),
987
+ deprecated: getTagCommentText(
988
+ primaryJsDoc
989
+ ?.getTags()
990
+ .find((tag: JSDocTag) => tag.getTagName() === 'deprecated'),
991
+ ),
992
+ },
324
993
  };
325
994
  }
326
995
 
327
- // Render a declaration-like object (node) which may not have a symbol
328
- function renderDeclaration(decl: any, forcedName?: string, forcedKind?: string, filePath?: string, parentName?: string): RenderedSymbol | null {
329
- if (!decl) return null;
996
+ /**
997
+ * Renders a declaration-like node that may not have a directly usable symbol.
998
+ *
999
+ * @param declaration - Declaration-like node.
1000
+ * @param forcedName - Optional forced display name.
1001
+ * @param forcedKind - Optional forced kind label.
1002
+ * @param filePath - Absolute source file path.
1003
+ * @param parentName - Optional parent display name.
1004
+ * @returns Rendered symbol or null when not renderable.
1005
+ */
1006
+ function renderDeclaration(
1007
+ declaration: any,
1008
+ forcedName?: string,
1009
+ forcedKind?: string,
1010
+ filePath?: string,
1011
+ parentName?: string,
1012
+ ): RenderedSymbol | null {
1013
+ if (!declaration) {
1014
+ return null;
1015
+ }
1016
+
330
1017
  try {
331
- const kind = forcedKind || decl.getKindName?.() || decl.getKind?.() || 'Declaration';
332
- const name = forcedName || decl.getName?.() || (decl.getSymbol && decl.getSymbol()?.getName?.()) || (parentName ? `${parentName}.${forcedName}` : '');
333
- const jsDocs = decl.getJsDocs?.() || [];
334
- if (!jsDocs.length) return null;
335
- if (jsDocs.some((j: any) => j.getTags().some((t: any) => t.getTagName() === 'internal'))) return null;
336
-
337
- const primary = jsDocs[0];
338
- const fullDesc = primary?.getDescription?.()?.trim();
339
- const summary = fullDesc?.split(/\r?\n\r?\n/)[0]?.trim();
340
- const tags: JSDocTag[] = primary?.getTags() || [];
341
- const paramsTags = tags.filter(t => t.getTagName() === 'param');
342
- const returnsTag = tags.find(t => t.getTagName() === 'returns' || t.getTagName() === 'return');
343
- const deprecatedTag = tags.find(t => t.getTagName() === 'deprecated');
344
-
345
- let signature: string | undefined;
346
- try {
347
- const type = decl.getType?.() || (decl.getSymbol && decl.getSymbol()?.getType?.());
348
- const sig = type?.getCallSignatures?.()[0];
349
- if (sig) {
350
- const params = sig.getParameters().map((p: any) => {
351
- const decls = p.getDeclarations();
352
- const t = p.getTypeAtLocation(decls[0] || decl).getText();
353
- return `${p.getName()}: ${t}`;
354
- }).join(', ');
355
- const ret = sig.getReturnType().getText();
356
- signature = `(${params}) => ${ret}`;
357
- }
358
- } catch { /* ignore */ }
1018
+ const jsDocs = getJsDocs(declaration);
1019
+ if (jsDocs.length === 0 || hasInternalTag(jsDocs)) {
1020
+ return null;
1021
+ }
359
1022
 
360
- const params = paramsTags.map(t => {
361
- const text = t.getText?.() || '';
362
- const match = text.match(/@param\s+(\w+)/);
363
- const name = match?.[1] || '';
364
- const raw = t.getComment?.();
365
- const doc = Array.isArray(raw) ? raw.map(r => (r as any).getText ? (r as any).getText() : String(r)).join(' ').trim() : (raw || undefined);
366
- return { name, doc };
367
- });
1023
+ const primaryJsDoc = jsDocs[0];
1024
+ const fullDescription = primaryJsDoc?.getDescription?.()?.trim();
368
1025
 
369
1026
  return {
370
- kind,
371
- name,
1027
+ kind:
1028
+ forcedKind ||
1029
+ declaration.getKindName?.() ||
1030
+ declaration.getKind?.() ||
1031
+ 'Declaration',
1032
+ name:
1033
+ forcedName ||
1034
+ declaration.getName?.() ||
1035
+ declaration.getSymbol?.()?.getName?.() ||
1036
+ (parentName ? `${parentName}.${forcedName}` : ''),
372
1037
  parent: parentName || undefined,
373
1038
  filePath: filePath || '',
374
- signature,
1039
+ signature: resolveCallSignature(declaration),
375
1040
  jsdoc: {
376
- summary,
377
- description: fullDesc,
378
- params: params.length ? params : undefined,
379
- returns: (() => { const r = returnsTag?.getComment?.(); return typeof r === 'string' ? r.trim() : undefined; })(),
380
- deprecated: (() => { const r = deprecatedTag?.getComment?.(); return typeof r === 'string' ? r.trim() : undefined; })()
381
- }
1041
+ summary: fullDescription?.split(/\r?\n\r?\n/)[0]?.trim(),
1042
+ description: fullDescription,
1043
+ params: extractParamDocs(primaryJsDoc?.getTags() || []),
1044
+ returns: getTagCommentText(
1045
+ primaryJsDoc
1046
+ ?.getTags()
1047
+ .find(
1048
+ (tag: JSDocTag) =>
1049
+ tag.getTagName() === 'returns' || tag.getTagName() === 'return',
1050
+ ),
1051
+ ),
1052
+ deprecated: getTagCommentText(
1053
+ primaryJsDoc
1054
+ ?.getTags()
1055
+ .find((tag: JSDocTag) => tag.getTagName() === 'deprecated'),
1056
+ ),
1057
+ },
382
1058
  };
383
- } catch (e) {
1059
+ } catch {
384
1060
  return null;
385
1061
  }
386
1062
  }
387
1063
 
388
- // Normalize a symbol name: prefer meaningful names over 'default', fallback to file basename
389
- function normalizeName(s: RenderedSymbol, filePath: string) {
390
- let name = (s.name || '').toString();
391
- if (!name || name === 'default' || name === '__file_summary__') {
392
- // try to derive from file path or signature
393
- const base = path.basename(filePath || '', '.ts');
394
- if (s.kind === 'File' || name === '__file_summary__') return base;
395
- // if parent exists, qualify with parent
396
- if (s.parent) return `${s.parent}.${base}`;
397
- // try to extract from signature
398
- if (s.signature) return `${base}${s.signature.split(')')[0]})`;
399
- return base;
400
- }
401
- // strip trailing redundant '()' or 'function ' prefixes
402
- name = name.replace(/^function\s+/, '').replace(/\(\)$/, '');
403
- return name;
1064
+ /**
1065
+ * Returns JSDoc blocks from a declaration-like node.
1066
+ *
1067
+ * @param node - Declaration-like node.
1068
+ * @returns JSDoc array.
1069
+ */
1070
+ function getJsDocs(node: any): any[] {
1071
+ return (node.getJsDocs?.() as any[]) || [];
404
1072
  }
405
1073
 
406
- function buildDirectoryReadme(relDir: string, fileMap: Map<string, RenderedSymbol[]>) {
407
- const title = relDir.replace(/\\/g, '/');
408
- const lines: string[] = [
409
- `# ${title}`,
410
- ''
411
- ];
1074
+ /**
1075
+ * Resolves the first JSDoc description from a declaration-like node.
1076
+ *
1077
+ * @param node - Declaration-like node.
1078
+ * @returns Description text when present.
1079
+ */
1080
+ function getFirstJsDocDescription(node: {
1081
+ getJsDocs?: () => unknown[];
1082
+ }): string | undefined {
1083
+ const firstJsDoc = getJsDocs(node)[0];
1084
+ return firstJsDoc?.getDescription?.()?.trim() as string | undefined;
1085
+ }
412
1086
 
413
- const filesSorted = [...fileMap.keys()].toSorted((left, right) => {
414
- const leftRank = rankFileForDirectoryReadme(left, fileMap);
415
- const rightRank = rankFileForDirectoryReadme(right, fileMap);
1087
+ /**
1088
+ * Checks whether any provided JSDoc block contains an internal tag.
1089
+ *
1090
+ * @param jsDocs - JSDoc array to inspect.
1091
+ * @returns True when the declaration is marked internal.
1092
+ */
1093
+ function hasInternalTag(jsDocs: readonly any[]): boolean {
1094
+ return jsDocs.some((jsDoc) =>
1095
+ jsDoc
1096
+ .getTags()
1097
+ .some((tag: { getTagName(): string }) => tag.getTagName() === 'internal'),
1098
+ );
1099
+ }
416
1100
 
417
- for (let idx = 0; idx < Math.max(leftRank.length, rightRank.length); idx++) {
418
- const leftValue = leftRank[idx] ?? 0;
419
- const rightValue = rightRank[idx] ?? 0;
420
- if (leftValue !== rightValue) return leftValue - rightValue;
1101
+ /**
1102
+ * Resolves a call signature string from a declaration-like node.
1103
+ *
1104
+ * @param declaration - Declaration-like node.
1105
+ * @returns Signature string when the declaration is callable.
1106
+ */
1107
+ function resolveCallSignature(declaration: any): string | undefined {
1108
+ try {
1109
+ const declarationType =
1110
+ declaration.getType?.() || declaration.getSymbol?.()?.getType?.();
1111
+ const callSignature = declarationType?.getCallSignatures?.()[0];
1112
+ if (!callSignature) {
1113
+ return undefined;
421
1114
  }
422
1115
 
423
- return left.localeCompare(right);
424
- });
1116
+ const renderedParameters = callSignature
1117
+ .getParameters()
1118
+ .map((parameter: any) => {
1119
+ const parameterDeclarations = parameter.getDeclarations();
1120
+ const parameterType = normalizeRenderedTypeText(
1121
+ parameter
1122
+ .getTypeAtLocation(parameterDeclarations[0] || declaration)
1123
+ .getText(),
1124
+ );
1125
+ return `${parameter.getName()}: ${parameterType}`;
1126
+ })
1127
+ .join(', ');
1128
+
1129
+ const returnType = normalizeRenderedTypeText(
1130
+ callSignature.getReturnType().getText(),
1131
+ );
1132
+ return `(${renderedParameters}) => ${returnType}`;
1133
+ } catch {
1134
+ return undefined;
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Rewrites absolute import paths from ts-morph type text into repo-relative paths.
1140
+ *
1141
+ * @param typeText - Raw type text returned by ts-morph.
1142
+ * @returns Normalized type text safe for generated docs.
1143
+ */
1144
+ function normalizeRenderedTypeText(typeText: string): string {
1145
+ return typeText.replace(
1146
+ /import\((['"])([^'"]+)\1\)/g,
1147
+ (_match, quote: string, importPath: string) => {
1148
+ const normalizedImportPath = path.normalize(importPath);
1149
+ if (!path.isAbsolute(normalizedImportPath)) {
1150
+ return `import(${quote}${importPath}${quote})`;
1151
+ }
1152
+
1153
+ const relativeImportPath = path
1154
+ .relative(WORKSPACE_ROOT_DIR, normalizedImportPath)
1155
+ .replace(/\\/g, '/');
1156
+
1157
+ const portableImportPath = relativeImportPath.startsWith('..')
1158
+ ? importPath.replace(/\\/g, '/')
1159
+ : relativeImportPath;
425
1160
 
426
- for (const file of filesSorted) {
427
- const relFile = path.relative(SRC_DIR, file).replace(/\\/g, '/');
428
- lines.push(`## ${relFile}`, '');
429
- const fileBaseName = path.basename(file, '.ts');
430
- const symbols = fileMap.get(file)!.toSorted((a, b) => {
431
- const aIsFilePrimary = !a.parent && a.name === fileBaseName;
432
- const bIsFilePrimary = !b.parent && b.name === fileBaseName;
433
- if (aIsFilePrimary !== bIsFilePrimary) return aIsFilePrimary ? -1 : 1;
434
- return (a.parent || a.name).localeCompare(b.parent || b.name) || a.name.localeCompare(b.name);
435
- });
436
-
437
- // extract file summary if present
438
- const fileSummaryIdx = symbols.findIndex(s => s.name === '__file_summary__' && s.kind === 'File');
439
- if (fileSummaryIdx >= 0) {
440
- const fsym = symbols.splice(fileSummaryIdx, 1)[0];
441
- if (fsym.jsdoc.description) lines.push(fsym.jsdoc.description, '');
1161
+ return `import(${quote}${portableImportPath}${quote})`;
1162
+ },
1163
+ );
1164
+ }
1165
+
1166
+ /**
1167
+ * Extracts rendered parameter docs from JSDoc tags.
1168
+ *
1169
+ * @param tags - JSDoc tags to inspect.
1170
+ * @returns Parameter docs when present.
1171
+ */
1172
+ function extractParamDocs(
1173
+ tags: readonly JSDocTag[],
1174
+ ): RenderedParameter[] | undefined {
1175
+ const parameterDocs = tags
1176
+ .filter((tag) => tag.getTagName() === 'param')
1177
+ .map((tag) => {
1178
+ const match = tag.getText().match(/@param\s+(\w+)/);
1179
+ return {
1180
+ name: match?.[1] || '',
1181
+ doc: getTagCommentText(tag),
1182
+ } satisfies RenderedParameter;
1183
+ })
1184
+ .filter((parameter) => Boolean(parameter.name));
1185
+
1186
+ return parameterDocs.length > 0 ? parameterDocs : undefined;
1187
+ }
1188
+
1189
+ /**
1190
+ * Resolves a tag comment into plain text.
1191
+ *
1192
+ * @param tag - JSDoc tag to inspect.
1193
+ * @returns Flattened comment string when present.
1194
+ */
1195
+ function getTagCommentText(tag: JSDocTag | undefined): string | undefined {
1196
+ const rawComment = tag?.getComment();
1197
+ if (typeof rawComment === 'string') {
1198
+ return sanitizeTagCommentText(rawComment);
1199
+ }
1200
+
1201
+ if (Array.isArray(rawComment)) {
1202
+ return sanitizeTagCommentText(
1203
+ rawComment
1204
+ .map(
1205
+ (commentPart) =>
1206
+ (commentPart as { getText?: () => string }).getText?.() ||
1207
+ String(commentPart),
1208
+ )
1209
+ .join(' ')
1210
+ .trim(),
1211
+ );
1212
+ }
1213
+
1214
+ return undefined;
1215
+ }
1216
+
1217
+ /**
1218
+ * Remove ts-morph JSDoc tag artifacts such as standalone trailing asterisks.
1219
+ *
1220
+ * @param commentText - Flattened tag comment text.
1221
+ * @returns Cleaned comment text or undefined when nothing meaningful remains.
1222
+ */
1223
+ function sanitizeTagCommentText(
1224
+ commentText: string | undefined,
1225
+ ): string | undefined {
1226
+ const normalizedComment = commentText
1227
+ ?.replace(/\r\n/g, '\n')
1228
+ .replace(/\n\s*\*\s*$/g, '')
1229
+ .replace(/^\s*\*\s*$/g, '')
1230
+ .trim();
1231
+
1232
+ return normalizedComment ? normalizedComment : undefined;
1233
+ }
1234
+
1235
+ /**
1236
+ * Normalizes rendered symbol names so generated headings remain stable.
1237
+ *
1238
+ * @param renderedSymbol - Symbol being normalized.
1239
+ * @param filePath - Source file path used as fallback context.
1240
+ * @returns Stable display name.
1241
+ */
1242
+ function normalizeName(
1243
+ renderedSymbol: RenderedSymbol,
1244
+ filePath: string,
1245
+ ): string {
1246
+ let name = String(renderedSymbol.name || '');
1247
+
1248
+ if (!name || name === 'default' || name === FILE_SUMMARY_SYMBOL_NAME) {
1249
+ const fileBaseName = path.basename(filePath || '', '.ts');
1250
+
1251
+ if (renderedSymbol.kind === 'File' || name === FILE_SUMMARY_SYMBOL_NAME) {
1252
+ return fileBaseName;
442
1253
  }
443
1254
 
444
- // Group by parent: top-level (no parent) and parent groups
445
- const topLevel = symbols.filter(s => !s.parent);
446
- const byParent = new Map<string, RenderedSymbol[]>();
447
- for (const s of symbols.filter(s => s.parent)) {
448
- const arr = byParent.get(s.parent!) || [];
449
- arr.push(s);
450
- byParent.set(s.parent!, arr);
1255
+ if (renderedSymbol.parent) {
1256
+ return `${renderedSymbol.parent}.${fileBaseName}`;
451
1257
  }
452
1258
 
453
- // render top-level symbols
454
- for (const s of topLevel) {
455
- lines.push(`### ${s.name}`);
456
- if (s.signature) lines.push('', '`' + s.signature + '`');
457
- if (s.jsdoc.description) lines.push('', s.jsdoc.description);
458
- if (s.jsdoc.deprecated) lines.push('', `**Deprecated:** ${s.jsdoc.deprecated}`);
459
- if (s.jsdoc.params?.length) {
460
- lines.push('', 'Parameters:');
461
- for (const p of s.jsdoc.params) lines.push(`- \`${p.name}\`${p.doc ? ' - ' + p.doc : ''}`);
462
- }
463
- if (s.jsdoc.returns) lines.push('', `Returns: ${s.jsdoc.returns}`);
464
- lines.push('');
465
-
466
- // if this top-level has grouped children (byParent keyed by this name), render them nested
467
- const children = byParent.get(s.name);
468
- if (children) {
469
- children.sort((a, b) => a.name.localeCompare(b.name));
470
- for (const c of children) {
471
- lines.push(`#### ${c.name}`);
472
- if (c.signature) lines.push('', '`' + c.signature + '`');
473
- if (c.jsdoc.description) lines.push('', c.jsdoc.description);
474
- if (c.jsdoc.deprecated) lines.push('', `**Deprecated:** ${c.jsdoc.deprecated}`);
475
- if (c.jsdoc.params?.length) {
476
- lines.push('', 'Parameters:');
477
- for (const p of c.jsdoc.params) lines.push(`- \`${p.name}\`${p.doc ? ' - ' + p.doc : ''}`);
478
- }
479
- if (c.jsdoc.returns) lines.push('', `Returns: ${c.jsdoc.returns}`);
480
- lines.push('');
481
- }
482
- }
1259
+ if (renderedSymbol.signature) {
1260
+ return `${fileBaseName}${renderedSymbol.signature.split(')')[0]})`;
483
1261
  }
484
1262
 
485
- // Render any parent groups that didn't have a top-level parent symbol (e.g., object literal properties grouped under exported var)
486
- for (const [parentName, group] of byParent) {
487
- if (topLevel.some(t => t.name === parentName)) continue; // already rendered under its parent item
488
- lines.push(`### ${parentName}`, '');
489
- group.sort((a, b) => a.name.localeCompare(b.name));
490
- for (const c of group) {
491
- lines.push(`#### ${c.name}`);
492
- if (c.signature) lines.push('', '`' + c.signature + '`');
493
- if (c.jsdoc.description) lines.push('', c.jsdoc.description);
494
- if (c.jsdoc.deprecated) lines.push('', `**Deprecated:** ${c.jsdoc.deprecated}`);
495
- if (c.jsdoc.params?.length) {
496
- lines.push('', 'Parameters:');
497
- for (const p of c.jsdoc.params) lines.push(`- \`${p.name}\`${p.doc ? ' - ' + p.doc : ''}`);
1263
+ return fileBaseName;
1264
+ }
1265
+
1266
+ return name.replace(/^function\s+/, '').replace(/\(\)$/, '');
1267
+ }
1268
+
1269
+ /**
1270
+ * Builds the README markdown for one directory.
1271
+ *
1272
+ * @param relativeDirectory - Directory path relative to the target root.
1273
+ * @param fileSymbolMap - Symbols grouped by file within the directory.
1274
+ * @param sourceDir - Absolute source root.
1275
+ * @returns Markdown README content.
1276
+ */
1277
+ function buildDirectoryReadme(
1278
+ relativeDirectory: string,
1279
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
1280
+ sourceDir: string,
1281
+ ): string {
1282
+ const title = (relativeDirectory || path.basename(sourceDir)).replace(
1283
+ /\\/g,
1284
+ '/',
1285
+ );
1286
+ const lines = [`# ${title}`, ''];
1287
+
1288
+ const sortedFiles = [...fileSymbolMap.keys()].toSorted(
1289
+ (leftFile, rightFile) => {
1290
+ const leftRank = rankFileForDirectoryReadme(leftFile, fileSymbolMap);
1291
+ const rightRank = rankFileForDirectoryReadme(rightFile, fileSymbolMap);
1292
+
1293
+ for (
1294
+ let index = 0;
1295
+ index < Math.max(leftRank.length, rightRank.length);
1296
+ index += 1
1297
+ ) {
1298
+ const leftValue = leftRank[index] ?? 0;
1299
+ const rightValue = rightRank[index] ?? 0;
1300
+ if (leftValue !== rightValue) {
1301
+ return leftValue - rightValue;
498
1302
  }
499
- if (c.jsdoc.returns) lines.push('', `Returns: ${c.jsdoc.returns}`);
500
- lines.push('');
501
1303
  }
1304
+
1305
+ return leftFile.localeCompare(rightFile);
1306
+ },
1307
+ );
1308
+
1309
+ for (const filePath of sortedFiles) {
1310
+ renderFileReadmeSection(lines, filePath, fileSymbolMap, sourceDir);
1311
+ }
1312
+
1313
+ return `${lines.join('\n').trim()}\n`;
1314
+ }
1315
+
1316
+ /**
1317
+ * Renders the README section for a single file.
1318
+ *
1319
+ * @param lines - Output line buffer.
1320
+ * @param filePath - Absolute source file path.
1321
+ * @param fileSymbolMap - Symbols grouped by file within the directory.
1322
+ * @param sourceDir - Absolute source root.
1323
+ * @returns Nothing.
1324
+ */
1325
+ function renderFileReadmeSection(
1326
+ lines: string[],
1327
+ filePath: string,
1328
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
1329
+ sourceDir: string,
1330
+ ): void {
1331
+ const relativeFilePath = path
1332
+ .relative(sourceDir, filePath)
1333
+ .replace(/\\/g, '/');
1334
+ lines.push(`## ${relativeFilePath}`, '');
1335
+
1336
+ const fileBaseName = path.basename(filePath, '.ts');
1337
+ const sortedSymbols = (fileSymbolMap.get(filePath) ?? []).toSorted(
1338
+ (left, right) => {
1339
+ const leftIsPrimary = !left.parent && left.name === fileBaseName;
1340
+ const rightIsPrimary = !right.parent && right.name === fileBaseName;
1341
+ if (leftIsPrimary !== rightIsPrimary) {
1342
+ return leftIsPrimary ? -1 : 1;
1343
+ }
1344
+
1345
+ return (
1346
+ (left.parent || left.name).localeCompare(right.parent || right.name) ||
1347
+ left.name.localeCompare(right.name)
1348
+ );
1349
+ },
1350
+ );
1351
+
1352
+ const fileSummaryIndex = sortedSymbols.findIndex(
1353
+ (symbol) =>
1354
+ symbol.kind === 'File' && symbol.name === FILE_SUMMARY_SYMBOL_NAME,
1355
+ );
1356
+ if (fileSummaryIndex >= 0) {
1357
+ const [fileSummarySymbol] = sortedSymbols.splice(fileSummaryIndex, 1);
1358
+ if (fileSummarySymbol.jsdoc.description) {
1359
+ lines.push(fileSummarySymbol.jsdoc.description, '');
502
1360
  }
503
1361
  }
504
- return lines.join('\n').trim() + '\n';
1362
+
1363
+ const topLevelSymbols = sortedSymbols.filter((symbol) => !symbol.parent);
1364
+ const symbolsByParent = groupSymbolsByParent(sortedSymbols);
1365
+
1366
+ for (const topLevelSymbol of topLevelSymbols) {
1367
+ renderSymbolBlock(lines, topLevelSymbol, 3);
1368
+
1369
+ const childSymbols = symbolsByParent.get(topLevelSymbol.name);
1370
+ if (!childSymbols) {
1371
+ continue;
1372
+ }
1373
+
1374
+ childSymbols.sort((left, right) => left.name.localeCompare(right.name));
1375
+ for (const childSymbol of childSymbols) {
1376
+ renderSymbolBlock(lines, childSymbol, 4);
1377
+ }
1378
+ }
1379
+
1380
+ for (const [parentName, childSymbols] of symbolsByParent) {
1381
+ if (topLevelSymbols.some((symbol) => symbol.name === parentName)) {
1382
+ continue;
1383
+ }
1384
+
1385
+ lines.push(`### ${parentName}`, '');
1386
+ childSymbols.sort((left, right) => left.name.localeCompare(right.name));
1387
+ for (const childSymbol of childSymbols) {
1388
+ renderSymbolBlock(lines, childSymbol, 4);
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * Groups symbols that have parent names.
1395
+ *
1396
+ * @param renderedSymbols - Symbols for one file.
1397
+ * @returns Parent-keyed symbol map.
1398
+ */
1399
+ function groupSymbolsByParent(
1400
+ renderedSymbols: readonly RenderedSymbol[],
1401
+ ): Map<string, RenderedSymbol[]> {
1402
+ const symbolsByParent = new Map<string, RenderedSymbol[]>();
1403
+
1404
+ for (const renderedSymbol of renderedSymbols.filter(
1405
+ (symbol) => symbol.parent,
1406
+ )) {
1407
+ const childSymbols = symbolsByParent.get(renderedSymbol.parent!) ?? [];
1408
+ childSymbols.push(renderedSymbol);
1409
+ symbolsByParent.set(renderedSymbol.parent!, childSymbols);
1410
+ }
1411
+
1412
+ return symbolsByParent;
505
1413
  }
506
1414
 
507
- function rankFileForDirectoryReadme(filePath: string, fileMap: Map<string, RenderedSymbol[]>) {
1415
+ /**
1416
+ * Renders one symbol block into the README line buffer.
1417
+ *
1418
+ * @param lines - Output line buffer.
1419
+ * @param renderedSymbol - Symbol to render.
1420
+ * @param headingLevel - Markdown heading level.
1421
+ * @returns Nothing.
1422
+ */
1423
+ function renderSymbolBlock(
1424
+ lines: string[],
1425
+ renderedSymbol: RenderedSymbol,
1426
+ headingLevel: number,
1427
+ ): void {
1428
+ lines.push(`${'#'.repeat(headingLevel)} ${renderedSymbol.name}`);
1429
+
1430
+ if (renderedSymbol.signature) {
1431
+ lines.push('', `\`${renderedSymbol.signature}\``);
1432
+ }
1433
+
1434
+ if (renderedSymbol.jsdoc.description) {
1435
+ lines.push('', renderedSymbol.jsdoc.description);
1436
+ }
1437
+
1438
+ if (renderedSymbol.jsdoc.deprecated) {
1439
+ lines.push('', `**Deprecated:** ${renderedSymbol.jsdoc.deprecated}`);
1440
+ }
1441
+
1442
+ if (renderedSymbol.jsdoc.params?.length) {
1443
+ lines.push('', 'Parameters:');
1444
+ for (const parameter of renderedSymbol.jsdoc.params) {
1445
+ lines.push(
1446
+ `- \`${parameter.name}\`${parameter.doc ? ` - ${parameter.doc}` : ''}`,
1447
+ );
1448
+ }
1449
+ }
1450
+
1451
+ if (renderedSymbol.jsdoc.returns) {
1452
+ lines.push('', `Returns: ${renderedSymbol.jsdoc.returns}`);
1453
+ }
1454
+
1455
+ lines.push('');
1456
+ }
1457
+
1458
+ /**
1459
+ * Ranks files within a directory README so entrypoints appear before helpers.
1460
+ *
1461
+ * @param filePath - Absolute source file path.
1462
+ * @param fileSymbolMap - Symbols grouped by file within the directory.
1463
+ * @returns Sort rank tuple.
1464
+ */
1465
+ function rankFileForDirectoryReadme(
1466
+ filePath: string,
1467
+ fileSymbolMap: Map<string, RenderedSymbol[]>,
1468
+ ): number[] {
508
1469
  const fileName = path.basename(filePath).toLowerCase();
509
- const symbols = fileMap.get(filePath) ?? [];
1470
+ const renderedSymbols = fileSymbolMap.get(filePath) ?? [];
1471
+
1472
+ const hasFileSummary = renderedSymbols.some((renderedSymbol) => {
1473
+ if (
1474
+ renderedSymbol.kind !== 'File' ||
1475
+ renderedSymbol.name !== FILE_SUMMARY_SYMBOL_NAME
1476
+ ) {
1477
+ return false;
1478
+ }
510
1479
 
511
- const hasFileSummary = symbols.some(s => {
512
- if (s.kind !== 'File' || s.name !== '__file_summary__') return false;
513
- return Boolean(s.jsdoc.description?.trim());
1480
+ return Boolean(renderedSymbol.jsdoc.description?.trim());
514
1481
  });
515
1482
 
516
1483
  const isIndexFile = fileName === 'index.ts';
517
- const isTypesFile = fileName.includes('.types.') || fileName.endsWith('.types.ts');
518
- const isUtilityLike = /(\.utils\.|\.export-|\.import-|\.internal\.|\.private\.)/i.test(fileName);
1484
+ const isTypesFile =
1485
+ fileName.includes('.types.') || fileName.endsWith('.types.ts');
1486
+ const isUtilityLike =
1487
+ /(\.utils\.|\.export-|\.import-|\.internal\.|\.private\.)/i.test(fileName);
519
1488
  const isEntrypointLike = !isUtilityLike;
520
1489
 
521
- // Rank tuple (lower is earlier):
522
- // 1) index.ts first
523
- // 2) files with file-level docs next (directory overview / entrypoints)
524
- // 3) entrypoint-like (non-utils) before helpers
525
- // 4) types early (after entrypoints)
526
- // 5) shorter file names tend to be higher-level modules
527
1490
  return [
528
1491
  isIndexFile ? 0 : 1,
529
1492
  hasFileSummary ? 0 : 1,
@@ -533,25 +1496,28 @@ function rankFileForDirectoryReadme(filePath: string, fileMap: Map<string, Rende
533
1496
  ];
534
1497
  }
535
1498
 
536
- async function writeIfChanged(file: string, content: string) {
537
- if (await fs.pathExists(file)) {
538
- const prev = await fs.readFile(file, 'utf8');
539
- if (prev === content) return;
1499
+ /**
1500
+ * Writes a file only when its content changed.
1501
+ *
1502
+ * @param filePath - Output file path.
1503
+ * @param content - Desired file content.
1504
+ * @returns Nothing.
1505
+ */
1506
+ async function writeFileIfChanged(
1507
+ filePath: string,
1508
+ content: string,
1509
+ ): Promise<void> {
1510
+ if (await fs.pathExists(filePath)) {
1511
+ const previousContent = await fs.readFile(filePath, 'utf8');
1512
+ if (previousContent === content) {
1513
+ return;
1514
+ }
540
1515
  }
541
- await fs.writeFile(file, content, 'utf8');
542
- }
543
1516
 
544
- // Write README into src folders, but avoid overwriting a manual README unless previously generated.
545
- async function emitSourceReadme(file: string, content: string) {
546
- // Always overwrite to keep docs in sync (educational repo preference: no banner / frictionless reading)
547
- if (await fs.pathExists(file)) {
548
- const prev = await fs.readFile(file, 'utf8');
549
- if (prev === content) return;
550
- }
551
- await fs.writeFile(file, content, 'utf8');
1517
+ await fs.writeFile(filePath, content, 'utf8');
552
1518
  }
553
1519
 
554
- main().catch(e => {
555
- console.error(e);
1520
+ main().catch((error: unknown) => {
1521
+ console.error(error);
556
1522
  process.exit(1);
557
1523
  });