docspack 1.1.0 → 1.3.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.
Files changed (77) hide show
  1. package/README.md +25 -1
  2. package/dist/build.d.ts +5 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +68 -5
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli-spec.d.ts +3 -0
  7. package/dist/cli-spec.d.ts.map +1 -0
  8. package/dist/cli-spec.js +667 -0
  9. package/dist/cli-spec.js.map +1 -0
  10. package/dist/cli.d.ts +146 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +80 -14
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cmdspec.json +1219 -0
  15. package/dist/commands.d.ts +78 -0
  16. package/dist/commands.d.ts.map +1 -0
  17. package/dist/commands.js +231 -0
  18. package/dist/commands.js.map +1 -0
  19. package/dist/config.d.ts +1 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +2 -0
  22. package/dist/config.js.map +1 -1
  23. package/dist/db.d.ts +6 -1
  24. package/dist/db.d.ts.map +1 -1
  25. package/dist/db.js +7 -1
  26. package/dist/db.js.map +1 -1
  27. package/dist/discovery.d.ts +22 -3
  28. package/dist/discovery.d.ts.map +1 -1
  29. package/dist/discovery.js +91 -12
  30. package/dist/discovery.js.map +1 -1
  31. package/dist/doctor.d.ts.map +1 -1
  32. package/dist/doctor.js +66 -1
  33. package/dist/doctor.js.map +1 -1
  34. package/dist/help.d.ts +10 -6
  35. package/dist/help.d.ts.map +1 -1
  36. package/dist/help.js +118 -371
  37. package/dist/help.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/init/plan.js +4 -3
  43. package/dist/init/plan.js.map +1 -1
  44. package/dist/init/templates.d.ts.map +1 -1
  45. package/dist/init/templates.js +4 -2
  46. package/dist/init/templates.js.map +1 -1
  47. package/dist/preview.d.ts.map +1 -1
  48. package/dist/preview.js +12 -6
  49. package/dist/preview.js.map +1 -1
  50. package/dist/search.d.ts +2 -0
  51. package/dist/search.d.ts.map +1 -1
  52. package/dist/search.js +66 -22
  53. package/dist/search.js.map +1 -1
  54. package/dist/spec.d.ts +21 -1
  55. package/dist/spec.d.ts.map +1 -1
  56. package/dist/spec.js +24 -2
  57. package/dist/spec.js.map +1 -1
  58. package/dist/sync.d.ts.map +1 -1
  59. package/dist/sync.js +99 -1
  60. package/dist/sync.js.map +1 -1
  61. package/package.json +8 -5
  62. package/src/build.ts +86 -7
  63. package/src/cli-spec.ts +688 -0
  64. package/src/cli.ts +90 -17
  65. package/src/commands.ts +298 -0
  66. package/src/config.ts +4 -1
  67. package/src/db.ts +9 -2
  68. package/src/discovery.ts +113 -12
  69. package/src/doctor.ts +67 -0
  70. package/src/help.ts +138 -380
  71. package/src/index.ts +1 -0
  72. package/src/init/plan.ts +4 -3
  73. package/src/init/templates.ts +4 -2
  74. package/src/preview.ts +14 -13
  75. package/src/search.ts +79 -22
  76. package/src/spec.ts +28 -2
  77. package/src/sync.ts +120 -1
package/src/discovery.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { dirname, join, resolve } from "node:path";
2
+ import { dirname, join, resolve, sep } from "node:path";
3
3
  import { DocspackError } from "./errors.js";
4
4
  import {
5
+ cliPackageId,
6
+ DISCOVERABLE_NAMES,
5
7
  isCommunityPackage,
6
8
  isDocsPackage,
7
9
  LLMS_DIR,
@@ -38,17 +40,20 @@ export interface Discovery {
38
40
  }
39
41
 
40
42
  /**
41
- * Finds the documentation packages a project depends on: every `@vendor/docspack` or
42
- * `@docspack-community/*` entry in `dependencies` or `devDependencies` that is installed and
43
- * ships a `.llms/manifest.json`.
43
+ * Finds the documentation packages a project depends on: every `@vendor/docspack`,
44
+ * `@vendor/<name>-docspack` or `@docspack-community/*` entry in `dependencies` or
45
+ * `devDependencies` that is installed and ships a `.llms/manifest.json`.
46
+ *
47
+ * A dependency that ships a payload under any other name is reported in `problems` rather than
48
+ * indexed, so a pack nobody can read says so instead of going missing from every answer.
44
49
  */
45
50
  export async function discoverPackages(cwd: string): Promise<Discovery> {
46
51
  const root = resolve(cwd);
47
- const declared = await declaredDocsPackages(root);
52
+ const declared = await declaredDependencies(root);
48
53
  const packages: DiscoveredPackage[] = [];
49
54
  const problems: string[] = [];
50
55
 
51
- for (const name of declared) {
56
+ for (const name of declared.filter(isDocsPackage)) {
52
57
  const dir = await resolvePackageDir(name, root);
53
58
  if (dir === undefined) {
54
59
  problems.push(`${name} is declared in package.json but is not installed`);
@@ -94,10 +99,62 @@ export async function discoverPackages(cwd: string): Promise<Discovery> {
94
99
  });
95
100
  }
96
101
 
102
+ problems.push(...(await skippedPayloads(declared.filter(isNotDocsPackage), root)));
103
+
97
104
  packages.sort((a, b) => a.name.localeCompare(b.name));
98
105
  return { packages, problems };
99
106
  }
100
107
 
108
+ /**
109
+ * Dependencies that ship a payload under a name discovery does not match.
110
+ *
111
+ * None of them is indexed, and that is the point: the name check is what decides whether a
112
+ * package may put content in front of a model, and widening it to "carries a manifest" would let
113
+ * any transitive dependency opt itself in. But a package built by `docspack build` and then named
114
+ * outside the discoverable shapes is a publishing mistake — a typo, a rename, or a reading of the
115
+ * naming rule — and saying nothing about it is worse than refusing it. The corpus is absent from
116
+ * every answer while the question it should have answered is answered confidently by another pack.
117
+ */
118
+ async function skippedPayloads(names: readonly string[], root: string): Promise<string[]> {
119
+ // Concurrent, unlike the loop over docs packages above: this one runs over every dependency
120
+ // the project declares rather than the handful that document something, and `docspack ask`
121
+ // pays for it on each question. The reads are independent, and `Promise.all` keeps the order
122
+ // of `names` so the report is the same on every run.
123
+ const found = await Promise.all(names.map((name) => skippedPayload(name, root)));
124
+ return found.filter((problem) => problem !== undefined);
125
+ }
126
+
127
+ async function skippedPayload(name: string, root: string): Promise<string | undefined> {
128
+ const dir = await resolvePackageDir(name, root);
129
+ if (dir === undefined) return undefined;
130
+
131
+ let raw: string;
132
+ try {
133
+ raw = await readFile(join(dir, LLMS_DIR, MANIFEST_FILE), "utf8");
134
+ } catch {
135
+ return undefined;
136
+ }
137
+
138
+ // Counted rather than validated: a manifest too malformed to parse is still a payload its
139
+ // publisher meant to be read, and the name is the problem to report either way.
140
+ let chunks = 0;
141
+ try {
142
+ const parsed = JSON.parse(raw) as { chunks?: unknown };
143
+ if (Array.isArray(parsed.chunks)) chunks = parsed.chunks.length;
144
+ } catch {
145
+ // Not JSON. Report the name anyway; `docspack doctor` is where the payload is checked.
146
+ }
147
+
148
+ return (
149
+ `${name} ships ${LLMS_DIR}/${MANIFEST_FILE} with ${chunks} ${chunks === 1 ? "chunk" : "chunks"} but was not indexed: ` +
150
+ `its name is not a discoverable pack shape (${DISCOVERABLE_NAMES})`
151
+ );
152
+ }
153
+
154
+ function isNotDocsPackage(name: string): boolean {
155
+ return !isDocsPackage(name);
156
+ }
157
+
101
158
  /**
102
159
  * Every library this project depends on directly, installed and readable.
103
160
  *
@@ -106,7 +163,7 @@ export async function discoverPackages(cwd: string): Promise<Discovery> {
106
163
  */
107
164
  export async function discoverLibraries(cwd: string): Promise<readonly DiscoveredLibrary[]> {
108
165
  const root = resolve(cwd);
109
- const names = (await declaredDependencies(root)).filter((name) => !isDocsPackage(name));
166
+ const names = (await declaredDependencies(root)).filter(isNotDocsPackage);
110
167
  const libraries: DiscoveredLibrary[] = [];
111
168
 
112
169
  for (const name of names) {
@@ -122,17 +179,61 @@ export async function discoverLibraries(cwd: string): Promise<readonly Discovere
122
179
  return libraries;
123
180
  }
124
181
 
182
+ /** An installed library that describes its own command-line interface. */
183
+ export interface DiscoveredCommandLine extends DiscoveredLibrary {
184
+ /** The id its commands are indexed under: the library's, marked as a CLI. */
185
+ readonly id: string;
186
+ /** Each description it names, as absolute paths inside the package. */
187
+ readonly documents: readonly string[];
188
+ }
189
+
190
+ /**
191
+ * Libraries whose `package.json` names a cmdspec document (`packages/cmdspec/SPEC.md` §13):
192
+ * `"cmdspec": "./cmdspec.json"`, or one per executable, `"cmdspec": { "tool": "./tool.json" }`.
193
+ *
194
+ * A path that resolves outside the package is ignored. The field is a dependency's own metadata,
195
+ * and reading wherever it points would let any installed package put any file on this machine
196
+ * into the index.
197
+ */
198
+ export async function discoverCommandLines(
199
+ cwd: string,
200
+ libraries?: readonly DiscoveredLibrary[],
201
+ ): Promise<DiscoveredCommandLine[]> {
202
+ const found: DiscoveredCommandLine[] = [];
203
+ for (const library of libraries ?? (await discoverLibraries(cwd))) {
204
+ let field: unknown;
205
+ try {
206
+ field = (
207
+ JSON.parse(await readFile(join(library.dir, "package.json"), "utf8")) as {
208
+ cmdspec?: unknown;
209
+ }
210
+ ).cmdspec;
211
+ } catch {
212
+ continue;
213
+ }
214
+ const named =
215
+ typeof field === "string"
216
+ ? [field]
217
+ : typeof field === "object" && field !== null && !Array.isArray(field)
218
+ ? Object.values(field).filter((value): value is string => typeof value === "string")
219
+ : [];
220
+ const root = resolve(library.dir);
221
+ const documents = named
222
+ .map((path) => resolve(root, path))
223
+ .filter((path) => path.startsWith(`${root}${sep}`));
224
+ if (documents.length > 0) {
225
+ found.push({ ...library, id: cliPackageId(library.name, library.version), documents });
226
+ }
227
+ }
228
+ return found;
229
+ }
230
+
125
231
  /** Package ids installed in this project, used to scope queries to the versions actually in use. */
126
232
  export async function projectPackageIds(cwd: string): Promise<string[]> {
127
233
  const { packages } = await discoverPackages(cwd);
128
234
  return packages.map((pkg) => pkg.id);
129
235
  }
130
236
 
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
-
136
237
  /**
137
238
  * Every dependency declared by this directory or by an ancestor of it. A workspace declares
138
239
  * shared tooling in the repository root and its members inherit the install, so reading only
package/src/doctor.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import type { Dirent } from "node:fs";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { commandFindings } from "./commands.js";
4
5
  import { readBuildConfig } from "./config.js";
5
6
  import { measureCoverage } from "./coverage.js";
6
7
  import { PLACEHOLDER } from "./init/templates.js";
7
8
  import {
9
+ DISCOVERABLE_NAMES,
8
10
  estimateTokens,
11
+ isDocsPackage,
9
12
  LLMS_DIR,
10
13
  MANIFEST_FILE,
11
14
  type PackageManifest,
@@ -208,6 +211,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
208
211
  }
209
212
 
210
213
  findings.push(...(await coverageFindings(options.dir, llmsDir, manifest)));
214
+ findings.push(...(await commandLineFindings(options.dir)));
211
215
 
212
216
  if (manifest.chunks.every((chunk) => chunk.entities.length === 0)) {
213
217
  findings.push({
@@ -347,6 +351,21 @@ function checkPackageJson(
347
351
  });
348
352
  }
349
353
 
354
+ // The one property that decides whether the indexer will open the package at all, and the one
355
+ // this check used to skip: every other check here describes a package that indexes badly, and
356
+ // a name outside the discoverable shapes describes one that is never read. It publishes, it
357
+ // installs, it passes every other check, and the corpus is absent from every answer.
358
+ const name = typeof pkg.name === "string" ? pkg.name : manifest.name;
359
+ if (!isDocsPackage(name)) {
360
+ findings.push({
361
+ check: "name-undiscoverable",
362
+ severity: "error",
363
+ message: `"${name}" is not a name the indexer discovers; the package would install and never be read`,
364
+ where: "package.json",
365
+ fix: `Rename it to one of ${DISCOVERABLE_NAMES}.`,
366
+ });
367
+ }
368
+
350
369
  const files = Array.isArray(pkg.files) ? pkg.files.map(String) : undefined;
351
370
  if (
352
371
  files !== undefined &&
@@ -373,6 +392,7 @@ async function newestSourceMtime(dir: string): Promise<number> {
373
392
  const config = await readBuildConfig(dir);
374
393
  const newest = await Promise.all([
375
394
  config.openapi === undefined ? 0 : mtime(join(dir, config.openapi)),
395
+ config.cmdspec === undefined ? 0 : mtime(join(dir, config.cmdspec)),
376
396
  config.from === undefined ? 0 : newestInTree(join(dir, config.from)),
377
397
  ]);
378
398
  return Math.max(...newest);
@@ -445,6 +465,53 @@ async function coverageFindings(
445
465
  return findings;
446
466
  }
447
467
 
468
+ /**
469
+ * The configured CLI description, checked the way `build` checks it: invalid is an error, because
470
+ * the next build will refuse it; the rest is `commandFindings`. Nothing to check without
471
+ * `docspack.cmdspec`, since a package built from a flag does not say where its description is.
472
+ */
473
+ async function commandLineFindings(dir: string): Promise<Finding[]> {
474
+ const source = (await readBuildConfig(dir)).cmdspec;
475
+ if (source === undefined) return [];
476
+ const file = join(dir, source);
477
+ const { convertText, readSource } = await import("@docspack/cmdspec/read");
478
+ const { validate } = await import("@docspack/cmdspec/validate");
479
+ let document: Awaited<ReturnType<typeof convertText>>["document"];
480
+ try {
481
+ ({ document } = await convertText(await readSource(file), { where: source }));
482
+ } catch (error) {
483
+ return [
484
+ {
485
+ check: "cli-unreadable",
486
+ severity: "error",
487
+ message: `${source}: ${error instanceof Error ? error.message : String(error)}`,
488
+ fix: "Point docspack.cmdspec at a cmdspec, OpenCLI or Usage document.",
489
+ },
490
+ ];
491
+ }
492
+ const result = validate(document);
493
+ if (!result.valid) {
494
+ return [
495
+ {
496
+ check: "cli-invalid",
497
+ severity: "error",
498
+ message: `${source} is not a valid CLI description: ${result.problems
499
+ .slice(0, 3)
500
+ .map((problem) => `${problem.at}: ${problem.message}`)
501
+ .join("; ")}${result.problems.length > 3 ? `; …${result.problems.length - 3} more` : ""}`,
502
+ where: source,
503
+ fix: `Run \`npx @docspack/cmdspec validate ${source}\` for every problem.`,
504
+ },
505
+ ];
506
+ }
507
+ return commandFindings(document).map((finding) => ({
508
+ check: "cli-incomplete",
509
+ severity: finding.severity,
510
+ message: finding.message,
511
+ where: source,
512
+ }));
513
+ }
514
+
448
515
  async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
449
516
  try {
450
517
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"));