docspack 0.0.1 → 0.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/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/docspack.js +25 -0
- package/dist/build.d.ts +31 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +435 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +763 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +118 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +60 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +204 -0
- package/dist/db.js.map +1 -0
- package/dist/discovery.d.ts +31 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +126 -0
- package/dist/discovery.js.map +1 -0
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +276 -0
- package/dist/doctor.js.map +1 -0
- package/dist/document.d.ts +13 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +47 -0
- package/dist/document.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -0
- package/dist/exports.d.ts +20 -0
- package/dist/exports.d.ts.map +1 -0
- package/dist/exports.js +100 -0
- package/dist/exports.js.map +1 -0
- package/dist/feedback.d.ts +68 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +0 -0
- package/dist/feedback.js.map +1 -0
- package/dist/html.d.ts +4 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +23 -0
- package/dist/html.js.map +1 -0
- package/dist/http.d.ts +30 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +144 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/init/detect.d.ts +16 -0
- package/dist/init/detect.d.ts.map +1 -0
- package/dist/init/detect.js +120 -0
- package/dist/init/detect.js.map +1 -0
- package/dist/init/plan.d.ts +43 -0
- package/dist/init/plan.d.ts.map +1 -0
- package/dist/init/plan.js +145 -0
- package/dist/init/plan.js.map +1 -0
- package/dist/init/run.d.ts +28 -0
- package/dist/init/run.d.ts.map +1 -0
- package/dist/init/run.js +96 -0
- package/dist/init/run.js.map +1 -0
- package/dist/init/templates.d.ts +24 -0
- package/dist/init/templates.d.ts.map +1 -0
- package/dist/init/templates.js +181 -0
- package/dist/init/templates.js.map +1 -0
- package/dist/init/write.d.ts +20 -0
- package/dist/init/write.d.ts.map +1 -0
- package/dist/init/write.js +56 -0
- package/dist/init/write.js.map +1 -0
- package/dist/kinds.d.ts +14 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +15 -0
- package/dist/kinds.js.map +1 -0
- package/dist/llms-txt.d.ts +25 -0
- package/dist/llms-txt.d.ts.map +1 -0
- package/dist/llms-txt.js +94 -0
- package/dist/llms-txt.js.map +1 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +158 -0
- package/dist/mcp.js.map +1 -0
- package/dist/preview.d.ts +18 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +72 -0
- package/dist/preview.js.map +1 -0
- package/dist/prompt.d.ts +27 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +79 -0
- package/dist/prompt.js.map +1 -0
- package/dist/search.d.ts +41 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +60 -0
- package/dist/search.js.map +1 -0
- package/dist/snippet.d.ts +20 -0
- package/dist/snippet.d.ts.map +1 -0
- package/dist/snippet.js +29 -0
- package/dist/snippet.js.map +1 -0
- package/dist/spec.d.ts +38 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +105 -0
- package/dist/spec.js.map +1 -0
- package/dist/style.d.ts +33 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +94 -0
- package/dist/style.js.map +1 -0
- package/dist/submit.d.ts +61 -0
- package/dist/submit.d.ts.map +1 -0
- package/dist/submit.js +111 -0
- package/dist/submit.js.map +1 -0
- package/dist/sync.d.ts +29 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +73 -0
- package/dist/sync.js.map +1 -0
- package/dist/verify.d.ts +44 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +291 -0
- package/dist/verify.js.map +1 -0
- package/package.json +60 -5
- package/src/build.ts +572 -0
- package/src/cli.ts +883 -0
- package/src/config.ts +158 -0
- package/src/db.ts +261 -0
- package/src/discovery.ts +161 -0
- package/src/doctor.ts +344 -0
- package/src/document.ts +59 -0
- package/src/errors.ts +10 -0
- package/src/exports.ts +120 -0
- package/src/feedback.ts +0 -0
- package/src/html.ts +24 -0
- package/src/http.ts +190 -0
- package/src/index.ts +132 -0
- package/src/init/detect.ts +142 -0
- package/src/init/plan.ts +215 -0
- package/src/init/run.ts +142 -0
- package/src/init/templates.ts +200 -0
- package/src/init/write.ts +83 -0
- package/src/kinds.ts +17 -0
- package/src/llms-txt.ts +116 -0
- package/src/mcp.ts +196 -0
- package/src/preview.ts +98 -0
- package/src/prompt.ts +103 -0
- package/src/search.ts +96 -0
- package/src/snippet.ts +30 -0
- package/src/spec.ts +138 -0
- package/src/style.ts +111 -0
- package/src/submit.ts +189 -0
- package/src/sync.ts +112 -0
- package/src/verify.ts +355 -0
- package/bin/cli.js +0 -2
package/src/config.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DocspackError } from "./errors.js";
|
|
4
|
+
import { type FindingKind, isFindingKind, KINDS } from "./kinds.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a vendor has asked to receive documentation problems.
|
|
8
|
+
*
|
|
9
|
+
* Declaring this is the only thing that lets `docspack feedback submit` route a finding
|
|
10
|
+
* anywhere. A package without it collects findings locally and routes nothing, which is the
|
|
11
|
+
* correct default: a maintainer who has not asked for this gets nothing.
|
|
12
|
+
*/
|
|
13
|
+
export interface FeedbackChannel {
|
|
14
|
+
/** `owner/repo` on GitHub. Reports become a prefilled issue URL for a human to open. */
|
|
15
|
+
readonly github: string;
|
|
16
|
+
readonly labels?: readonly string[];
|
|
17
|
+
/** Kinds this vendor will accept. Omitted means all of them. */
|
|
18
|
+
readonly accepts?: readonly FindingKind[];
|
|
19
|
+
/** The vendor's own rules for reporting, shown before anything is filed. */
|
|
20
|
+
readonly policy?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build settings read from the `docspack` key of a package.json. Having them on disk keeps the
|
|
25
|
+
* build script, the release workflow and `prepublishOnly` from drifting apart.
|
|
26
|
+
*/
|
|
27
|
+
export interface BuildConfig {
|
|
28
|
+
readonly from?: string;
|
|
29
|
+
readonly openapi?: string;
|
|
30
|
+
readonly source?: string;
|
|
31
|
+
/** The library this package documents, e.g. `@acme/sdk`. What `docspack verify` checks against. */
|
|
32
|
+
readonly documents?: string;
|
|
33
|
+
readonly feedback?: FeedbackChannel;
|
|
34
|
+
readonly maxChunkTokens?: number;
|
|
35
|
+
readonly pages?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const CONFIG_KEY = "docspack";
|
|
39
|
+
|
|
40
|
+
export async function readBuildConfig(dir: string): Promise<BuildConfig> {
|
|
41
|
+
let raw: string;
|
|
42
|
+
try {
|
|
43
|
+
raw = await readFile(join(dir, "package.json"), "utf8");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const value = (parsed as Record<string, unknown>)[CONFIG_KEY];
|
|
57
|
+
if (value === undefined) return {};
|
|
58
|
+
return parseBuildConfig(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseBuildConfig(value: unknown): BuildConfig {
|
|
62
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
63
|
+
throw new DocspackError(`package.json: "${CONFIG_KEY}" must be an object`);
|
|
64
|
+
}
|
|
65
|
+
const raw = value as Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
const text = (key: "from" | "openapi" | "source" | "documents"): string | undefined => {
|
|
68
|
+
const found = raw[key];
|
|
69
|
+
if (found === undefined) return undefined;
|
|
70
|
+
if (typeof found !== "string" || found.length === 0) {
|
|
71
|
+
throw new DocspackError(`package.json: "${CONFIG_KEY}.${key}" must be a non-empty string`);
|
|
72
|
+
}
|
|
73
|
+
return found;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const count = (key: "maxChunkTokens" | "pages"): number | undefined => {
|
|
77
|
+
const found = raw[key];
|
|
78
|
+
if (found === undefined) return undefined;
|
|
79
|
+
if (!Number.isInteger(found) || (found as number) < 1) {
|
|
80
|
+
throw new DocspackError(`package.json: "${CONFIG_KEY}.${key}" must be a positive integer`);
|
|
81
|
+
}
|
|
82
|
+
return found as number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const from = text("from");
|
|
86
|
+
const openapi = text("openapi");
|
|
87
|
+
const source = text("source");
|
|
88
|
+
const documents = text("documents");
|
|
89
|
+
const feedback = parseFeedbackChannel(raw.feedback);
|
|
90
|
+
const maxChunkTokens = count("maxChunkTokens");
|
|
91
|
+
const pages = count("pages");
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...(from === undefined ? {} : { from }),
|
|
95
|
+
...(openapi === undefined ? {} : { openapi }),
|
|
96
|
+
...(source === undefined ? {} : { source }),
|
|
97
|
+
...(documents === undefined ? {} : { documents }),
|
|
98
|
+
...(feedback === undefined ? {} : { feedback }),
|
|
99
|
+
...(maxChunkTokens === undefined ? {} : { maxChunkTokens }),
|
|
100
|
+
...(pages === undefined ? {} : { pages }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `owner/repo`, the only shape a prefilled issue URL can be built from. */
|
|
105
|
+
const GITHUB_REPO = /^[\w.-]+\/[\w.-]+$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A malformed channel is an error rather than a shrug. Getting this wrong silently would
|
|
109
|
+
* either route a report to the wrong place or quietly route nothing, and both are worse than
|
|
110
|
+
* refusing to read the package.
|
|
111
|
+
*/
|
|
112
|
+
export function parseFeedbackChannel(value: unknown): FeedbackChannel | undefined {
|
|
113
|
+
if (value === undefined) return undefined;
|
|
114
|
+
|
|
115
|
+
const where = `${CONFIG_KEY}.feedback`;
|
|
116
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
117
|
+
throw new DocspackError(`package.json: "${where}" must be an object`);
|
|
118
|
+
}
|
|
119
|
+
const raw = value as Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
const github = raw.github;
|
|
122
|
+
if (typeof github !== "string" || !GITHUB_REPO.test(github)) {
|
|
123
|
+
throw new DocspackError(`package.json: "${where}.github" must look like "owner/repo"`, {
|
|
124
|
+
hint: "GitHub is the only channel docspack can build a prefilled issue URL for.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const labels = stringList(raw.labels, `${where}.labels`);
|
|
129
|
+
const policy = raw.policy;
|
|
130
|
+
if (policy !== undefined && (typeof policy !== "string" || policy.length === 0)) {
|
|
131
|
+
throw new DocspackError(`package.json: "${where}.policy" must be a URL`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const accepts = stringList(raw.accepts, `${where}.accepts`);
|
|
135
|
+
if (accepts !== undefined) {
|
|
136
|
+
const unknown = accepts.filter((kind) => !isFindingKind(kind));
|
|
137
|
+
if (unknown.length > 0) {
|
|
138
|
+
throw new DocspackError(`package.json: "${where}.accepts" has unknown kinds: ${unknown}`, {
|
|
139
|
+
hint: `Use any of: ${KINDS.join(", ")}.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
github,
|
|
146
|
+
...(labels === undefined ? {} : { labels }),
|
|
147
|
+
...(accepts === undefined ? {} : { accepts: accepts as FindingKind[] }),
|
|
148
|
+
...(policy === undefined ? {} : { policy }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stringList(value: unknown, where: string): string[] | undefined {
|
|
153
|
+
if (value === undefined) return undefined;
|
|
154
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
155
|
+
throw new DocspackError(`package.json: "${where}" must be an array of strings`);
|
|
156
|
+
}
|
|
157
|
+
return value as string[];
|
|
158
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { DocspackError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
export interface IndexedPackage {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly version: string;
|
|
14
|
+
readonly indexedAt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IndexedChunk {
|
|
18
|
+
readonly chunkId: string;
|
|
19
|
+
readonly filePath: string;
|
|
20
|
+
readonly tokens: number;
|
|
21
|
+
readonly content: string;
|
|
22
|
+
readonly tags: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SearchHit {
|
|
26
|
+
readonly chunkId: string;
|
|
27
|
+
readonly packageId: string;
|
|
28
|
+
readonly filePath: string;
|
|
29
|
+
readonly tokens: number;
|
|
30
|
+
readonly content: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SearchOptions {
|
|
34
|
+
/** Restrict to these package ids. An empty array matches nothing. */
|
|
35
|
+
readonly packageIds?: readonly string[];
|
|
36
|
+
/** SQL LIKE pattern applied to package_id, e.g. `@stripe/%`. */
|
|
37
|
+
readonly packageFilter?: string;
|
|
38
|
+
readonly limit?: number;
|
|
39
|
+
/** Stop returning chunks once this many tokens have been collected. */
|
|
40
|
+
readonly maxTokens?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SCHEMA_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
const SCHEMA = `
|
|
46
|
+
CREATE TABLE IF NOT EXISTS packages (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
version TEXT NOT NULL,
|
|
50
|
+
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
54
|
+
chunk_id TEXT PRIMARY KEY,
|
|
55
|
+
package_id TEXT REFERENCES packages(id),
|
|
56
|
+
file_path TEXT NOT NULL,
|
|
57
|
+
tokens INTEGER,
|
|
58
|
+
content TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE INDEX IF NOT EXISTS chunks_by_package ON chunks(package_id);
|
|
62
|
+
|
|
63
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
64
|
+
content,
|
|
65
|
+
tags,
|
|
66
|
+
package_id UNINDEXED,
|
|
67
|
+
chunk_id UNINDEXED,
|
|
68
|
+
tokenize="porter unicode61"
|
|
69
|
+
);
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `node:sqlite` is still flagged experimental on Node 22 and warns when it is loaded. The database
|
|
74
|
+
* is an implementation detail of this tool, so the warning is noise for its users.
|
|
75
|
+
*/
|
|
76
|
+
export function silenceSqliteWarning(): void {
|
|
77
|
+
const original = process.emitWarning.bind(process);
|
|
78
|
+
process.emitWarning = ((warning: string | Error, ...rest: unknown[]): void => {
|
|
79
|
+
const message = typeof warning === "string" ? warning : warning.message;
|
|
80
|
+
if (message.includes("SQLite is an experimental feature")) return;
|
|
81
|
+
(original as (...args: unknown[]) => void)(warning, ...rest);
|
|
82
|
+
}) as typeof process.emitWarning;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Loads `node:sqlite` on first use. The warning above is emitted when the module loads, so this
|
|
87
|
+
* must not be a static import: that would run before anything has a chance to silence it.
|
|
88
|
+
*/
|
|
89
|
+
function loadSqlite(): typeof import("node:sqlite") {
|
|
90
|
+
silenceSqliteWarning();
|
|
91
|
+
return require("node:sqlite") as typeof import("node:sqlite");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Default location of the shared index, mirroring pnpm's global store. */
|
|
95
|
+
export function defaultStorePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
96
|
+
const override = env.DOCSPACK_STORE;
|
|
97
|
+
if (override !== undefined && override.length > 0) return override;
|
|
98
|
+
return join(homedir(), ".docspack", "store.db");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Turns free text into an FTS5 MATCH expression. Every term is quoted, so punctuation in a user
|
|
103
|
+
* query can never be interpreted as FTS syntax.
|
|
104
|
+
*/
|
|
105
|
+
export function toFtsQuery(raw: string): string {
|
|
106
|
+
const terms = raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
107
|
+
if (terms.length === 0) {
|
|
108
|
+
throw new DocspackError(`"${raw}" contains nothing searchable`, {
|
|
109
|
+
hint: "Search for words, for example: docspack search webhook signature",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return terms.map((term) => `"${term}"`).join(" OR ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The global chunk index: one SQLite database shared by every project on the machine. */
|
|
116
|
+
export class Store {
|
|
117
|
+
readonly #db: DatabaseSync;
|
|
118
|
+
|
|
119
|
+
private constructor(db: DatabaseSync) {
|
|
120
|
+
this.#db = db;
|
|
121
|
+
this.#db.exec("PRAGMA journal_mode = WAL");
|
|
122
|
+
this.#db.exec(SCHEMA);
|
|
123
|
+
this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static open(path: string = defaultStorePath()): Store {
|
|
127
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
128
|
+
try {
|
|
129
|
+
return new Store(new (loadSqlite().DatabaseSync)(path));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new DocspackError(`Could not open the docspack store at ${path}`, {
|
|
132
|
+
hint: "Check the path is writable, or set DOCSPACK_STORE to another location.",
|
|
133
|
+
cause: error,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
hasPackage(id: string): boolean {
|
|
139
|
+
return this.#db.prepare("SELECT 1 FROM packages WHERE id = ?").get(id) !== undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
listPackages(): IndexedPackage[] {
|
|
143
|
+
const rows = this.#db
|
|
144
|
+
.prepare("SELECT id, name, version, indexed_at FROM packages ORDER BY name, version")
|
|
145
|
+
.all() as { id: string; name: string; version: string; indexed_at: string }[];
|
|
146
|
+
return rows.map((row) => ({
|
|
147
|
+
id: row.id,
|
|
148
|
+
name: row.name,
|
|
149
|
+
version: row.version,
|
|
150
|
+
indexedAt: row.indexed_at,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
countChunks(packageId: string): number {
|
|
155
|
+
const row = this.#db
|
|
156
|
+
.prepare("SELECT COUNT(*) AS total FROM chunks WHERE package_id = ?")
|
|
157
|
+
.get(packageId) as { total: number };
|
|
158
|
+
return row.total;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Replaces a package and all of its chunks in a single transaction. */
|
|
162
|
+
indexPackage(pkg: IndexedPackage, chunks: readonly IndexedChunk[]): void {
|
|
163
|
+
const insertPackage = this.#db.prepare(
|
|
164
|
+
"INSERT OR REPLACE INTO packages (id, name, version) VALUES (?, ?, ?)",
|
|
165
|
+
);
|
|
166
|
+
const insertChunk = this.#db.prepare(
|
|
167
|
+
"INSERT OR REPLACE INTO chunks (chunk_id, package_id, file_path, tokens, content) VALUES (?, ?, ?, ?, ?)",
|
|
168
|
+
);
|
|
169
|
+
const insertFts = this.#db.prepare(
|
|
170
|
+
"INSERT INTO chunks_fts (content, tags, package_id, chunk_id) VALUES (?, ?, ?, ?)",
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
this.#db.exec("BEGIN");
|
|
174
|
+
try {
|
|
175
|
+
this.#deleteChunks(pkg.id);
|
|
176
|
+
insertPackage.run(pkg.id, pkg.name, pkg.version);
|
|
177
|
+
for (const chunk of chunks) {
|
|
178
|
+
insertChunk.run(chunk.chunkId, pkg.id, chunk.filePath, chunk.tokens, chunk.content);
|
|
179
|
+
insertFts.run(chunk.content, chunk.tags.join(" "), pkg.id, chunk.chunkId);
|
|
180
|
+
}
|
|
181
|
+
this.#db.exec("COMMIT");
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.#db.exec("ROLLBACK");
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
removePackage(id: string): void {
|
|
189
|
+
this.#db.exec("BEGIN");
|
|
190
|
+
try {
|
|
191
|
+
this.#deleteChunks(id);
|
|
192
|
+
this.#db.prepare("DELETE FROM packages WHERE id = ?").run(id);
|
|
193
|
+
this.#db.exec("COMMIT");
|
|
194
|
+
} catch (error) {
|
|
195
|
+
this.#db.exec("ROLLBACK");
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Full-text search, ranked by bm25. Results stop early once `maxTokens` is reached, so a caller
|
|
202
|
+
* can bound how much context it hands to a model.
|
|
203
|
+
*/
|
|
204
|
+
search(query: string, options: SearchOptions = {}): SearchHit[] {
|
|
205
|
+
const limit = options.limit ?? 3;
|
|
206
|
+
const conditions = ["chunks_fts MATCH ?"];
|
|
207
|
+
const parameters: (string | number)[] = [toFtsQuery(query)];
|
|
208
|
+
|
|
209
|
+
if (options.packageIds !== undefined) {
|
|
210
|
+
if (options.packageIds.length === 0) return [];
|
|
211
|
+
conditions.push(`f.package_id IN (${options.packageIds.map(() => "?").join(", ")})`);
|
|
212
|
+
parameters.push(...options.packageIds);
|
|
213
|
+
}
|
|
214
|
+
if (options.packageFilter !== undefined) {
|
|
215
|
+
conditions.push("f.package_id LIKE ?");
|
|
216
|
+
parameters.push(options.packageFilter);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rows = this.#db
|
|
220
|
+
.prepare(
|
|
221
|
+
`SELECT f.chunk_id AS chunkId, f.package_id AS packageId, c.file_path AS filePath,
|
|
222
|
+
c.tokens AS tokens, c.content AS content
|
|
223
|
+
FROM chunks_fts f
|
|
224
|
+
JOIN chunks c ON c.chunk_id = f.chunk_id
|
|
225
|
+
WHERE ${conditions.join(" AND ")}
|
|
226
|
+
ORDER BY bm25(chunks_fts)
|
|
227
|
+
LIMIT ?`,
|
|
228
|
+
)
|
|
229
|
+
.all(...parameters, limit)
|
|
230
|
+
.map(
|
|
231
|
+
(row): SearchHit => ({
|
|
232
|
+
chunkId: String(row.chunkId),
|
|
233
|
+
packageId: String(row.packageId),
|
|
234
|
+
filePath: String(row.filePath),
|
|
235
|
+
tokens: Number(row.tokens ?? 0),
|
|
236
|
+
content: String(row.content),
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (options.maxTokens === undefined) return rows;
|
|
241
|
+
|
|
242
|
+
const budgeted: SearchHit[] = [];
|
|
243
|
+
let spent = 0;
|
|
244
|
+
for (const row of rows) {
|
|
245
|
+
const tokens = row.tokens > 0 ? row.tokens : 1;
|
|
246
|
+
if (budgeted.length > 0 && spent + tokens > options.maxTokens) break;
|
|
247
|
+
budgeted.push(row);
|
|
248
|
+
spent += tokens;
|
|
249
|
+
}
|
|
250
|
+
return budgeted;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
close(): void {
|
|
254
|
+
this.#db.close();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#deleteChunks(packageId: string): void {
|
|
258
|
+
this.#db.prepare("DELETE FROM chunks_fts WHERE package_id = ?").run(packageId);
|
|
259
|
+
this.#db.prepare("DELETE FROM chunks WHERE package_id = ?").run(packageId);
|
|
260
|
+
}
|
|
261
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { DocspackError } from "./errors.js";
|
|
4
|
+
import {
|
|
5
|
+
isCommunityPackage,
|
|
6
|
+
isDocsPackage,
|
|
7
|
+
LLMS_DIR,
|
|
8
|
+
MANIFEST_FILE,
|
|
9
|
+
type PackageManifest,
|
|
10
|
+
packageId,
|
|
11
|
+
parseManifest,
|
|
12
|
+
} from "./spec.js";
|
|
13
|
+
|
|
14
|
+
export interface DiscoveredPackage {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly version: string;
|
|
18
|
+
/** Root of the installed package. */
|
|
19
|
+
readonly dir: string;
|
|
20
|
+
readonly llmsDir: string;
|
|
21
|
+
readonly manifest: PackageManifest;
|
|
22
|
+
/** False for `@docspack-community/*`, whose contents nobody has vetted. */
|
|
23
|
+
readonly trusted: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Discovery {
|
|
27
|
+
readonly packages: readonly DiscoveredPackage[];
|
|
28
|
+
/** Human-readable problems that did not stop discovery, e.g. a declared but uninstalled package. */
|
|
29
|
+
readonly problems: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Finds the documentation packages a project depends on: every `@vendor/docspack` or
|
|
34
|
+
* `@docspack-community/*` entry in `dependencies` or `devDependencies` that is installed and
|
|
35
|
+
* ships a `.llms/manifest.json`.
|
|
36
|
+
*/
|
|
37
|
+
export async function discoverPackages(cwd: string): Promise<Discovery> {
|
|
38
|
+
const root = resolve(cwd);
|
|
39
|
+
const declared = await declaredDocsPackages(root);
|
|
40
|
+
const packages: DiscoveredPackage[] = [];
|
|
41
|
+
const problems: string[] = [];
|
|
42
|
+
|
|
43
|
+
for (const name of declared) {
|
|
44
|
+
const dir = await resolvePackageDir(name, root);
|
|
45
|
+
if (dir === undefined) {
|
|
46
|
+
problems.push(`${name} is declared in package.json but is not installed`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let version: string;
|
|
51
|
+
try {
|
|
52
|
+
version = await installedVersion(name, dir);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
problems.push(describe(error));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const llmsDir = join(dir, LLMS_DIR);
|
|
59
|
+
let manifest: PackageManifest;
|
|
60
|
+
try {
|
|
61
|
+
const raw = await readFile(join(llmsDir, MANIFEST_FILE), "utf8");
|
|
62
|
+
manifest = parseManifest(JSON.parse(raw), `${name} ${LLMS_DIR}/${MANIFEST_FILE}`);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
problems.push(
|
|
65
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
66
|
+
? `${name} has no ${LLMS_DIR}/${MANIFEST_FILE} and cannot be indexed`
|
|
67
|
+
: describe(error),
|
|
68
|
+
);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (manifest.version !== version) {
|
|
73
|
+
problems.push(
|
|
74
|
+
`${name}: manifest says version ${manifest.version}, package.json says ${version}; using ${version}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
packages.push({
|
|
79
|
+
id: packageId(name, version),
|
|
80
|
+
name,
|
|
81
|
+
version,
|
|
82
|
+
dir,
|
|
83
|
+
llmsDir,
|
|
84
|
+
manifest,
|
|
85
|
+
trusted: !isCommunityPackage(name),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
return { packages, problems };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Package ids installed in this project, used to scope queries to the versions actually in use. */
|
|
94
|
+
export async function projectPackageIds(cwd: string): Promise<string[]> {
|
|
95
|
+
const { packages } = await discoverPackages(cwd);
|
|
96
|
+
return packages.map((pkg) => pkg.id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function declaredDocsPackages(cwd: string): Promise<string[]> {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = await readFile(join(cwd, "package.json"), "utf8");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
105
|
+
throw new DocspackError(`No package.json found in ${cwd}`, {
|
|
106
|
+
hint: "Run docspack from a project directory, or pass --cwd <dir>.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new DocspackError(`${join(cwd, "package.json")} is not valid JSON`, { cause: error });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const root = parsed as { dependencies?: unknown; devDependencies?: unknown };
|
|
120
|
+
const names = new Set<string>();
|
|
121
|
+
for (const field of [root.dependencies, root.devDependencies]) {
|
|
122
|
+
if (typeof field !== "object" || field === null) continue;
|
|
123
|
+
for (const name of Object.keys(field as Record<string, unknown>)) {
|
|
124
|
+
if (isDocsPackage(name)) names.add(name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [...names].sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Walks `node_modules` upwards from the project, which resolves npm, pnpm (through its symlinks)
|
|
132
|
+
* and workspace layouts without depending on a package's export map.
|
|
133
|
+
*/
|
|
134
|
+
export async function resolvePackageDir(name: string, cwd: string): Promise<string | undefined> {
|
|
135
|
+
let dir = cwd;
|
|
136
|
+
while (true) {
|
|
137
|
+
const candidate = join(dir, "node_modules", ...name.split("/"));
|
|
138
|
+
try {
|
|
139
|
+
await readFile(join(candidate, "package.json"), "utf8");
|
|
140
|
+
return candidate;
|
|
141
|
+
} catch {
|
|
142
|
+
// Not here; keep walking up.
|
|
143
|
+
}
|
|
144
|
+
const parent = dirname(dir);
|
|
145
|
+
if (parent === dir) return undefined;
|
|
146
|
+
dir = parent;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function installedVersion(name: string, dir: string): Promise<string> {
|
|
151
|
+
const parsed: unknown = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
152
|
+
const version = (parsed as { version?: unknown }).version;
|
|
153
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
154
|
+
throw new DocspackError(`${name} has no version in its package.json`);
|
|
155
|
+
}
|
|
156
|
+
return version;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function describe(error: unknown): string {
|
|
160
|
+
return error instanceof Error ? error.message : String(error);
|
|
161
|
+
}
|