docspack 0.3.0 → 1.0.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/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.map +1 -1
- package/dist/build.js +75 -9
- 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 +131 -10
- 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 +38 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +109 -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 +90 -3
- package/dist/doctor.js.map +1 -1
- package/dist/document.d.ts +2 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +7 -3
- package/dist/document.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +65 -4
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- 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 +1 -0
- package/dist/preview.js.map +1 -1
- package/dist/search.d.ts +17 -3
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +116 -21
- package/dist/search.js.map +1 -1
- package/dist/spec.d.ts +6 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +12 -3
- package/dist/spec.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 +55 -27
- package/dist/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/agent.ts +308 -0
- package/src/artifact.ts +110 -0
- package/src/build.ts +103 -10
- package/src/changed.ts +99 -0
- package/src/cli.ts +167 -10
- package/src/coverage.ts +96 -0
- package/src/db.ts +168 -7
- package/src/discovery.ts +40 -5
- package/src/doctor.ts +100 -4
- package/src/document.ts +13 -4
- package/src/help.ts +65 -4
- package/src/index.ts +30 -0
- package/src/mcp.ts +3 -0
- package/src/preview.ts +1 -0
- package/src/search.ts +158 -24
- package/src/spec.ts +21 -3
- package/src/surface.ts +265 -0
- package/src/sync.ts +66 -2
- package/src/verify.ts +57 -27
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
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -8,13 +8,29 @@ 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. The
|
|
14
|
+
* distinction is not cosmetic — a signature is a fact about the build, prose is a claim by its
|
|
15
|
+
* author — so it is stored rather than inferred from the name.
|
|
16
|
+
*/
|
|
17
|
+
export type PackageKind = "docs" | "artifact";
|
|
18
|
+
|
|
11
19
|
export interface IndexedPackage {
|
|
12
20
|
readonly id: string;
|
|
13
21
|
readonly name: string;
|
|
14
22
|
readonly version: string;
|
|
23
|
+
readonly kind?: PackageKind;
|
|
15
24
|
readonly indexedAt?: string;
|
|
16
25
|
}
|
|
17
26
|
|
|
27
|
+
/** An exported name and the chunk carrying its declaration. */
|
|
28
|
+
export interface SymbolHit {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly packageId: string;
|
|
31
|
+
readonly chunkId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export interface IndexedChunk {
|
|
19
35
|
readonly chunkId: string;
|
|
20
36
|
readonly filePath: string;
|
|
@@ -39,15 +55,18 @@ export interface SearchOptions {
|
|
|
39
55
|
readonly limit?: number;
|
|
40
56
|
/** Stop returning chunks once this many tokens have been collected. */
|
|
41
57
|
readonly maxTokens?: number;
|
|
58
|
+
/** Restrict to packages of these kinds. Defaults to every kind. */
|
|
59
|
+
readonly kinds?: readonly PackageKind[];
|
|
42
60
|
}
|
|
43
61
|
|
|
44
|
-
const SCHEMA_VERSION =
|
|
62
|
+
const SCHEMA_VERSION = 2;
|
|
45
63
|
|
|
46
64
|
const SCHEMA = `
|
|
47
65
|
CREATE TABLE IF NOT EXISTS packages (
|
|
48
66
|
id TEXT PRIMARY KEY,
|
|
49
67
|
name TEXT NOT NULL,
|
|
50
68
|
version TEXT NOT NULL,
|
|
69
|
+
kind TEXT NOT NULL DEFAULT 'docs',
|
|
51
70
|
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
52
71
|
);
|
|
53
72
|
|
|
@@ -61,6 +80,15 @@ CREATE TABLE IF NOT EXISTS chunks (
|
|
|
61
80
|
|
|
62
81
|
CREATE INDEX IF NOT EXISTS chunks_by_package ON chunks(package_id);
|
|
63
82
|
|
|
83
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
84
|
+
package_id TEXT NOT NULL REFERENCES packages(id),
|
|
85
|
+
name TEXT NOT NULL,
|
|
86
|
+
chunk_id TEXT NOT NULL,
|
|
87
|
+
PRIMARY KEY (package_id, name)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE INDEX IF NOT EXISTS symbols_by_name ON symbols(name);
|
|
91
|
+
|
|
64
92
|
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
65
93
|
content,
|
|
66
94
|
tags,
|
|
@@ -133,9 +161,22 @@ export class Store {
|
|
|
133
161
|
this.#db = db;
|
|
134
162
|
this.#db.exec("PRAGMA journal_mode = WAL");
|
|
135
163
|
this.#db.exec(SCHEMA);
|
|
164
|
+
this.#migrate();
|
|
136
165
|
this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
137
166
|
}
|
|
138
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Brings a store written by an older version forward. `CREATE TABLE IF NOT EXISTS` covers a
|
|
170
|
+
* new table, but not a new column on one that already exists, and rebuilding the whole index
|
|
171
|
+
* to add one would make an upgrade re-read every package on the machine.
|
|
172
|
+
*/
|
|
173
|
+
#migrate(): void {
|
|
174
|
+
const columns = this.#db.prepare("PRAGMA table_info(packages)").all() as { name: string }[];
|
|
175
|
+
if (!columns.some((column) => column.name === "kind")) {
|
|
176
|
+
this.#db.exec("ALTER TABLE packages ADD COLUMN kind TEXT NOT NULL DEFAULT 'docs'");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
139
180
|
static open(path: string = defaultStorePath()): Store {
|
|
140
181
|
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
141
182
|
try {
|
|
@@ -154,16 +195,115 @@ export class Store {
|
|
|
154
195
|
|
|
155
196
|
listPackages(): IndexedPackage[] {
|
|
156
197
|
const rows = this.#db
|
|
157
|
-
.prepare("SELECT id, name, version, indexed_at FROM packages ORDER BY name, version")
|
|
158
|
-
.all() as {
|
|
198
|
+
.prepare("SELECT id, name, version, kind, indexed_at FROM packages ORDER BY name, version")
|
|
199
|
+
.all() as {
|
|
200
|
+
id: string;
|
|
201
|
+
name: string;
|
|
202
|
+
version: string;
|
|
203
|
+
kind: string;
|
|
204
|
+
indexed_at: string;
|
|
205
|
+
}[];
|
|
159
206
|
return rows.map((row) => ({
|
|
160
207
|
id: row.id,
|
|
161
208
|
name: row.name,
|
|
162
209
|
version: row.version,
|
|
210
|
+
kind: row.kind === "artifact" ? "artifact" : "docs",
|
|
163
211
|
indexedAt: row.indexed_at,
|
|
164
212
|
}));
|
|
165
213
|
}
|
|
166
214
|
|
|
215
|
+
/** Every version of one library in the store, newest indexing first. */
|
|
216
|
+
versionsOf(name: string): IndexedPackage[] {
|
|
217
|
+
return this.listPackages().filter((pkg) => pkg.name === name);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Chunks declaring an exported name, by exact match rather than by ranking.
|
|
222
|
+
*
|
|
223
|
+
* A symbol is a key, not a phrase. Ranking one is the mistake that made `docspack ask` unable
|
|
224
|
+
* to tell "the documentation does not mention this" from "nothing matched".
|
|
225
|
+
*/
|
|
226
|
+
lookupSymbol(name: string, packageIds?: readonly string[]): SymbolHit[] {
|
|
227
|
+
if (packageIds !== undefined && packageIds.length === 0) return [];
|
|
228
|
+
const scope =
|
|
229
|
+
packageIds === undefined
|
|
230
|
+
? ""
|
|
231
|
+
: ` AND package_id IN (${packageIds.map(() => "?").join(", ")})`;
|
|
232
|
+
const rows = this.#db
|
|
233
|
+
.prepare(
|
|
234
|
+
// A package that declares the name comes first: an empty chunk id means the name is
|
|
235
|
+
// exported but its declaration was not readable, which answers less.
|
|
236
|
+
`SELECT name, package_id AS packageId, chunk_id AS chunkId
|
|
237
|
+
FROM symbols WHERE name = ?${scope}
|
|
238
|
+
ORDER BY (chunk_id = '') ASC, package_id`,
|
|
239
|
+
)
|
|
240
|
+
.all(name, ...(packageIds ?? [])) as {
|
|
241
|
+
name: string;
|
|
242
|
+
packageId: string;
|
|
243
|
+
chunkId: string;
|
|
244
|
+
}[];
|
|
245
|
+
return rows.map((row) => ({ name: row.name, packageId: row.packageId, chunkId: row.chunkId }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Every chunk of one package, for a check that has to read the whole corpus. */
|
|
249
|
+
chunksOf(packageId: string): SearchHit[] {
|
|
250
|
+
const rows = this.#db
|
|
251
|
+
.prepare(
|
|
252
|
+
`SELECT chunk_id AS chunkId, package_id AS packageId, file_path AS filePath,
|
|
253
|
+
tokens, content
|
|
254
|
+
FROM chunks WHERE package_id = ?`,
|
|
255
|
+
)
|
|
256
|
+
.all(packageId) as {
|
|
257
|
+
chunkId: string;
|
|
258
|
+
packageId: string;
|
|
259
|
+
filePath: string;
|
|
260
|
+
tokens: number;
|
|
261
|
+
content: string;
|
|
262
|
+
}[];
|
|
263
|
+
return rows.map((row) => ({
|
|
264
|
+
chunkId: row.chunkId,
|
|
265
|
+
packageId: row.packageId,
|
|
266
|
+
filePath: row.filePath,
|
|
267
|
+
tokens: Number(row.tokens ?? 0),
|
|
268
|
+
content: row.content,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Every exported name recorded for one package. */
|
|
273
|
+
symbolsOf(packageId: string): string[] {
|
|
274
|
+
const rows = this.#db
|
|
275
|
+
.prepare("SELECT name FROM symbols WHERE package_id = ? ORDER BY name")
|
|
276
|
+
.all(packageId) as { name: string }[];
|
|
277
|
+
return rows.map((row) => row.name);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One chunk by its id, however it was found. */
|
|
281
|
+
chunk(chunkId: string): SearchHit | undefined {
|
|
282
|
+
const row = this.#db
|
|
283
|
+
.prepare(
|
|
284
|
+
`SELECT chunk_id AS chunkId, package_id AS packageId, file_path AS filePath,
|
|
285
|
+
tokens, content
|
|
286
|
+
FROM chunks WHERE chunk_id = ?`,
|
|
287
|
+
)
|
|
288
|
+
.get(chunkId) as
|
|
289
|
+
| {
|
|
290
|
+
chunkId: string;
|
|
291
|
+
packageId: string;
|
|
292
|
+
filePath: string;
|
|
293
|
+
tokens: number;
|
|
294
|
+
content: string;
|
|
295
|
+
}
|
|
296
|
+
| undefined;
|
|
297
|
+
if (row === undefined) return undefined;
|
|
298
|
+
return {
|
|
299
|
+
chunkId: row.chunkId,
|
|
300
|
+
packageId: row.packageId,
|
|
301
|
+
filePath: row.filePath,
|
|
302
|
+
tokens: Number(row.tokens ?? 0),
|
|
303
|
+
content: row.content,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
167
307
|
countChunks(packageId: string): number {
|
|
168
308
|
const row = this.#db
|
|
169
309
|
.prepare("SELECT COUNT(*) AS total FROM chunks WHERE package_id = ?")
|
|
@@ -171,10 +311,19 @@ export class Store {
|
|
|
171
311
|
return row.total;
|
|
172
312
|
}
|
|
173
313
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Replaces a package, all of its chunks and its symbol table in a single transaction.
|
|
316
|
+
*
|
|
317
|
+
* `symbols` maps an exported name to the chunk declaring it, and is how a query about a name
|
|
318
|
+
* is answered without ranking anything.
|
|
319
|
+
*/
|
|
320
|
+
indexPackage(
|
|
321
|
+
pkg: IndexedPackage,
|
|
322
|
+
chunks: readonly IndexedChunk[],
|
|
323
|
+
symbols?: ReadonlyMap<string, string>,
|
|
324
|
+
): void {
|
|
176
325
|
const insertPackage = this.#db.prepare(
|
|
177
|
-
"INSERT OR REPLACE INTO packages (id, name, version) VALUES (?, ?, ?)",
|
|
326
|
+
"INSERT OR REPLACE INTO packages (id, name, version, kind) VALUES (?, ?, ?, ?)",
|
|
178
327
|
);
|
|
179
328
|
const insertChunk = this.#db.prepare(
|
|
180
329
|
"INSERT OR REPLACE INTO chunks (chunk_id, package_id, file_path, tokens, content) VALUES (?, ?, ?, ?, ?)",
|
|
@@ -182,15 +331,19 @@ export class Store {
|
|
|
182
331
|
const insertFts = this.#db.prepare(
|
|
183
332
|
"INSERT INTO chunks_fts (content, tags, package_id, chunk_id) VALUES (?, ?, ?, ?)",
|
|
184
333
|
);
|
|
334
|
+
const insertSymbol = this.#db.prepare(
|
|
335
|
+
"INSERT OR REPLACE INTO symbols (package_id, name, chunk_id) VALUES (?, ?, ?)",
|
|
336
|
+
);
|
|
185
337
|
|
|
186
338
|
this.#db.exec("BEGIN");
|
|
187
339
|
try {
|
|
188
340
|
this.#deleteChunks(pkg.id);
|
|
189
|
-
insertPackage.run(pkg.id, pkg.name, pkg.version);
|
|
341
|
+
insertPackage.run(pkg.id, pkg.name, pkg.version, pkg.kind ?? "docs");
|
|
190
342
|
for (const chunk of chunks) {
|
|
191
343
|
insertChunk.run(chunk.chunkId, pkg.id, chunk.filePath, chunk.tokens, chunk.content);
|
|
192
344
|
insertFts.run(chunk.content, chunk.tags.join(" "), pkg.id, chunk.chunkId);
|
|
193
345
|
}
|
|
346
|
+
for (const [name, chunk] of symbols ?? []) insertSymbol.run(pkg.id, name, chunk);
|
|
194
347
|
this.#db.exec("COMMIT");
|
|
195
348
|
} catch (error) {
|
|
196
349
|
this.#db.exec("ROLLBACK");
|
|
@@ -228,6 +381,13 @@ export class Store {
|
|
|
228
381
|
conditions.push("f.package_id LIKE ?");
|
|
229
382
|
parameters.push(options.packageFilter);
|
|
230
383
|
}
|
|
384
|
+
if (options.kinds !== undefined) {
|
|
385
|
+
if (options.kinds.length === 0) return [];
|
|
386
|
+
conditions.push(
|
|
387
|
+
`f.package_id IN (SELECT id FROM packages WHERE kind IN (${options.kinds.map(() => "?").join(", ")}))`,
|
|
388
|
+
);
|
|
389
|
+
parameters.push(...options.kinds);
|
|
390
|
+
}
|
|
231
391
|
|
|
232
392
|
const rows = this.#db
|
|
233
393
|
.prepare(
|
|
@@ -272,5 +432,6 @@ export class Store {
|
|
|
272
432
|
#deleteChunks(packageId: string): void {
|
|
273
433
|
this.#db.prepare("DELETE FROM chunks_fts WHERE package_id = ?").run(packageId);
|
|
274
434
|
this.#db.prepare("DELETE FROM chunks WHERE package_id = ?").run(packageId);
|
|
435
|
+
this.#db.prepare("DELETE FROM symbols WHERE package_id = ?").run(packageId);
|
|
275
436
|
}
|
|
276
437
|
}
|
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
|
|
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
|
|
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;
|
|
@@ -64,12 +72,14 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
64
72
|
const prose: Severity = options.pedantic === true ? "warn" : "info";
|
|
65
73
|
|
|
66
74
|
const pkg = await readJson(join(options.dir, "package.json"));
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
75
|
+
const loaded = await loadManifest(llmsDir, findings);
|
|
76
|
+
if (loaded === undefined) {
|
|
69
77
|
return { ok: false, findings, chunks: 0, tokens: 0 };
|
|
70
78
|
}
|
|
79
|
+
const { manifest, raw } = loaded;
|
|
71
80
|
|
|
72
81
|
checkPackageJson(pkg, manifest, findings);
|
|
82
|
+
checkUnknownFields(raw, findings);
|
|
73
83
|
|
|
74
84
|
let tokens = 0;
|
|
75
85
|
let newestSource = 0;
|
|
@@ -197,6 +207,8 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
197
207
|
});
|
|
198
208
|
}
|
|
199
209
|
|
|
210
|
+
findings.push(...(await coverageFindings(options.dir, llmsDir, manifest)));
|
|
211
|
+
|
|
200
212
|
if (manifest.chunks.every((chunk) => chunk.entities.length === 0)) {
|
|
201
213
|
findings.push({
|
|
202
214
|
check: "no-entities",
|
|
@@ -233,7 +245,7 @@ function plural(count: number, noun: string): string {
|
|
|
233
245
|
async function loadManifest(
|
|
234
246
|
llmsDir: string,
|
|
235
247
|
findings: Finding[],
|
|
236
|
-
): Promise<PackageManifest | undefined> {
|
|
248
|
+
): Promise<{ manifest: PackageManifest; raw: unknown } | undefined> {
|
|
237
249
|
let raw: string;
|
|
238
250
|
try {
|
|
239
251
|
raw = await readFile(join(llmsDir, MANIFEST_FILE), "utf8");
|
|
@@ -248,7 +260,8 @@ async function loadManifest(
|
|
|
248
260
|
}
|
|
249
261
|
|
|
250
262
|
try {
|
|
251
|
-
|
|
263
|
+
const parsed: unknown = JSON.parse(raw);
|
|
264
|
+
return { manifest: parseManifest(parsed, `${LLMS_DIR}/${MANIFEST_FILE}`), raw: parsed };
|
|
252
265
|
} catch (error) {
|
|
253
266
|
findings.push({
|
|
254
267
|
check: "manifest-invalid",
|
|
@@ -261,6 +274,47 @@ async function loadManifest(
|
|
|
261
274
|
}
|
|
262
275
|
}
|
|
263
276
|
|
|
277
|
+
/** Every key the format defines. `additionalProperties` is true, so nothing else is read. */
|
|
278
|
+
const MANIFEST_KEYS = new Set(["$schema", "name", "version", "documents", "chunks"]);
|
|
279
|
+
const CHUNK_KEYS = new Set(["id", "file", "tokens", "tags", "entities", "documents"]);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Reports keys outside the closed set.
|
|
283
|
+
*
|
|
284
|
+
* Unknown fields are accepted and ignored, which is the forward-compatibility rule and worth
|
|
285
|
+
* keeping — but it also means `"tokens"` written as `"token"` validates, publishes, and does
|
|
286
|
+
* nothing. A publisher is the one party who can still tell a typo from an extension, so the
|
|
287
|
+
* check belongs here rather than in the reader, at a severity `--strict` fails on.
|
|
288
|
+
*/
|
|
289
|
+
function checkUnknownFields(raw: unknown, findings: Finding[]): void {
|
|
290
|
+
const root = raw as Record<string, unknown>;
|
|
291
|
+
const unknown = Object.keys(root).filter((key) => !MANIFEST_KEYS.has(key));
|
|
292
|
+
if (unknown.length > 0) {
|
|
293
|
+
findings.push({
|
|
294
|
+
check: "unknown-field",
|
|
295
|
+
severity: "warn",
|
|
296
|
+
message: `the manifest has ${unknown.length === 1 ? "a field" : "fields"} nothing reads: ${unknown.join(", ")}`,
|
|
297
|
+
where: `${LLMS_DIR}/${MANIFEST_FILE}`,
|
|
298
|
+
fix: "Remove it, or correct the spelling. Unknown fields are accepted and ignored.",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const chunks = Array.isArray(root.chunks) ? root.chunks : [];
|
|
303
|
+
for (const entry of chunks) {
|
|
304
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
305
|
+
const chunk = entry as Record<string, unknown>;
|
|
306
|
+
const extra = Object.keys(chunk).filter((key) => !CHUNK_KEYS.has(key));
|
|
307
|
+
if (extra.length === 0) continue;
|
|
308
|
+
findings.push({
|
|
309
|
+
check: "unknown-field",
|
|
310
|
+
severity: "warn",
|
|
311
|
+
message: `chunk "${String(chunk.id)}" has ${extra.length === 1 ? "a field" : "fields"} nothing reads: ${extra.join(", ")}`,
|
|
312
|
+
where: `${LLMS_DIR}/${MANIFEST_FILE}`,
|
|
313
|
+
fix: "Remove it, or correct the spelling. Unknown fields are accepted and ignored.",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
264
318
|
function checkPackageJson(
|
|
265
319
|
pkg: Record<string, unknown> | undefined,
|
|
266
320
|
manifest: PackageManifest,
|
|
@@ -341,6 +395,48 @@ async function mtime(path: string): Promise<number> {
|
|
|
341
395
|
}
|
|
342
396
|
}
|
|
343
397
|
|
|
398
|
+
/**
|
|
399
|
+
* How much of the documented library's public surface the package mentions.
|
|
400
|
+
*
|
|
401
|
+
* A documentation site is written page by page around tasks and an API grows name by name, so
|
|
402
|
+
* they drift apart with nobody noticing. This is the check that notices — mechanical, from two
|
|
403
|
+
* files, with no model and no judgement in it.
|
|
404
|
+
*/
|
|
405
|
+
async function coverageFindings(
|
|
406
|
+
dir: string,
|
|
407
|
+
llmsDir: string,
|
|
408
|
+
manifest: PackageManifest,
|
|
409
|
+
): Promise<Finding[]> {
|
|
410
|
+
const findings: Finding[] = [];
|
|
411
|
+
let report: Awaited<ReturnType<typeof measureCoverage>>;
|
|
412
|
+
try {
|
|
413
|
+
report = await measureCoverage({ packageDir: dir, llmsDir, manifest, cwd: dir });
|
|
414
|
+
} catch {
|
|
415
|
+
// Coverage is a note about the documentation, never a reason a package fails to check.
|
|
416
|
+
return findings;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const library of report.libraries) {
|
|
420
|
+
if (library.names === 0) continue;
|
|
421
|
+
const share = library.documented / library.names;
|
|
422
|
+
const percent = Math.round(100 * share);
|
|
423
|
+
findings.push({
|
|
424
|
+
check: "coverage",
|
|
425
|
+
severity: "info",
|
|
426
|
+
message:
|
|
427
|
+
`mentions ${library.documented} of ${library.names} names ${library.library} exports (${percent}%)` +
|
|
428
|
+
(library.uncovered.length === 0 ? "" : `; missing ${library.uncovered.join(", ")}`),
|
|
429
|
+
...(share < THIN_COVERAGE
|
|
430
|
+
? {
|
|
431
|
+
fix: "Document the exports nobody has written about, or narrow what the package claims to document.",
|
|
432
|
+
}
|
|
433
|
+
: {}),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return findings;
|
|
438
|
+
}
|
|
439
|
+
|
|
344
440
|
async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
|
|
345
441
|
try {
|
|
346
442
|
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
package/src/document.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface CleanedDocument {
|
|
|
6
6
|
readonly title?: string;
|
|
7
7
|
/** Keywords taken from front matter, indexed alongside the prose. */
|
|
8
8
|
readonly tags: readonly string[];
|
|
9
|
+
/** Libraries this document describes, when it describes fewer than the whole package. */
|
|
10
|
+
readonly documents: readonly string[];
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const FRONT_MATTER = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
@@ -17,23 +19,29 @@ const FRONT_MATTER = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
|
17
19
|
export function cleanDocument(raw: string): CleanedDocument {
|
|
18
20
|
const match = FRONT_MATTER.exec(raw);
|
|
19
21
|
const body = stripBoilerplate(match === null ? raw : raw.slice(match[0].length)).trim();
|
|
20
|
-
if (match?.[1] === undefined) return { text: body, tags: [] };
|
|
22
|
+
if (match?.[1] === undefined) return { text: body, tags: [], documents: [] };
|
|
21
23
|
|
|
22
24
|
const meta = parseFrontMatter(match[1]);
|
|
23
25
|
return {
|
|
24
26
|
text: body,
|
|
25
27
|
...(meta.title === undefined ? {} : { title: meta.title }),
|
|
26
28
|
tags: meta.tags,
|
|
29
|
+
documents: meta.documents,
|
|
27
30
|
};
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
* Reads the
|
|
34
|
+
* Reads the three front matter fields worth reading. This is deliberately not a YAML parser:
|
|
32
35
|
* anything more complex is not metadata a retrieval index can use.
|
|
33
36
|
*/
|
|
34
|
-
function parseFrontMatter(block: string): {
|
|
37
|
+
function parseFrontMatter(block: string): {
|
|
38
|
+
title?: string;
|
|
39
|
+
tags: string[];
|
|
40
|
+
documents: string[];
|
|
41
|
+
} {
|
|
35
42
|
let title: string | undefined;
|
|
36
43
|
const tags: string[] = [];
|
|
44
|
+
const documents: string[] = [];
|
|
37
45
|
|
|
38
46
|
for (const line of block.split("\n")) {
|
|
39
47
|
const entry = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
@@ -45,9 +53,10 @@ function parseFrontMatter(block: string): { title?: string; tags: string[] } {
|
|
|
45
53
|
if ((key === "tags" || key === "keywords") && value.startsWith("[")) {
|
|
46
54
|
tags.push(...splitList(value));
|
|
47
55
|
}
|
|
56
|
+
if (key === "documents" && value.startsWith("[")) documents.push(...splitList(value));
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
return { ...(title === undefined ? {} : { title }), tags };
|
|
59
|
+
return { ...(title === undefined ? {} : { title }), tags, documents };
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
function splitList(value: string): string[] {
|