docspack 0.1.1 → 0.2.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/src/cli.ts CHANGED
@@ -9,7 +9,13 @@ import { type DoctorReport, runDoctor } from "./doctor.js";
9
9
  import { DocspackError } from "./errors.js";
10
10
  import { renderTree } from "./init/write.js";
11
11
  import { previewPackage } from "./preview.js";
12
- import { DEFAULT_LIMIT, DEFAULT_MAX_TOKENS, queryDocs, renderAnswer } from "./search.js";
12
+ import {
13
+ DEFAULT_LIMIT,
14
+ DEFAULT_MAX_TOKENS,
15
+ type QueryResult,
16
+ queryDocs,
17
+ renderAnswer,
18
+ } from "./search.js";
13
19
  import { AGENTS_SNIPPET, FEEDBACK_SNIPPET } from "./snippet.js";
14
20
  import { syncProject } from "./sync.js";
15
21
  import { verifyProject } from "./verify.js";
@@ -46,6 +52,7 @@ const OPTIONS = {
46
52
  yes: { type: "boolean", short: "y" },
47
53
  "dry-run": { type: "boolean" },
48
54
  strict: { type: "boolean" },
55
+ pedantic: { type: "boolean" },
49
56
  "package-dir": { type: "string" },
50
57
  chunk: { type: "string" },
51
58
  kind: { type: "string" },
@@ -55,6 +62,14 @@ const OPTIONS = {
55
62
  repro: { type: "string" },
56
63
  } as const;
57
64
 
65
+ /**
66
+ * A query that returned nothing is not a failure, but "you have not indexed anything yet" and
67
+ * "the documentation does not cover this" are different answers and a wrapper has to tell them
68
+ * apart. Both are non-zero; neither collides with 1 (failed) or 2 (used wrongly).
69
+ */
70
+ const EXIT_NOT_INDEXED = 3;
71
+ const EXIT_NO_MATCH = 4;
72
+
58
73
  const HELP = `docspack — local, version-locked documentation for AI agents
59
74
 
60
75
  Usage
@@ -134,7 +149,8 @@ Options
134
149
  --no-workflow init: skip the release workflow
135
150
  --no-build init: scaffold only
136
151
  --template <t> init: full (default) or minimal
137
- --strict doctor: treat warnings as failures
152
+ --strict doctor: treat structural warnings as failures
153
+ --pedantic doctor: --strict, and fail on prose style as well
138
154
  --package-dir <d> doctor, preview, verify: the package to inspect (default: .)
139
155
  --chunk <id> feedback add: the chunk the problem is in
140
156
  --kind <k> feedback add: drift, incorrect or missing
@@ -143,10 +159,11 @@ Options
143
159
  --actual <text> feedback add: what happened instead
144
160
  --repro <code> feedback add: code that demonstrates it
145
161
  --force sync: re-index packages already in the store; init: overwrite files
146
- -p, --package <s> search: only packages whose name contains this text
147
- --limit <n> search: maximum chunks to return (default ${DEFAULT_LIMIT})
148
- --max-tokens <n> search: token ceiling for the result set (default ${DEFAULT_MAX_TOKENS})
149
- --all search: the whole store, not just this project;
162
+ -p, --package <s> ask, search: only packages whose name contains this text
163
+ --limit <n> ask, search, preview: maximum chunks to return (default ${DEFAULT_LIMIT})
164
+ --max-tokens <n> ask, search, preview: token ceiling for the result set
165
+ (default ${DEFAULT_MAX_TOKENS})
166
+ --all ask, search: the whole store, not just this project;
150
167
  feedback remove: every recorded finding
151
168
  --from <dir> build: directory of Markdown to package
152
169
  --openapi <file> build: OpenAPI JSON to package, one chunk per operation
@@ -161,6 +178,15 @@ Options
161
178
  -h, --help Show this help
162
179
  -v, --version Show the version
163
180
 
181
+ Exit codes
182
+ \`ask\` and \`search\` answer with an exit code a script can branch on:
183
+
184
+ 0 chunks were returned
185
+ ${EXIT_NOT_INDEXED} nothing matched, and a docs package is installed but not indexed —
186
+ run \`docspack sync\`
187
+ ${EXIT_NO_MATCH} nothing matched, and everything installed is already indexed
188
+ 1 the command failed; 2 the command was used wrongly
189
+
164
190
  Examples
165
191
  npx docspack sync
166
192
  npx docspack ask "how do I verify a webhook signature"
@@ -239,6 +265,12 @@ function reportDoctor(report: DoctorReport, quiet: boolean): void {
239
265
  }
240
266
  }
241
267
 
268
+ /** 0 when the query was answered; otherwise which of the two empty answers this was. */
269
+ function queryExit(result: QueryResult): number {
270
+ if (result.hits.length > 0) return 0;
271
+ return result.unindexed.length > 0 ? EXIT_NOT_INDEXED : EXIT_NO_MATCH;
272
+ }
273
+
242
274
  function openStore(values: Values): Store {
243
275
  return Store.open(values.store ?? defaultStorePath());
244
276
  }
@@ -358,8 +390,7 @@ async function main(argv: readonly string[]): Promise<number> {
358
390
  // Exactly what the MCP tool returns, so both interfaces answer identically.
359
391
  process.stdout.write(`${renderAnswer(result, question)}\n`);
360
392
  }
361
- // Answering "nothing matched" is a successful answer, not a failed command.
362
- return 0;
393
+ return queryExit(result);
363
394
  } finally {
364
395
  store.close();
365
396
  }
@@ -389,11 +420,16 @@ async function main(argv: readonly string[]): Promise<number> {
389
420
 
390
421
  if (json) {
391
422
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
392
- return 0;
423
+ return queryExit(result);
393
424
  }
394
425
  if (result.hits.length === 0) {
395
426
  process.stdout.write(`No local documentation matched "${query}".\n`);
396
- return 1;
427
+ if (result.unindexed.length > 0) {
428
+ process.stderr.write(
429
+ `${yellow("!")} ${result.unindexed.join(", ")} installed but not indexed. Run \`docspack sync\`.\n`,
430
+ );
431
+ }
432
+ return queryExit(result);
397
433
  }
398
434
 
399
435
  for (const hit of result.hits) {
@@ -793,6 +829,7 @@ async function main(argv: readonly string[]): Promise<number> {
793
829
  const report = await runDoctor({
794
830
  dir,
795
831
  ...(values.strict === true ? { strict: true } : {}),
832
+ ...(values.pedantic === true ? { pedantic: true } : {}),
796
833
  });
797
834
 
798
835
  if (json) {
package/src/db.ts CHANGED
@@ -101,6 +101,11 @@ export function defaultStorePath(env: NodeJS.ProcessEnv = process.env): string {
101
101
  /**
102
102
  * Turns free text into an FTS5 MATCH expression. Every term is quoted, so punctuation in a user
103
103
  * query can never be interpreted as FTS syntax.
104
+ *
105
+ * Terms of one or two characters are dropped. Questions are mostly `how`, `do`, `i`, `in`, and
106
+ * every chunk containing one becomes a candidate — which is how a chunk that merely quotes an
107
+ * example question outranks the chunk that answers it. Two characters is the cut because it
108
+ * leaves real short names (`id`, `db`) out and keeps three-letter ones (`api`, `key`) in.
104
109
  */
105
110
  export function toFtsQuery(raw: string): string {
106
111
  const terms = raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
@@ -109,7 +114,9 @@ export function toFtsQuery(raw: string): string {
109
114
  hint: "Search for words, for example: docspack search webhook signature",
110
115
  });
111
116
  }
112
- return terms.map((term) => `"${term}"`).join(" OR ");
117
+ const meaningful = terms.filter((term) => term.length > 2);
118
+ // A query made only of short terms still has to search for something.
119
+ return (meaningful.length > 0 ? meaningful : terms).map((term) => `"${term}"`).join(" OR ");
113
120
  }
114
121
 
115
122
  /** The global chunk index: one SQLite database shared by every project on the machine. */
@@ -223,7 +230,9 @@ export class Store {
223
230
  FROM chunks_fts f
224
231
  JOIN chunks c ON c.chunk_id = f.chunk_id
225
232
  WHERE ${conditions.join(" AND ")}
226
- ORDER BY bm25(chunks_fts)
233
+ -- Tags are what an author writes to aim a chunk, and front matter did nothing at all
234
+ -- while they scored the same as prose. Weights are (content, tags).
235
+ ORDER BY bm25(chunks_fts, 1.0, 3.0)
227
236
  LIMIT ?`,
228
237
  )
229
238
  .all(...parameters, limit)
package/src/discovery.ts CHANGED
@@ -96,33 +96,55 @@ export async function projectPackageIds(cwd: string): Promise<string[]> {
96
96
  return packages.map((pkg) => pkg.id);
97
97
  }
98
98
 
99
+ /**
100
+ * Every docs package declared by this directory or by an ancestor of it. A workspace declares
101
+ * shared tooling in the repository root and its members inherit the install, so reading only
102
+ * `cwd/package.json` answers "this project has no documentation" for most monorepos — the same
103
+ * upward walk `resolvePackageDir` already does for the install.
104
+ */
99
105
  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
- });
106
+ const names = new Set<string>();
107
+ let found = false;
108
+ let dir = cwd;
109
+
110
+ while (true) {
111
+ const path = join(dir, "package.json");
112
+ let raw: string;
113
+ try {
114
+ raw = await readFile(path, "utf8");
115
+ } catch (error) {
116
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
117
+ const parent = dirname(dir);
118
+ if (parent === dir) break;
119
+ dir = parent;
120
+ continue;
108
121
  }
109
- throw error;
110
- }
111
122
 
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
- }
123
+ found = true;
124
+ let parsed: unknown;
125
+ try {
126
+ parsed = JSON.parse(raw);
127
+ } catch (error) {
128
+ throw new DocspackError(`${path} is not valid JSON`, { cause: error });
129
+ }
118
130
 
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);
131
+ const manifest = parsed as { dependencies?: unknown; devDependencies?: unknown };
132
+ for (const field of [manifest.dependencies, manifest.devDependencies]) {
133
+ 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
+ }
125
137
  }
138
+
139
+ const parent = dirname(dir);
140
+ if (parent === dir) break;
141
+ dir = parent;
142
+ }
143
+
144
+ if (!found) {
145
+ throw new DocspackError(`No package.json found in ${cwd}`, {
146
+ hint: "Run docspack from a project directory, or pass --cwd <dir>.",
147
+ });
126
148
  }
127
149
  return [...names].sort();
128
150
  }
package/src/doctor.ts CHANGED
@@ -40,6 +40,12 @@ export interface DoctorOptions {
40
40
  readonly dir: string;
41
41
  /** Treat warnings as failures. Used by prepublishOnly and CI. */
42
42
  readonly strict?: boolean;
43
+ /**
44
+ * Also fail on prose style — filler and long sentences. Implies `strict`. Off by default
45
+ * because `--strict` is the gate `init` scaffolds, and prose written by a human is not a
46
+ * reason to block a first publish.
47
+ */
48
+ readonly pedantic?: boolean;
43
49
  }
44
50
 
45
51
  /** Over this, a chunk crowds out the response budget; under it, a chunk answers nothing. */
@@ -53,6 +59,9 @@ const MIN_CHUNK_TOKENS = 30;
53
59
  export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
54
60
  const findings: Finding[] = [];
55
61
  const llmsDir = join(options.dir, LLMS_DIR);
62
+ // Structural checks describe a package that will not retrieve properly; the two prose checks
63
+ // are opinions about writing. Only the first kind may block a publish by default.
64
+ const prose: Severity = options.pedantic === true ? "warn" : "info";
56
65
 
57
66
  const pkg = await readJson(join(options.dir, "package.json"));
58
67
  const manifest = await loadManifest(llmsDir, findings);
@@ -145,7 +154,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
145
154
  .join(", ");
146
155
  findings.push({
147
156
  check: "filler",
148
- severity: "warn",
157
+ severity: prose,
149
158
  message: `chunk "${chunk.id}" contains narration a model does not need: ${listed}`,
150
159
  where: chunk.file,
151
160
  fix: "Delete it. Nobody searches for these words and they carry no fact.",
@@ -156,7 +165,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
156
165
  if (long.length > 0) {
157
166
  findings.push({
158
167
  check: "long-sentence",
159
- severity: "warn",
168
+ severity: prose,
160
169
  message: `chunk "${chunk.id}" has ${plural(long.length, "sentence")} over 40 words`,
161
170
  where: chunk.file,
162
171
  fix: "Split them. One claim per sentence retrieves and reads better.",
@@ -209,9 +218,9 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
209
218
  });
210
219
  }
211
220
 
221
+ const strict = options.strict === true || options.pedantic === true;
212
222
  const failed = findings.some(
213
- (finding) =>
214
- finding.severity === "error" || (options.strict === true && finding.severity === "warn"),
223
+ (finding) => finding.severity === "error" || (strict && finding.severity === "warn"),
215
224
  );
216
225
 
217
226
  return { ok: !failed, findings, chunks: manifest.chunks.length, tokens };
package/src/index.ts CHANGED
@@ -103,6 +103,7 @@ export {
103
103
  parseManifest,
104
104
  resolveChunkFile,
105
105
  SCHEMA_URL,
106
+ SPEC_URL,
106
107
  serializeManifest,
107
108
  } from "./spec.js";
108
109
  export {
package/src/search.ts CHANGED
@@ -32,13 +32,21 @@ export interface QueryResult {
32
32
  readonly tokens: number;
33
33
  /** True when any hit came from an unvetted community package. */
34
34
  readonly untrusted: boolean;
35
+ /**
36
+ * Packages this project depends on that are installed but absent from the store. Nothing they
37
+ * document can match until `docspack sync` runs, which is a different answer from "nothing
38
+ * covers this question".
39
+ */
40
+ readonly unindexed: readonly string[];
35
41
  }
36
42
 
37
43
  export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
38
44
  const scoped = options.scoped !== false;
39
- const packageIds = scoped
40
- ? (await discoverPackages(options.cwd)).packages.map((pkg) => pkg.id)
41
- : undefined;
45
+ const installed = scoped ? (await discoverPackages(options.cwd)).packages : undefined;
46
+ const packageIds = installed?.map((pkg) => pkg.id);
47
+ const unindexed = (installed ?? [])
48
+ .filter((pkg) => !options.store.hasPackage(pkg.id))
49
+ .map((pkg) => pkg.id);
42
50
 
43
51
  const hits = options.store
44
52
  .search(options.query, {
@@ -58,6 +66,7 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
58
66
  hits,
59
67
  tokens: hits.reduce((total, hit) => total + hit.tokens, 0),
60
68
  untrusted: hits.some((hit) => !hit.trusted),
69
+ unindexed,
61
70
  };
62
71
  }
63
72
 
@@ -71,6 +80,11 @@ const UNTRUSTED_NOTICE =
71
80
  */
72
81
  export function renderAnswer(result: QueryResult, query: string): string {
73
82
  if (result.hits.length === 0) {
83
+ // Forgetting `sync` is the likeliest first mistake, and the tool can tell it apart from a
84
+ // question nothing covers: it can see what is installed and what is in the store.
85
+ if (result.unindexed.length > 0) {
86
+ return `No local documentation matched "${query}", and ${result.unindexed.join(", ")} ${result.unindexed.length === 1 ? "is" : "are"} installed but not indexed. Run \`docspack sync\`, then ask again.`;
87
+ }
74
88
  return `No local documentation matched "${query}". The project may not depend on a docspack package covering it.`;
75
89
  }
76
90
 
package/src/spec.ts CHANGED
@@ -6,6 +6,8 @@ export const LLMS_DIR = ".llms";
6
6
  export const MANIFEST_FILE = "manifest.json";
7
7
  export const CHUNKS_DIR = "chunks";
8
8
  export const SCHEMA_URL = "https://docspack.dev/schema/v1.json";
9
+ /** Prose specification of the package format. The hint on a validation failure points here. */
10
+ export const SPEC_URL = "https://docspack.dev/spec";
9
11
 
10
12
  /** One retrievable unit of documentation. */
11
13
  export interface ChunkSpec {
@@ -73,7 +75,7 @@ export function parseManifest(raw: unknown, where: string): PackageManifest {
73
75
  // The explicit annotation is what lets TypeScript treat a `fail(...)` call as unreachable-after.
74
76
  const fail: (message: string) => never = (message: string): never => {
75
77
  throw new DocspackError(`${where}: ${message}`, {
76
- hint: `See the package specification: ${SCHEMA_URL}`,
78
+ hint: `See the package specification: ${SPEC_URL}`,
77
79
  });
78
80
  };
79
81
 
package/src/style.ts CHANGED
@@ -34,6 +34,11 @@ const SENTENCE_SPLIT = /(?<=[.!?])\s+/;
34
34
  /** A heading or list item starts a new unit, whatever the previous line ended with. */
35
35
  const BLOCK_START = /\n(?=\s*(?:[-*+]\s|\d+\.\s|#{1,6}\s|\|))/;
36
36
  const LONG_SENTENCE_WORDS = 40;
37
+ /**
38
+ * A word carries a letter or a digit. Stripped code leaves its punctuation behind, so counting
39
+ * everything between spaces reports an enumeration of forty code spans as a forty-word sentence.
40
+ */
41
+ const WORD = /[\p{L}\p{N}]/u;
37
42
 
38
43
  export interface FillerHit {
39
44
  readonly phrase: string;
@@ -68,7 +73,7 @@ export function findLongSentences(text: string, maxWords = LONG_SENTENCE_WORDS):
68
73
  .flatMap((block) => block.split(BLOCK_START))
69
74
  .flatMap((block) => block.split(SENTENCE_SPLIT))
70
75
  .map((sentence) => sentence.replace(/\s+/g, " ").trim())
71
- .filter((sentence) => sentence.split(" ").filter(Boolean).length > maxWords);
76
+ .filter((sentence) => sentence.split(" ").filter((word) => WORD.test(word)).length > maxWords);
72
77
  }
73
78
 
74
79
  /**
@@ -94,11 +99,16 @@ export function contentFingerprint(text: string): string {
94
99
  .trim();
95
100
  }
96
101
 
102
+ /** An authored directive, e.g. `<!-- docspack: tags=grid -->`. Metadata, not site boilerplate. */
103
+ const DIRECTIVE_LINE = /^\s*<!--\s*docspack:/i;
104
+
97
105
  /** Removes lines a documentation site wraps around its content. */
98
106
  export function stripBoilerplate(text: string): string {
99
107
  return text
100
108
  .split("\n")
101
- .filter((line) => !BOILERPLATE.some((pattern) => pattern.test(line)))
109
+ .filter(
110
+ (line) => DIRECTIVE_LINE.test(line) || !BOILERPLATE.some((pattern) => pattern.test(line)),
111
+ )
102
112
  .join("\n");
103
113
  }
104
114