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/cli.ts CHANGED
@@ -3,7 +3,10 @@ import { readFileSync } from "node:fs";
3
3
  import { posix } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseArgs } from "node:util";
6
- import { defaultStorePath, Store, silenceSqliteWarning } from "./db.js";
6
+ import { applyAgentSetup, planAgentSetup } from "./agent.js";
7
+ import { changedSurface } from "./changed.js";
8
+ import { measureCoverage } from "./coverage.js";
9
+ import { defaultStorePath, localStorePath, Store, silenceSqliteWarning } from "./db.js";
7
10
  import { discoverPackages } from "./discovery.js";
8
11
  import { type DoctorReport, runDoctor } from "./doctor.js";
9
12
  import { DocspackError } from "./errors.js";
@@ -27,11 +30,17 @@ const OPTIONS = {
27
30
  cwd: { type: "string" },
28
31
  store: { type: "string" },
29
32
  force: { type: "boolean" },
33
+ "no-artifacts": { type: "boolean" },
34
+ coverage: { type: "boolean" },
35
+ hooks: { type: "boolean" },
36
+ mcp: { type: "boolean" },
37
+ feedback: { type: "boolean" },
30
38
  package: { type: "string", short: "p" },
31
39
  limit: { type: "string" },
32
40
  "max-tokens": { type: "string" },
33
41
  all: { type: "boolean" },
34
42
  from: { type: "string" },
43
+ "from-json": { type: "string" },
35
44
  openapi: { type: "string" },
36
45
  out: { type: "string" },
37
46
  name: { type: "string" },
@@ -39,6 +48,7 @@ const OPTIONS = {
39
48
  pages: { type: "string" },
40
49
  "max-chunk-tokens": { type: "string" },
41
50
  "min-chunk-tokens": { type: "string" },
51
+ local: { type: "boolean" },
42
52
  documents: { type: "string", multiple: true },
43
53
  "min-hit-rate": { type: "string" },
44
54
  mirror: { type: "string" },
@@ -141,6 +151,12 @@ function reportDoctor(report: DoctorReport, quiet: boolean): void {
141
151
  }
142
152
 
143
153
  /** 0 when the query was answered; otherwise which of the two empty answers this was. */
154
+ /** Names, capped. A hundred identifiers on one line is a wall, and `--json` has them all. */
155
+ function listed(names: readonly string[], limit = 20): string {
156
+ if (names.length <= limit) return names.join(", ");
157
+ return `${names.slice(0, limit).join(", ")} … and ${names.length - limit} more (--json for all)`;
158
+ }
159
+
144
160
  function queryExit(result: QueryResult): number {
145
161
  if (result.hits.length > 0) return 0;
146
162
  return result.unindexed.length > 0 ? EXIT_NOT_INDEXED : EXIT_NO_MATCH;
@@ -150,6 +166,11 @@ function openStore(values: Values): Store {
150
166
  return Store.open(values.store ?? defaultStorePath());
151
167
  }
152
168
 
169
+ /** The project's own corpus, which is never the machine-wide store unless asked for by path. */
170
+ function openLocalStore(values: Values, cwd: string): Store {
171
+ return Store.open(values.store ?? localStorePath(cwd));
172
+ }
173
+
153
174
  async function main(argv: readonly string[]): Promise<number> {
154
175
  let values: Values;
155
176
  let positionals: string[];
@@ -201,6 +222,7 @@ async function main(argv: readonly string[]): Promise<number> {
201
222
  cwd,
202
223
  store,
203
224
  ...(values.force === true ? { force: true } : {}),
225
+ ...(values["no-artifacts"] === true ? { artifacts: false } : {}),
204
226
  ...(quiet || json
205
227
  ? {}
206
228
  : {
@@ -218,8 +240,11 @@ async function main(argv: readonly string[]): Promise<number> {
218
240
  for (const pkg of result.packages) {
219
241
  const mark = pkg.status === "indexed" ? green("+") : dim("=");
220
242
  const trust = pkg.trusted ? "" : ` ${yellow("(community)")}`;
243
+ // Declarations read from an installed build are not documentation somebody wrote, and
244
+ // the listing says so rather than letting them pass for it.
245
+ const kind = pkg.kind === "artifact" ? ` ${dim("(declarations)")}` : "";
221
246
  process.stdout.write(
222
- `${mark} ${bold(pkg.id)} ${pkg.chunks} chunks ${dim(pkg.status)}${trust}\n`,
247
+ `${mark} ${bold(pkg.id)} ${pkg.chunks} chunks ${dim(pkg.status)}${trust}${kind}\n`,
223
248
  );
224
249
  }
225
250
  for (const problem of result.problems) {
@@ -284,6 +309,83 @@ async function main(argv: readonly string[]): Promise<number> {
284
309
  }
285
310
  }
286
311
 
312
+ case "index": {
313
+ const { indexLocal } = await import("./local.js");
314
+ const store = openLocalStore(values, cwd);
315
+ try {
316
+ const result = await indexLocal({
317
+ cwd,
318
+ store,
319
+ ...(values.from === undefined ? {} : { from: values.from }),
320
+ ...(values["from-json"] === undefined ? {} : { json: values["from-json"] }),
321
+ ...(values.name === undefined ? {} : { name: values.name }),
322
+ ...(values.force === true ? { force: true } : {}),
323
+ ...(quiet || json
324
+ ? {}
325
+ : {
326
+ onProgress: (message: string): void => {
327
+ process.stderr.write(`${dim(message)}\n`);
328
+ },
329
+ }),
330
+ });
331
+
332
+ if (json) {
333
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
334
+ return 0;
335
+ }
336
+ const state =
337
+ result.status === "unchanged"
338
+ ? dim("unchanged")
339
+ : `${String(result.chunks)} chunks ${dim(`~${String(result.tokens)} tokens`)}`;
340
+ process.stdout.write(`${green("+")} ${bold(result.name)} ${state}\n`);
341
+ for (const warning of result.warnings) process.stderr.write(`${yellow("!")} ${warning}\n`);
342
+ if (!quiet) {
343
+ process.stdout.write(
344
+ result.streamed
345
+ ? `\nIndexed from a stream, so nothing can tell later whether it is still current.\nAsk it with \`docspack recall "<question>"\`.\n`
346
+ : `\nAsk it with \`docspack recall "<question>"\`. Re-run this after editing the sources.\n`,
347
+ );
348
+ }
349
+ return 0;
350
+ } finally {
351
+ store.close();
352
+ }
353
+ }
354
+
355
+ case "recall": {
356
+ const question = rest.join(" ");
357
+ if (question.length === 0) {
358
+ throw new DocspackError('Usage: docspack recall "<question>"');
359
+ }
360
+
361
+ const { recallLocal, renderRecall } = await import("./local.js");
362
+ const store = openLocalStore(values, cwd);
363
+ try {
364
+ const result = await recallLocal({
365
+ cwd,
366
+ store,
367
+ query: question,
368
+ ...(() => {
369
+ const limit = integer(values.limit, "--limit");
370
+ return limit === undefined ? {} : { limit };
371
+ })(),
372
+ ...(() => {
373
+ const maxTokens = integer(values["max-tokens"], "--max-tokens");
374
+ return maxTokens === undefined ? {} : { maxTokens };
375
+ })(),
376
+ });
377
+
378
+ if (json) {
379
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
380
+ } else {
381
+ process.stdout.write(`${renderRecall(result, question)}\n`);
382
+ }
383
+ return result.hits.length === 0 ? 1 : 0;
384
+ } finally {
385
+ store.close();
386
+ }
387
+ }
388
+
287
389
  case "search": {
288
390
  const query = rest.join(" ");
289
391
  if (query.length === 0) throw new DocspackError("Usage: docspack search <query>");
@@ -330,6 +432,11 @@ async function main(argv: readonly string[]): Promise<number> {
330
432
  }
331
433
  process.stdout.write("\n");
332
434
  }
435
+ if (result.endpoints.length > 0) {
436
+ // Which hits were addressed rather than ranked. A reader who asked about a concrete URL
437
+ // and got a chunk about a template path needs to see that the match was the template.
438
+ process.stdout.write(dim(`matched by endpoint: ${result.endpoints.join(", ")}\n`));
439
+ }
333
440
  process.stdout.write(
334
441
  dim(`${result.tokens} tokens across ${plural(result.hits.length, "chunk")}\n`),
335
442
  );
@@ -343,17 +450,38 @@ async function main(argv: readonly string[]): Promise<number> {
343
450
  const store = openStore(values);
344
451
  try {
345
452
  const { packages, problems } = await discoverPackages(cwd);
346
- const rows = packages.map((pkg) => ({
347
- id: pkg.id,
348
- name: pkg.name,
349
- version: pkg.version,
350
- chunks: pkg.manifest.chunks.length,
351
- trusted: pkg.trusted,
352
- indexed: store.hasPackage(pkg.id),
353
- }));
453
+ const wantCoverage = values.coverage === true || json;
454
+ const rows = [];
455
+ for (const pkg of packages) {
456
+ rows.push({
457
+ id: pkg.id,
458
+ name: pkg.name,
459
+ version: pkg.version,
460
+ chunks: pkg.manifest.chunks.length,
461
+ trusted: pkg.trusted,
462
+ indexed: store.hasPackage(pkg.id),
463
+ ...(wantCoverage
464
+ ? {
465
+ coverage: await measureCoverage({
466
+ packageDir: pkg.dir,
467
+ llmsDir: pkg.llmsDir,
468
+ manifest: pkg.manifest,
469
+ cwd,
470
+ }),
471
+ }
472
+ : {}),
473
+ });
474
+ }
475
+
476
+ const declarations = store
477
+ .listPackages()
478
+ .filter((pkg) => pkg.kind === "artifact")
479
+ .map((pkg) => ({ id: pkg.id, chunks: store.countChunks(pkg.id) }));
354
480
 
355
481
  if (json) {
356
- process.stdout.write(`${JSON.stringify({ packages: rows, problems }, null, 2)}\n`);
482
+ process.stdout.write(
483
+ `${JSON.stringify({ packages: rows, declarations, problems }, null, 2)}\n`,
484
+ );
357
485
  return 0;
358
486
  }
359
487
  if (rows.length === 0) {
@@ -363,6 +491,24 @@ async function main(argv: readonly string[]): Promise<number> {
363
491
  const state = row.indexed ? green("indexed") : yellow("not indexed");
364
492
  const trust = row.trusted ? "" : ` ${yellow("(community)")}`;
365
493
  process.stdout.write(`${bold(row.id)} ${row.chunks} chunks ${state}${trust}\n`);
494
+ for (const library of row.coverage?.libraries ?? []) {
495
+ const percent =
496
+ library.names === 0 ? 0 : Math.round((100 * library.documented) / library.names);
497
+ process.stdout.write(
498
+ ` ${dim(`documents ${library.documented}/${library.names} (${percent}%) of ${library.library}'s exports`)}\n`,
499
+ );
500
+ if (library.uncovered.length > 0) {
501
+ process.stdout.write(` ${dim(`missing: ${library.uncovered.join(", ")}`)}\n`);
502
+ }
503
+ }
504
+ }
505
+ if (declarations.length > 0 && !quiet) {
506
+ const chunks = declarations.reduce((total, row) => total + row.chunks, 0);
507
+ process.stdout.write(
508
+ dim(
509
+ `\n${declarations.length} installed libraries indexed by declaration, ${chunks} names in all.\n`,
510
+ ),
511
+ );
366
512
  }
367
513
  for (const problem of problems) process.stderr.write(`${yellow("!")} ${problem}\n`);
368
514
  return 0;
@@ -371,6 +517,106 @@ async function main(argv: readonly string[]): Promise<number> {
371
517
  }
372
518
  }
373
519
 
520
+ case "agent": {
521
+ const [sub = "install"] = rest;
522
+ if (sub !== "install" && sub !== "check") {
523
+ throw new DocspackError(`Unknown subcommand "${sub}"`, {
524
+ hint: "Usage: docspack agent <install|check>",
525
+ });
526
+ }
527
+
528
+ const plan = await planAgentSetup({
529
+ cwd,
530
+ ...(values.feedback === true ? { feedback: true } : {}),
531
+ ...(values.hooks === true ? { hooks: true } : {}),
532
+ ...(values.mcp === true ? { mcp: true } : {}),
533
+ });
534
+ const pending = plan.files.filter((file) => file.status !== "unchanged");
535
+
536
+ if (json) {
537
+ process.stdout.write(
538
+ `${JSON.stringify({ files: plan.files.map(({ contents, ...rest }) => rest) }, null, 2)}\n`,
539
+ );
540
+ return sub === "check" && pending.length > 0 ? 1 : 0;
541
+ }
542
+
543
+ // `check` is the CI half: it writes nothing and fails when the wiring is missing or stale,
544
+ // which is the only way a pasted instruction ever gets noticed after it drifts.
545
+ if (sub === "check") {
546
+ for (const file of plan.files) {
547
+ const mark = file.status === "unchanged" ? green("ok") : yellow(file.status);
548
+ process.stdout.write(`${mark} ${file.path}\n`);
549
+ }
550
+ if (pending.length > 0) {
551
+ process.stderr.write(
552
+ `\n${yellow("!")} ${plural(pending.length, "file")} out of date. Run \`docspack agent install\`.\n`,
553
+ );
554
+ }
555
+ return pending.length > 0 ? 1 : 0;
556
+ }
557
+
558
+ if (values["dry-run"] === true) {
559
+ for (const file of plan.files) {
560
+ process.stdout.write(
561
+ `${file.status === "unchanged" ? dim("=") : green("+")} ${bold(file.path)} ${dim(file.reason)}\n`,
562
+ );
563
+ }
564
+ return 0;
565
+ }
566
+
567
+ const written = await applyAgentSetup({ cwd }, plan);
568
+ for (const file of plan.files) {
569
+ const mark = file.status === "unchanged" ? dim("=") : green("+");
570
+ process.stdout.write(
571
+ `${mark} ${bold(file.path)} ${dim(file.status === "unchanged" ? "unchanged" : file.reason)}\n`,
572
+ );
573
+ }
574
+ if (written.length > 0 && !quiet) {
575
+ process.stdout.write(
576
+ `\n${dim("Commit these. Every agent working in this repository can now read the")}\n${dim("documentation of its dependencies, without anyone being told the command exists.")}\n`,
577
+ );
578
+ }
579
+ return 0;
580
+ }
581
+
582
+ case "changed": {
583
+ const library = rest[0];
584
+ if (library === undefined) {
585
+ throw new DocspackError("Usage: docspack changed <library>[@version]");
586
+ }
587
+
588
+ const store = openStore(values);
589
+ try {
590
+ const change = await changedSurface({ cwd, store, library });
591
+ if (json) {
592
+ process.stdout.write(`${JSON.stringify(change, null, 2)}\n`);
593
+ return 0;
594
+ }
595
+
596
+ process.stdout.write(
597
+ `${bold(`${change.library} ${change.from} → ${change.to}`)} ${dim(
598
+ `${change.added.length} added, ${change.removed.length} removed`,
599
+ )}\n`,
600
+ );
601
+ if (change.removed.length > 0) {
602
+ process.stdout.write(`\n${yellow("gone")} ${listed(change.removed)}\n`);
603
+ }
604
+ if (change.added.length > 0) {
605
+ process.stdout.write(`\n${green("new")} ${listed(change.added)}\n`);
606
+ }
607
+ if (change.undocumented.length > 0) {
608
+ process.stdout.write(
609
+ `\n${dim(
610
+ `${plural(change.undocumented.length, "new name")} that no documentation package here mentions:`,
611
+ )}\n${listed(change.undocumented)}\n`,
612
+ );
613
+ }
614
+ return 0;
615
+ } finally {
616
+ store.close();
617
+ }
618
+ }
619
+
374
620
  case "verify": {
375
621
  const report = await verifyProject({
376
622
  cwd,
@@ -605,7 +851,9 @@ async function main(argv: readonly string[]): Promise<number> {
605
851
  out: values.out ?? cwd,
606
852
  ...(rest[0] === undefined ? {} : { source: rest[0] }),
607
853
  ...(values.from === undefined ? {} : { from: values.from }),
854
+ ...(values["from-json"] === undefined ? {} : { json: values["from-json"] }),
608
855
  ...(values.openapi === undefined ? {} : { openapi: values.openapi }),
856
+ ...(values.local === true ? { local: true } : {}),
609
857
  ...(values.name === undefined ? {} : { name: values.name }),
610
858
  ...(values["pkg-version"] === undefined ? {} : { version: values["pkg-version"] }),
611
859
  ...(() => {
@@ -639,8 +887,11 @@ async function main(argv: readonly string[]): Promise<number> {
639
887
  );
640
888
  for (const warning of result.warnings) process.stderr.write(`${yellow("!")} ${warning}\n`);
641
889
  if (!quiet) {
890
+ // A local build is not going to npm, so the publishing instruction would be wrong advice.
642
891
  process.stdout.write(
643
- `\nWrote ${result.dir}/.llms and ${result.dir}/llms.txt.\nPublish with \`npm publish\`, then add it to a project and run \`docspack sync\`.\n`,
892
+ values.local === true
893
+ ? `\nWrote ${result.dir}/.llms and ${result.dir}/llms.txt.\nTo index a corpus into this project instead, use \`docspack index\`.\n`
894
+ : `\nWrote ${result.dir}/.llms and ${result.dir}/llms.txt.\nPublish with \`npm publish\`, then add it to a project and run \`docspack sync\`.\n`,
644
895
  );
645
896
  }
646
897
  return 0;
@@ -0,0 +1,96 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolvePackageDir } from "./discovery.js";
3
+ import { formatDocumentedLibrary, type PackageManifest, resolveChunkFile } from "./spec.js";
4
+ import { readPublicSurface } from "./surface.js";
5
+ import { documentedLibraries } from "./verify.js";
6
+
7
+ /** How much of one library's public surface the documentation mentions. */
8
+ export interface LibraryCoverage {
9
+ readonly library: string;
10
+ /** Exported names long enough to search prose for. */
11
+ readonly names: number;
12
+ /** Those the documentation mentions at least once, anywhere. */
13
+ readonly documented: number;
14
+ /** A sample of what it does not mention, for an author to act on. */
15
+ readonly uncovered: readonly string[];
16
+ }
17
+
18
+ export interface CoverageReport {
19
+ readonly libraries: readonly LibraryCoverage[];
20
+ /** Libraries the package documents whose declarations could not be read. */
21
+ readonly unreadable: readonly string[];
22
+ }
23
+
24
+ /**
25
+ * A name shorter than this is too generic to look for in prose, and its absence would say
26
+ * nothing: `Env`, `z`, `app`.
27
+ */
28
+ const MIN_NAME = 4;
29
+
30
+ /** How many uncovered names are named in the report. */
31
+ const SAMPLE = 12;
32
+
33
+ export interface CoverageOptions {
34
+ readonly packageDir: string;
35
+ readonly llmsDir: string;
36
+ readonly manifest: PackageManifest;
37
+ /** Project the library is resolved from when the docs package does not depend on it itself. */
38
+ readonly cwd: string;
39
+ }
40
+
41
+ /**
42
+ * Compares what a documentation package says against what the library it documents exports.
43
+ *
44
+ * A documentation site is written page by page around tasks and an API grows name by name, so
45
+ * the two drift apart with nobody noticing — measured across two well-documented projects, half
46
+ * of the exported surface is mentioned nowhere. This is the number that makes that visible, and
47
+ * it is mechanical: two files, no model, no judgement.
48
+ */
49
+ export async function measureCoverage(options: CoverageOptions): Promise<CoverageReport> {
50
+ const libraries = await documentedLibraries(options.packageDir, options.manifest);
51
+ if (libraries.length === 0) return { libraries: [], unreadable: [] };
52
+
53
+ const text = await readCorpus(options);
54
+ const mentioned = identifiers(text);
55
+ const covered: LibraryCoverage[] = [];
56
+ const unreadable: string[] = [];
57
+
58
+ for (const library of libraries) {
59
+ const dir =
60
+ (await resolvePackageDir(library.name, options.packageDir)) ??
61
+ (await resolvePackageDir(library.name, options.cwd));
62
+ const surface = dir === undefined ? undefined : await readPublicSurface(dir);
63
+ if (surface === undefined) {
64
+ unreadable.push(formatDocumentedLibrary(library));
65
+ continue;
66
+ }
67
+
68
+ const names = [...surface.symbols.keys()].filter((name) => name.length >= MIN_NAME);
69
+ const uncovered = names.filter((name) => !mentioned.has(name));
70
+ covered.push({
71
+ library: formatDocumentedLibrary(library),
72
+ names: names.length,
73
+ documented: names.length - uncovered.length,
74
+ uncovered: uncovered.slice(0, SAMPLE),
75
+ });
76
+ }
77
+
78
+ return { libraries: covered, unreadable };
79
+ }
80
+
81
+ /** Splits text into the identifier-shaped tokens an exact mention would produce. */
82
+ export function identifiers(text: string): Set<string> {
83
+ return new Set(text.split(/[^A-Za-z0-9_$]+/).filter((token) => token.length > 0));
84
+ }
85
+
86
+ async function readCorpus(options: CoverageOptions): Promise<string> {
87
+ const parts: string[] = [];
88
+ for (const chunk of options.manifest.chunks) {
89
+ try {
90
+ parts.push(await readFile(resolveChunkFile(options.llmsDir, chunk.file), "utf8"));
91
+ } catch {
92
+ // A chunk that cannot be read is doctor's finding to report, not coverage's.
93
+ }
94
+ }
95
+ return parts.join("\n");
96
+ }