@rarusoft/dendrite-wiki 0.1.0-alpha.0

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 (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,552 @@
1
+ /**
2
+ * Orchestrator for API reference generation.
3
+ *
4
+ * Top-level entry point invoked by the CLI (`dendrite-wiki docs:api`), the MCP tool
5
+ * (`wiki_generate_api_reference`), and the wiki refresh pipeline (`refreshGeneratedWikiDocs`
6
+ * in `./generated-docs.ts`). Picks a `LanguageExtractor` via registry-ordered `detect()`,
7
+ * walks the project's sources, runs a two-pass extract-then-render so cross-file
8
+ * `{@link}` resolution sees every symbol before any page renders, writes one markdown
9
+ * page per source file under `docs/wiki/api/`, and tracks ownership via a manifest at
10
+ * `docs/public/api-reference-manifest.json`.
11
+ *
12
+ * Determinism rules are load-bearing:
13
+ * - Per-page markdown contains no clock-derived fields. Idempotent runs produce zero diffs.
14
+ * - The manifest's top-level `generatedAt` is the only timestamp the orchestrator stamps.
15
+ * - Orphan cleanup only ever deletes slugs present in the *previous* manifest under the
16
+ * `api/` prefix; we never delete a page that was not previously claimed by this generator.
17
+ *
18
+ * Phases A1–A7 of the API reference roadmap progressively built this surface.
19
+ */
20
+
21
+ import { createHash } from 'node:crypto';
22
+ import { promises as fs } from 'node:fs';
23
+ import path from 'node:path';
24
+ import type { LanguageExtractor } from './api-extractor/language-extractor.js';
25
+ import { pythonExtractor } from './api-extractor/python-extractor.js';
26
+ import { anchorFor, renderApiPage, type LinkResolution, type LinkResolver } from './api-extractor/render.js';
27
+ import { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
28
+ import type { ApiFileReference } from './api-extractor/types.js';
29
+ import { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
30
+ import { type WalkOptions } from './api-extractor/walk.js';
31
+
32
+ // Built-in language extractors, registration-ordered. The first whose `detect()` returns
33
+ // true claims the project. Python is registered ahead of TypeScript so a project that
34
+ // mixes a `pyproject.toml` with a `package.json` (e.g., a Python tool with Node-side
35
+ // scripting) gets its Python source documented; pure-TS projects with no Python signals
36
+ // still flow cleanly through to typeScriptExtractor. The tree-sitter extractor handles
37
+ // the long-tail languages (Rust today; Go/Java/Ruby/C/C++/PHP next) and is registered
38
+ // FIRST so a Rust+Node hybrid lands on Rust output rather than the TS extractor's
39
+ // high-recall claim. Pure-TS projects fall through to typeScriptExtractor as before.
40
+ const builtInLanguageExtractors: readonly LanguageExtractor[] = [
41
+ treeSitterExtractor,
42
+ pythonExtractor,
43
+ typeScriptExtractor
44
+ ];
45
+
46
+ export type { LanguageExtractor } from './api-extractor/language-extractor.js';
47
+ export { pythonExtractor } from './api-extractor/python-extractor.js';
48
+ export { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
49
+ export { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
50
+
51
+ const API_PAGES_ROOT = path.join('docs', 'wiki', 'api');
52
+ const MANIFEST_RELATIVE_PATH = path.join('docs', 'public', 'api-reference-manifest.json');
53
+ const MANIFEST_SCHEMA_VERSION = 1;
54
+ const MANIFEST_OWNED_PREFIX = 'api/';
55
+
56
+ export interface ApiReferenceWarning {
57
+ kind: 'low-coverage' | 'extraction-error' | 'unresolved-link' | 'ambiguous-link';
58
+ message: string;
59
+ sourceFile?: string;
60
+ }
61
+
62
+ export interface ApiReferenceSourceSkip {
63
+ path: string;
64
+ reason: string;
65
+ }
66
+
67
+ export interface ApiReferenceManifestEntry {
68
+ slug: string;
69
+ sourceFile: string;
70
+ symbolCount: number;
71
+ contentHash: string;
72
+ }
73
+
74
+ export interface ApiReferenceManifest {
75
+ schemaVersion: number;
76
+ generatedAt: string;
77
+ pages: ApiReferenceManifestEntry[];
78
+ }
79
+
80
+ export interface ApiReferenceResult {
81
+ pagesWritten: number;
82
+ pagesChanged: string[];
83
+ pagesDeleted: string[];
84
+ warnings: ApiReferenceWarning[];
85
+ sourcesScanned: number;
86
+ sourcesSkipped: ApiReferenceSourceSkip[];
87
+ manifest: ApiReferenceManifest;
88
+ }
89
+
90
+ export interface RefreshOptions {
91
+ rootDir?: string;
92
+ walkOptions?: WalkOptions;
93
+ dryRun?: boolean;
94
+ // Fixed timestamp for deterministic test runs. If omitted, `new Date().toISOString()` is used.
95
+ now?: string;
96
+ // Override the built-in language extractor registry. Mostly useful for tests; production
97
+ // callers leave this undefined and pick up the default `[typeScriptExtractor]` list.
98
+ extractors?: readonly LanguageExtractor[];
99
+ }
100
+
101
+ export async function refreshApiReference(options: RefreshOptions = {}): Promise<ApiReferenceResult> {
102
+ const rootDir = options.rootDir ? path.resolve(options.rootDir) : process.cwd();
103
+ const dryRun = options.dryRun ?? false;
104
+ const generatedAt = options.now ?? new Date().toISOString();
105
+
106
+ // Pluggability dispatch: pick the first registered extractor whose detect() matches the
107
+ // project. When none match (e.g., a non-TS project with no recognizable signals), return
108
+ // an empty result — the same behavior as the previous "walk found 0 sources" path.
109
+ const extractors = options.extractors ?? builtInLanguageExtractors;
110
+ let activeExtractor: LanguageExtractor | undefined;
111
+ for (const candidate of extractors) {
112
+ if (await candidate.detect(rootDir)) {
113
+ activeExtractor = candidate;
114
+ break;
115
+ }
116
+ }
117
+
118
+ const previousManifest = await readManifest(rootDir);
119
+ const previousBySlug = new Map(previousManifest.pages.map((entry) => [entry.slug, entry]));
120
+
121
+ if (!activeExtractor) {
122
+ // No extractor claimed this project — nothing to scan. Still produce a valid manifest
123
+ // (an empty one) and run the orphan-cleanup pass against the previous manifest so a
124
+ // project that switches stacks doesn't strand stale pages.
125
+ const orphanSlugs = previousManifest.pages.map((entry) => entry.slug);
126
+ const newManifest: ApiReferenceManifest = {
127
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
128
+ generatedAt,
129
+ pages: []
130
+ };
131
+ if (!dryRun && orphanSlugs.length > 0) {
132
+ for (const slug of orphanSlugs) {
133
+ const orphanPath = resolveSafeOrphanPath(rootDir, slug);
134
+ if (!orphanPath) continue;
135
+ await fs.rm(orphanPath, { force: true });
136
+ }
137
+ await writeManifest(rootDir, newManifest);
138
+ }
139
+ return {
140
+ pagesWritten: 0,
141
+ pagesChanged: [],
142
+ pagesDeleted: orphanSlugs,
143
+ warnings: [],
144
+ sourcesScanned: 0,
145
+ sourcesSkipped: [],
146
+ manifest: newManifest
147
+ };
148
+ }
149
+
150
+ const sources = await activeExtractor.walk(rootDir, options.walkOptions);
151
+ // Build a per-project source-link resolver. When the project has a detectable GitHub
152
+ // repository URL in package.json, source links go to https://github.com/... so they
153
+ // work in VitePress, on GitHub web, and in IDEs alike. When no repository URL is
154
+ // detectable, the resolver returns null → the renderer emits plain text (no link)
155
+ // instead of a relative path that 404s in the browser-served VitePress site.
156
+ const sourceLinkResolver = await buildSourceLinkResolver(rootDir);
157
+
158
+ const warnings: ApiReferenceWarning[] = [];
159
+ const sourcesSkipped: ApiReferenceSourceSkip[] = [];
160
+
161
+ // Pass 1: extract every source file. We collect successful refs into `pendingRefs` so the
162
+ // link index (built next) sees the full symbol universe before we render anything.
163
+ interface PendingRef {
164
+ ref: ApiFileReference;
165
+ slug: string;
166
+ pageRelativePath: string;
167
+ sourceLinkBase: string;
168
+ }
169
+ const pendingRefs: PendingRef[] = [];
170
+
171
+ for (const source of sources) {
172
+ let ref: ApiFileReference;
173
+ try {
174
+ ref = await activeExtractor.extract(source, { rootDir });
175
+ } catch (error) {
176
+ warnings.push({
177
+ kind: 'extraction-error',
178
+ message: error instanceof Error ? error.message : String(error),
179
+ sourceFile: source
180
+ });
181
+ sourcesSkipped.push({ path: source, reason: 'extraction-error' });
182
+ continue;
183
+ }
184
+
185
+ if (ref.symbols.length === 0) {
186
+ sourcesSkipped.push({ path: source, reason: 'no-exports' });
187
+ continue;
188
+ }
189
+
190
+ const slug = ref.moduleSlug;
191
+ if (!slug.startsWith(MANIFEST_OWNED_PREFIX)) {
192
+ throw new Error(
193
+ `refreshApiReference: derived slug "${slug}" does not start with required prefix "${MANIFEST_OWNED_PREFIX}" — refusing to claim`
194
+ );
195
+ }
196
+
197
+ const pageRelativePath = `${path.posix.join(API_PAGES_ROOT.replace(/\\/g, '/'), slug.slice(MANIFEST_OWNED_PREFIX.length))}.md`;
198
+ const sourceLinkBase = computeSourceLinkBase(pageRelativePath);
199
+
200
+ if (countDocumented(ref) === 0) {
201
+ warnings.push({
202
+ kind: 'low-coverage',
203
+ message: `${source} has ${ref.symbols.length} export(s) but 0 with doc comments`,
204
+ sourceFile: source
205
+ });
206
+ }
207
+
208
+ pendingRefs.push({ ref, slug, pageRelativePath, sourceLinkBase });
209
+ }
210
+
211
+ // Pass 2: build the cross-file link index and render with a resolver per page. The
212
+ // resolver pushes link warnings back into the shared `warnings` list so unresolved or
213
+ // ambiguous references surface in the result.
214
+ const linkIndex = buildLinkIndex(pendingRefs.map((entry) => ({ slug: entry.slug, ref: entry.ref })));
215
+
216
+ const renderedPages: { slug: string; absolutePath: string; body: string; entry: ApiReferenceManifestEntry }[] = [];
217
+
218
+ for (const pending of pendingRefs) {
219
+ const resolveLink: LinkResolver = (target, displayText) =>
220
+ resolveCrossReference({
221
+ target,
222
+ displayText,
223
+ currentSlug: pending.slug,
224
+ currentSourceFile: pending.ref.sourcePath,
225
+ linkIndex,
226
+ warnings
227
+ });
228
+
229
+ const body = renderApiPage(pending.ref, {
230
+ sourceLinkBase: pending.sourceLinkBase,
231
+ sourceLinkResolver,
232
+ resolveLink
233
+ });
234
+ const finalBody = ensureTrailingNewline(body);
235
+ const contentHash = sha256(finalBody);
236
+
237
+ renderedPages.push({
238
+ slug: pending.slug,
239
+ absolutePath: path.resolve(rootDir, pending.pageRelativePath),
240
+ body: finalBody,
241
+ entry: {
242
+ slug: pending.slug,
243
+ sourceFile: pending.ref.sourcePath,
244
+ symbolCount: pending.ref.symbols.length,
245
+ contentHash
246
+ }
247
+ });
248
+ }
249
+
250
+ renderedPages.sort((a, b) => a.slug.localeCompare(b.slug));
251
+
252
+ const newSlugs = new Set(renderedPages.map((page) => page.slug));
253
+ const orphanSlugs = previousManifest.pages
254
+ .map((entry) => entry.slug)
255
+ .filter((slug) => !newSlugs.has(slug));
256
+
257
+ for (const slug of orphanSlugs) {
258
+ // Defense-in-depth: validate every orphan slug now, before any I/O. If a corrupted
259
+ // manifest entry ever escaped the api/ tree (e.g., `api/../../etc/passwd`), this throw
260
+ // surfaces it loudly — and short-circuits before we touch the filesystem.
261
+ if (resolveSafeOrphanPath(rootDir, slug) === null) {
262
+ throw new Error(
263
+ `refreshApiReference: previous manifest contained unsafe slug "${slug}" — refusing to act on it`
264
+ );
265
+ }
266
+ }
267
+
268
+ const pagesChanged: string[] = [];
269
+ for (const page of renderedPages) {
270
+ const previous = previousBySlug.get(page.slug);
271
+ if (!previous || previous.contentHash !== page.entry.contentHash) {
272
+ pagesChanged.push(page.slug);
273
+ }
274
+ }
275
+
276
+ const pagesDeleted: string[] = [...orphanSlugs];
277
+
278
+ const newManifest: ApiReferenceManifest = {
279
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
280
+ generatedAt,
281
+ pages: renderedPages.map((page) => page.entry)
282
+ };
283
+
284
+ if (!dryRun) {
285
+ for (const page of renderedPages) {
286
+ await writeIfChanged(page.absolutePath, page.body);
287
+ }
288
+ for (const slug of orphanSlugs) {
289
+ // resolveSafeOrphanPath was already validated above; non-null is guaranteed.
290
+ const orphanPath = resolveSafeOrphanPath(rootDir, slug);
291
+ if (orphanPath) {
292
+ await fs.rm(orphanPath, { force: true });
293
+ }
294
+ }
295
+ await writeManifest(rootDir, newManifest);
296
+ }
297
+
298
+ return {
299
+ pagesWritten: renderedPages.length,
300
+ pagesChanged,
301
+ pagesDeleted,
302
+ warnings,
303
+ sourcesScanned: sources.length,
304
+ sourcesSkipped,
305
+ manifest: newManifest
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Builds a function that turns a (sourcePath, line) pair into a source-link URL the
311
+ * rendered API page can hyperlink to. Reads the project's `package.json` and inspects
312
+ * the `repository` field (handles both string and object forms, and the `git+` /
313
+ * `.git` decorations npm conventionally adds). When it finds a github.com URL, returns
314
+ * a resolver that emits full `https://github.com/<owner>/<repo>/blob/main/<path>#L<line>`
315
+ * URLs — those work in every viewing context (VitePress static site, GitHub web view,
316
+ * IDE markdown preview). When no GitHub URL is detected, returns a resolver that always
317
+ * returns null, signaling the renderer to emit plain-text source references rather than
318
+ * relative-path links that 404 in browser-served sites.
319
+ *
320
+ * Branch resolution: hardcoded to `main` for simplicity. Future enhancement could read
321
+ * `git rev-parse --abbrev-ref HEAD` for the actual default branch, or honor a
322
+ * `DENDRITE_API_REFERENCE_BRANCH` env var.
323
+ *
324
+ * Other git hosts (GitLab, Bitbucket, codeberg.org, sr.ht, self-hosted Gitea) are not
325
+ * yet detected; their projects fall through to plain-text source references. Adding
326
+ * support is a small follow-up — each host has a stable `<host>/<owner>/<repo>/blob/<branch>/<path>`
327
+ * URL pattern (or close variant) that can be detected the same way.
328
+ */
329
+ async function buildSourceLinkResolver(
330
+ rootDir: string
331
+ ): Promise<((sourcePath: string, line: number) => string | null) | undefined> {
332
+ let pkgRaw: string;
333
+ try {
334
+ pkgRaw = await fs.readFile(path.join(rootDir, 'package.json'), 'utf8');
335
+ } catch {
336
+ return () => null;
337
+ }
338
+ let pkg: { repository?: string | { url?: string } };
339
+ try {
340
+ pkg = JSON.parse(pkgRaw);
341
+ } catch {
342
+ return () => null;
343
+ }
344
+ const repoField = pkg.repository;
345
+ const repoUrl = typeof repoField === 'string' ? repoField : repoField?.url;
346
+ if (!repoUrl) return () => null;
347
+ // Strip the `git+` prefix npm adds and the `.git` suffix git remotes use, then look
348
+ // for github.com followed by `/<owner>/<repo>`. SSH-form `git@github.com:owner/repo`
349
+ // and HTTPS `https://github.com/owner/repo` both match.
350
+ const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
351
+ if (!match) return () => null;
352
+ const owner = match[1];
353
+ const repo = match[2];
354
+ const branch = process.env.DENDRITE_API_REFERENCE_BRANCH ?? 'main';
355
+ return (sourcePath: string, line: number) =>
356
+ `https://github.com/${owner}/${repo}/blob/${branch}/${sourcePath}#L${line}`;
357
+ }
358
+
359
+ /**
360
+ * Resolves the absolute on-disk path for an orphan slug, returning null when the slug is
361
+ * not owned by this generator OR when the resolved path escapes the API tree. Centralizes
362
+ * the path-traversal guard so both code paths in `refreshApiReference` use the same check.
363
+ *
364
+ * Why this matters: a corrupt manifest entry like `{"slug": "api/../../etc/passwd"}`
365
+ * passes a naive `slug.startsWith("api/")` check, but `path.posix.join("docs/wiki/api",
366
+ * "../../etc/passwd")` resolves outside the API tree. `fs.rm({ force: true })` would
367
+ * happily delete it. This guard short-circuits before any filesystem I/O.
368
+ */
369
+ function resolveSafeOrphanPath(rootDir: string, slug: string): string | null {
370
+ if (!slug.startsWith(MANIFEST_OWNED_PREFIX)) return null;
371
+ // Reject slugs carrying characters that have meaning to the filesystem layer regardless
372
+ // of platform: `\0` (POSIX path terminator), and `:` (Windows drive-letter separator,
373
+ // which would otherwise cause `path.resolve` to switch drives). Slugs from our own
374
+ // derivation never contain these; rejecting them here closes a malformed-manifest
375
+ // attack surface up front.
376
+ if (slug.includes('\0') || slug.includes(':')) return null;
377
+ const apiRootAbs = path.resolve(rootDir, API_PAGES_ROOT);
378
+ const candidate = path.resolve(apiRootAbs, `${slug.slice(MANIFEST_OWNED_PREFIX.length)}.md`);
379
+ const relativeFromApi = path.relative(apiRootAbs, candidate);
380
+ // Outside the API tree iff:
381
+ // - the path is absolute (e.g., a Windows drive-letter switch slipped past slug
382
+ // validation, or POSIX absolute path)
383
+ // - the FIRST path segment is exactly `..` (escapes upward by a directory).
384
+ // We compare segments rather than using `startsWith('..')` to avoid false-rejecting a
385
+ // file legitimately named e.g. `..config` inside the API tree.
386
+ if (path.isAbsolute(relativeFromApi)) return null;
387
+ const firstSegment = relativeFromApi.split(/[\\/]/)[0];
388
+ if (firstSegment === '..') return null;
389
+ return candidate;
390
+ }
391
+
392
+ async function readManifest(rootDir: string): Promise<ApiReferenceManifest> {
393
+ const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
394
+ try {
395
+ const raw = await fs.readFile(manifestPath, 'utf8');
396
+ const parsed = JSON.parse(raw) as ApiReferenceManifest;
397
+ if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
398
+ // Unknown schema — treat as empty so we regenerate cleanly. Human can resolve any
399
+ // resulting orphan churn via the next git diff.
400
+ return emptyManifest();
401
+ }
402
+ return parsed;
403
+ } catch (error) {
404
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
405
+ return emptyManifest();
406
+ }
407
+ throw error;
408
+ }
409
+ }
410
+
411
+ async function writeManifest(rootDir: string, manifest: ApiReferenceManifest): Promise<void> {
412
+ const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
413
+ const sortedManifest: ApiReferenceManifest = {
414
+ ...manifest,
415
+ pages: [...manifest.pages].sort((a, b) => a.slug.localeCompare(b.slug))
416
+ };
417
+ const body = `${JSON.stringify(sortedManifest, null, 2)}\n`;
418
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
419
+ await fs.writeFile(manifestPath, body, 'utf8');
420
+ }
421
+
422
+ function emptyManifest(): ApiReferenceManifest {
423
+ return { schemaVersion: MANIFEST_SCHEMA_VERSION, generatedAt: '', pages: [] };
424
+ }
425
+
426
+ async function writeIfChanged(absolutePath: string, body: string): Promise<void> {
427
+ let existing: string | undefined;
428
+ try {
429
+ existing = await fs.readFile(absolutePath, 'utf8');
430
+ } catch (error) {
431
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
432
+ throw error;
433
+ }
434
+ }
435
+ if (existing === body) {
436
+ return;
437
+ }
438
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
439
+ await fs.writeFile(absolutePath, body, 'utf8');
440
+ }
441
+
442
+ function sha256(body: string): string {
443
+ return createHash('sha256').update(body).digest('hex');
444
+ }
445
+
446
+ function ensureTrailingNewline(body: string): string {
447
+ return body.endsWith('\n') ? body : `${body}\n`;
448
+ }
449
+
450
+ function countDocumented(ref: { symbols: { docComment: string | null }[] }): number {
451
+ return ref.symbols.filter((symbol) => symbol.docComment !== null && symbol.docComment.trim().length > 0).length;
452
+ }
453
+
454
+ function computeSourceLinkBase(pageRelativePath: string): string {
455
+ // Page lives at e.g. `docs/wiki/api/wiki/i18n.md`. To reach `src/wiki/i18n.ts` from the page,
456
+ // we go up by the page's directory depth from the project root.
457
+ const directoryDepth = pageRelativePath.replace(/\\/g, '/').split('/').length - 1;
458
+ return Array.from({ length: directoryDepth }, () => '..').join('/');
459
+ }
460
+
461
+ interface LinkIndexEntry {
462
+ slug: string;
463
+ anchor: string;
464
+ sourceFile: string;
465
+ }
466
+
467
+ type LinkIndex = Map<string, LinkIndexEntry[]>;
468
+
469
+ function buildLinkIndex(entries: { slug: string; ref: ApiFileReference }[]): LinkIndex {
470
+ const index: LinkIndex = new Map();
471
+ for (const { slug, ref } of entries) {
472
+ for (const symbol of ref.symbols) {
473
+ const anchor = anchorFor(symbol.name);
474
+ const bucket = index.get(symbol.name);
475
+ const entry: LinkIndexEntry = { slug, anchor, sourceFile: ref.sourcePath };
476
+ if (bucket) {
477
+ bucket.push(entry);
478
+ } else {
479
+ index.set(symbol.name, [entry]);
480
+ }
481
+ }
482
+ }
483
+ return index;
484
+ }
485
+
486
+ interface ResolveCrossReferenceArgs {
487
+ target: string;
488
+ displayText: string | undefined;
489
+ currentSlug: string;
490
+ currentSourceFile: string;
491
+ linkIndex: LinkIndex;
492
+ warnings: ApiReferenceWarning[];
493
+ }
494
+
495
+ function resolveCrossReference(args: ResolveCrossReferenceArgs): LinkResolution {
496
+ // Allow `Foo.bar` qualified forms by resolving against the head and keeping the full
497
+ // expression as the default display label (so the dotted name remains visible to readers).
498
+ const headTarget = args.target.split('.')[0];
499
+ const display = args.displayText ?? args.target;
500
+
501
+ const matches = args.linkIndex.get(headTarget) ?? [];
502
+ if (matches.length === 0) {
503
+ args.warnings.push({
504
+ kind: 'unresolved-link',
505
+ message: `cannot resolve {@link ${args.target}} in ${args.currentSourceFile}`,
506
+ sourceFile: args.currentSourceFile
507
+ });
508
+ return { url: null, display };
509
+ }
510
+
511
+ let chosen: LinkIndexEntry | undefined;
512
+ if (matches.length === 1) {
513
+ chosen = matches[0];
514
+ } else {
515
+ // Disambiguate by shared module path prefix: prefer a match whose slug starts with the
516
+ // current page's slug-directory.
517
+ const currentDir = args.currentSlug.includes('/')
518
+ ? args.currentSlug.slice(0, args.currentSlug.lastIndexOf('/'))
519
+ : '';
520
+ if (currentDir) {
521
+ const sameModule = matches.filter((entry) => entry.slug.startsWith(`${currentDir}/`) || entry.slug === currentDir);
522
+ if (sameModule.length === 1) {
523
+ chosen = sameModule[0];
524
+ }
525
+ }
526
+ if (!chosen) {
527
+ args.warnings.push({
528
+ kind: 'ambiguous-link',
529
+ message: `{@link ${args.target}} in ${args.currentSourceFile} is ambiguous: ${matches.map((entry) => entry.slug).join(', ')}`,
530
+ sourceFile: args.currentSourceFile
531
+ });
532
+ return { url: null, display, comment: `ambiguous link: ${args.target}` };
533
+ }
534
+ }
535
+
536
+ const url = chosen.slug === args.currentSlug
537
+ ? `#${chosen.anchor}`
538
+ : `${relativePagePath(args.currentSlug, chosen.slug)}#${chosen.anchor}`;
539
+ return { url, display };
540
+ }
541
+
542
+ function relativePagePath(currentSlug: string, targetSlug: string): string {
543
+ const currentDir = currentSlug.includes('/') ? currentSlug.slice(0, currentSlug.lastIndexOf('/')) : '';
544
+ const targetPath = `${targetSlug}.md`;
545
+ const rel = path.posix.relative(currentDir, targetPath);
546
+ // path.posix.relative returns 'foo.md' for sibling pages. We want './foo.md' so the link
547
+ // is unambiguous in markdown renderers that distinguish relative-from-here vs. anchor-only.
548
+ if (!rel.startsWith('.')) {
549
+ return `./${rel}`;
550
+ }
551
+ return rel;
552
+ }