docspack 0.4.0 → 1.1.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 (91) hide show
  1. package/README.md +31 -0
  2. package/dist/agent.d.ts +48 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +243 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/artifact.d.ts +32 -0
  7. package/dist/artifact.d.ts.map +1 -0
  8. package/dist/artifact.js +78 -0
  9. package/dist/artifact.js.map +1 -0
  10. package/dist/build.d.ts +23 -0
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +201 -96
  13. package/dist/build.js.map +1 -1
  14. package/dist/changed.d.ts +31 -0
  15. package/dist/changed.d.ts.map +1 -0
  16. package/dist/changed.js +71 -0
  17. package/dist/changed.js.map +1 -0
  18. package/dist/cli.js +222 -12
  19. package/dist/cli.js.map +1 -1
  20. package/dist/coverage.d.ts +35 -0
  21. package/dist/coverage.d.ts.map +1 -0
  22. package/dist/coverage.js +64 -0
  23. package/dist/coverage.js.map +1 -0
  24. package/dist/db.d.ts +75 -2
  25. package/dist/db.d.ts.map +1 -1
  26. package/dist/db.js +168 -6
  27. package/dist/db.js.map +1 -1
  28. package/dist/discovery.d.ts +14 -0
  29. package/dist/discovery.d.ts.map +1 -1
  30. package/dist/discovery.js +31 -6
  31. package/dist/discovery.js.map +1 -1
  32. package/dist/doctor.d.ts.map +1 -1
  33. package/dist/doctor.js +60 -9
  34. package/dist/doctor.js.map +1 -1
  35. package/dist/endpoints.d.ts +45 -0
  36. package/dist/endpoints.d.ts.map +1 -0
  37. package/dist/endpoints.js +155 -0
  38. package/dist/endpoints.js.map +1 -0
  39. package/dist/help.d.ts.map +1 -1
  40. package/dist/help.js +120 -5
  41. package/dist/help.js.map +1 -1
  42. package/dist/index.d.ts +10 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +10 -4
  45. package/dist/index.js.map +1 -1
  46. package/dist/local.d.ts +67 -0
  47. package/dist/local.d.ts.map +1 -0
  48. package/dist/local.js +242 -0
  49. package/dist/local.js.map +1 -0
  50. package/dist/mcp.d.ts.map +1 -1
  51. package/dist/mcp.js +3 -0
  52. package/dist/mcp.js.map +1 -1
  53. package/dist/preview.d.ts.map +1 -1
  54. package/dist/preview.js +26 -7
  55. package/dist/preview.js.map +1 -1
  56. package/dist/search.d.ts +22 -1
  57. package/dist/search.d.ts.map +1 -1
  58. package/dist/search.js +147 -22
  59. package/dist/search.js.map +1 -1
  60. package/dist/surface.d.ts +45 -0
  61. package/dist/surface.d.ts.map +1 -0
  62. package/dist/surface.js +208 -0
  63. package/dist/surface.js.map +1 -0
  64. package/dist/sync.d.ts +8 -1
  65. package/dist/sync.d.ts.map +1 -1
  66. package/dist/sync.js +50 -1
  67. package/dist/sync.js.map +1 -1
  68. package/dist/verify.d.ts +9 -0
  69. package/dist/verify.d.ts.map +1 -1
  70. package/dist/verify.js +1 -1
  71. package/dist/verify.js.map +1 -1
  72. package/package.json +7 -5
  73. package/src/agent.ts +308 -0
  74. package/src/artifact.ts +110 -0
  75. package/src/build.ts +240 -111
  76. package/src/changed.ts +99 -0
  77. package/src/cli.ts +263 -12
  78. package/src/coverage.ts +96 -0
  79. package/src/db.ts +250 -7
  80. package/src/discovery.ts +40 -5
  81. package/src/doctor.ts +68 -8
  82. package/src/endpoints.ts +184 -0
  83. package/src/help.ts +120 -5
  84. package/src/index.ts +52 -1
  85. package/src/local.ts +335 -0
  86. package/src/mcp.ts +3 -0
  87. package/src/preview.ts +38 -13
  88. package/src/search.ts +203 -23
  89. package/src/surface.ts +265 -0
  90. package/src/sync.ts +66 -2
  91. package/src/verify.ts +1 -1
package/src/search.ts CHANGED
@@ -1,11 +1,21 @@
1
- import type { SearchHit, Store } from "./db.js";
2
- import { discoverPackages } from "./discovery.js";
1
+ import type { PackageKind, SearchHit, Store } from "./db.js";
2
+ import { discoverLibraries, discoverPackages } from "./discovery.js";
3
+ import { endpointIndex, matchEndpoints } from "./endpoints.js";
3
4
  import { chunkId, formatDocumentedLibrary, isCommunityPackage } from "./spec.js";
4
5
 
5
6
  /** Ceiling on how much context one query may return, per the blueprint's context-exhaustion rule. */
6
7
  export const DEFAULT_MAX_TOKENS = 3000;
7
8
  export const DEFAULT_LIMIT = 3;
8
9
 
10
+ /**
11
+ * How many declarations one answer may carry. A question names one or two things; more than
12
+ * that and the names are incidental, and pinning them would spend the budget on noise.
13
+ */
14
+ const MAX_DECLARATIONS = 2;
15
+
16
+ /** Below this a token is too short to be a name worth looking up: `c`, `id`. */
17
+ const MIN_SYMBOL = 3;
18
+
9
19
  export interface QueryOptions {
10
20
  readonly cwd: string;
11
21
  readonly store: Store;
@@ -19,12 +29,19 @@ export interface QueryOptions {
19
29
  * sees documentation for a version this project does not use.
20
30
  */
21
31
  readonly scoped?: boolean;
32
+ /**
33
+ * Answer a question that names an exported symbol from the installed library's own
34
+ * declarations when the documentation does not mention it. On by default.
35
+ */
36
+ readonly artifacts?: boolean;
22
37
  }
23
38
 
24
39
  export interface QueryHit extends SearchHit {
25
40
  readonly name: string;
26
41
  readonly version: string;
27
42
  readonly trusted: boolean;
43
+ /** Whether this text was published as documentation or derived from the installed build. */
44
+ readonly kind: PackageKind;
28
45
  /**
29
46
  * Libraries this chunk documents, from its manifest. A docs package version cannot always
30
47
  * imply this — a monorepo documents many libraries at many versions from one surface — so the
@@ -45,6 +62,20 @@ export interface QueryResult {
45
62
  * covers this question".
46
63
  */
47
64
  readonly unindexed: readonly string[];
65
+ /**
66
+ * Names the question used that an installed library exports and no documentation mentions.
67
+ * This is the difference between "the documentation does not cover this" and "nothing
68
+ * matched", which a ranker alone cannot tell apart — it always returns its best three.
69
+ */
70
+ readonly undocumented: readonly string[];
71
+ /**
72
+ * Endpoints the question named that a documented operation answers, pinned rather than ranked.
73
+ *
74
+ * Reported so an answer can say *why* a chunk is first. A reader who asked about
75
+ * `GET /v1/charges/ch_3Ox7` and is shown `GET /v1/charges/{charge}` has been given the right
76
+ * chunk, and has to be told that the match was against a template.
77
+ */
78
+ readonly endpoints: readonly string[];
48
79
  }
49
80
 
50
81
  export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
@@ -60,44 +91,179 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
60
91
  // Keyed by package id and by chunk id in the same map, because a chunk id already carries its
61
92
  // package: `@acme/docspack@1.4.0/api-auth` cannot collide with `@acme/docspack@1.4.0`.
62
93
  const documented = new Map<string, readonly string[]>();
94
+ const operations: { chunkId: string; entities: readonly string[] }[] = [];
63
95
  for (const pkg of installed ?? []) {
64
96
  const documents = pkg.manifest.documents;
65
97
  if (documents !== undefined && documents.length > 0) {
66
98
  documented.set(pkg.id, documents.map(formatDocumentedLibrary));
67
99
  }
68
100
  for (const chunk of pkg.manifest.chunks) {
101
+ operations.push({ chunkId: chunkId(pkg.id, chunk.id), entities: chunk.entities });
69
102
  if (chunk.documents === undefined || chunk.documents.length === 0) continue;
70
103
  documented.set(chunkId(pkg.id, chunk.id), chunk.documents.map(formatDocumentedLibrary));
71
104
  }
72
105
  }
73
106
 
74
- const hits = options.store
107
+ const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
108
+ const limit = options.limit ?? DEFAULT_LIMIT;
109
+
110
+ // Endpoints first, and the ranker is then asked for less: a pinned operation is the answer, and
111
+ // three ranked chunks beside it would spend the budget restating what it already says.
112
+ const { hits: pinned, endpoints } = findEndpoints(options, operations, documented, maxTokens);
113
+ const spent = pinned.reduce((total, hit) => total + hit.tokens, 0);
114
+ const pinnedIds = new Set(pinned.map((hit) => hit.chunkId));
115
+
116
+ const prose = options.store
75
117
  .search(options.query, {
76
118
  ...(packageIds === undefined ? {} : { packageIds }),
77
119
  ...(options.packageFilter === undefined
78
120
  ? {}
79
121
  : { packageFilter: `%${options.packageFilter}%` }),
80
- limit: options.limit ?? DEFAULT_LIMIT,
81
- maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
122
+ limit: Math.max(1, limit - pinned.length),
123
+ maxTokens: Math.max(1, maxTokens - spent),
124
+ // Declarations are addressed by name, never ranked against prose: a library exports far
125
+ // more names than its documentation has pages, and ranking them together would answer
126
+ // every question with type machinery.
127
+ kinds: ["docs"],
82
128
  })
83
- .map((hit): QueryHit => {
84
- const { name, version } = splitPackageId(hit.packageId);
85
- // The chunk's own libraries win: they are the narrower, and therefore the truer, claim.
86
- const documents = documented.get(hit.chunkId) ?? documented.get(hit.packageId);
87
- return {
88
- ...hit,
89
- name,
90
- version,
91
- trusted: !isCommunityPackage(name),
92
- ...(documents === undefined ? {} : { documents }),
93
- };
94
- });
129
+ .filter((hit) => !pinnedIds.has(hit.chunkId))
130
+ .map((hit) => toQueryHit(hit, "docs", documented));
131
+
132
+ const { declarations, undocumented } =
133
+ options.artifacts === false
134
+ ? { declarations: [] as QueryHit[], undocumented: [] as string[] }
135
+ : await findDeclarations(options, [...pinned, ...prose], maxTokens - spent);
136
+
137
+ // Pinned operations, then declarations, then the ranked prose. Both of the first two were
138
+ // addressed by name rather than guessed at, and for an agent about to write a call the exact
139
+ // thing it named is the load-bearing line.
140
+ const hits = [...pinned, ...declarations, ...prose];
95
141
 
96
142
  return {
97
143
  hits,
98
144
  tokens: hits.reduce((total, hit) => total + hit.tokens, 0),
99
145
  untrusted: hits.some((hit) => !hit.trusted),
100
146
  unindexed,
147
+ undocumented,
148
+ endpoints,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Operation chunks the question addressed.
154
+ *
155
+ * Bounded by the same token budget as everything else, and the first match is always kept: an answer
156
+ * whose one pinned operation did not fit would have pinned nothing and said nothing about it.
157
+ */
158
+ function findEndpoints(
159
+ options: QueryOptions,
160
+ operations: readonly { chunkId: string; entities: readonly string[] }[],
161
+ documented: ReadonlyMap<string, readonly string[]>,
162
+ maxTokens: number,
163
+ ): { hits: QueryHit[]; endpoints: string[] } {
164
+ const matches = matchEndpoints(options.query, endpointIndex(operations));
165
+ const hits: QueryHit[] = [];
166
+ const endpoints: string[] = [];
167
+ let spent = 0;
168
+
169
+ for (const match of matches) {
170
+ if (options.packageFilter !== undefined && !match.chunkId.includes(options.packageFilter)) {
171
+ continue;
172
+ }
173
+ const chunk = options.store.chunk(match.chunkId);
174
+ if (chunk === undefined) continue;
175
+ if (hits.length > 0 && spent + chunk.tokens > maxTokens) break;
176
+ hits.push(toQueryHit(chunk, "docs", documented));
177
+ endpoints.push(`${match.method} ${match.path}`);
178
+ spent += chunk.tokens;
179
+ }
180
+
181
+ return { hits, endpoints };
182
+ }
183
+
184
+ /**
185
+ * Looks up the names a question used against the symbols the installed libraries export.
186
+ *
187
+ * Matching is case-sensitive on purpose. Someone asking about an API writes it as it is spelled,
188
+ * because they read it in a stack trace or an editor; matching loosely would let "how do I use
189
+ * data" pin hono's `Data` type ahead of the page that answers the question.
190
+ */
191
+ async function findDeclarations(
192
+ options: QueryOptions,
193
+ prose: readonly QueryHit[],
194
+ maxTokens: number,
195
+ ): Promise<{ declarations: QueryHit[]; undocumented: string[] }> {
196
+ const scope =
197
+ options.scoped === false
198
+ ? undefined
199
+ : (await discoverLibraries(options.cwd)).map(
200
+ (library) => `${library.name}@${library.version}`,
201
+ );
202
+
203
+ const declarations: QueryHit[] = [];
204
+ const undocumented: string[] = [];
205
+ let spent = prose.reduce((total, hit) => total + hit.tokens, 0);
206
+
207
+ for (const term of symbolTerms(options.query)) {
208
+ if (declarations.length >= MAX_DECLARATIONS) break;
209
+
210
+ const found = options.store
211
+ .lookupSymbol(term, scope)
212
+ .filter(
213
+ (hit) =>
214
+ options.packageFilter === undefined || hit.packageId.includes(options.packageFilter),
215
+ );
216
+ if (found.length === 0) continue;
217
+
218
+ // The documentation covers it, so the prose that just came back is the better answer.
219
+ if (prose.some((hit) => mentions(hit.content, term))) continue;
220
+ undocumented.push(term);
221
+
222
+ // The name is exported and undocumented either way; whether a declaration can be shown for
223
+ // it is a separate question, and a package can export a name it declares nowhere readable.
224
+ const first = found[0];
225
+ if (first === undefined || first.chunkId.length === 0) continue;
226
+ const chunk = options.store.chunk(first.chunkId);
227
+ if (chunk === undefined) continue;
228
+ if (declarations.length > 0 && spent + chunk.tokens > maxTokens) break;
229
+
230
+ declarations.push(toQueryHit(chunk, "artifact", new Map()));
231
+ spent += chunk.tokens;
232
+ }
233
+
234
+ return { declarations, undocumented };
235
+ }
236
+
237
+ /** Identifier-shaped words in a question, longest first so the specific name is tried first. */
238
+ function symbolTerms(query: string): string[] {
239
+ const terms = query.match(/[A-Za-z_$][\w$]*/g) ?? [];
240
+ const unique = [...new Set(terms.filter((term) => term.length >= MIN_SYMBOL))];
241
+ return unique.sort((a, b) => b.length - a.length);
242
+ }
243
+
244
+ function mentions(content: string, name: string): boolean {
245
+ return new RegExp(`(^|[^A-Za-z0-9_$])${escapeRegex(name)}([^A-Za-z0-9_$]|$)`).test(content);
246
+ }
247
+
248
+ function escapeRegex(value: string): string {
249
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
250
+ }
251
+
252
+ function toQueryHit(
253
+ hit: SearchHit,
254
+ kind: PackageKind,
255
+ documented: ReadonlyMap<string, readonly string[]>,
256
+ ): QueryHit {
257
+ const { name, version } = splitPackageId(hit.packageId);
258
+ // The chunk's own libraries win: they are the narrower, and therefore the truer, claim.
259
+ const documents = documented.get(hit.chunkId) ?? documented.get(hit.packageId);
260
+ return {
261
+ ...hit,
262
+ name,
263
+ version,
264
+ kind,
265
+ trusted: !isCommunityPackage(name),
266
+ ...(documents === undefined ? {} : { documents }),
101
267
  };
102
268
  }
103
269
 
@@ -128,18 +294,32 @@ export function renderAnswer(result: QueryResult, query: string): string {
128
294
  hit.documents === undefined || hit.documents.length === 0
129
295
  ? ""
130
296
  : ` · documents ${hit.documents.join(", ")}`;
131
- return [
132
- `## ${hit.chunkId}${trust}`,
133
- `Source: ${hit.name}@${hit.version} — ${hit.filePath}${describes}`,
134
- "",
135
- hit.content,
136
- ].join("\n");
297
+ const source =
298
+ hit.kind === "artifact"
299
+ ? `Source: ${hit.name}@${hit.version} — declared in ${hit.filePath}, read from the installed package`
300
+ : `Source: ${hit.name}@${hit.version} — ${hit.filePath}${describes}`;
301
+ return [`## ${hit.chunkId}${trust}`, source, "", hit.content].join("\n");
137
302
  });
138
303
 
304
+ if (result.undocumented.length > 0) sections.push(undocumentedNotice(result));
139
305
  if (result.untrusted) sections.push(UNTRUSTED_NOTICE);
140
306
  return sections.join("\n\n---\n\n");
141
307
  }
142
308
 
309
+ /**
310
+ * Saying what the documentation does *not* cover. A ranker always returns its best three matches
311
+ * and "best" is not "relevant", so without this an answer about a name nobody documented looks
312
+ * exactly like an answer about one they did.
313
+ */
314
+ function undocumentedNotice(result: QueryResult): string {
315
+ const names = result.undocumented.map((name) => `\`${name}\``).join(", ");
316
+ const plural = result.undocumented.length === 1 ? "is" : "are";
317
+ const declared = result.hits.some((hit) => hit.kind === "artifact");
318
+ return declared
319
+ ? `NOTE: ${names} ${plural} exported by the installed library but mentioned in no documentation package here. The declaration above is the installed build's own, not prose anyone wrote.`
320
+ : `NOTE: ${names} ${plural} exported by the installed library but mentioned in no documentation package here.`;
321
+ }
322
+
143
323
  /** Splits `@stripe/docspack@2025.4.1` into its name and version. */
144
324
  export function splitPackageId(id: string): { name: string; version: string } {
145
325
  const at = id.lastIndexOf("@");
package/src/surface.ts ADDED
@@ -0,0 +1,265 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+
5
+ /**
6
+ * One name a library publishes, and how a caller reaches it.
7
+ *
8
+ * This is a narrower reading of a package than [`readExportSurface`](./exports.ts), which
9
+ * collects every identifier in every `.d.ts` so that `verify` accuses as little as possible.
10
+ * Here the opposite is wanted: only what an entry point actually exports, because these names
11
+ * become answers, and an answer about a library's private type helps nobody.
12
+ */
13
+ export interface ExportedSymbol {
14
+ readonly name: string;
15
+ /** What a caller writes to import it, e.g. `hono/jwt`. */
16
+ readonly from: string;
17
+ /** Declaration file the name was read from, relative to the package root. */
18
+ readonly file: string;
19
+ }
20
+
21
+ export interface PublicSurface {
22
+ readonly name: string;
23
+ readonly version: string;
24
+ /** Subpaths of the `exports` map that resolve to a declaration file. */
25
+ readonly entryPoints: number;
26
+ readonly symbols: ReadonlyMap<string, ExportedSymbol>;
27
+ }
28
+
29
+ /** How far `export … from` chains are followed out of an entry point. */
30
+ const MAX_DEPTH = 6;
31
+
32
+ /** A package this large is generated, and reading all of it would dominate a sync. */
33
+ const MAX_FILES = 600;
34
+
35
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
36
+
37
+ const DECLARATION =
38
+ /export\s+(?:declare\s+)?(?:abstract\s+)?(?:class|function|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
39
+ /**
40
+ * The same declaration, with the JSDoc comment above it captured as well.
41
+ *
42
+ * The comment body is written as "anything that is not the comment's end" rather than as a lazy
43
+ * run of any character. A lazy run expands across dozens of declarations to reach a distant
44
+ * comment terminator, and in a single global pass that swallows everything in between: on hono's
45
+ * 75kB type file it found 28 declarations where there are hundreds.
46
+ */
47
+ const DECLARED =
48
+ /(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+(?:declare\s+)?(?:abstract\s+)?(?:class|function|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
49
+
50
+ /** How much of a declaration is carried: long enough for a signature, short enough to rank. */
51
+ const DECLARATION_LIMIT = 400;
52
+ const EXPORT_LIST = /export\s*\{([^}]*)\}/g;
53
+ const REEXPORT =
54
+ /export\s+(?:\*|\{[^}]*\})\s*(?:as\s+[A-Za-z_$][\w$]*\s*)?from\s+['"]([^'"]+)['"]/g;
55
+
56
+ /**
57
+ * Reads what a library exports, starting from its `exports` map. Returns nothing when the
58
+ * package ships no type declarations, which is not an error: most of a project's dependencies
59
+ * are not the ones anybody asks questions about.
60
+ */
61
+ export async function readPublicSurface(dir: string): Promise<PublicSurface | undefined> {
62
+ const manifest = await readJson(join(dir, "package.json"));
63
+ if (manifest === undefined) return undefined;
64
+
65
+ const symbols = new Map<string, ExportedSymbol>();
66
+ const name = typeof manifest.name === "string" ? manifest.name : "";
67
+ const budget = { files: MAX_FILES };
68
+ let entryPoints = 0;
69
+
70
+ for (const entry of entryPointsOf(manifest, dir, name)) {
71
+ entryPoints += 1;
72
+ await collect(entry.file, entry.specifier, dir, 0, new Set<string>(), symbols, budget);
73
+ }
74
+
75
+ if (entryPoints === 0) return undefined;
76
+
77
+ return {
78
+ name,
79
+ version: typeof manifest.version === "string" ? manifest.version : "",
80
+ entryPoints,
81
+ symbols,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Every declaration in one file, with the JSDoc comment above it when there is one, keyed by the
87
+ * name it declares.
88
+ *
89
+ * One pass over the file rather than one per name. A generated declaration file can be megabytes
90
+ * and export hundreds of names, and scanning it once per name made indexing TypeScript itself
91
+ * take longer than everything else in a sync put together.
92
+ */
93
+ export function declarationsIn(source: string, limit = DECLARATION_LIMIT): Map<string, string> {
94
+ const found = new Map<string, string>();
95
+ DECLARED.lastIndex = 0;
96
+ for (const match of source.matchAll(DECLARED)) {
97
+ const name = match[2];
98
+ if (name === undefined || found.has(name) || match.index === undefined) continue;
99
+ const head = match[0];
100
+ const start = match.index + head.length;
101
+ const body = source.slice(start, start + limit);
102
+ found.set(name, head + trimToDeclaration(body, body.length === limit));
103
+ }
104
+ return found;
105
+ }
106
+
107
+ /**
108
+ * Cuts a declaration's body where the declaration ends.
109
+ *
110
+ * A fixed window runs into whatever follows it, so the body is cut at the next thing that starts
111
+ * at the left margin — another declaration, or the comment above one. What is left is a
112
+ * signature rather than a signature plus the first half of its neighbour.
113
+ */
114
+ function trimToDeclaration(body: string, truncated: boolean): string {
115
+ // A declaration's continuation lines are indented, so an unindented keyword is a new statement.
116
+ const next = body.search(
117
+ /\n(?:export|declare|type|interface|function|class|const|let|var|enum)\s|\n\/\*\*/,
118
+ );
119
+ if (next !== -1) return body.slice(0, next);
120
+ if (!truncated) return body;
121
+ // The window ran out mid-declaration. Ending on a whole line beats ending mid-identifier.
122
+ const lastLine = body.lastIndexOf("\n");
123
+ return lastLine === -1 ? body : `${body.slice(0, lastLine)}\n…`;
124
+ }
125
+
126
+ /**
127
+ * The declaration of one name, with the JSDoc comment above it when there is one. This is the
128
+ * text that answers a question the prose never covered.
129
+ */
130
+ export function declarationOf(
131
+ source: string,
132
+ name: string,
133
+ limit = DECLARATION_LIMIT,
134
+ ): string | undefined {
135
+ return declarationsIn(source, limit).get(name);
136
+ }
137
+
138
+ /** True when the declaration `declarationOf` returned carries a JSDoc comment. */
139
+ export function hasJsdoc(declaration: string): boolean {
140
+ return declaration.startsWith("/**");
141
+ }
142
+
143
+ interface EntryPoint {
144
+ readonly file: string;
145
+ /** The specifier a caller imports, e.g. `hono` or `hono/jwt`. */
146
+ readonly specifier: string;
147
+ }
148
+
149
+ function* entryPointsOf(
150
+ manifest: Record<string, unknown>,
151
+ dir: string,
152
+ name: string,
153
+ ): Generator<EntryPoint> {
154
+ const exports = manifest.exports;
155
+ const entries: [string, unknown][] =
156
+ typeof exports === "object" && exports !== null
157
+ ? Object.entries(exports as Record<string, unknown>)
158
+ : [[".", { types: manifest.types ?? manifest.typings }]];
159
+
160
+ for (const [subpath, entry] of entries) {
161
+ const types = declarationTarget(entry);
162
+ if (types === undefined) continue;
163
+ // A wildcard subpath names no single module a caller could import.
164
+ if (subpath.includes("*")) continue;
165
+ const file = resolve(dir, types);
166
+ if (!existsSync(file)) continue;
167
+ yield { file, specifier: specifierFor(name, subpath) };
168
+ }
169
+ }
170
+
171
+ function specifierFor(name: string, subpath: string): string {
172
+ if (subpath === "." || subpath === "") return name;
173
+ return `${name}/${subpath.replace(/^\.\//, "")}`;
174
+ }
175
+
176
+ function declarationTarget(entry: unknown): string | undefined {
177
+ if (typeof entry !== "object" || entry === null) return undefined;
178
+ const record = entry as Record<string, unknown>;
179
+ if (typeof record.types === "string") return record.types;
180
+ for (const key of ["import", "require", "default"] as const) {
181
+ const nested = record[key];
182
+ if (typeof nested === "object" && nested !== null) {
183
+ const types = (nested as Record<string, unknown>).types;
184
+ if (typeof types === "string") return types;
185
+ }
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ async function collect(
191
+ file: string,
192
+ specifier: string,
193
+ root: string,
194
+ depth: number,
195
+ seen: Set<string>,
196
+ into: Map<string, ExportedSymbol>,
197
+ budget: { files: number },
198
+ ): Promise<void> {
199
+ if (depth > MAX_DEPTH || budget.files <= 0 || seen.has(file) || !existsSync(file)) return;
200
+ seen.add(file);
201
+ budget.files -= 1;
202
+
203
+ let source: string;
204
+ try {
205
+ source = await readFile(file, "utf8");
206
+ } catch {
207
+ return;
208
+ }
209
+
210
+ // The first entry point that publishes a name wins, so a name re-exported from several
211
+ // subpaths is attributed to the one listed first — which is the package's own ordering.
212
+ const record = (name: string): void => {
213
+ if (!IDENTIFIER.test(name) || into.has(name)) return;
214
+ into.set(name, { name, from: specifier, file: relativePath(root, file) });
215
+ };
216
+
217
+ for (const match of source.matchAll(DECLARATION)) {
218
+ if (match[1] !== undefined) record(match[1]);
219
+ }
220
+
221
+ for (const match of source.matchAll(EXPORT_LIST)) {
222
+ for (const entry of (match[1] ?? "").split(",")) {
223
+ // `export { internal as public }` publishes the second name.
224
+ const parts = entry.split(/\s+as\s+/);
225
+ record((parts[parts.length - 1] ?? "").trim().replace(/^type\s+/, ""));
226
+ }
227
+ }
228
+
229
+ for (const match of source.matchAll(REEXPORT)) {
230
+ const target = match[1];
231
+ // A bare specifier points into another package, whose names are not this one's to publish.
232
+ if (target === undefined || !target.startsWith(".")) continue;
233
+ const next = resolveDeclaration(resolve(dirname(file), target));
234
+ if (next !== undefined) await collect(next, specifier, root, depth + 1, seen, into, budget);
235
+ }
236
+ }
237
+
238
+ /** `./external.cjs` inside a declaration file means `./external.d.cts`, not a JavaScript file. */
239
+ function resolveDeclaration(specifier: string): string | undefined {
240
+ const base = specifier.replace(/\.(js|cjs|mjs)$/, "");
241
+ for (const candidate of [
242
+ `${base}.d.ts`,
243
+ `${base}.d.cts`,
244
+ `${base}.d.mts`,
245
+ join(base, "index.d.ts"),
246
+ join(base, "index.d.cts"),
247
+ ]) {
248
+ if (existsSync(candidate)) return candidate;
249
+ }
250
+ return undefined;
251
+ }
252
+
253
+ function relativePath(root: string, file: string): string {
254
+ return relative(root, file).split(sep).join("/");
255
+ }
256
+
257
+ async function readJson(file: string): Promise<Record<string, unknown> | undefined> {
258
+ try {
259
+ const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
260
+ if (typeof parsed !== "object" || parsed === null) return undefined;
261
+ return parsed as Record<string, unknown>;
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
package/src/sync.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import type { IndexedChunk, Store } from "./db.js";
3
- import { type DiscoveredPackage, discoverPackages } from "./discovery.js";
2
+ import { readArtifact } from "./artifact.js";
3
+ import type { IndexedChunk, PackageKind, Store } from "./db.js";
4
+ import { type DiscoveredPackage, discoverLibraries, discoverPackages } from "./discovery.js";
4
5
  import { chunkId, estimateTokens, resolveChunkFile } from "./spec.js";
5
6
 
6
7
  export interface SyncOptions {
@@ -8,6 +9,12 @@ export interface SyncOptions {
8
9
  readonly store: Store;
9
10
  /** Re-index packages that are already in the store. */
10
11
  readonly force?: boolean;
12
+ /**
13
+ * Also derive an index from each installed library's own type declarations. On by default:
14
+ * half of a library's exported names appear in no documentation anyone published, and the
15
+ * declarations are the only local answer for those.
16
+ */
17
+ readonly artifacts?: boolean;
11
18
  readonly onProgress?: (message: string) => void;
12
19
  }
13
20
 
@@ -21,6 +28,7 @@ export interface SyncedPackage {
21
28
  readonly tokens: number;
22
29
  readonly status: SyncStatus;
23
30
  readonly trusted: boolean;
31
+ readonly kind: PackageKind;
24
32
  }
25
33
 
26
34
  export interface SyncResult {
@@ -48,6 +56,7 @@ export async function syncProject(options: SyncOptions): Promise<SyncResult> {
48
56
  tokens: 0,
49
57
  status: "cached",
50
58
  trusted: pkg.trusted,
59
+ kind: "docs",
51
60
  });
52
61
  continue;
53
62
  }
@@ -70,12 +79,67 @@ export async function syncProject(options: SyncOptions): Promise<SyncResult> {
70
79
  tokens: chunks.reduce((total, chunk) => total + chunk.tokens, 0),
71
80
  status: "indexed",
72
81
  trusted: pkg.trusted,
82
+ kind: "docs",
73
83
  });
74
84
  }
75
85
 
86
+ if (options.artifacts !== false) synced.push(...(await syncArtifacts(options)));
87
+
76
88
  return { packages: synced, problems };
77
89
  }
78
90
 
91
+ /**
92
+ * Indexes each installed library's exported declarations.
93
+ *
94
+ * These are not ranked alongside prose. They are addressed by name, so a question that names a
95
+ * symbol can be answered from the installed build even when nobody documented it — and a corpus
96
+ * of generated type machinery cannot crowd out the documentation that does exist.
97
+ */
98
+ async function syncArtifacts(options: SyncOptions): Promise<SyncedPackage[]> {
99
+ const synced: SyncedPackage[] = [];
100
+
101
+ for (const library of await discoverLibraries(options.cwd)) {
102
+ const id = `${library.name}@${library.version}`;
103
+ if (options.force !== true && options.store.hasPackage(id)) {
104
+ synced.push({
105
+ id,
106
+ name: library.name,
107
+ version: library.version,
108
+ chunks: options.store.countChunks(id),
109
+ tokens: 0,
110
+ status: "cached",
111
+ trusted: true,
112
+ kind: "artifact",
113
+ });
114
+ continue;
115
+ }
116
+
117
+ options.onProgress?.(`reading ${id}`);
118
+ const artifact = await readArtifact(library.dir);
119
+ // A dependency that ships no type declarations has nothing to derive, which is ordinary
120
+ // rather than a problem worth reporting on every sync.
121
+ if (artifact === undefined) continue;
122
+
123
+ options.store.indexPackage(
124
+ { id: artifact.id, name: artifact.name, version: artifact.version, kind: "artifact" },
125
+ artifact.chunks,
126
+ artifact.symbols,
127
+ );
128
+ synced.push({
129
+ id: artifact.id,
130
+ name: artifact.name,
131
+ version: artifact.version,
132
+ chunks: artifact.chunks.length,
133
+ tokens: artifact.chunks.reduce((total, chunk) => total + chunk.tokens, 0),
134
+ status: "indexed",
135
+ trusted: true,
136
+ kind: "artifact",
137
+ });
138
+ }
139
+
140
+ return synced;
141
+ }
142
+
79
143
  async function readChunks(
80
144
  pkg: DiscoveredPackage,
81
145
  ): Promise<{ chunks: IndexedChunk[]; problems: string[] }> {
package/src/verify.ts CHANGED
@@ -315,7 +315,7 @@ async function verifyPackage(
315
315
  * installed package can see. The `docspack` key is read as a fallback: it is build configuration
316
316
  * rather than payload, and a package built before `documents` reached the manifest has only that.
317
317
  */
318
- async function documentedLibraries(
318
+ export async function documentedLibraries(
319
319
  packageDir: string,
320
320
  manifest: PackageManifest,
321
321
  ): Promise<readonly DocumentedLibrary[]> {