@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,409 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultTrust = void 0;
4
+ exports.hasExplicitDiscoverySource = hasExplicitDiscoverySource;
5
+ exports.discoverRayfinDocsPackages = discoverRayfinDocsPackages;
6
+ exports.validateRayfinDocsManifest = validateRayfinDocsManifest;
7
+ exports.docKindToModule = docKindToModule;
8
+ /**
9
+ * Per-package docs discovery - Phase 2 of `docs-package-architecture`.
10
+ *
11
+ * Walks a project's `node_modules` to find packages declaring the
12
+ * `rayfinDocs` field in their `package.json`. Returns a list of
13
+ * discovered package roots so {@link DocsService} can load each tree's
14
+ * markdown alongside any explicit `assetsRoot`.
15
+ *
16
+ * **Trust model.** Discovery is conservative by default: only
17
+ * `@microsoft/rayfin-*` scoped packages are auto-trusted. Anything else
18
+ * requires the caller to pass an explicit `trust` predicate. This
19
+ * matches the spec-review resolution that broad walking is too trusting
20
+ * (see `openspec/changes/docs-package-architecture/spec-review-findings.md`,
21
+ * finding B3).
22
+ *
23
+ * Discovery is intentionally explicit: callers choose either a fixed
24
+ * candidate list, workspace package roots, or all-installed node_modules
25
+ * walking via `scanInstalledPackages: true`.
26
+ */
27
+ const fs_1 = require("fs");
28
+ const module_1 = require("module");
29
+ const path_1 = require("path");
30
+ const url_1 = require("url");
31
+ /** Default trust predicate. Auto-trusts the Microsoft Rayfin namespace. */
32
+ const defaultTrust = (packageName) => packageName.startsWith('@microsoft/rayfin-');
33
+ exports.defaultTrust = defaultTrust;
34
+ /**
35
+ * Returns whether callers already selected a discovery source.
36
+ * Wrappers use this to decide whether to inject their own bounded defaults.
37
+ */
38
+ function hasExplicitDiscoverySource(options) {
39
+ return countDiscoverySources(options) > 0;
40
+ }
41
+ function discoverRayfinDocsPackages(options) {
42
+ const trust = options.trust ?? exports.defaultTrust;
43
+ const fromDir = options.from.startsWith('file:') || options.from.includes('://')
44
+ ? (0, path_1.dirname)((0, url_1.fileURLToPath)(options.from))
45
+ : resolveExistingDir(options.from);
46
+ const requireFromBase = (0, module_1.createRequire)((0, path_1.join)(fromDir, 'noop.js'));
47
+ const nodeModulesChain = buildNodeModulesChain(fromDir);
48
+ const discovered = [];
49
+ const notInstalled = [];
50
+ const untrustedSkipped = [];
51
+ const invalidManifest = [];
52
+ const sourceCount = countDiscoverySources(options);
53
+ if (sourceCount === 0) {
54
+ throw new Error('discoverRayfinDocsPackages requires an explicit source: pass `candidates`, `packageRoots`, or `scanInstalledPackages: true`.');
55
+ }
56
+ if (sourceCount > 1) {
57
+ throw new Error('discoverRayfinDocsPackages requires exactly one explicit source: pass only one of `candidates`, `packageRoots`, or `scanInstalledPackages: true`.');
58
+ }
59
+ let packagesToRead;
60
+ if (options.packageRoots !== undefined) {
61
+ packagesToRead = resolvePackageRootJsons(options.packageRoots);
62
+ }
63
+ else if (options.candidates !== undefined) {
64
+ packagesToRead = resolveCandidatePackageJsons(options.candidates, requireFromBase, nodeModulesChain, notInstalled, untrustedSkipped, trust);
65
+ }
66
+ else if (options.scanInstalledPackages) {
67
+ packagesToRead = discoverInstalledPackageJsons(nodeModulesChain);
68
+ }
69
+ else {
70
+ throw new Error('Invariant violation: explicit discovery source count did not match selected source.');
71
+ }
72
+ for (const { packageName: candidate, pkgJsonPath } of packagesToRead) {
73
+ let pkgJson;
74
+ try {
75
+ pkgJson = JSON.parse((0, fs_1.readFileSync)(pkgJsonPath, 'utf8'));
76
+ }
77
+ catch (err) {
78
+ invalidManifest.push({
79
+ packageName: candidate,
80
+ reason: `unreadable package.json: ${err.message}`,
81
+ });
82
+ continue;
83
+ }
84
+ const parsedName = pkgJson?.name;
85
+ const packageName = typeof parsedName === 'string' ? parsedName : candidate;
86
+ const hasRayfinDocs = typeof pkgJson === 'object' &&
87
+ pkgJson !== null &&
88
+ 'rayfinDocs' in pkgJson;
89
+ if (!hasRayfinDocs) {
90
+ continue;
91
+ }
92
+ const parsedVersion = pkgJson?.version;
93
+ if (typeof parsedVersion !== 'string' || parsedVersion.length === 0) {
94
+ invalidManifest.push({
95
+ packageName,
96
+ reason: 'package.json must declare a non-empty string version when rayfinDocs is present',
97
+ });
98
+ continue;
99
+ }
100
+ if (!trust(packageName)) {
101
+ untrustedSkipped.push(packageName);
102
+ continue;
103
+ }
104
+ const validation = validateRayfinDocsManifest(pkgJson, candidate);
105
+ if (!validation.ok) {
106
+ if (validation.kind === 'missing') {
107
+ // Package is installed but doesn't declare rayfinDocs. This is
108
+ // not an error - it's the common case for any package that
109
+ // hasn't migrated yet. Don't surface as invalid.
110
+ continue;
111
+ }
112
+ invalidManifest.push({
113
+ packageName: candidate,
114
+ reason: validation.reason,
115
+ });
116
+ continue;
117
+ }
118
+ const packageRoot = (0, path_1.dirname)(pkgJsonPath);
119
+ discovered.push({
120
+ packageName,
121
+ packageVersion: parsedVersion,
122
+ packageRoot,
123
+ manifest: validation.manifest,
124
+ });
125
+ }
126
+ return { discovered, notInstalled, untrustedSkipped, invalidManifest };
127
+ }
128
+ function countDiscoverySources(options) {
129
+ return [
130
+ options.packageRoots !== undefined,
131
+ options.candidates !== undefined,
132
+ options.scanInstalledPackages === true,
133
+ ].filter(Boolean).length;
134
+ }
135
+ function resolvePackageRootJsons(packageRoots) {
136
+ return packageRoots.map((packageRoot) => ({
137
+ packageName: packageRoot,
138
+ pkgJsonPath: (0, path_1.join)(resolveExistingDir(packageRoot), 'package.json'),
139
+ }));
140
+ }
141
+ function resolveCandidatePackageJsons(candidates, requireFromBase, nodeModulesChain, notInstalled, untrustedSkipped, trust) {
142
+ const resolved = [];
143
+ for (const candidate of candidates) {
144
+ if (!trust(candidate)) {
145
+ untrustedSkipped.push(candidate);
146
+ continue;
147
+ }
148
+ const pkgJsonPath = resolvePackageJson(requireFromBase, candidate, nodeModulesChain);
149
+ if (!pkgJsonPath) {
150
+ notInstalled.push(candidate);
151
+ continue;
152
+ }
153
+ resolved.push({ packageName: candidate, pkgJsonPath });
154
+ }
155
+ return resolved;
156
+ }
157
+ function discoverInstalledPackageJsons(nodeModulesChain) {
158
+ const discovered = [];
159
+ const seen = new Set();
160
+ for (const dir of nodeModulesChain) {
161
+ const nodeModules = (0, path_1.join)(dir, 'node_modules');
162
+ if (!(0, fs_1.existsSync)(nodeModules) || !(0, fs_1.statSync)(nodeModules).isDirectory()) {
163
+ continue;
164
+ }
165
+ for (const entry of (0, fs_1.readdirSync)(nodeModules, { withFileTypes: true })) {
166
+ if ((!entry.isDirectory() && !entry.isSymbolicLink()) ||
167
+ entry.name.startsWith('.')) {
168
+ continue;
169
+ }
170
+ if (entry.name.startsWith('@')) {
171
+ const scopeRoot = (0, path_1.join)(nodeModules, entry.name);
172
+ for (const scoped of (0, fs_1.readdirSync)(scopeRoot, { withFileTypes: true })) {
173
+ if (!scoped.isDirectory() && !scoped.isSymbolicLink())
174
+ continue;
175
+ const packageName = `${entry.name}/${scoped.name}`;
176
+ const pkgJsonPath = (0, path_1.join)(scopeRoot, scoped.name, 'package.json');
177
+ if (!seen.has(packageName) && (0, fs_1.existsSync)(pkgJsonPath)) {
178
+ seen.add(packageName);
179
+ discovered.push({ packageName, pkgJsonPath });
180
+ }
181
+ }
182
+ }
183
+ else {
184
+ const packageName = entry.name;
185
+ const pkgJsonPath = (0, path_1.join)(nodeModules, entry.name, 'package.json');
186
+ if (!seen.has(packageName) && (0, fs_1.existsSync)(pkgJsonPath)) {
187
+ seen.add(packageName);
188
+ discovered.push({ packageName, pkgJsonPath });
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return discovered;
194
+ }
195
+ /**
196
+ * Validate the `rayfinDocs` field on a parsed `package.json`. Returns
197
+ * a discriminated result so callers can distinguish "no manifest"
198
+ * (silently skip) from "broken manifest" (surface as warning).
199
+ *
200
+ * Path validation rejects absolute paths and parent-directory
201
+ * traversal in `dir` - discovered packages must keep their docs
202
+ * inside their own root.
203
+ */
204
+ function validateRayfinDocsManifest(pkgJson, packageName) {
205
+ if (!pkgJson || typeof pkgJson !== 'object') {
206
+ return {
207
+ ok: false,
208
+ kind: 'invalid',
209
+ reason: 'package.json is not an object',
210
+ };
211
+ }
212
+ const rec = pkgJson;
213
+ const raw = rec['rayfinDocs'];
214
+ if (raw === undefined) {
215
+ return { ok: false, kind: 'missing' };
216
+ }
217
+ if (!raw || typeof raw !== 'object') {
218
+ return {
219
+ ok: false,
220
+ kind: 'invalid',
221
+ reason: '`rayfinDocs` field is not an object',
222
+ };
223
+ }
224
+ const m = raw;
225
+ if (m['version'] !== 1) {
226
+ return {
227
+ ok: false,
228
+ kind: 'invalid',
229
+ reason: `unsupported rayfinDocs.version (got ${JSON.stringify(m['version'])}, expected 1); skipping ${packageName}`,
230
+ };
231
+ }
232
+ if (m['dir'] !== undefined && typeof m['dir'] !== 'string') {
233
+ return {
234
+ ok: false,
235
+ kind: 'invalid',
236
+ reason: '`rayfinDocs.dir` must be a non-empty string',
237
+ };
238
+ }
239
+ if (m['dir'] === '') {
240
+ return {
241
+ ok: false,
242
+ kind: 'invalid',
243
+ reason: '`rayfinDocs.dir` must be a non-empty string',
244
+ };
245
+ }
246
+ const dir = m['dir'] ?? 'assets/docs';
247
+ if ((0, path_1.isAbsolute)(dir)) {
248
+ return {
249
+ ok: false,
250
+ kind: 'invalid',
251
+ reason: '`rayfinDocs.dir` must be a relative path inside the package root',
252
+ };
253
+ }
254
+ // Reject `..` segments anywhere in the relative path. Segment-based
255
+ // check uniformly handles `..`, `./..`, `docs/..`, `a/../b`, and
256
+ // mixed separators.
257
+ const normalized = dir.replace(/\\/g, '/');
258
+ if (normalized.split('/').some((segment) => segment === '..')) {
259
+ return {
260
+ ok: false,
261
+ kind: 'invalid',
262
+ reason: '`rayfinDocs.dir` must not traverse outside the package root (no `..` segments)',
263
+ };
264
+ }
265
+ if (typeof m['module'] !== 'string' || m['module'].length === 0) {
266
+ return {
267
+ ok: false,
268
+ kind: 'invalid',
269
+ reason: '`rayfinDocs.module` must be a non-empty string',
270
+ };
271
+ }
272
+ if (m['kind'] !== 'guide' &&
273
+ m['kind'] !== 'host' &&
274
+ m['kind'] !== 'api-reference') {
275
+ return {
276
+ ok: false,
277
+ kind: 'invalid',
278
+ reason: `\`rayfinDocs.kind\` must be one of "guide" | "host" | "api-reference" (got ${JSON.stringify(m['kind'])}). ` +
279
+ `If you need a new kind, request it in the rayfin-docs repo: ` +
280
+ `https://github.com/microsoft/project-rayfin/issues/new`,
281
+ };
282
+ }
283
+ return {
284
+ ok: true,
285
+ manifest: {
286
+ version: 1,
287
+ dir,
288
+ module: m['module'],
289
+ kind: m['kind'],
290
+ },
291
+ };
292
+ }
293
+ /**
294
+ * Map a `DocKind` to the legacy `DocModule` value used throughout the
295
+ * existing CLI/MCP surfaces. Phase 2 keeps the `DocModule` union for
296
+ * back-compat; `--module ts-sdk` continues to mean "all api-reference
297
+ * docs" for end users.
298
+ */
299
+ function docKindToModule(kind) {
300
+ switch (kind) {
301
+ case 'guide':
302
+ return 'guide';
303
+ case 'host':
304
+ return 'host';
305
+ case 'api-reference':
306
+ return 'ts-sdk';
307
+ }
308
+ }
309
+ /**
310
+ * Compute the chain of `node_modules` ancestor directories from
311
+ * `fromDir` upward, capped at 16 levels. Used by the tier-3 fallback
312
+ * in {@link resolvePackageJson} to avoid re-walking the same ancestors
313
+ * once per candidate (`9 candidates × 16 walks = 144 stat calls` in
314
+ * the original implementation; this caches the chain to 16 stats).
315
+ */
316
+ function buildNodeModulesChain(fromDir) {
317
+ const chain = [];
318
+ let dir = fromDir;
319
+ for (let i = 0; i < 16; i += 1) {
320
+ chain.push(dir);
321
+ const parent = (0, path_1.dirname)(dir);
322
+ if (parent === dir)
323
+ break;
324
+ dir = parent;
325
+ }
326
+ return chain;
327
+ }
328
+ /**
329
+ * Find a package's `package.json` path even when the package's
330
+ * `exports` field doesn't expose it as a subpath OR when the package
331
+ * is ESM-only (`exports."."` is `{ "import": ... }` without a CJS
332
+ * `default`/`require` branch).
333
+ *
334
+ * SDK packages with restricted `exports` typically don't list
335
+ * `./package.json` and so `require.resolve('<pkg>/package.json')`
336
+ * throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. ESM-only packages additionally
337
+ * fail `require.resolve('<pkg>')` (CJS resolution can't see the
338
+ * `import:` branch). We finally fall back to walking the node_modules
339
+ * tree from the resolution base - this matches what every npm/pnpm
340
+ * tool does internally.
341
+ *
342
+ * Returns the absolute path to `package.json`, or `undefined` when the
343
+ * package isn't installed at all.
344
+ */
345
+ function resolvePackageJson(req, packageName, nodeModulesChain) {
346
+ // Fast path: works when the package omits `exports` or explicitly
347
+ // includes `./package.json` in it.
348
+ try {
349
+ return req.resolve(`${packageName}/package.json`);
350
+ }
351
+ catch (err) {
352
+ const code = err?.code;
353
+ if (code === 'MODULE_NOT_FOUND') {
354
+ // Package isn't installed.
355
+ return undefined;
356
+ }
357
+ // ERR_PACKAGE_PATH_NOT_EXPORTED or similar: keep going.
358
+ }
359
+ // Second fall-back: resolve the main entry, then walk up directories
360
+ // searching for a package.json whose name matches.
361
+ try {
362
+ const entry = req.resolve(packageName);
363
+ let dir = (0, path_1.dirname)(entry);
364
+ for (let i = 0; i < 16; i += 1) {
365
+ const candidate = (0, path_1.join)(dir, 'package.json');
366
+ if ((0, fs_1.existsSync)(candidate)) {
367
+ try {
368
+ const parsed = JSON.parse((0, fs_1.readFileSync)(candidate, 'utf8'));
369
+ if (parsed.name === packageName) {
370
+ return candidate;
371
+ }
372
+ }
373
+ catch {
374
+ // keep walking
375
+ }
376
+ }
377
+ const parent = (0, path_1.dirname)(dir);
378
+ if (parent === dir)
379
+ break;
380
+ dir = parent;
381
+ }
382
+ }
383
+ catch {
384
+ // ESM-only package or similar: fall through to the node_modules walk.
385
+ }
386
+ // Third fall-back: walk the precomputed `node_modules` ancestor
387
+ // chain looking for `<ancestor>/node_modules/<packageName>/package.json`.
388
+ // This is what npm/pnpm/yarn tooling does internally and works
389
+ // regardless of how the package's `exports` are configured.
390
+ for (const dir of nodeModulesChain) {
391
+ const candidate = (0, path_1.join)(dir, 'node_modules', packageName, 'package.json');
392
+ if ((0, fs_1.existsSync)(candidate)) {
393
+ return candidate;
394
+ }
395
+ }
396
+ return undefined;
397
+ }
398
+ /** Internal: ensure `from` exists for `createRequire` when given a dir. */
399
+ function resolveExistingDir(from) {
400
+ const absolute = (0, path_1.resolve)(from);
401
+ if (!(0, fs_1.existsSync)(absolute)) {
402
+ throw new Error(`discoverRayfinDocsPackages: \`from\` path does not exist: ${absolute}`);
403
+ }
404
+ const st = (0, fs_1.statSync)(absolute);
405
+ if (!st.isDirectory()) {
406
+ throw new Error(`discoverRayfinDocsPackages: \`from\` must be a directory: ${absolute}`);
407
+ }
408
+ return absolute;
409
+ }
@@ -0,0 +1 @@
1
+ { "type": "commonjs" }\n
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,89 @@
1
+ export type DocModule = 'guide' | 'host' | 'ts-sdk';
2
+ export type DocKind = 'guide' | 'host' | 'api-reference';
3
+ export type DocSearchScope = 'docs' | 'symbols' | 'all';
4
+ /**
5
+ * `rayfinDocs` package.json field declaring where a package's docs live
6
+ * and what kind they are. Read by the discovery walker; the manifest
7
+ * is per-package and points at a single doc tree.
8
+ */
9
+ export interface RayfinDocsManifest {
10
+ version: 1;
11
+ /** Path to the docs root, relative to package.json's directory. */
12
+ dir: string;
13
+ /** Stable, human-readable module identity. Conventionally the
14
+ * unscoped package name (e.g. `rayfin-core`, `rayfin-guide`,
15
+ * `rayfin-host`). */
16
+ module: string;
17
+ /** Doc kind. Maps to the legacy `DocModule` for back-compat:
18
+ * - `guide` → `module: guide`
19
+ * - `host` → `module: host`
20
+ * - `api-reference` → `module: ts-sdk` */
21
+ kind: DocKind;
22
+ }
23
+ export interface DocSection {
24
+ heading: string;
25
+ level: number;
26
+ content: string;
27
+ symbols: string[];
28
+ }
29
+ export interface DocEntry {
30
+ id: string;
31
+ module: DocModule;
32
+ path: string;
33
+ title: string;
34
+ content: string;
35
+ symbols: string[];
36
+ sections: DocSection[];
37
+ /** When this entry came from a discovered package (Phase 2+), the
38
+ * identifying package metadata. `undefined` for entries loaded from
39
+ * the bundled `assetsRoot` (Phase 1 mode). */
40
+ source?: DocSource;
41
+ }
42
+ export interface DocSource {
43
+ /** Manifest module identity (e.g. `rayfin-core`). */
44
+ module: string;
45
+ /** Doc kind from the manifest. */
46
+ kind: DocKind;
47
+ /** Package name from the manifest's `package.json` (e.g.
48
+ * `@microsoft/rayfin-core`). The package's filesystem path is
49
+ * intentionally NOT exposed in this public type — it leaks the
50
+ * user's home directory into MCP/CLI output. Internal callers that
51
+ * need the absolute path use the `DiscoveredPackage.packageRoot`
52
+ * field on the discovery report instead. */
53
+ packageName: string;
54
+ /** Version from the source package's `package.json`. */
55
+ packageVersion: string;
56
+ }
57
+ export interface DocListItem {
58
+ id: string;
59
+ module: DocModule;
60
+ path: string;
61
+ title: string;
62
+ source?: DocSource;
63
+ }
64
+ export interface DocSearchResult {
65
+ id: string;
66
+ module: DocModule;
67
+ path: string;
68
+ title: string;
69
+ heading?: string;
70
+ symbol?: string;
71
+ kind?: 'doc' | 'symbol';
72
+ snippet: string;
73
+ snippetStart: number;
74
+ snippetEnd: number;
75
+ score: number;
76
+ symbols: string[];
77
+ source?: DocSource;
78
+ }
79
+ export interface DocSectionRef {
80
+ symbol: string;
81
+ entryId: string;
82
+ module: DocModule;
83
+ path: string;
84
+ title: string;
85
+ heading: string;
86
+ content: string;
87
+ source?: DocSource;
88
+ }
89
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@microsoft/rayfin-docs",
3
+ "version": "1.27.0",
4
+ "description": "Rayfin docs indexing and search library — used by the MCP server, CLI, and other doc-facing surfaces",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./_internal/build-index": {
14
+ "types": "./dist/_internal/build-index.d.ts",
15
+ "import": "./dist/_internal/build-index.js"
16
+ },
17
+ "./_internal/site-discovery": {
18
+ "types": "./dist/discovery.d.ts",
19
+ "import": "./dist/discovery.js",
20
+ "require": "./dist/site-cjs/discovery.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist/**/*.js",
26
+ "dist/**/*.d.ts",
27
+ "dist/**/*.json",
28
+ "!dist/cjs/**",
29
+ "!dist/**/__tests__/**",
30
+ "assets/catalog.json",
31
+ "LICENSE"
32
+ ],
33
+ "dependencies": {
34
+ "minisearch": "~7.2.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.0.10",
38
+ "eslint": "^9.28.0",
39
+ "prettier": "^3.5.3",
40
+ "rimraf": "~6.0.1",
41
+ "typescript": "^5.8.3",
42
+ "vitest": "^3.2.3",
43
+ "@vitest/coverage-v8": "~3.2.4"
44
+ },
45
+ "publishConfig": {
46
+ "registry": "https://npm.pkg.github.com",
47
+ "access": "restricted"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/microsoft/project-rayfin.git",
52
+ "directory": "packages/tools/docs-lib"
53
+ },
54
+ "license": "MIT",
55
+ "scripts": {
56
+ "build": "tsc && tsc -p tsconfig.site-cjs.json && node scripts/write-site-cjs-package-json.mjs",
57
+ "build:watch": "tsc --watch",
58
+ "clean": "rimraf dist .tsbuildinfo .tsbuildinfo.site-cjs",
59
+ "test": "vitest --run",
60
+ "test:watch": "vitest"
61
+ }
62
+ }