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/db.ts CHANGED
@@ -8,13 +8,57 @@ import { STOPWORDS } from "./stopwords.js";
8
8
 
9
9
  const require = createRequire(import.meta.url);
10
10
 
11
+ /**
12
+ * Where a package's text came from. `docs` is written by a human and published as a docs
13
+ * package; `artifact` is derived from an installed library's own type declarations; `local` is a
14
+ * working corpus indexed from this project's own sources and never published. The
15
+ * distinction is not cosmetic — a signature is a fact about the build, prose is a claim by its
16
+ * author, and a working corpus is a copy that can go stale under the reader — so it is stored
17
+ * rather than inferred from the name.
18
+ */
19
+ export type PackageKind = "docs" | "artifact" | "local";
20
+
21
+ /**
22
+ * Reads the stored kind, defaulting an unrecognized one to `docs`.
23
+ *
24
+ * Written once rather than inline at each read: a store may have been written by a newer version
25
+ * that knows a kind this one does not, and silently relabelling it as something it is not is how
26
+ * a local corpus would end up presented as published documentation.
27
+ */
28
+ export function toPackageKind(value: string): PackageKind {
29
+ return value === "artifact" || value === "local" ? value : "docs";
30
+ }
31
+
11
32
  export interface IndexedPackage {
12
33
  readonly id: string;
13
34
  readonly name: string;
14
35
  readonly version: string;
36
+ readonly kind?: PackageKind;
15
37
  readonly indexedAt?: string;
16
38
  }
17
39
 
40
+ /** An exported name and the chunk carrying its declaration. */
41
+ export interface SymbolHit {
42
+ readonly name: string;
43
+ readonly packageId: string;
44
+ readonly chunkId: string;
45
+ }
46
+
47
+ /**
48
+ * A file an indexed package was built from, as it was at index time.
49
+ *
50
+ * Only a working corpus records these. Published documentation is immutable for the life of its
51
+ * version, so there is nothing to detect; a corpus of the reader's own files is edited underneath
52
+ * the index, and an answer quoted from a superseded source is worse than no answer.
53
+ */
54
+ export interface IndexedSource {
55
+ readonly path: string;
56
+ readonly size: number;
57
+ /** ISO timestamp, as text, because that is what survives a round trip through SQLite. */
58
+ readonly mtime: string;
59
+ readonly hash: string;
60
+ }
61
+
18
62
  export interface IndexedChunk {
19
63
  readonly chunkId: string;
20
64
  readonly filePath: string;
@@ -39,15 +83,21 @@ export interface SearchOptions {
39
83
  readonly limit?: number;
40
84
  /** Stop returning chunks once this many tokens have been collected. */
41
85
  readonly maxTokens?: number;
86
+ /** Restrict to packages of these kinds. Defaults to every kind. */
87
+ readonly kinds?: readonly PackageKind[];
42
88
  }
43
89
 
44
- const SCHEMA_VERSION = 1;
90
+ /** Where per-project state lives, alongside the feedback file already written there. */
91
+ export const LOCAL_STORE_DIR = ".docspack";
92
+
93
+ const SCHEMA_VERSION = 3;
45
94
 
46
95
  const SCHEMA = `
47
96
  CREATE TABLE IF NOT EXISTS packages (
48
97
  id TEXT PRIMARY KEY,
49
98
  name TEXT NOT NULL,
50
99
  version TEXT NOT NULL,
100
+ kind TEXT NOT NULL DEFAULT 'docs',
51
101
  indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
52
102
  );
53
103
 
@@ -61,6 +111,24 @@ CREATE TABLE IF NOT EXISTS chunks (
61
111
 
62
112
  CREATE INDEX IF NOT EXISTS chunks_by_package ON chunks(package_id);
63
113
 
114
+ CREATE TABLE IF NOT EXISTS symbols (
115
+ package_id TEXT NOT NULL REFERENCES packages(id),
116
+ name TEXT NOT NULL,
117
+ chunk_id TEXT NOT NULL,
118
+ PRIMARY KEY (package_id, name)
119
+ );
120
+
121
+ CREATE INDEX IF NOT EXISTS symbols_by_name ON symbols(name);
122
+
123
+ CREATE TABLE IF NOT EXISTS sources (
124
+ package_id TEXT NOT NULL REFERENCES packages(id),
125
+ path TEXT NOT NULL,
126
+ size INTEGER NOT NULL,
127
+ mtime TEXT NOT NULL,
128
+ hash TEXT NOT NULL,
129
+ PRIMARY KEY (package_id, path)
130
+ );
131
+
64
132
  CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
65
133
  content,
66
134
  tags,
@@ -92,6 +160,17 @@ function loadSqlite(): typeof import("node:sqlite") {
92
160
  return require("node:sqlite") as typeof import("node:sqlite");
93
161
  }
94
162
 
163
+ /**
164
+ * Where a project's own working corpus is indexed.
165
+ *
166
+ * Deliberately not the global store: these chunks are a plaintext copy of one project's sources,
167
+ * they are worthless to any other project, and a machine-wide store would accumulate them from
168
+ * every checkout with nothing to evict them.
169
+ */
170
+ export function localStorePath(cwd: string): string {
171
+ return join(cwd, LOCAL_STORE_DIR, "local.db");
172
+ }
173
+
95
174
  /** Default location of the shared index, mirroring pnpm's global store. */
96
175
  export function defaultStorePath(env: NodeJS.ProcessEnv = process.env): string {
97
176
  const override = env.DOCSPACK_STORE;
@@ -133,9 +212,22 @@ export class Store {
133
212
  this.#db = db;
134
213
  this.#db.exec("PRAGMA journal_mode = WAL");
135
214
  this.#db.exec(SCHEMA);
215
+ this.#migrate();
136
216
  this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
137
217
  }
138
218
 
219
+ /**
220
+ * Brings a store written by an older version forward. `CREATE TABLE IF NOT EXISTS` covers a
221
+ * new table, but not a new column on one that already exists, and rebuilding the whole index
222
+ * to add one would make an upgrade re-read every package on the machine.
223
+ */
224
+ #migrate(): void {
225
+ const columns = this.#db.prepare("PRAGMA table_info(packages)").all() as { name: string }[];
226
+ if (!columns.some((column) => column.name === "kind")) {
227
+ this.#db.exec("ALTER TABLE packages ADD COLUMN kind TEXT NOT NULL DEFAULT 'docs'");
228
+ }
229
+ }
230
+
139
231
  static open(path: string = defaultStorePath()): Store {
140
232
  if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
141
233
  try {
@@ -154,16 +246,115 @@ export class Store {
154
246
 
155
247
  listPackages(): IndexedPackage[] {
156
248
  const rows = this.#db
157
- .prepare("SELECT id, name, version, indexed_at FROM packages ORDER BY name, version")
158
- .all() as { id: string; name: string; version: string; indexed_at: string }[];
249
+ .prepare("SELECT id, name, version, kind, indexed_at FROM packages ORDER BY name, version")
250
+ .all() as {
251
+ id: string;
252
+ name: string;
253
+ version: string;
254
+ kind: string;
255
+ indexed_at: string;
256
+ }[];
159
257
  return rows.map((row) => ({
160
258
  id: row.id,
161
259
  name: row.name,
162
260
  version: row.version,
261
+ kind: toPackageKind(row.kind),
163
262
  indexedAt: row.indexed_at,
164
263
  }));
165
264
  }
166
265
 
266
+ /** Every version of one library in the store, newest indexing first. */
267
+ versionsOf(name: string): IndexedPackage[] {
268
+ return this.listPackages().filter((pkg) => pkg.name === name);
269
+ }
270
+
271
+ /**
272
+ * Chunks declaring an exported name, by exact match rather than by ranking.
273
+ *
274
+ * A symbol is a key, not a phrase. Ranking one is the mistake that made `docspack ask` unable
275
+ * to tell "the documentation does not mention this" from "nothing matched".
276
+ */
277
+ lookupSymbol(name: string, packageIds?: readonly string[]): SymbolHit[] {
278
+ if (packageIds !== undefined && packageIds.length === 0) return [];
279
+ const scope =
280
+ packageIds === undefined
281
+ ? ""
282
+ : ` AND package_id IN (${packageIds.map(() => "?").join(", ")})`;
283
+ const rows = this.#db
284
+ .prepare(
285
+ // A package that declares the name comes first: an empty chunk id means the name is
286
+ // exported but its declaration was not readable, which answers less.
287
+ `SELECT name, package_id AS packageId, chunk_id AS chunkId
288
+ FROM symbols WHERE name = ?${scope}
289
+ ORDER BY (chunk_id = '') ASC, package_id`,
290
+ )
291
+ .all(name, ...(packageIds ?? [])) as {
292
+ name: string;
293
+ packageId: string;
294
+ chunkId: string;
295
+ }[];
296
+ return rows.map((row) => ({ name: row.name, packageId: row.packageId, chunkId: row.chunkId }));
297
+ }
298
+
299
+ /** Every chunk of one package, for a check that has to read the whole corpus. */
300
+ chunksOf(packageId: string): SearchHit[] {
301
+ const rows = this.#db
302
+ .prepare(
303
+ `SELECT chunk_id AS chunkId, package_id AS packageId, file_path AS filePath,
304
+ tokens, content
305
+ FROM chunks WHERE package_id = ?`,
306
+ )
307
+ .all(packageId) as {
308
+ chunkId: string;
309
+ packageId: string;
310
+ filePath: string;
311
+ tokens: number;
312
+ content: string;
313
+ }[];
314
+ return rows.map((row) => ({
315
+ chunkId: row.chunkId,
316
+ packageId: row.packageId,
317
+ filePath: row.filePath,
318
+ tokens: Number(row.tokens ?? 0),
319
+ content: row.content,
320
+ }));
321
+ }
322
+
323
+ /** Every exported name recorded for one package. */
324
+ symbolsOf(packageId: string): string[] {
325
+ const rows = this.#db
326
+ .prepare("SELECT name FROM symbols WHERE package_id = ? ORDER BY name")
327
+ .all(packageId) as { name: string }[];
328
+ return rows.map((row) => row.name);
329
+ }
330
+
331
+ /** One chunk by its id, however it was found. */
332
+ chunk(chunkId: string): SearchHit | undefined {
333
+ const row = this.#db
334
+ .prepare(
335
+ `SELECT chunk_id AS chunkId, package_id AS packageId, file_path AS filePath,
336
+ tokens, content
337
+ FROM chunks WHERE chunk_id = ?`,
338
+ )
339
+ .get(chunkId) as
340
+ | {
341
+ chunkId: string;
342
+ packageId: string;
343
+ filePath: string;
344
+ tokens: number;
345
+ content: string;
346
+ }
347
+ | undefined;
348
+ if (row === undefined) return undefined;
349
+ return {
350
+ chunkId: row.chunkId,
351
+ packageId: row.packageId,
352
+ filePath: row.filePath,
353
+ tokens: Number(row.tokens ?? 0),
354
+ content: row.content,
355
+ };
356
+ }
357
+
167
358
  countChunks(packageId: string): number {
168
359
  const row = this.#db
169
360
  .prepare("SELECT COUNT(*) AS total FROM chunks WHERE package_id = ?")
@@ -171,10 +362,19 @@ export class Store {
171
362
  return row.total;
172
363
  }
173
364
 
174
- /** Replaces a package and all of its chunks in a single transaction. */
175
- indexPackage(pkg: IndexedPackage, chunks: readonly IndexedChunk[]): void {
365
+ /**
366
+ * Replaces a package, all of its chunks and its symbol table in a single transaction.
367
+ *
368
+ * `symbols` maps an exported name to the chunk declaring it, and is how a query about a name
369
+ * is answered without ranking anything.
370
+ */
371
+ indexPackage(
372
+ pkg: IndexedPackage,
373
+ chunks: readonly IndexedChunk[],
374
+ symbols?: ReadonlyMap<string, string>,
375
+ ): void {
176
376
  const insertPackage = this.#db.prepare(
177
- "INSERT OR REPLACE INTO packages (id, name, version) VALUES (?, ?, ?)",
377
+ "INSERT OR REPLACE INTO packages (id, name, version, kind) VALUES (?, ?, ?, ?)",
178
378
  );
179
379
  const insertChunk = this.#db.prepare(
180
380
  "INSERT OR REPLACE INTO chunks (chunk_id, package_id, file_path, tokens, content) VALUES (?, ?, ?, ?, ?)",
@@ -182,15 +382,37 @@ export class Store {
182
382
  const insertFts = this.#db.prepare(
183
383
  "INSERT INTO chunks_fts (content, tags, package_id, chunk_id) VALUES (?, ?, ?, ?)",
184
384
  );
385
+ const insertSymbol = this.#db.prepare(
386
+ "INSERT OR REPLACE INTO symbols (package_id, name, chunk_id) VALUES (?, ?, ?)",
387
+ );
185
388
 
186
389
  this.#db.exec("BEGIN");
187
390
  try {
188
391
  this.#deleteChunks(pkg.id);
189
- insertPackage.run(pkg.id, pkg.name, pkg.version);
392
+ insertPackage.run(pkg.id, pkg.name, pkg.version, pkg.kind ?? "docs");
190
393
  for (const chunk of chunks) {
191
394
  insertChunk.run(chunk.chunkId, pkg.id, chunk.filePath, chunk.tokens, chunk.content);
192
395
  insertFts.run(chunk.content, chunk.tags.join(" "), pkg.id, chunk.chunkId);
193
396
  }
397
+ for (const [name, chunk] of symbols ?? []) insertSymbol.run(pkg.id, name, chunk);
398
+ this.#db.exec("COMMIT");
399
+ } catch (error) {
400
+ this.#db.exec("ROLLBACK");
401
+ throw error;
402
+ }
403
+ }
404
+
405
+ /** Replaces the record of what a package was built from. */
406
+ recordSources(packageId: string, sources: readonly IndexedSource[]): void {
407
+ const insert = this.#db.prepare(
408
+ "INSERT OR REPLACE INTO sources (package_id, path, size, mtime, hash) VALUES (?, ?, ?, ?, ?)",
409
+ );
410
+ this.#db.exec("BEGIN");
411
+ try {
412
+ this.#db.prepare("DELETE FROM sources WHERE package_id = ?").run(packageId);
413
+ for (const source of sources) {
414
+ insert.run(packageId, source.path, source.size, source.mtime, source.hash);
415
+ }
194
416
  this.#db.exec("COMMIT");
195
417
  } catch (error) {
196
418
  this.#db.exec("ROLLBACK");
@@ -198,6 +420,18 @@ export class Store {
198
420
  }
199
421
  }
200
422
 
423
+ sourcesOf(packageId: string): IndexedSource[] {
424
+ const rows = this.#db
425
+ .prepare("SELECT path, size, mtime, hash FROM sources WHERE package_id = ? ORDER BY path")
426
+ .all(packageId) as { path: string; size: number; mtime: string; hash: string }[];
427
+ return rows.map((row) => ({
428
+ path: row.path,
429
+ size: Number(row.size),
430
+ mtime: row.mtime,
431
+ hash: row.hash,
432
+ }));
433
+ }
434
+
201
435
  removePackage(id: string): void {
202
436
  this.#db.exec("BEGIN");
203
437
  try {
@@ -228,6 +462,13 @@ export class Store {
228
462
  conditions.push("f.package_id LIKE ?");
229
463
  parameters.push(options.packageFilter);
230
464
  }
465
+ if (options.kinds !== undefined) {
466
+ if (options.kinds.length === 0) return [];
467
+ conditions.push(
468
+ `f.package_id IN (SELECT id FROM packages WHERE kind IN (${options.kinds.map(() => "?").join(", ")}))`,
469
+ );
470
+ parameters.push(...options.kinds);
471
+ }
231
472
 
232
473
  const rows = this.#db
233
474
  .prepare(
@@ -270,7 +511,9 @@ export class Store {
270
511
  }
271
512
 
272
513
  #deleteChunks(packageId: string): void {
514
+ this.#db.prepare("DELETE FROM sources WHERE package_id = ?").run(packageId);
273
515
  this.#db.prepare("DELETE FROM chunks_fts WHERE package_id = ?").run(packageId);
274
516
  this.#db.prepare("DELETE FROM chunks WHERE package_id = ?").run(packageId);
517
+ this.#db.prepare("DELETE FROM symbols WHERE package_id = ?").run(packageId);
275
518
  }
276
519
  }
package/src/discovery.ts CHANGED
@@ -23,6 +23,14 @@ export interface DiscoveredPackage {
23
23
  readonly trusted: boolean;
24
24
  }
25
25
 
26
+ /** An ordinary dependency: a library this project installed, whatever documents it. */
27
+ export interface DiscoveredLibrary {
28
+ readonly name: string;
29
+ readonly version: string;
30
+ /** Root of the installed package. */
31
+ readonly dir: string;
32
+ }
33
+
26
34
  export interface Discovery {
27
35
  readonly packages: readonly DiscoveredPackage[];
28
36
  /** Human-readable problems that did not stop discovery, e.g. a declared but uninstalled package. */
@@ -90,19 +98,48 @@ export async function discoverPackages(cwd: string): Promise<Discovery> {
90
98
  return { packages, problems };
91
99
  }
92
100
 
101
+ /**
102
+ * Every library this project depends on directly, installed and readable.
103
+ *
104
+ * Documentation packages are excluded: those are handled by `discoverPackages`, and a docs
105
+ * package's own type declarations describe nothing anybody asks about.
106
+ */
107
+ export async function discoverLibraries(cwd: string): Promise<readonly DiscoveredLibrary[]> {
108
+ const root = resolve(cwd);
109
+ const names = (await declaredDependencies(root)).filter((name) => !isDocsPackage(name));
110
+ const libraries: DiscoveredLibrary[] = [];
111
+
112
+ for (const name of names) {
113
+ const dir = await resolvePackageDir(name, root);
114
+ if (dir === undefined) continue;
115
+ try {
116
+ libraries.push({ name, version: await installedVersion(name, dir), dir });
117
+ } catch {
118
+ // A dependency with no version in its manifest is not one to index.
119
+ }
120
+ }
121
+
122
+ return libraries;
123
+ }
124
+
93
125
  /** Package ids installed in this project, used to scope queries to the versions actually in use. */
94
126
  export async function projectPackageIds(cwd: string): Promise<string[]> {
95
127
  const { packages } = await discoverPackages(cwd);
96
128
  return packages.map((pkg) => pkg.id);
97
129
  }
98
130
 
131
+ /** Docs packages this project depends on, the input to `discoverPackages`. */
132
+ async function declaredDocsPackages(cwd: string): Promise<string[]> {
133
+ return (await declaredDependencies(cwd)).filter(isDocsPackage);
134
+ }
135
+
99
136
  /**
100
- * Every docs package declared by this directory or by an ancestor of it. A workspace declares
137
+ * Every dependency declared by this directory or by an ancestor of it. A workspace declares
101
138
  * shared tooling in the repository root and its members inherit the install, so reading only
102
139
  * `cwd/package.json` answers "this project has no documentation" for most monorepos — the same
103
140
  * upward walk `resolvePackageDir` already does for the install.
104
141
  */
105
- async function declaredDocsPackages(cwd: string): Promise<string[]> {
142
+ async function declaredDependencies(cwd: string): Promise<string[]> {
106
143
  const names = new Set<string>();
107
144
  let found = false;
108
145
  let dir = cwd;
@@ -131,9 +168,7 @@ async function declaredDocsPackages(cwd: string): Promise<string[]> {
131
168
  const manifest = parsed as { dependencies?: unknown; devDependencies?: unknown };
132
169
  for (const field of [manifest.dependencies, manifest.devDependencies]) {
133
170
  if (typeof field !== "object" || field === null) continue;
134
- for (const name of Object.keys(field as Record<string, unknown>)) {
135
- if (isDocsPackage(name)) names.add(name);
136
- }
171
+ for (const name of Object.keys(field as Record<string, unknown>)) names.add(name);
137
172
  }
138
173
 
139
174
  const parent = dirname(dir);
package/src/doctor.ts CHANGED
@@ -2,6 +2,7 @@ import type { Dirent } from "node:fs";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { readBuildConfig } from "./config.js";
5
+ import { measureCoverage } from "./coverage.js";
5
6
  import { PLACEHOLDER } from "./init/templates.js";
6
7
  import {
7
8
  estimateTokens,
@@ -48,6 +49,13 @@ export interface DoctorOptions {
48
49
  readonly pedantic?: boolean;
49
50
  }
50
51
 
52
+ /**
53
+ * Below this share of a library's exported names, a package is documenting a fraction of what
54
+ * the library publishes. Reported, never gated on: a page listing every export and explaining
55
+ * none would score full marks, so this is a number for an author to read, not a wall.
56
+ */
57
+ const THIN_COVERAGE = 0.6;
58
+
51
59
  /** Over this, a chunk crowds out the response budget; under it, a chunk answers nothing. */
52
60
  const MAX_CHUNK_TOKENS = 1500;
53
61
  const MIN_CHUNK_TOKENS = 30;
@@ -199,6 +207,8 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
199
207
  });
200
208
  }
201
209
 
210
+ findings.push(...(await coverageFindings(options.dir, llmsDir, manifest)));
211
+
202
212
  if (manifest.chunks.every((chunk) => chunk.entities.length === 0)) {
203
213
  findings.push({
204
214
  check: "no-entities",
@@ -352,21 +362,29 @@ function checkPackageJson(
352
362
  }
353
363
  }
354
364
 
355
- /** Newest mtime across the configured documentation source, used to spot a stale payload. */
365
+ /**
366
+ * Newest mtime across every configured documentation source, used to spot a stale payload.
367
+ *
368
+ * Both sources are checked, not the first one found: a package configured with prose *and* an
369
+ * OpenAPI document would otherwise report a fresh payload after the document changed, which is the
370
+ * one case this check exists to catch.
371
+ */
356
372
  async function newestSourceMtime(dir: string): Promise<number> {
357
373
  const config = await readBuildConfig(dir);
358
- const source = config.from ?? config.openapi;
359
- if (source === undefined) return 0;
360
-
361
- const target = join(dir, source);
362
- const single = await mtime(target);
363
- if (single > 0 && config.openapi !== undefined) return single;
374
+ const newest = await Promise.all([
375
+ config.openapi === undefined ? 0 : mtime(join(dir, config.openapi)),
376
+ config.from === undefined ? 0 : newestInTree(join(dir, config.from)),
377
+ ]);
378
+ return Math.max(...newest);
379
+ }
364
380
 
381
+ /** Newest mtime in a directory tree, or the file's own when the path is a file. */
382
+ async function newestInTree(target: string): Promise<number> {
365
383
  let found: Dirent[];
366
384
  try {
367
385
  found = await readdir(target, { recursive: true, withFileTypes: true });
368
386
  } catch {
369
- return single;
387
+ return mtime(target);
370
388
  }
371
389
 
372
390
  let newest = 0;
@@ -385,6 +403,48 @@ async function mtime(path: string): Promise<number> {
385
403
  }
386
404
  }
387
405
 
406
+ /**
407
+ * How much of the documented library's public surface the package mentions.
408
+ *
409
+ * A documentation site is written page by page around tasks and an API grows name by name, so
410
+ * they drift apart with nobody noticing. This is the check that notices — mechanical, from two
411
+ * files, with no model and no judgement in it.
412
+ */
413
+ async function coverageFindings(
414
+ dir: string,
415
+ llmsDir: string,
416
+ manifest: PackageManifest,
417
+ ): Promise<Finding[]> {
418
+ const findings: Finding[] = [];
419
+ let report: Awaited<ReturnType<typeof measureCoverage>>;
420
+ try {
421
+ report = await measureCoverage({ packageDir: dir, llmsDir, manifest, cwd: dir });
422
+ } catch {
423
+ // Coverage is a note about the documentation, never a reason a package fails to check.
424
+ return findings;
425
+ }
426
+
427
+ for (const library of report.libraries) {
428
+ if (library.names === 0) continue;
429
+ const share = library.documented / library.names;
430
+ const percent = Math.round(100 * share);
431
+ findings.push({
432
+ check: "coverage",
433
+ severity: "info",
434
+ message:
435
+ `mentions ${library.documented} of ${library.names} names ${library.library} exports (${percent}%)` +
436
+ (library.uncovered.length === 0 ? "" : `; missing ${library.uncovered.join(", ")}`),
437
+ ...(share < THIN_COVERAGE
438
+ ? {
439
+ fix: "Document the exports nobody has written about, or narrow what the package claims to document.",
440
+ }
441
+ : {}),
442
+ });
443
+ }
444
+
445
+ return findings;
446
+ }
447
+
388
448
  async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
389
449
  try {
390
450
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"));