@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,11 @@
1
+ /**
2
+ * Shared types for the API reference extractor.
3
+ *
4
+ * Defines `ApiSymbol`, `ApiDocTag`, and `ApiFileReference` — the structured shape every
5
+ * `LanguageExtractor` must produce. The renderer in `./render.ts` and the orchestrator in
6
+ * `../api-reference.ts` consume this shape; the TypeScript implementation in
7
+ * `./extract.ts` and `./typescript-extractor.ts` produces it. Future Python/Rust/Go
8
+ * extractors will produce the same shape, which is what makes language pluggability work
9
+ * without orchestrator changes. Phase A1 of the API reference roadmap defines the contract.
10
+ */
11
+ export {};
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The built-in TypeScript `LanguageExtractor`.
3
+ *
4
+ * Thin adapter that wraps `extractApiFileReference` (from `./extract.ts`) and
5
+ * `walkProjectSources` (from `./walk.ts`) behind the language-agnostic interface, so the
6
+ * orchestrator's dispatch loop is uniform across languages. `detect()` is intentionally
7
+ * high-recall: returns true on `tsconfig.json`, `package.json`, OR a bare `src/` directory
8
+ * — any of those is a strong "this is a Node/TypeScript project" signal. When future
9
+ * extractors are added, registration order in `../api-reference.ts` decides which one
10
+ * claims a project where multiple `detect()` would match.
11
+ */
12
+ import { promises as fs } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { extractApiFileReference } from './extract.js';
15
+ import { walkProjectSources } from './walk.js';
16
+ async function exists(filePath) {
17
+ try {
18
+ await fs.access(filePath);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export const typeScriptExtractor = {
26
+ id: 'typescript',
27
+ async detect(rootDir) {
28
+ // High-recall detection: any of the conventional Node/TS signals counts. For projects
29
+ // that have just `src/` with .ts files (e.g., test fixtures) this still resolves true.
30
+ // When a second language extractor is added, its detect() runs in registration order,
31
+ // so the orchestrator can prefer (say) Python over TypeScript by registering python
32
+ // first.
33
+ if (await exists(path.join(rootDir, 'tsconfig.json'))) {
34
+ return true;
35
+ }
36
+ if (await exists(path.join(rootDir, 'package.json'))) {
37
+ return true;
38
+ }
39
+ if (await exists(path.join(rootDir, 'src'))) {
40
+ return true;
41
+ }
42
+ return false;
43
+ },
44
+ async walk(rootDir, options) {
45
+ return walkProjectSources(rootDir, options);
46
+ },
47
+ async extract(sourcePath, options) {
48
+ return extractApiFileReference(sourcePath, { rootDir: options?.rootDir });
49
+ }
50
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Walk a project directory and return source paths the API reference generator should
3
+ * extract — project-relative, forward slashes, alphabetically sorted.
4
+ *
5
+ * Pure Node 20+ — no glob library. The matcher is a small custom converter from glob to
6
+ * regex covering double-star, single-star, single-char, and literal segments. That covers
7
+ * the patterns the generator's defaults pass in: source globs under the root source tree
8
+ * plus workspace package source trees, test-file exclusions, internal-convention directory
9
+ * exclusions, and `node_modules` pruning.
10
+ *
11
+ * A second filter respects file-level `@internal` JSDoc on the source itself: when
12
+ * `respectInternalConvention` is true (default), each candidate's first 2KB is read and
13
+ * the file is skipped if a top-of-file JSDoc block contains an `@internal` tag. That
14
+ * mirrors how individual symbols are filtered in `./extract.ts` and lets a whole module
15
+ * opt out of the API reference without moving it into an `internal/` directory.
16
+ */
17
+ import { promises as fs } from 'node:fs';
18
+ import path from 'node:path';
19
+ // TypeScript's modern file extensions: `.ts` is the canonical, `.tsx` covers JSX/React,
20
+ // `.cts`/`.mts` are the ESM-compat node variants. The slug regex in extract.ts already
21
+ // strips all four; the walker now lists them explicitly because brace expansion isn't
22
+ // supported in the matcher (see `toMatcher`).
23
+ const DEFAULT_INCLUDE = [
24
+ 'src/**/*.ts',
25
+ 'src/**/*.tsx',
26
+ 'src/**/*.cts',
27
+ 'src/**/*.mts',
28
+ 'packages/*/src/**/*.ts',
29
+ 'packages/*/src/**/*.tsx',
30
+ 'packages/*/src/**/*.cts',
31
+ 'packages/*/src/**/*.mts'
32
+ ];
33
+ const DEFAULT_EXCLUDE = [
34
+ '**/*.test.ts',
35
+ '**/*.test.tsx',
36
+ '**/*.d.ts',
37
+ '**/internal/**',
38
+ '**/_internal/**',
39
+ '**/node_modules/**'
40
+ ];
41
+ export async function walkProjectSources(rootDir, options = {}) {
42
+ const include = (options.include ?? DEFAULT_INCLUDE).map(toMatcher);
43
+ const exclude = (options.exclude ?? DEFAULT_EXCLUDE).map(toMatcher);
44
+ const respectInternal = options.respectInternalConvention ?? true;
45
+ // Distinguish `undefined` (no limit, unbounded walk) from `0` (caller explicitly asked
46
+ // for zero results, perhaps via arithmetic that drained their budget). Treating 0 as
47
+ // Infinity would silently turn a probe with depleted budget into a full tree walk.
48
+ const limit = typeof options.limit === 'number' && options.limit >= 0 ? options.limit : Infinity;
49
+ const matches = [];
50
+ await walk(rootDir, '', matches, include, exclude, limit);
51
+ matches.sort();
52
+ if (respectInternal) {
53
+ const filtered = [];
54
+ for (const match of matches) {
55
+ if (await fileTopJSDocHasInternalTag(path.resolve(rootDir, match))) {
56
+ continue;
57
+ }
58
+ filtered.push(match);
59
+ }
60
+ return filtered;
61
+ }
62
+ return matches;
63
+ }
64
+ async function walk(rootDir, relativeDir, out, include, exclude, limit) {
65
+ if (out.length >= limit)
66
+ return;
67
+ const absoluteDir = path.resolve(rootDir, relativeDir);
68
+ let entries;
69
+ try {
70
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
71
+ }
72
+ catch (error) {
73
+ if (error.code === 'ENOENT') {
74
+ return;
75
+ }
76
+ throw error;
77
+ }
78
+ for (const entry of entries) {
79
+ if (out.length >= limit)
80
+ return;
81
+ const childRel = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
82
+ if (entry.isDirectory()) {
83
+ // Pre-prune common heavy directories before recursing.
84
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
85
+ continue;
86
+ }
87
+ await walk(rootDir, childRel, out, include, exclude, limit);
88
+ continue;
89
+ }
90
+ if (!entry.isFile()) {
91
+ continue;
92
+ }
93
+ if (exclude.some((re) => re.test(childRel))) {
94
+ continue;
95
+ }
96
+ if (!include.some((re) => re.test(childRel))) {
97
+ continue;
98
+ }
99
+ out.push(childRel);
100
+ }
101
+ }
102
+ async function fileTopJSDocHasInternalTag(absolutePath) {
103
+ // Cheap scan of the leading bytes — read just enough to cover any plausible top-of-file
104
+ // doc comment without slurping the whole source.
105
+ let head;
106
+ try {
107
+ const handle = await fs.open(absolutePath, 'r');
108
+ try {
109
+ const buffer = Buffer.alloc(2048);
110
+ const result = await handle.read(buffer, 0, buffer.length, 0);
111
+ head = buffer.slice(0, result.bytesRead).toString('utf8');
112
+ }
113
+ finally {
114
+ await handle.close();
115
+ }
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ // Look for a top-of-file JSDoc block, then check whether it carries an @internal tag.
121
+ // The match anchors on tag-position — start of line, optionally after the JSDoc `*`
122
+ // prefix — so prose mentions of "@internal" inside descriptions don't accidentally
123
+ // self-filter the file (the docstring on this very file mentions @internal in prose).
124
+ const match = head.match(/^\s*\/\*\*([\s\S]*?)\*\//);
125
+ if (!match) {
126
+ return false;
127
+ }
128
+ return /^[ \t]*\*?[ \t]*@internal\b/m.test(match[1]);
129
+ }
130
+ function toMatcher(pattern) {
131
+ // Brace expansion (`{a,b}`) and POSIX character classes (`[abc]`) are not supported.
132
+ // Silently escaping the metacharacters as literals would produce regexes that match
133
+ // patterns like `{ts,tsx}` literally — silent miscompiles. Refuse with a clear error
134
+ // and tell the caller to pass each variant as a separate include glob.
135
+ if (/[{}]/.test(pattern)) {
136
+ throw new Error(`walkProjectSources: brace expansion is not supported in glob "${pattern}". ` +
137
+ `Pass each variant as a separate include pattern (e.g., 'src/**/*.ts' AND 'src/**/*.tsx' instead of 'src/**/*.{ts,tsx}').`);
138
+ }
139
+ if (/\[(?!\])/.test(pattern)) {
140
+ throw new Error(`walkProjectSources: character classes are not supported in glob "${pattern}". ` +
141
+ `Use literal patterns instead.`);
142
+ }
143
+ // Escape regex specials, then translate glob tokens. Order matters: handle `**` before `*`.
144
+ let escaped = '';
145
+ let i = 0;
146
+ while (i < pattern.length) {
147
+ const char = pattern[i];
148
+ if (char === '*' && pattern[i + 1] === '*') {
149
+ // `**/` matches zero or more path segments. `**` alone matches anything.
150
+ if (pattern[i + 2] === '/') {
151
+ escaped += '(?:.*/)?';
152
+ i += 3;
153
+ continue;
154
+ }
155
+ escaped += '.*';
156
+ i += 2;
157
+ continue;
158
+ }
159
+ if (char === '*') {
160
+ escaped += '[^/]*';
161
+ i += 1;
162
+ continue;
163
+ }
164
+ if (char === '?') {
165
+ escaped += '[^/]';
166
+ i += 1;
167
+ continue;
168
+ }
169
+ if (/[.+^${}()|[\]\\]/.test(char)) {
170
+ escaped += `\\${char}`;
171
+ i += 1;
172
+ continue;
173
+ }
174
+ escaped += char;
175
+ i += 1;
176
+ }
177
+ return new RegExp(`^${escaped}$`);
178
+ }
@@ -0,0 +1,438 @@
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
+ import { createHash } from 'node:crypto';
21
+ import { promises as fs } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { pythonExtractor } from './api-extractor/python-extractor.js';
24
+ import { anchorFor, renderApiPage } from './api-extractor/render.js';
25
+ import { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
26
+ import { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
27
+ // Built-in language extractors, registration-ordered. The first whose `detect()` returns
28
+ // true claims the project. Python is registered ahead of TypeScript so a project that
29
+ // mixes a `pyproject.toml` with a `package.json` (e.g., a Python tool with Node-side
30
+ // scripting) gets its Python source documented; pure-TS projects with no Python signals
31
+ // still flow cleanly through to typeScriptExtractor. The tree-sitter extractor handles
32
+ // the long-tail languages (Rust today; Go/Java/Ruby/C/C++/PHP next) and is registered
33
+ // FIRST so a Rust+Node hybrid lands on Rust output rather than the TS extractor's
34
+ // high-recall claim. Pure-TS projects fall through to typeScriptExtractor as before.
35
+ const builtInLanguageExtractors = [
36
+ treeSitterExtractor,
37
+ pythonExtractor,
38
+ typeScriptExtractor
39
+ ];
40
+ export { pythonExtractor } from './api-extractor/python-extractor.js';
41
+ export { treeSitterExtractor } from './api-extractor/tree-sitter-extractor.js';
42
+ export { typeScriptExtractor } from './api-extractor/typescript-extractor.js';
43
+ const API_PAGES_ROOT = path.join('docs', 'wiki', 'api');
44
+ const MANIFEST_RELATIVE_PATH = path.join('docs', 'public', 'api-reference-manifest.json');
45
+ const MANIFEST_SCHEMA_VERSION = 1;
46
+ const MANIFEST_OWNED_PREFIX = 'api/';
47
+ export async function refreshApiReference(options = {}) {
48
+ const rootDir = options.rootDir ? path.resolve(options.rootDir) : process.cwd();
49
+ const dryRun = options.dryRun ?? false;
50
+ const generatedAt = options.now ?? new Date().toISOString();
51
+ // Pluggability dispatch: pick the first registered extractor whose detect() matches the
52
+ // project. When none match (e.g., a non-TS project with no recognizable signals), return
53
+ // an empty result — the same behavior as the previous "walk found 0 sources" path.
54
+ const extractors = options.extractors ?? builtInLanguageExtractors;
55
+ let activeExtractor;
56
+ for (const candidate of extractors) {
57
+ if (await candidate.detect(rootDir)) {
58
+ activeExtractor = candidate;
59
+ break;
60
+ }
61
+ }
62
+ const previousManifest = await readManifest(rootDir);
63
+ const previousBySlug = new Map(previousManifest.pages.map((entry) => [entry.slug, entry]));
64
+ if (!activeExtractor) {
65
+ // No extractor claimed this project — nothing to scan. Still produce a valid manifest
66
+ // (an empty one) and run the orphan-cleanup pass against the previous manifest so a
67
+ // project that switches stacks doesn't strand stale pages.
68
+ const orphanSlugs = previousManifest.pages.map((entry) => entry.slug);
69
+ const newManifest = {
70
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
71
+ generatedAt,
72
+ pages: []
73
+ };
74
+ if (!dryRun && orphanSlugs.length > 0) {
75
+ for (const slug of orphanSlugs) {
76
+ const orphanPath = resolveSafeOrphanPath(rootDir, slug);
77
+ if (!orphanPath)
78
+ continue;
79
+ await fs.rm(orphanPath, { force: true });
80
+ }
81
+ await writeManifest(rootDir, newManifest);
82
+ }
83
+ return {
84
+ pagesWritten: 0,
85
+ pagesChanged: [],
86
+ pagesDeleted: orphanSlugs,
87
+ warnings: [],
88
+ sourcesScanned: 0,
89
+ sourcesSkipped: [],
90
+ manifest: newManifest
91
+ };
92
+ }
93
+ const sources = await activeExtractor.walk(rootDir, options.walkOptions);
94
+ // Build a per-project source-link resolver. When the project has a detectable GitHub
95
+ // repository URL in package.json, source links go to https://github.com/... so they
96
+ // work in VitePress, on GitHub web, and in IDEs alike. When no repository URL is
97
+ // detectable, the resolver returns null → the renderer emits plain text (no link)
98
+ // instead of a relative path that 404s in the browser-served VitePress site.
99
+ const sourceLinkResolver = await buildSourceLinkResolver(rootDir);
100
+ const warnings = [];
101
+ const sourcesSkipped = [];
102
+ const pendingRefs = [];
103
+ for (const source of sources) {
104
+ let ref;
105
+ try {
106
+ ref = await activeExtractor.extract(source, { rootDir });
107
+ }
108
+ catch (error) {
109
+ warnings.push({
110
+ kind: 'extraction-error',
111
+ message: error instanceof Error ? error.message : String(error),
112
+ sourceFile: source
113
+ });
114
+ sourcesSkipped.push({ path: source, reason: 'extraction-error' });
115
+ continue;
116
+ }
117
+ if (ref.symbols.length === 0) {
118
+ sourcesSkipped.push({ path: source, reason: 'no-exports' });
119
+ continue;
120
+ }
121
+ const slug = ref.moduleSlug;
122
+ if (!slug.startsWith(MANIFEST_OWNED_PREFIX)) {
123
+ throw new Error(`refreshApiReference: derived slug "${slug}" does not start with required prefix "${MANIFEST_OWNED_PREFIX}" — refusing to claim`);
124
+ }
125
+ const pageRelativePath = `${path.posix.join(API_PAGES_ROOT.replace(/\\/g, '/'), slug.slice(MANIFEST_OWNED_PREFIX.length))}.md`;
126
+ const sourceLinkBase = computeSourceLinkBase(pageRelativePath);
127
+ if (countDocumented(ref) === 0) {
128
+ warnings.push({
129
+ kind: 'low-coverage',
130
+ message: `${source} has ${ref.symbols.length} export(s) but 0 with doc comments`,
131
+ sourceFile: source
132
+ });
133
+ }
134
+ pendingRefs.push({ ref, slug, pageRelativePath, sourceLinkBase });
135
+ }
136
+ // Pass 2: build the cross-file link index and render with a resolver per page. The
137
+ // resolver pushes link warnings back into the shared `warnings` list so unresolved or
138
+ // ambiguous references surface in the result.
139
+ const linkIndex = buildLinkIndex(pendingRefs.map((entry) => ({ slug: entry.slug, ref: entry.ref })));
140
+ const renderedPages = [];
141
+ for (const pending of pendingRefs) {
142
+ const resolveLink = (target, displayText) => resolveCrossReference({
143
+ target,
144
+ displayText,
145
+ currentSlug: pending.slug,
146
+ currentSourceFile: pending.ref.sourcePath,
147
+ linkIndex,
148
+ warnings
149
+ });
150
+ const body = renderApiPage(pending.ref, {
151
+ sourceLinkBase: pending.sourceLinkBase,
152
+ sourceLinkResolver,
153
+ resolveLink
154
+ });
155
+ const finalBody = ensureTrailingNewline(body);
156
+ const contentHash = sha256(finalBody);
157
+ renderedPages.push({
158
+ slug: pending.slug,
159
+ absolutePath: path.resolve(rootDir, pending.pageRelativePath),
160
+ body: finalBody,
161
+ entry: {
162
+ slug: pending.slug,
163
+ sourceFile: pending.ref.sourcePath,
164
+ symbolCount: pending.ref.symbols.length,
165
+ contentHash
166
+ }
167
+ });
168
+ }
169
+ renderedPages.sort((a, b) => a.slug.localeCompare(b.slug));
170
+ const newSlugs = new Set(renderedPages.map((page) => page.slug));
171
+ const orphanSlugs = previousManifest.pages
172
+ .map((entry) => entry.slug)
173
+ .filter((slug) => !newSlugs.has(slug));
174
+ for (const slug of orphanSlugs) {
175
+ // Defense-in-depth: validate every orphan slug now, before any I/O. If a corrupted
176
+ // manifest entry ever escaped the api/ tree (e.g., `api/../../etc/passwd`), this throw
177
+ // surfaces it loudly — and short-circuits before we touch the filesystem.
178
+ if (resolveSafeOrphanPath(rootDir, slug) === null) {
179
+ throw new Error(`refreshApiReference: previous manifest contained unsafe slug "${slug}" — refusing to act on it`);
180
+ }
181
+ }
182
+ const pagesChanged = [];
183
+ for (const page of renderedPages) {
184
+ const previous = previousBySlug.get(page.slug);
185
+ if (!previous || previous.contentHash !== page.entry.contentHash) {
186
+ pagesChanged.push(page.slug);
187
+ }
188
+ }
189
+ const pagesDeleted = [...orphanSlugs];
190
+ const newManifest = {
191
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
192
+ generatedAt,
193
+ pages: renderedPages.map((page) => page.entry)
194
+ };
195
+ if (!dryRun) {
196
+ for (const page of renderedPages) {
197
+ await writeIfChanged(page.absolutePath, page.body);
198
+ }
199
+ for (const slug of orphanSlugs) {
200
+ // resolveSafeOrphanPath was already validated above; non-null is guaranteed.
201
+ const orphanPath = resolveSafeOrphanPath(rootDir, slug);
202
+ if (orphanPath) {
203
+ await fs.rm(orphanPath, { force: true });
204
+ }
205
+ }
206
+ await writeManifest(rootDir, newManifest);
207
+ }
208
+ return {
209
+ pagesWritten: renderedPages.length,
210
+ pagesChanged,
211
+ pagesDeleted,
212
+ warnings,
213
+ sourcesScanned: sources.length,
214
+ sourcesSkipped,
215
+ manifest: newManifest
216
+ };
217
+ }
218
+ /**
219
+ * Builds a function that turns a (sourcePath, line) pair into a source-link URL the
220
+ * rendered API page can hyperlink to. Reads the project's `package.json` and inspects
221
+ * the `repository` field (handles both string and object forms, and the `git+` /
222
+ * `.git` decorations npm conventionally adds). When it finds a github.com URL, returns
223
+ * a resolver that emits full `https://github.com/<owner>/<repo>/blob/main/<path>#L<line>`
224
+ * URLs — those work in every viewing context (VitePress static site, GitHub web view,
225
+ * IDE markdown preview). When no GitHub URL is detected, returns a resolver that always
226
+ * returns null, signaling the renderer to emit plain-text source references rather than
227
+ * relative-path links that 404 in browser-served sites.
228
+ *
229
+ * Branch resolution: hardcoded to `main` for simplicity. Future enhancement could read
230
+ * `git rev-parse --abbrev-ref HEAD` for the actual default branch, or honor a
231
+ * `DENDRITE_API_REFERENCE_BRANCH` env var.
232
+ *
233
+ * Other git hosts (GitLab, Bitbucket, codeberg.org, sr.ht, self-hosted Gitea) are not
234
+ * yet detected; their projects fall through to plain-text source references. Adding
235
+ * support is a small follow-up — each host has a stable `<host>/<owner>/<repo>/blob/<branch>/<path>`
236
+ * URL pattern (or close variant) that can be detected the same way.
237
+ */
238
+ async function buildSourceLinkResolver(rootDir) {
239
+ let pkgRaw;
240
+ try {
241
+ pkgRaw = await fs.readFile(path.join(rootDir, 'package.json'), 'utf8');
242
+ }
243
+ catch {
244
+ return () => null;
245
+ }
246
+ let pkg;
247
+ try {
248
+ pkg = JSON.parse(pkgRaw);
249
+ }
250
+ catch {
251
+ return () => null;
252
+ }
253
+ const repoField = pkg.repository;
254
+ const repoUrl = typeof repoField === 'string' ? repoField : repoField?.url;
255
+ if (!repoUrl)
256
+ return () => null;
257
+ // Strip the `git+` prefix npm adds and the `.git` suffix git remotes use, then look
258
+ // for github.com followed by `/<owner>/<repo>`. SSH-form `git@github.com:owner/repo`
259
+ // and HTTPS `https://github.com/owner/repo` both match.
260
+ const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
261
+ if (!match)
262
+ return () => null;
263
+ const owner = match[1];
264
+ const repo = match[2];
265
+ const branch = process.env.DENDRITE_API_REFERENCE_BRANCH ?? 'main';
266
+ return (sourcePath, line) => `https://github.com/${owner}/${repo}/blob/${branch}/${sourcePath}#L${line}`;
267
+ }
268
+ /**
269
+ * Resolves the absolute on-disk path for an orphan slug, returning null when the slug is
270
+ * not owned by this generator OR when the resolved path escapes the API tree. Centralizes
271
+ * the path-traversal guard so both code paths in `refreshApiReference` use the same check.
272
+ *
273
+ * Why this matters: a corrupt manifest entry like `{"slug": "api/../../etc/passwd"}`
274
+ * passes a naive `slug.startsWith("api/")` check, but `path.posix.join("docs/wiki/api",
275
+ * "../../etc/passwd")` resolves outside the API tree. `fs.rm({ force: true })` would
276
+ * happily delete it. This guard short-circuits before any filesystem I/O.
277
+ */
278
+ function resolveSafeOrphanPath(rootDir, slug) {
279
+ if (!slug.startsWith(MANIFEST_OWNED_PREFIX))
280
+ return null;
281
+ // Reject slugs carrying characters that have meaning to the filesystem layer regardless
282
+ // of platform: `\0` (POSIX path terminator), and `:` (Windows drive-letter separator,
283
+ // which would otherwise cause `path.resolve` to switch drives). Slugs from our own
284
+ // derivation never contain these; rejecting them here closes a malformed-manifest
285
+ // attack surface up front.
286
+ if (slug.includes('\0') || slug.includes(':'))
287
+ return null;
288
+ const apiRootAbs = path.resolve(rootDir, API_PAGES_ROOT);
289
+ const candidate = path.resolve(apiRootAbs, `${slug.slice(MANIFEST_OWNED_PREFIX.length)}.md`);
290
+ const relativeFromApi = path.relative(apiRootAbs, candidate);
291
+ // Outside the API tree iff:
292
+ // - the path is absolute (e.g., a Windows drive-letter switch slipped past slug
293
+ // validation, or POSIX absolute path)
294
+ // - the FIRST path segment is exactly `..` (escapes upward by a directory).
295
+ // We compare segments rather than using `startsWith('..')` to avoid false-rejecting a
296
+ // file legitimately named e.g. `..config` inside the API tree.
297
+ if (path.isAbsolute(relativeFromApi))
298
+ return null;
299
+ const firstSegment = relativeFromApi.split(/[\\/]/)[0];
300
+ if (firstSegment === '..')
301
+ return null;
302
+ return candidate;
303
+ }
304
+ async function readManifest(rootDir) {
305
+ const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
306
+ try {
307
+ const raw = await fs.readFile(manifestPath, 'utf8');
308
+ const parsed = JSON.parse(raw);
309
+ if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
310
+ // Unknown schema — treat as empty so we regenerate cleanly. Human can resolve any
311
+ // resulting orphan churn via the next git diff.
312
+ return emptyManifest();
313
+ }
314
+ return parsed;
315
+ }
316
+ catch (error) {
317
+ if (error.code === 'ENOENT') {
318
+ return emptyManifest();
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+ async function writeManifest(rootDir, manifest) {
324
+ const manifestPath = path.resolve(rootDir, MANIFEST_RELATIVE_PATH);
325
+ const sortedManifest = {
326
+ ...manifest,
327
+ pages: [...manifest.pages].sort((a, b) => a.slug.localeCompare(b.slug))
328
+ };
329
+ const body = `${JSON.stringify(sortedManifest, null, 2)}\n`;
330
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
331
+ await fs.writeFile(manifestPath, body, 'utf8');
332
+ }
333
+ function emptyManifest() {
334
+ return { schemaVersion: MANIFEST_SCHEMA_VERSION, generatedAt: '', pages: [] };
335
+ }
336
+ async function writeIfChanged(absolutePath, body) {
337
+ let existing;
338
+ try {
339
+ existing = await fs.readFile(absolutePath, 'utf8');
340
+ }
341
+ catch (error) {
342
+ if (error.code !== 'ENOENT') {
343
+ throw error;
344
+ }
345
+ }
346
+ if (existing === body) {
347
+ return;
348
+ }
349
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
350
+ await fs.writeFile(absolutePath, body, 'utf8');
351
+ }
352
+ function sha256(body) {
353
+ return createHash('sha256').update(body).digest('hex');
354
+ }
355
+ function ensureTrailingNewline(body) {
356
+ return body.endsWith('\n') ? body : `${body}\n`;
357
+ }
358
+ function countDocumented(ref) {
359
+ return ref.symbols.filter((symbol) => symbol.docComment !== null && symbol.docComment.trim().length > 0).length;
360
+ }
361
+ function computeSourceLinkBase(pageRelativePath) {
362
+ // Page lives at e.g. `docs/wiki/api/wiki/i18n.md`. To reach `src/wiki/i18n.ts` from the page,
363
+ // we go up by the page's directory depth from the project root.
364
+ const directoryDepth = pageRelativePath.replace(/\\/g, '/').split('/').length - 1;
365
+ return Array.from({ length: directoryDepth }, () => '..').join('/');
366
+ }
367
+ function buildLinkIndex(entries) {
368
+ const index = new Map();
369
+ for (const { slug, ref } of entries) {
370
+ for (const symbol of ref.symbols) {
371
+ const anchor = anchorFor(symbol.name);
372
+ const bucket = index.get(symbol.name);
373
+ const entry = { slug, anchor, sourceFile: ref.sourcePath };
374
+ if (bucket) {
375
+ bucket.push(entry);
376
+ }
377
+ else {
378
+ index.set(symbol.name, [entry]);
379
+ }
380
+ }
381
+ }
382
+ return index;
383
+ }
384
+ function resolveCrossReference(args) {
385
+ // Allow `Foo.bar` qualified forms by resolving against the head and keeping the full
386
+ // expression as the default display label (so the dotted name remains visible to readers).
387
+ const headTarget = args.target.split('.')[0];
388
+ const display = args.displayText ?? args.target;
389
+ const matches = args.linkIndex.get(headTarget) ?? [];
390
+ if (matches.length === 0) {
391
+ args.warnings.push({
392
+ kind: 'unresolved-link',
393
+ message: `cannot resolve {@link ${args.target}} in ${args.currentSourceFile}`,
394
+ sourceFile: args.currentSourceFile
395
+ });
396
+ return { url: null, display };
397
+ }
398
+ let chosen;
399
+ if (matches.length === 1) {
400
+ chosen = matches[0];
401
+ }
402
+ else {
403
+ // Disambiguate by shared module path prefix: prefer a match whose slug starts with the
404
+ // current page's slug-directory.
405
+ const currentDir = args.currentSlug.includes('/')
406
+ ? args.currentSlug.slice(0, args.currentSlug.lastIndexOf('/'))
407
+ : '';
408
+ if (currentDir) {
409
+ const sameModule = matches.filter((entry) => entry.slug.startsWith(`${currentDir}/`) || entry.slug === currentDir);
410
+ if (sameModule.length === 1) {
411
+ chosen = sameModule[0];
412
+ }
413
+ }
414
+ if (!chosen) {
415
+ args.warnings.push({
416
+ kind: 'ambiguous-link',
417
+ message: `{@link ${args.target}} in ${args.currentSourceFile} is ambiguous: ${matches.map((entry) => entry.slug).join(', ')}`,
418
+ sourceFile: args.currentSourceFile
419
+ });
420
+ return { url: null, display, comment: `ambiguous link: ${args.target}` };
421
+ }
422
+ }
423
+ const url = chosen.slug === args.currentSlug
424
+ ? `#${chosen.anchor}`
425
+ : `${relativePagePath(args.currentSlug, chosen.slug)}#${chosen.anchor}`;
426
+ return { url, display };
427
+ }
428
+ function relativePagePath(currentSlug, targetSlug) {
429
+ const currentDir = currentSlug.includes('/') ? currentSlug.slice(0, currentSlug.lastIndexOf('/')) : '';
430
+ const targetPath = `${targetSlug}.md`;
431
+ const rel = path.posix.relative(currentDir, targetPath);
432
+ // path.posix.relative returns 'foo.md' for sibling pages. We want './foo.md' so the link
433
+ // is unambiguous in markdown renderers that distinguish relative-from-here vs. anchor-only.
434
+ if (!rel.startsWith('.')) {
435
+ return `./${rel}`;
436
+ }
437
+ return rel;
438
+ }