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.
- package/README.md +31 -0
- package/dist/agent.d.ts +48 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +243 -0
- package/dist/agent.js.map +1 -0
- package/dist/artifact.d.ts +32 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +78 -0
- package/dist/artifact.js.map +1 -0
- package/dist/build.d.ts +23 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +201 -96
- package/dist/build.js.map +1 -1
- package/dist/changed.d.ts +31 -0
- package/dist/changed.d.ts.map +1 -0
- package/dist/changed.js +71 -0
- package/dist/changed.js.map +1 -0
- package/dist/cli.js +222 -12
- package/dist/cli.js.map +1 -1
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +64 -0
- package/dist/coverage.js.map +1 -0
- package/dist/db.d.ts +75 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +168 -6
- package/dist/db.js.map +1 -1
- package/dist/discovery.d.ts +14 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +31 -6
- package/dist/discovery.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +60 -9
- package/dist/doctor.js.map +1 -1
- package/dist/endpoints.d.ts +45 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +155 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +120 -5
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/local.d.ts +67 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +242 -0
- package/dist/local.js.map +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +3 -0
- package/dist/mcp.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +26 -7
- package/dist/preview.js.map +1 -1
- package/dist/search.d.ts +22 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +147 -22
- package/dist/search.js.map +1 -1
- package/dist/surface.d.ts +45 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +208 -0
- package/dist/surface.js.map +1 -0
- package/dist/sync.d.ts +8 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +50 -1
- package/dist/sync.js.map +1 -1
- package/dist/verify.d.ts +9 -0
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +1 -1
- package/dist/verify.js.map +1 -1
- package/package.json +7 -5
- package/src/agent.ts +308 -0
- package/src/artifact.ts +110 -0
- package/src/build.ts +240 -111
- package/src/changed.ts +99 -0
- package/src/cli.ts +263 -12
- package/src/coverage.ts +96 -0
- package/src/db.ts +250 -7
- package/src/discovery.ts +40 -5
- package/src/doctor.ts +68 -8
- package/src/endpoints.ts +184 -0
- package/src/help.ts +120 -5
- package/src/index.ts +52 -1
- package/src/local.ts +335 -0
- package/src/mcp.ts +3 -0
- package/src/preview.ts +38 -13
- package/src/search.ts +203 -23
- package/src/surface.ts +265 -0
- package/src/sync.ts +66 -2
- 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 {
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/src/coverage.ts
ADDED
|
@@ -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
|
+
}
|