@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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Per-package docs discovery - Phase 2 of `docs-package-architecture`.
3
+ *
4
+ * Walks a project's `node_modules` to find packages declaring the
5
+ * `rayfinDocs` field in their `package.json`. Returns a list of
6
+ * discovered package roots so {@link DocsService} can load each tree's
7
+ * markdown alongside any explicit `assetsRoot`.
8
+ *
9
+ * **Trust model.** Discovery is conservative by default: only
10
+ * `@microsoft/rayfin-*` scoped packages are auto-trusted. Anything else
11
+ * requires the caller to pass an explicit `trust` predicate. This
12
+ * matches the spec-review resolution that broad walking is too trusting
13
+ * (see `openspec/changes/docs-package-architecture/spec-review-findings.md`,
14
+ * finding B3).
15
+ *
16
+ * Discovery is intentionally explicit: callers choose either a fixed
17
+ * candidate list, workspace package roots, or all-installed node_modules
18
+ * walking via `scanInstalledPackages: true`.
19
+ */
20
+ import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
21
+ import { createRequire } from 'module';
22
+ import { dirname, isAbsolute, join, resolve } from 'path';
23
+ import { fileURLToPath } from 'url';
24
+ /** Default trust predicate. Auto-trusts the Microsoft Rayfin namespace. */
25
+ export const defaultTrust = (packageName) => packageName.startsWith('@microsoft/rayfin-');
26
+ /**
27
+ * Returns whether callers already selected a discovery source.
28
+ * Wrappers use this to decide whether to inject their own bounded defaults.
29
+ */
30
+ export function hasExplicitDiscoverySource(options) {
31
+ return countDiscoverySources(options) > 0;
32
+ }
33
+ export function discoverRayfinDocsPackages(options) {
34
+ const trust = options.trust ?? defaultTrust;
35
+ const fromDir = options.from.startsWith('file:') || options.from.includes('://')
36
+ ? dirname(fileURLToPath(options.from))
37
+ : resolveExistingDir(options.from);
38
+ const requireFromBase = createRequire(join(fromDir, 'noop.js'));
39
+ const nodeModulesChain = buildNodeModulesChain(fromDir);
40
+ const discovered = [];
41
+ const notInstalled = [];
42
+ const untrustedSkipped = [];
43
+ const invalidManifest = [];
44
+ const sourceCount = countDiscoverySources(options);
45
+ if (sourceCount === 0) {
46
+ throw new Error('discoverRayfinDocsPackages requires an explicit source: pass `candidates`, `packageRoots`, or `scanInstalledPackages: true`.');
47
+ }
48
+ if (sourceCount > 1) {
49
+ throw new Error('discoverRayfinDocsPackages requires exactly one explicit source: pass only one of `candidates`, `packageRoots`, or `scanInstalledPackages: true`.');
50
+ }
51
+ let packagesToRead;
52
+ if (options.packageRoots !== undefined) {
53
+ packagesToRead = resolvePackageRootJsons(options.packageRoots);
54
+ }
55
+ else if (options.candidates !== undefined) {
56
+ packagesToRead = resolveCandidatePackageJsons(options.candidates, requireFromBase, nodeModulesChain, notInstalled, untrustedSkipped, trust);
57
+ }
58
+ else if (options.scanInstalledPackages) {
59
+ packagesToRead = discoverInstalledPackageJsons(nodeModulesChain);
60
+ }
61
+ else {
62
+ throw new Error('Invariant violation: explicit discovery source count did not match selected source.');
63
+ }
64
+ for (const { packageName: candidate, pkgJsonPath } of packagesToRead) {
65
+ let pkgJson;
66
+ try {
67
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
68
+ }
69
+ catch (err) {
70
+ invalidManifest.push({
71
+ packageName: candidate,
72
+ reason: `unreadable package.json: ${err.message}`,
73
+ });
74
+ continue;
75
+ }
76
+ const parsedName = pkgJson?.name;
77
+ const packageName = typeof parsedName === 'string' ? parsedName : candidate;
78
+ const hasRayfinDocs = typeof pkgJson === 'object' &&
79
+ pkgJson !== null &&
80
+ 'rayfinDocs' in pkgJson;
81
+ if (!hasRayfinDocs) {
82
+ continue;
83
+ }
84
+ const parsedVersion = pkgJson?.version;
85
+ if (typeof parsedVersion !== 'string' || parsedVersion.length === 0) {
86
+ invalidManifest.push({
87
+ packageName,
88
+ reason: 'package.json must declare a non-empty string version when rayfinDocs is present',
89
+ });
90
+ continue;
91
+ }
92
+ if (!trust(packageName)) {
93
+ untrustedSkipped.push(packageName);
94
+ continue;
95
+ }
96
+ const validation = validateRayfinDocsManifest(pkgJson, candidate);
97
+ if (!validation.ok) {
98
+ if (validation.kind === 'missing') {
99
+ // Package is installed but doesn't declare rayfinDocs. This is
100
+ // not an error - it's the common case for any package that
101
+ // hasn't migrated yet. Don't surface as invalid.
102
+ continue;
103
+ }
104
+ invalidManifest.push({
105
+ packageName: candidate,
106
+ reason: validation.reason,
107
+ });
108
+ continue;
109
+ }
110
+ const packageRoot = dirname(pkgJsonPath);
111
+ discovered.push({
112
+ packageName,
113
+ packageVersion: parsedVersion,
114
+ packageRoot,
115
+ manifest: validation.manifest,
116
+ });
117
+ }
118
+ return { discovered, notInstalled, untrustedSkipped, invalidManifest };
119
+ }
120
+ function countDiscoverySources(options) {
121
+ return [
122
+ options.packageRoots !== undefined,
123
+ options.candidates !== undefined,
124
+ options.scanInstalledPackages === true,
125
+ ].filter(Boolean).length;
126
+ }
127
+ function resolvePackageRootJsons(packageRoots) {
128
+ return packageRoots.map((packageRoot) => ({
129
+ packageName: packageRoot,
130
+ pkgJsonPath: join(resolveExistingDir(packageRoot), 'package.json'),
131
+ }));
132
+ }
133
+ function resolveCandidatePackageJsons(candidates, requireFromBase, nodeModulesChain, notInstalled, untrustedSkipped, trust) {
134
+ const resolved = [];
135
+ for (const candidate of candidates) {
136
+ if (!trust(candidate)) {
137
+ untrustedSkipped.push(candidate);
138
+ continue;
139
+ }
140
+ const pkgJsonPath = resolvePackageJson(requireFromBase, candidate, nodeModulesChain);
141
+ if (!pkgJsonPath) {
142
+ notInstalled.push(candidate);
143
+ continue;
144
+ }
145
+ resolved.push({ packageName: candidate, pkgJsonPath });
146
+ }
147
+ return resolved;
148
+ }
149
+ function discoverInstalledPackageJsons(nodeModulesChain) {
150
+ const discovered = [];
151
+ const seen = new Set();
152
+ for (const dir of nodeModulesChain) {
153
+ const nodeModules = join(dir, 'node_modules');
154
+ if (!existsSync(nodeModules) || !statSync(nodeModules).isDirectory()) {
155
+ continue;
156
+ }
157
+ for (const entry of readdirSync(nodeModules, { withFileTypes: true })) {
158
+ if ((!entry.isDirectory() && !entry.isSymbolicLink()) ||
159
+ entry.name.startsWith('.')) {
160
+ continue;
161
+ }
162
+ if (entry.name.startsWith('@')) {
163
+ const scopeRoot = join(nodeModules, entry.name);
164
+ for (const scoped of readdirSync(scopeRoot, { withFileTypes: true })) {
165
+ if (!scoped.isDirectory() && !scoped.isSymbolicLink())
166
+ continue;
167
+ const packageName = `${entry.name}/${scoped.name}`;
168
+ const pkgJsonPath = join(scopeRoot, scoped.name, 'package.json');
169
+ if (!seen.has(packageName) && existsSync(pkgJsonPath)) {
170
+ seen.add(packageName);
171
+ discovered.push({ packageName, pkgJsonPath });
172
+ }
173
+ }
174
+ }
175
+ else {
176
+ const packageName = entry.name;
177
+ const pkgJsonPath = join(nodeModules, entry.name, 'package.json');
178
+ if (!seen.has(packageName) && existsSync(pkgJsonPath)) {
179
+ seen.add(packageName);
180
+ discovered.push({ packageName, pkgJsonPath });
181
+ }
182
+ }
183
+ }
184
+ }
185
+ return discovered;
186
+ }
187
+ /**
188
+ * Validate the `rayfinDocs` field on a parsed `package.json`. Returns
189
+ * a discriminated result so callers can distinguish "no manifest"
190
+ * (silently skip) from "broken manifest" (surface as warning).
191
+ *
192
+ * Path validation rejects absolute paths and parent-directory
193
+ * traversal in `dir` - discovered packages must keep their docs
194
+ * inside their own root.
195
+ */
196
+ export function validateRayfinDocsManifest(pkgJson, packageName) {
197
+ if (!pkgJson || typeof pkgJson !== 'object') {
198
+ return {
199
+ ok: false,
200
+ kind: 'invalid',
201
+ reason: 'package.json is not an object',
202
+ };
203
+ }
204
+ const rec = pkgJson;
205
+ const raw = rec['rayfinDocs'];
206
+ if (raw === undefined) {
207
+ return { ok: false, kind: 'missing' };
208
+ }
209
+ if (!raw || typeof raw !== 'object') {
210
+ return {
211
+ ok: false,
212
+ kind: 'invalid',
213
+ reason: '`rayfinDocs` field is not an object',
214
+ };
215
+ }
216
+ const m = raw;
217
+ if (m['version'] !== 1) {
218
+ return {
219
+ ok: false,
220
+ kind: 'invalid',
221
+ reason: `unsupported rayfinDocs.version (got ${JSON.stringify(m['version'])}, expected 1); skipping ${packageName}`,
222
+ };
223
+ }
224
+ if (m['dir'] !== undefined && typeof m['dir'] !== 'string') {
225
+ return {
226
+ ok: false,
227
+ kind: 'invalid',
228
+ reason: '`rayfinDocs.dir` must be a non-empty string',
229
+ };
230
+ }
231
+ if (m['dir'] === '') {
232
+ return {
233
+ ok: false,
234
+ kind: 'invalid',
235
+ reason: '`rayfinDocs.dir` must be a non-empty string',
236
+ };
237
+ }
238
+ const dir = m['dir'] ?? 'assets/docs';
239
+ if (isAbsolute(dir)) {
240
+ return {
241
+ ok: false,
242
+ kind: 'invalid',
243
+ reason: '`rayfinDocs.dir` must be a relative path inside the package root',
244
+ };
245
+ }
246
+ // Reject `..` segments anywhere in the relative path. Segment-based
247
+ // check uniformly handles `..`, `./..`, `docs/..`, `a/../b`, and
248
+ // mixed separators.
249
+ const normalized = dir.replace(/\\/g, '/');
250
+ if (normalized.split('/').some((segment) => segment === '..')) {
251
+ return {
252
+ ok: false,
253
+ kind: 'invalid',
254
+ reason: '`rayfinDocs.dir` must not traverse outside the package root (no `..` segments)',
255
+ };
256
+ }
257
+ if (typeof m['module'] !== 'string' || m['module'].length === 0) {
258
+ return {
259
+ ok: false,
260
+ kind: 'invalid',
261
+ reason: '`rayfinDocs.module` must be a non-empty string',
262
+ };
263
+ }
264
+ if (m['kind'] !== 'guide' &&
265
+ m['kind'] !== 'host' &&
266
+ m['kind'] !== 'api-reference') {
267
+ return {
268
+ ok: false,
269
+ kind: 'invalid',
270
+ reason: `\`rayfinDocs.kind\` must be one of "guide" | "host" | "api-reference" (got ${JSON.stringify(m['kind'])}). ` +
271
+ `If you need a new kind, request it in the rayfin-docs repo: ` +
272
+ `https://github.com/microsoft/project-rayfin/issues/new`,
273
+ };
274
+ }
275
+ return {
276
+ ok: true,
277
+ manifest: {
278
+ version: 1,
279
+ dir,
280
+ module: m['module'],
281
+ kind: m['kind'],
282
+ },
283
+ };
284
+ }
285
+ /**
286
+ * Map a `DocKind` to the legacy `DocModule` value used throughout the
287
+ * existing CLI/MCP surfaces. Phase 2 keeps the `DocModule` union for
288
+ * back-compat; `--module ts-sdk` continues to mean "all api-reference
289
+ * docs" for end users.
290
+ */
291
+ export function docKindToModule(kind) {
292
+ switch (kind) {
293
+ case 'guide':
294
+ return 'guide';
295
+ case 'host':
296
+ return 'host';
297
+ case 'api-reference':
298
+ return 'ts-sdk';
299
+ }
300
+ }
301
+ /**
302
+ * Compute the chain of `node_modules` ancestor directories from
303
+ * `fromDir` upward, capped at 16 levels. Used by the tier-3 fallback
304
+ * in {@link resolvePackageJson} to avoid re-walking the same ancestors
305
+ * once per candidate (`9 candidates × 16 walks = 144 stat calls` in
306
+ * the original implementation; this caches the chain to 16 stats).
307
+ */
308
+ function buildNodeModulesChain(fromDir) {
309
+ const chain = [];
310
+ let dir = fromDir;
311
+ for (let i = 0; i < 16; i += 1) {
312
+ chain.push(dir);
313
+ const parent = dirname(dir);
314
+ if (parent === dir)
315
+ break;
316
+ dir = parent;
317
+ }
318
+ return chain;
319
+ }
320
+ /**
321
+ * Find a package's `package.json` path even when the package's
322
+ * `exports` field doesn't expose it as a subpath OR when the package
323
+ * is ESM-only (`exports."."` is `{ "import": ... }` without a CJS
324
+ * `default`/`require` branch).
325
+ *
326
+ * SDK packages with restricted `exports` typically don't list
327
+ * `./package.json` and so `require.resolve('<pkg>/package.json')`
328
+ * throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. ESM-only packages additionally
329
+ * fail `require.resolve('<pkg>')` (CJS resolution can't see the
330
+ * `import:` branch). We finally fall back to walking the node_modules
331
+ * tree from the resolution base - this matches what every npm/pnpm
332
+ * tool does internally.
333
+ *
334
+ * Returns the absolute path to `package.json`, or `undefined` when the
335
+ * package isn't installed at all.
336
+ */
337
+ function resolvePackageJson(req, packageName, nodeModulesChain) {
338
+ // Fast path: works when the package omits `exports` or explicitly
339
+ // includes `./package.json` in it.
340
+ try {
341
+ return req.resolve(`${packageName}/package.json`);
342
+ }
343
+ catch (err) {
344
+ const code = err?.code;
345
+ if (code === 'MODULE_NOT_FOUND') {
346
+ // Package isn't installed.
347
+ return undefined;
348
+ }
349
+ // ERR_PACKAGE_PATH_NOT_EXPORTED or similar: keep going.
350
+ }
351
+ // Second fall-back: resolve the main entry, then walk up directories
352
+ // searching for a package.json whose name matches.
353
+ try {
354
+ const entry = req.resolve(packageName);
355
+ let dir = dirname(entry);
356
+ for (let i = 0; i < 16; i += 1) {
357
+ const candidate = join(dir, 'package.json');
358
+ if (existsSync(candidate)) {
359
+ try {
360
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
361
+ if (parsed.name === packageName) {
362
+ return candidate;
363
+ }
364
+ }
365
+ catch {
366
+ // keep walking
367
+ }
368
+ }
369
+ const parent = dirname(dir);
370
+ if (parent === dir)
371
+ break;
372
+ dir = parent;
373
+ }
374
+ }
375
+ catch {
376
+ // ESM-only package or similar: fall through to the node_modules walk.
377
+ }
378
+ // Third fall-back: walk the precomputed `node_modules` ancestor
379
+ // chain looking for `<ancestor>/node_modules/<packageName>/package.json`.
380
+ // This is what npm/pnpm/yarn tooling does internally and works
381
+ // regardless of how the package's `exports` are configured.
382
+ for (const dir of nodeModulesChain) {
383
+ const candidate = join(dir, 'node_modules', packageName, 'package.json');
384
+ if (existsSync(candidate)) {
385
+ return candidate;
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+ /** Internal: ensure `from` exists for `createRequire` when given a dir. */
391
+ function resolveExistingDir(from) {
392
+ const absolute = resolve(from);
393
+ if (!existsSync(absolute)) {
394
+ throw new Error(`discoverRayfinDocsPackages: \`from\` path does not exist: ${absolute}`);
395
+ }
396
+ const st = statSync(absolute);
397
+ if (!st.isDirectory()) {
398
+ throw new Error(`discoverRayfinDocsPackages: \`from\` must be a directory: ${absolute}`);
399
+ }
400
+ return absolute;
401
+ }
402
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1,91 @@
1
+ import { type DiscoveryResult, type DiscoveryOptions } from './discovery.js';
2
+ import type { DocEntry, DocListItem, DocModule, DocSearchScope, DocSearchResult, DocSectionRef } from './types.js';
3
+ export interface DocsIndexCacheOptions {
4
+ /**
5
+ * Directory used for on-disk index cache files. Defaults to
6
+ * `~/.rayfin/cache` with a temp-directory fallback for write failures.
7
+ */
8
+ dir?: string;
9
+ /**
10
+ * Whether to read an existing cache file. Set `false` for CLI/MCP
11
+ * `--no-cache` behavior while still updating the cache after rebuild.
12
+ */
13
+ read?: boolean;
14
+ /** Whether to write a rebuilt cache file. Defaults to `true`. */
15
+ write?: boolean;
16
+ }
17
+ /**
18
+ * Skip reading existing on-disk cache files while still writing a fresh index
19
+ * after rebuild. Used by CLI/MCP `--no-cache` flags.
20
+ */
21
+ export declare const DOCS_CACHE_SKIP_READ: DocsIndexCacheOptions;
22
+ export declare const DOCS_NO_CACHE_OPTION_DESCRIPTION = "Bypass any existing on-disk docs index cache and rebuild from installed package docs.";
23
+ /**
24
+ * Default loaded module set when no `modules` option is provided. Mirrors
25
+ * the MCP `start` default and the CLI's builder default. Host docs are
26
+ * .NET reference content (DocFX-generated, ~70% of corpus) and are
27
+ * loaded only when explicitly requested via `--module host` (CLI) or
28
+ * `--host-docs` (MCP).
29
+ *
30
+ * Exported so the CLI command group, the MCP `start` command, and the
31
+ * build-time prebuilt-index emitter all read the same constant - changing
32
+ * the default loaded set requires editing one site.
33
+ */
34
+ export declare const DOCS_DEFAULT_MODULES: readonly DocModule[];
35
+ export declare function getKnownRayfinDocsPackages(): readonly string[];
36
+ export type { DocEntry, DocListItem, DocModule, DocSearchResult, DocSearchScope, DocSection, DocSectionRef, DocSource, DocKind, RayfinDocsManifest, } from './types.js';
37
+ export { type DiscoveredPackage, type DiscoveryResult, type DiscoveryOptions, type TrustPredicate, defaultTrust, discoverRayfinDocsPackages, validateRayfinDocsManifest, docKindToModule, } from './discovery.js';
38
+ export { type Catalog, type CatalogPackage, type CatalogPackageKind, type DiscoverItem, type DiscoverScore, getCatalog, discoverPackages, mapDiscoverResult, deriveInstallCommand, deriveUpdateCommand, _resetCatalogForTesting, } from './catalog.js';
39
+ export declare class DocsService {
40
+ private entries;
41
+ private entriesById;
42
+ private symbolToSections;
43
+ private searchIndex;
44
+ private discoveryReport?;
45
+ private hydrateFromPrebuilt;
46
+ constructor(options: {
47
+ /**
48
+ * Directory containing the legacy bundled corpus layout
49
+ * `docs/<module>/...` plus an optional `docs.search.json` prebuilt
50
+ * index. At least one of `assetsRoot` and `discover` must be set.
51
+ */
52
+ assetsRoot?: string;
53
+ /**
54
+ * Phase 2+ source: discover packages declaring the `rayfinDocs` field
55
+ * and load each declaring package's docs. If `candidates` is omitted,
56
+ * DocsService probes only Rayfin's known docs package list instead of
57
+ * walking the entire `node_modules` tree. The `from` argument is passed
58
+ * to {@link discoverRayfinDocsPackages} and used as the `createRequire`
59
+ * base.
60
+ */
61
+ discover?: DiscoveryOptions;
62
+ /**
63
+ * Optional filter applied to both legacy `assetsRoot` docs and
64
+ * discovered package docs.
65
+ */
66
+ modules?: readonly DocModule[];
67
+ /**
68
+ * Optional on-disk cache for indexes built from discovered installed
69
+ * packages. Enabled by default for discover-only mode so CLI/MCP cold
70
+ * starts stay fast while docs remain version-locked to installed package
71
+ * versions. Pass `false` or `{ read: false }` for `--no-cache` behavior.
72
+ */
73
+ cache?: boolean | DocsIndexCacheOptions;
74
+ });
75
+ /**
76
+ * Returns a frozen snapshot of the discovery report from
77
+ * construction, if `discover` was passed. `undefined` when no
78
+ * discovery ran (bundled-only mode). Useful for surfacing "package X
79
+ * is in your trust list but not installed" or "manifest invalid;
80
+ * skipped" diagnostics. The returned object is deep-frozen so
81
+ * consumers cannot mutate the service's internal state.
82
+ */
83
+ getDiscoveryReport(): DiscoveryResult | undefined;
84
+ listDocs(module?: DocModule): DocListItem[];
85
+ getDocById(id: string): DocEntry | undefined;
86
+ getDocsByPath(path: string, module?: DocModule): DocEntry[];
87
+ getDocByPath(path: string, module?: DocModule): DocEntry | undefined;
88
+ searchDocs(query: string, module?: DocModule, limit?: number, scope?: DocSearchScope): DocSearchResult[];
89
+ getSymbolDocs(symbol: string, module?: DocModule): DocSectionRef[];
90
+ }
91
+ //# sourceMappingURL=index.d.ts.map