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/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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
175
|
-
|
|
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
|
|
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;
|
|
@@ -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
|
-
/**
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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"));
|