@microsoft/rayfin-docs 1.27.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.
package/dist/index.js ADDED
@@ -0,0 +1,422 @@
1
+ import { createHash } from 'crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
3
+ import { homedir, tmpdir } from 'os';
4
+ import { basename, dirname, join, resolve } from 'path';
5
+ import { getCatalog } from './catalog.js';
6
+ import { docKindToModule, discoverRayfinDocsPackages, hasExplicitDiscoverySource, } from './discovery.js';
7
+ import { loadDocs, loadDocsFromPackage } from './loader.js';
8
+ import { DOCS_INDEX_SCHEMA_VERSION, DOCS_SEARCH_CONFIG_FINGERPRINT, createDocsIndex, loadDocsIndexFromPrebuilt, searchDocs, serializeDocsIndex, } from './search.js';
9
+ const PREBUILT_INDEX_FILENAME = 'docs.search.json';
10
+ const DISCOVERED_CACHE_PREFIX = 'docs-index-';
11
+ /**
12
+ * Skip reading existing on-disk cache files while still writing a fresh index
13
+ * after rebuild. Used by CLI/MCP `--no-cache` flags.
14
+ */
15
+ export const DOCS_CACHE_SKIP_READ = { read: false };
16
+ export const DOCS_NO_CACHE_OPTION_DESCRIPTION = 'Bypass any existing on-disk docs index cache and rebuild from installed package docs.';
17
+ /**
18
+ * Default loaded module set when no `modules` option is provided. Mirrors
19
+ * the MCP `start` default and the CLI's builder default. Host docs are
20
+ * .NET reference content (DocFX-generated, ~70% of corpus) and are
21
+ * loaded only when explicitly requested via `--module host` (CLI) or
22
+ * `--host-docs` (MCP).
23
+ *
24
+ * Exported so the CLI command group, the MCP `start` command, and the
25
+ * build-time prebuilt-index emitter all read the same constant - changing
26
+ * the default loaded set requires editing one site.
27
+ */
28
+ export const DOCS_DEFAULT_MODULES = ['guide', 'ts-sdk'];
29
+ /**
30
+ * Catalog-derived list of packages CLI/MCP probe for the `rayfinDocs`
31
+ * field. Kept lazy so importing the docs library does not read bundled
32
+ * catalog assets until discovery needs the default candidates.
33
+ *
34
+ * External consumers SHOULD NOT import this constant directly; treat it
35
+ * as an internal default that may change shape between versions. The
36
+ * `discover` option on `DocsService` accepts any candidate list — pass
37
+ * your own if you need stable behavior across Rayfin versions.
38
+ *
39
+ * Packages not yet migrated to ship their own docs are silently skipped
40
+ * by `discoverRayfinDocsPackages`; catalog candidates not present in
41
+ * `node_modules` appear in `DiscoveryResult.notInstalled`.
42
+ *
43
+ * **`--module ts-sdk` compatibility:** when a discovered package
44
+ * declares `kind: 'api-reference'`, its docs are tagged with the
45
+ * legacy `module: 'ts-sdk'` so existing CLI/MCP filters keep working
46
+ * across the migration. Individual packages remain addressable through
47
+ * their globally unique doc ids (for example, `rayfin-core:index.md`).
48
+ */
49
+ let knownRayfinDocsPackages;
50
+ export function getKnownRayfinDocsPackages() {
51
+ return (knownRayfinDocsPackages ??= getCatalog()
52
+ .packages.filter((p) => p.modules.length > 0)
53
+ .map((p) => p.name));
54
+ }
55
+ export { defaultTrust, discoverRayfinDocsPackages, validateRayfinDocsManifest, docKindToModule, } from './discovery.js';
56
+ // Catalog API — folded into rayfin-docs in the post-Phase-5
57
+ // consolidation. The catalog updates with the same trigger as the
58
+ // indexer ("we shipped a new SDK package"), so splitting them across
59
+ // packages just multiplies release coordination.
60
+ export { getCatalog, discoverPackages, mapDiscoverResult, deriveInstallCommand, deriveUpdateCommand, _resetCatalogForTesting, } from './catalog.js';
61
+ function modulesKey(modules) {
62
+ return [...modules].sort().join(',');
63
+ }
64
+ function isPrebuiltDocsIndex(value) {
65
+ return (!!value &&
66
+ typeof value === 'object' &&
67
+ typeof value.schemaVersion === 'number' &&
68
+ typeof value.configFingerprint === 'string' &&
69
+ Array.isArray(value.modules) &&
70
+ Array.isArray(value.entries) &&
71
+ !!value.miniSerialized &&
72
+ typeof value.miniSerialized === 'object');
73
+ }
74
+ function isCompatiblePrebuiltIndex(parsed, modules) {
75
+ return (parsed.schemaVersion === DOCS_INDEX_SCHEMA_VERSION &&
76
+ parsed.configFingerprint === DOCS_SEARCH_CONFIG_FINGERPRINT &&
77
+ modulesKey(parsed.modules) === modulesKey(modules));
78
+ }
79
+ /**
80
+ * Load the prebuilt search index from disk if present and compatible with
81
+ * the runtime's expected schema/config/module set. Returns `undefined`
82
+ * when the prebuilt is missing, malformed, or stale - callers fall back to
83
+ * source-building.
84
+ */
85
+ function tryLoadPrebuiltIndex(assetsRoot, modules) {
86
+ const path = join(assetsRoot, PREBUILT_INDEX_FILENAME);
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
90
+ }
91
+ catch {
92
+ // Missing file (ENOENT) or malformed JSON - fall back to source build.
93
+ return undefined;
94
+ }
95
+ // Shape guard: a partially-written or hand-crafted file might parse as
96
+ // JSON but be missing required fields. The contract of this helper is
97
+ // "return undefined on any problem so the caller can fall back" - if the
98
+ // payload is malformed, returning `parsed` would crash inside
99
+ // `loadDocsIndexFromPrebuilt` instead of falling back cleanly.
100
+ if (!isPrebuiltDocsIndex(parsed)) {
101
+ return undefined;
102
+ }
103
+ return isCompatiblePrebuiltIndex(parsed, modules) ? parsed : undefined;
104
+ }
105
+ function normalizeCacheOptions(cache, defaultEnabled) {
106
+ if (cache === false) {
107
+ return { enabled: false, read: false, write: false };
108
+ }
109
+ if (cache === true || cache === undefined) {
110
+ return {
111
+ enabled: cache === true || defaultEnabled,
112
+ read: true,
113
+ write: true,
114
+ };
115
+ }
116
+ return {
117
+ enabled: true,
118
+ ...(cache.dir ? { dir: cache.dir } : {}),
119
+ read: cache.read ?? true,
120
+ write: cache.write ?? true,
121
+ };
122
+ }
123
+ function defaultDocsCacheBase(useTemp = false) {
124
+ if (useTemp) {
125
+ return join(tmpdir(), 'rayfin', 'cache');
126
+ }
127
+ const home = homedir();
128
+ return home
129
+ ? join(home, '.rayfin', 'cache')
130
+ : join(tmpdir(), 'rayfin', 'cache');
131
+ }
132
+ function defaultDocsCacheDir(discoverFrom, useTemp = false) {
133
+ const base = defaultDocsCacheBase(useTemp);
134
+ if (!discoverFrom) {
135
+ return base;
136
+ }
137
+ const projectHash = createHash('sha256')
138
+ .update(resolve(discoverFrom))
139
+ .digest('hex')
140
+ .slice(0, 16);
141
+ return join(base, 'projects', projectHash);
142
+ }
143
+ function discoveredCacheKey(packages, modules) {
144
+ const payload = {
145
+ schemaVersion: DOCS_INDEX_SCHEMA_VERSION,
146
+ configFingerprint: DOCS_SEARCH_CONFIG_FINGERPRINT,
147
+ modules: [...modules].sort(),
148
+ packages: packages
149
+ .map((pkg) => ({
150
+ name: pkg.packageName,
151
+ version: pkg.packageVersion,
152
+ module: pkg.manifest.module,
153
+ kind: pkg.manifest.kind,
154
+ }))
155
+ .sort((a, b) => a.name.localeCompare(b.name)),
156
+ };
157
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
158
+ }
159
+ function discoveredCachePath(packages, modules, cache, discoverFrom) {
160
+ const dir = cache.dir ?? defaultDocsCacheDir(discoverFrom);
161
+ return join(dir, `${DISCOVERED_CACHE_PREFIX}${discoveredCacheKey(packages, modules)}.json`);
162
+ }
163
+ function fallbackDiscoveredCachePath(packages, modules, cache, discoverFrom) {
164
+ if (cache.dir) {
165
+ return undefined;
166
+ }
167
+ return discoveredCachePath(packages, modules, {
168
+ ...cache,
169
+ dir: defaultDocsCacheDir(discoverFrom, true),
170
+ });
171
+ }
172
+ function tryLoadDiscoveredIndexCache(cachePaths, modules, cache) {
173
+ if (!cache.enabled || !cache.read || cachePaths.length === 0) {
174
+ return undefined;
175
+ }
176
+ for (const cachePath of cachePaths) {
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ if (!isPrebuiltDocsIndex(parsed)) {
185
+ continue;
186
+ }
187
+ if (isCompatiblePrebuiltIndex(parsed, modules)) {
188
+ return parsed;
189
+ }
190
+ }
191
+ return undefined;
192
+ }
193
+ function normalizeDiscoveryOptions(discover) {
194
+ if (hasExplicitDiscoverySource(discover)) {
195
+ return discover;
196
+ }
197
+ return {
198
+ ...discover,
199
+ candidates: getKnownRayfinDocsPackages(),
200
+ };
201
+ }
202
+ function pruneStaleDiscoveredIndexCaches(currentPath) {
203
+ try {
204
+ const dir = dirname(currentPath);
205
+ const currentName = basename(currentPath);
206
+ for (const entry of readdirSync(dir)) {
207
+ if (entry !== currentName &&
208
+ entry.startsWith(DISCOVERED_CACHE_PREFIX) &&
209
+ entry.endsWith('.json')) {
210
+ unlinkSync(join(dir, entry));
211
+ }
212
+ }
213
+ }
214
+ catch {
215
+ // Cache cleanup is best-effort; stale files are harmless but bounded.
216
+ }
217
+ }
218
+ function writeDiscoveredIndexCache(targetPath, fallbackPath, cache, prebuilt) {
219
+ if (!cache.enabled || !cache.write || !targetPath) {
220
+ return;
221
+ }
222
+ try {
223
+ mkdirSync(dirname(targetPath), { recursive: true });
224
+ writeFileSync(targetPath, JSON.stringify(prebuilt));
225
+ pruneStaleDiscoveredIndexCaches(targetPath);
226
+ return;
227
+ }
228
+ catch (err) {
229
+ if (cache.dir) {
230
+ throw err;
231
+ }
232
+ }
233
+ if (!fallbackPath) {
234
+ return;
235
+ }
236
+ try {
237
+ mkdirSync(dirname(fallbackPath), { recursive: true });
238
+ writeFileSync(fallbackPath, JSON.stringify(prebuilt));
239
+ pruneStaleDiscoveredIndexCaches(fallbackPath);
240
+ }
241
+ catch {
242
+ // Cache writes are a performance optimization; docs can still be served.
243
+ }
244
+ }
245
+ function parseQualifiedSymbol(symbol) {
246
+ const separator = symbol.lastIndexOf('::');
247
+ if (separator <= 0 || separator >= symbol.length - 2) {
248
+ return undefined;
249
+ }
250
+ return {
251
+ owner: symbol.slice(0, separator).toLowerCase(),
252
+ symbol: symbol.slice(separator + 2),
253
+ };
254
+ }
255
+ export class DocsService {
256
+ entries;
257
+ entriesById;
258
+ symbolToSections;
259
+ searchIndex;
260
+ discoveryReport;
261
+ hydrateFromPrebuilt(prebuilt) {
262
+ this.entries = prebuilt.entries;
263
+ const index = loadDocsIndexFromPrebuilt(prebuilt);
264
+ this.entriesById = index.entriesById;
265
+ this.symbolToSections = index.symbolToSections;
266
+ this.searchIndex = index.mini;
267
+ }
268
+ constructor(options) {
269
+ if (!options.assetsRoot && !options.discover) {
270
+ throw new Error('DocsService requires at least one source: pass `assetsRoot` (legacy bundled corpus), `discover` (per-package discovery), or both. Both modes can compose during the Phase 2/3 migration; per-package entries override bundled ones on collision.');
271
+ }
272
+ if (options.assetsRoot !== undefined && options.assetsRoot.length === 0) {
273
+ throw new Error('`assetsRoot` must be a non-empty path. Pass `undefined` (omit the option) to skip the bundled-corpus source entirely.');
274
+ }
275
+ const modules = options.modules ?? DOCS_DEFAULT_MODULES;
276
+ const cache = normalizeCacheOptions(options.cache, !!options.discover && !options.assetsRoot);
277
+ // Discovery results — empty array when discover wasn't passed OR
278
+ // when discovery returned no packages. We compute this BEFORE
279
+ // committing to the bundled fast path so an empty discovery doesn't
280
+ // sacrifice the prebuilt-index speedup.
281
+ const discoveredEntries = [];
282
+ let discoveredPackages = [];
283
+ let discoveredCacheFile;
284
+ let discoveredFallbackCacheFile;
285
+ if (options.discover) {
286
+ const discoveryOptions = normalizeDiscoveryOptions(options.discover);
287
+ const report = discoverRayfinDocsPackages(discoveryOptions);
288
+ this.discoveryReport = report;
289
+ discoveredPackages = report.discovered.filter((pkg) => modules.includes(docKindToModule(pkg.manifest.kind)));
290
+ discoveredCacheFile =
291
+ !options.assetsRoot && discoveredPackages.length > 0 && cache.enabled
292
+ ? discoveredCachePath(discoveredPackages, modules, cache, discoveryOptions.from)
293
+ : undefined;
294
+ discoveredFallbackCacheFile =
295
+ !options.assetsRoot && discoveredPackages.length > 0 && cache.enabled
296
+ ? fallbackDiscoveredCachePath(discoveredPackages, modules, cache, discoveryOptions.from)
297
+ : undefined;
298
+ if (!options.assetsRoot) {
299
+ const cached = tryLoadDiscoveredIndexCache([discoveredCacheFile, discoveredFallbackCacheFile].filter((path) => !!path), modules, cache);
300
+ if (cached) {
301
+ this.hydrateFromPrebuilt(cached);
302
+ return;
303
+ }
304
+ }
305
+ for (const pkg of discoveredPackages) {
306
+ discoveredEntries.push(...loadDocsFromPackage(pkg));
307
+ }
308
+ }
309
+ if (options.assetsRoot) {
310
+ const root = options.assetsRoot;
311
+ const prebuilt = tryLoadPrebuiltIndex(root, modules);
312
+ if (prebuilt && discoveredEntries.length === 0) {
313
+ // Fast path: prebuilt-only mode (no discovery to merge with).
314
+ // Rehydrate index + entries from the prebuilt envelope without
315
+ // re-walking markdown OR rebuilding the MiniSearch index.
316
+ // Critical for CLI cold start — Phase 2 broke this path before
317
+ // by always passing `discover` even when no packages declared
318
+ // rayfinDocs; restored here by deferring the fast-path decision
319
+ // until AFTER discovery has actually produced results.
320
+ this.hydrateFromPrebuilt(prebuilt);
321
+ return;
322
+ }
323
+ const bundled = prebuilt
324
+ ? prebuilt.entries
325
+ : loadDocs(root, [...modules]);
326
+ if (discoveredEntries.length === 0) {
327
+ // No discovery overlay — skip the merge map entirely.
328
+ this.entries = bundled;
329
+ }
330
+ else {
331
+ const merged = new Map();
332
+ for (const entry of bundled) {
333
+ merged.set(entry.id, entry);
334
+ }
335
+ for (const entry of discoveredEntries) {
336
+ // Per-package wins on collision: later `set` overwrites the
337
+ // earlier bundled entry with the same id.
338
+ merged.set(entry.id, entry);
339
+ }
340
+ this.entries = [...merged.values()];
341
+ }
342
+ }
343
+ else {
344
+ // discover-only mode (no bundled corpus).
345
+ this.entries = discoveredEntries;
346
+ }
347
+ const index = createDocsIndex(this.entries);
348
+ this.entriesById = index.entriesById;
349
+ this.symbolToSections = index.symbolToSections;
350
+ this.searchIndex = index.mini;
351
+ if (!options.assetsRoot && discoveredPackages.length > 0) {
352
+ writeDiscoveredIndexCache(discoveredCacheFile, discoveredFallbackCacheFile, cache, serializeDocsIndex(this.entries, [...modules], index));
353
+ }
354
+ }
355
+ /**
356
+ * Returns a frozen snapshot of the discovery report from
357
+ * construction, if `discover` was passed. `undefined` when no
358
+ * discovery ran (bundled-only mode). Useful for surfacing "package X
359
+ * is in your trust list but not installed" or "manifest invalid;
360
+ * skipped" diagnostics. The returned object is deep-frozen so
361
+ * consumers cannot mutate the service's internal state.
362
+ */
363
+ getDiscoveryReport() {
364
+ if (!this.discoveryReport)
365
+ return undefined;
366
+ // Deep clone via JSON round-trip then freeze. Discovery results
367
+ // are small structured data — never functions or class instances —
368
+ // so JSON is safe and avoids exposing the internal references.
369
+ const frozen = JSON.parse(JSON.stringify(this.discoveryReport));
370
+ Object.freeze(frozen);
371
+ Object.freeze(frozen.discovered);
372
+ Object.freeze(frozen.notInstalled);
373
+ Object.freeze(frozen.untrustedSkipped);
374
+ Object.freeze(frozen.invalidManifest);
375
+ return frozen;
376
+ }
377
+ listDocs(module) {
378
+ return this.entries
379
+ .filter((entry) => (module ? entry.module === module : true))
380
+ .map((entry) => ({
381
+ id: entry.id,
382
+ module: entry.module,
383
+ path: entry.path,
384
+ title: entry.title,
385
+ ...(entry.source ? { source: entry.source } : {}),
386
+ }));
387
+ }
388
+ getDocById(id) {
389
+ return this.entriesById.get(id);
390
+ }
391
+ getDocsByPath(path, module) {
392
+ const normalized = path.replace(/^\//, '');
393
+ return this.entries.filter((entry) => entry.path === normalized && (module ? entry.module === module : true));
394
+ }
395
+ getDocByPath(path, module) {
396
+ const matches = this.getDocsByPath(path, module);
397
+ return matches.length === 1 ? matches[0] : undefined;
398
+ }
399
+ searchDocs(query, module, limit = 10, scope = 'docs') {
400
+ return searchDocs({
401
+ mini: this.searchIndex,
402
+ entriesById: this.entriesById,
403
+ symbolToSections: this.symbolToSections,
404
+ }, query, module, limit, scope);
405
+ }
406
+ getSymbolDocs(symbol, module) {
407
+ const qualified = parseQualifiedSymbol(symbol);
408
+ const key = (qualified?.symbol ?? symbol).toLowerCase();
409
+ const sections = this.symbolToSections.get(key) ?? [];
410
+ return sections.filter((section) => {
411
+ if (module && section.module !== module) {
412
+ return false;
413
+ }
414
+ if (!qualified) {
415
+ return true;
416
+ }
417
+ return (section.source?.packageName.toLowerCase() === qualified.owner ||
418
+ section.source?.module.toLowerCase() === qualified.owner);
419
+ });
420
+ }
421
+ }
422
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ import type { DiscoveredPackage } from './discovery.js';
2
+ import type { DocEntry, DocModule } from './types.js';
3
+ export declare function loadDocs(assetsRoot: string, modules?: DocModule[]): DocEntry[];
4
+ /**
5
+ * Walk the docs tree of a discovered package and produce {@link DocEntry}
6
+ * items tagged with the package's source metadata.
7
+ *
8
+ * Per design.md, discovered packages have a flat `<packageRoot>/<dir>/`
9
+ * layout (no `/docs/<module>/` nesting). The manifest's `kind` is
10
+ * mapped to a legacy `DocModule` so existing CLI/MCP filters
11
+ * (`--module ts-sdk`) keep working through the migration.
12
+ *
13
+ * **Symlink safety.** The manifest validator in `discovery.ts` rejects
14
+ * `..` segments in the manifest's `dir` string, but a malicious package
15
+ * could still place a symlink at `<packageRoot>/<dir>` or inside that
16
+ * tree pointing to e.g. `/etc/passwd`. We resolve the package root,
17
+ * docs root, walked directories, and every walked file's realpath, then
18
+ * skip anything that escapes the trusted package/docs boundaries.
19
+ */
20
+ export declare function loadDocsFromPackage(pkg: DiscoveredPackage): DocEntry[];
21
+ //# sourceMappingURL=loader.d.ts.map