@markstanden/opencode-docs-plugin 0.6.0 → 0.8.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 CHANGED
@@ -89,7 +89,21 @@ The simplest setup uses `root` — one repo lake:
89
89
 
90
90
  `root` expands `~`, resolves a relative path against the location, and defaults
91
91
  to `<location>/docs`. A blank value counts as unset. `root` declares a **repo**
92
- lake; for a personal or global lake, use `lakes` with the class instead.
92
+ lake; for a personal or global lake, use `lakes` with the class instead. Its
93
+ name follows the root folder unless you set the `name` option beside it:
94
+
95
+ ```jsonc
96
+ {
97
+ "plugins": [
98
+ {
99
+ "package": "@markstanden/opencode-docs-plugin",
100
+ "options": { "root": "documentation", "name": "doclake" },
101
+ },
102
+ ],
103
+ }
104
+ ```
105
+
106
+ `name` only applies to `root`; inside `lakes`, set it on the entry.
93
107
 
94
108
  For several lakes, list them inline, in a base file, or both. The base file is
95
109
  usually a personal or managed file; the repo file adds local lakes:
@@ -159,6 +173,47 @@ Lake paths never follow symlinks: a symlinked doc is skipped by search and
159
173
  `check`, refused by `read`, and refused by `add`. The configured `root` itself
160
174
  may be a symlink; the content beneath it must be real.
161
175
 
176
+ A lake root nested inside another lake's root is indexed once. The outer lake
177
+ skips the nested root, so the doc is returned by the inner lake only — never
178
+ twice, and never under both roots. Class and writability make no difference: a
179
+ writable repo lake may sit inside a larger read-only wiki lake, and search,
180
+ `read` and `add` all resolve to the writable inner lake. The outer lake's class
181
+ only decides its own profile and defaults; `repo`, `machine`, `global` and
182
+ `wiki` all skip a nested root the same way.
183
+
184
+ The skip compares the configured roots as written, so reach the tree the same
185
+ way from both declarations. If one lake names the real directory and the other
186
+ reaches it through a symlink, junction or mapped drive, the two paths never
187
+ match and the nested doc is indexed twice.
188
+
189
+ ```jsonc
190
+ {
191
+ "plugins": [
192
+ {
193
+ "package": "@markstanden/opencode-docs-plugin",
194
+ "options": {
195
+ "lakes": [
196
+ {
197
+ "class": "wiki",
198
+ "root": "~/reference-wiki",
199
+ "name": "wiki",
200
+ },
201
+ {
202
+ "class": "repo",
203
+ "root": "~/reference-wiki/work/docs",
204
+ "name": "work",
205
+ },
206
+ ],
207
+ },
208
+ },
209
+ ],
210
+ }
211
+ ```
212
+
213
+ `class` is one of `repo`, `machine`, `global` or `wiki`; `root` and `name` are
214
+ strings, and `writable` is a boolean that is fixed `false` for `wiki`. Do not
215
+ add `root` alongside `lakes` — they are mutually exclusive.
216
+
162
217
  A missing lake root is flagged, never created. The lake stays dormant until the
163
218
  directory exists: `search` appends a note naming the lake and path, `help` marks
164
219
  it `— missing`, and `add` refuses with the path rather than bootstrapping a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over configurable knowledge lakes (repo, machine, global and read-only wiki), nested README.md search, and the writing convention they share",
6
6
  "repository": {
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "scripts": {
23
23
  "test": "node --test 'tests/**/*.test.ts'",
24
+ "test:coverage": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'",
24
25
  "test:watch": "node --test --watch 'tests/**/*.test.ts'",
25
26
  "typecheck": "tsc --noEmit"
26
27
  },
package/src/SKILL.md CHANGED
@@ -21,14 +21,16 @@ means two docs; one omnibus doc is out of place everywhere.
21
21
 
22
22
  A **blank `search`** lists recent docs, one line each; a **`search`** ranks
23
23
  them, falling back to `Closest matches` and matching terms as **substrings** —
24
- so a typo finds nothing and terms under `MIN_TERM_LENGTH` (3) are ignored.
25
- `help` lists the lakes and ranking.
24
+ so a typo finds nothing. Terms shorter than `MIN_TERM_LENGTH` (3) are dropped
25
+ when longer terms exist; otherwise all query terms are kept. `help` lists the
26
+ lakes and ranking.
26
27
 
27
28
  Open the match with `read`: the root-relative path, or `lake:path` when several
28
29
  lakes exist. A README result is `instructions:path` and opens with the built-in
29
30
  `read`. Consult matching docs before a broad grep; a tie ranks repo,
30
- instructions, global, machine, wiki. Excluded areas: `plans/`, `requirements/`
31
- and `wiki/`.
31
+ instructions, global, machine, wiki. Knowledge lakes exclude `plans/`,
32
+ `requirements/` and `wiki/` at the root; wiki lakes exclude only
33
+ dot-directories and `node_modules`.
32
34
 
33
35
  ## Record it
34
36
 
package/src/index.ts CHANGED
@@ -250,9 +250,77 @@ function splitKeywords(value: string): string[] {
250
250
  .filter((keyword) => keyword !== "");
251
251
  }
252
252
 
253
- /** Whether a line opens or closes a fenced code block. */
254
- function isFence(line: string): boolean {
255
- return line.trimStart().startsWith("```");
253
+ /**
254
+ * An open fenced code block: the opener's delimiter character and run length.
255
+ * The length and character are remembered so a shorter or different-character
256
+ * run inside the block cannot close it.
257
+ */
258
+ interface Fence {
259
+ readonly char: "`" | "~";
260
+ readonly length: number;
261
+ }
262
+
263
+ /**
264
+ * The supported Markdown fence subset: a backtick or tilde run of three or
265
+ * more, indented by at most three spaces. A backtick opener's info string may
266
+ * not contain a backtick. Four-space indentation is an indented code block, not
267
+ * a fence, and is not modelled; nor are tab-indented fences or the full
268
+ * CommonMark block grammar.
269
+ * @param line One source line.
270
+ * @returns The opened fence, or null when the line is not an opener.
271
+ */
272
+ function fenceOpener(line: string): Fence | null {
273
+ const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
274
+ if (match === null) {
275
+ return null;
276
+ }
277
+ const run = match[1] ?? "";
278
+ const info = line.slice(match[0].length);
279
+ const char = run[0] as Fence["char"];
280
+ if (char === "`" && info.includes("`")) {
281
+ return null;
282
+ }
283
+ return { char, length: run.length };
284
+ }
285
+
286
+ /**
287
+ * Whether a line closes `fence`: the same delimiter character, a run at least
288
+ * as long as the opener, and nothing after it but spaces or tabs.
289
+ * @param line One source line.
290
+ * @param fence The open fence.
291
+ * @returns True when the line closes the block.
292
+ */
293
+ function closesFence(line: string, fence: Fence): boolean {
294
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
295
+ if (match === null) {
296
+ return false;
297
+ }
298
+ const run = match[1] ?? "";
299
+ return run.startsWith(fence.char) && run.length >= fence.length;
300
+ }
301
+
302
+ /**
303
+ * The body's lines outside fenced code blocks, fence delimiters included in
304
+ * neither. An unclosed fence keeps every remaining line inside the block.
305
+ * @param body Doc body, front-matter already stripped.
306
+ * @returns Source lines with fenced blocks removed.
307
+ */
308
+ function outsideFenceLines(body: string): string[] {
309
+ const lines: string[] = [];
310
+ let fence: Fence | null = null;
311
+ for (const line of body.split("\n")) {
312
+ if (fence === null) {
313
+ const opener = fenceOpener(line);
314
+ if (opener !== null) {
315
+ fence = opener;
316
+ continue;
317
+ }
318
+ lines.push(line);
319
+ } else if (closesFence(line, fence)) {
320
+ fence = null;
321
+ }
322
+ }
323
+ return lines;
256
324
  }
257
325
 
258
326
  /** Drop an ATX heading's trailing `#` decoration (and any trailing spaces). */
@@ -279,19 +347,11 @@ interface BodyScan {
279
347
  function scanBody(body: string): BodyScan {
280
348
  const headings: string[] = [];
281
349
  const spans: string[] = [];
282
- let fenced = false;
283
- for (const line of body.split("\n")) {
284
- if (isFence(line)) {
285
- fenced = !fenced;
286
- continue;
287
- }
288
- if (fenced) {
289
- continue;
290
- }
350
+ for (const line of outsideFenceLines(body)) {
291
351
  const heading = /^(#{2,3})\s+/.exec(line);
292
352
  if (heading !== null) {
293
353
  const text = stripTrailingHashes(
294
- line.slice(heading[0].length).replace(/`/g, ""),
354
+ line.slice(heading[0].length).replaceAll("`", ""),
295
355
  ).trim();
296
356
  if (text !== "") {
297
357
  headings.push(text);
@@ -358,18 +418,7 @@ function plainProse(text: string): string {
358
418
 
359
419
  /** The body with fenced code blocks removed. */
360
420
  function withoutFences(body: string): string {
361
- const lines: string[] = [];
362
- let fenced = false;
363
- for (const line of body.split("\n")) {
364
- if (isFence(line)) {
365
- fenced = !fenced;
366
- continue;
367
- }
368
- if (!fenced) {
369
- lines.push(line);
370
- }
371
- }
372
- return lines.join("\n");
421
+ return outsideFenceLines(body).join("\n");
373
422
  }
374
423
 
375
424
  /** Block prefixes that are never a wiki summary. */
@@ -752,6 +801,20 @@ export function rankDocs(
752
801
  };
753
802
  }
754
803
 
804
+ /** The provenance prefix for a result: instructions, non-primary lake, or none. */
805
+ function resultPrefix(
806
+ entry: DocEntry,
807
+ primaryLake: string | undefined,
808
+ ): string {
809
+ if (entry.class === "instructions") {
810
+ return "instructions";
811
+ }
812
+ if (entry.lake !== "" && entry.lake !== primaryLake) {
813
+ return entry.lake;
814
+ }
815
+ return "";
816
+ }
817
+
755
818
  /**
756
819
  * Render matched docs as `path — Area — date` lines with the summary indented
757
820
  * beneath; a non-primary lake is prefixed `lake:` so provenance is clear. A
@@ -782,12 +845,8 @@ export function render(
782
845
  );
783
846
  const meta = parts.join(" — ");
784
847
  const head = meta === "" ? entry.path : `${entry.path} — ${meta}`;
785
- const labelled =
786
- entry.class === "instructions"
787
- ? `instructions:${head}`
788
- : entry.lake !== "" && entry.lake !== options.primaryLake
789
- ? `${entry.lake}:${head}`
790
- : head;
848
+ const prefix = resultPrefix(entry, options.primaryLake);
849
+ const labelled = prefix === "" ? head : `${prefix}:${head}`;
791
850
  return entry.summary === ""
792
851
  ? labelled
793
852
  : `${labelled}\n ${entry.summary}`;
@@ -824,7 +883,7 @@ export function explainRanking(): string {
824
883
  "How docs search ranks:",
825
884
  `- Fields weighted: title ${FIELD_WEIGHTS.title} > path ${FIELD_WEIGHTS.path} > keywords ${FIELD_WEIGHTS.keywords} > identifiers ${FIELD_WEIGHTS.identifiers} > area ${FIELD_WEIGHTS.area} > summary ${FIELD_WEIGHTS.summary} > wiki prose ${FIELD_WEIGHTS.text}.`,
826
885
  `- Each term scores its best field as weight × quality; quality is exact > prefix > substring.`,
827
- `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are ignored unless they are the whole query.`,
886
+ `- Terms shorter than MIN_TERM_LENGTH (${MIN_TERM_LENGTH}) are dropped when longer terms exist; otherwise all query terms are kept.`,
828
887
  "- Search covers every configured lake and nested project `README.md` files, honouring `.gitignore` (dependency and build directories are skipped when the project is not in git). READMEs are searchable only: `add` and `check` ignore them, and their results are prefixed `instructions:` so they read with the built-in `read` tool.",
829
888
  `- Each source reserves up to ${DEFAULT_PER_LAKE_QUOTA} results (configurable), then leftover slots fill by score. Every call returns at most MAX_RESULTS (${MAX_RESULTS}), best matches first, then the most recently edited; anything cut is counted as "… and N more not shown."`,
830
889
  "- Class precedence on a tie: repo > instructions > global > machine > wiki.",
@@ -945,7 +1004,7 @@ export function gitDates(
945
1004
  return dates;
946
1005
  }
947
1006
  const result = spawnSync(
948
- "git",
1007
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config, not model input.
949
1008
  // `--relative` keeps the output paths root-relative to `cwd`; without
950
1009
  // it a lake rooted at `<repo>/docs` maps `docs/foo.md` while the
951
1010
  // requested paths are `foo.md`, silently falling back to mtimes.
@@ -1059,6 +1118,22 @@ function skipDirectory(
1059
1118
  return directory === root && excluded.includes(item.name);
1060
1119
  }
1061
1120
 
1121
+ /**
1122
+ * Code-unit string order, matching the default sort but stated explicitly.
1123
+ * @param a Left string.
1124
+ * @param b Right string.
1125
+ * @returns Negative, zero or positive in code-unit order.
1126
+ */
1127
+ function compareText(a: string, b: string): number {
1128
+ if (a < b) {
1129
+ return -1;
1130
+ }
1131
+ if (a > b) {
1132
+ return 1;
1133
+ }
1134
+ return 0;
1135
+ }
1136
+
1062
1137
  /**
1063
1138
  * Walk a lake root, returning its `.md` paths relative to the root. Dot
1064
1139
  * directories are skipped (which drops `.git` and `.attachments`), as are
@@ -1106,7 +1181,7 @@ export function collectDocPaths(
1106
1181
  if (existsSync(root)) {
1107
1182
  walk(root);
1108
1183
  }
1109
- return paths.sort();
1184
+ return paths.sort(compareText);
1110
1185
  }
1111
1186
 
1112
1187
  /**
@@ -1221,7 +1296,7 @@ export function readmePaths(
1221
1296
  .filter(
1222
1297
  (path) => !skip.some((root) => isUnder(join(location, path), root)),
1223
1298
  )
1224
- .sort();
1299
+ .sort(compareText);
1225
1300
  }
1226
1301
 
1227
1302
  /**
@@ -1230,7 +1305,7 @@ export function readmePaths(
1230
1305
  */
1231
1306
  function gitReadmePaths(location: string): string[] | undefined {
1232
1307
  const result = spawnSync(
1233
- "git",
1308
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config, not model input.
1234
1309
  ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
1235
1310
  { cwd: location, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
1236
1311
  );
@@ -1616,9 +1691,11 @@ export const CLAIM_SECTIONS = [
1616
1691
  * @returns True when a claim-section heading is present.
1617
1692
  */
1618
1693
  export function hasClaimSection(body: string): boolean {
1619
- const claims = CLAIM_SECTIONS.map((section) => section.toLowerCase());
1694
+ const claims = new Set(
1695
+ CLAIM_SECTIONS.map((section) => section.toLowerCase()),
1696
+ );
1620
1697
  return scanBody(body).headings.some((heading) =>
1621
- claims.includes(heading.toLowerCase()),
1698
+ claims.has(heading.toLowerCase()),
1622
1699
  );
1623
1700
  }
1624
1701
 
@@ -1698,7 +1775,7 @@ function nearTwinPairs(
1698
1775
  const query = identityQuery(doc.title, doc.keywords);
1699
1776
  const others = entries.filter((other) => other.path !== doc.path);
1700
1777
  for (const twin of nearTwins(query, doc.area, others)) {
1701
- const key = [doc.path, twin.path].sort().join("\n");
1778
+ const key = [doc.path, twin.path].sort(compareText).join("\n");
1702
1779
  if (!pairs.has(key)) {
1703
1780
  pairs.set(key, [doc, twin]);
1704
1781
  }
@@ -1732,26 +1809,47 @@ function resolveLink(from: string, target: string): string | null {
1732
1809
  return stack.join("/");
1733
1810
  }
1734
1811
 
1812
+ /** An inline link's destination and the index of its closing `)`. */
1813
+ interface InlineLink {
1814
+ readonly destination: string | undefined;
1815
+ readonly end: number;
1816
+ }
1817
+
1818
+ /**
1819
+ * Parse an inline `[text](target)` link starting at `start`.
1820
+ * @param line One source line.
1821
+ * @param start Index of the opening `[`.
1822
+ * @returns The destination and closing index, or null when the link is incomplete.
1823
+ */
1824
+ function inlineLinkAt(line: string, start: number): InlineLink | null {
1825
+ const close = line.indexOf("](", start + 1);
1826
+ if (close === -1) {
1827
+ return null;
1828
+ }
1829
+ const end = line.indexOf(")", close + 2);
1830
+ if (end === -1) {
1831
+ return null;
1832
+ }
1833
+ const destination = line
1834
+ .slice(close + 2, end)
1835
+ .trim()
1836
+ .split(/\s+/)[0];
1837
+ return { destination, end };
1838
+ }
1839
+
1735
1840
  /** Inline `[text](target)` destinations on one line; images are skipped. */
1736
1841
  function inlineTargets(line: string): string[] {
1737
1842
  const targets: string[] = [];
1738
1843
  let index = 0;
1739
1844
  while (index < line.length) {
1740
1845
  if (line[index] === "[" && line[index - 1] !== "!") {
1741
- const close = line.indexOf("](", index + 1);
1742
- if (close !== -1) {
1743
- const end = line.indexOf(")", close + 2);
1744
- if (end !== -1) {
1745
- const destination = line
1746
- .slice(close + 2, end)
1747
- .trim()
1748
- .split(/\s+/)[0];
1749
- if (destination !== undefined && destination !== "") {
1750
- targets.push(destination);
1751
- }
1752
- index = end + 1;
1753
- continue;
1846
+ const link = inlineLinkAt(line, index);
1847
+ if (link !== null) {
1848
+ if (link.destination !== undefined && link.destination !== "") {
1849
+ targets.push(link.destination);
1754
1850
  }
1851
+ index = link.end + 1;
1852
+ continue;
1755
1853
  }
1756
1854
  }
1757
1855
  index += 1;
@@ -1768,15 +1866,7 @@ function inlineTargets(line: string): string[] {
1768
1866
  */
1769
1867
  export function markdownTargets(body: string): string[] {
1770
1868
  const targets: string[] = [];
1771
- let fenced = false;
1772
- for (const line of body.split("\n")) {
1773
- if (isFence(line)) {
1774
- fenced = !fenced;
1775
- continue;
1776
- }
1777
- if (fenced) {
1778
- continue;
1779
- }
1869
+ for (const line of outsideFenceLines(body)) {
1780
1870
  targets.push(...inlineTargets(line));
1781
1871
  const reference = /^\s*\[[^\]]+\]:\s*(\S+)/.exec(line);
1782
1872
  if (reference?.[1] !== undefined) {
@@ -1962,7 +2052,8 @@ export function checkDocs(
1962
2052
  if (advisory.length === 0) {
1963
2053
  return head;
1964
2054
  }
1965
- return `${head}\n\nNotes:\n${advisory.map((note) => `- ${note}`).join("\n")}`;
2055
+ const noteLines = advisory.map((note) => `- ${note}`).join("\n");
2056
+ return `${head}\n\nNotes:\n${noteLines}`;
1966
2057
  }
1967
2058
 
1968
2059
  /** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
@@ -2279,6 +2370,8 @@ export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
2279
2370
  export interface DocsPluginOptions {
2280
2371
  /** The single repo-lake override; mutually exclusive with `lakes`. */
2281
2372
  root?: unknown;
2373
+ /** The `root` lake's name; defaults to the root folder name. Needs `root`. */
2374
+ name?: unknown;
2282
2375
  /** Base lake declarations, merged after `lakesFile` and the repo file. */
2283
2376
  lakes?: unknown;
2284
2377
  /** A JSON file of base lakes (`{ "lakes": [...] }`). */
@@ -2359,13 +2452,20 @@ function readLakesJson(
2359
2452
  { cause: error },
2360
2453
  );
2361
2454
  }
2362
- const lakes = Array.isArray(parsed)
2363
- ? parsed
2364
- : typeof parsed === "object" && parsed !== null && "lakes" in parsed
2365
- ? (parsed as { lakes: unknown }).lakes
2366
- : undefined;
2455
+ let lakes: unknown;
2456
+ if (Array.isArray(parsed)) {
2457
+ lakes = parsed;
2458
+ } else if (
2459
+ typeof parsed === "object" &&
2460
+ parsed !== null &&
2461
+ "lakes" in parsed
2462
+ ) {
2463
+ lakes = (parsed as { lakes: unknown }).lakes;
2464
+ } else {
2465
+ lakes = undefined;
2466
+ }
2367
2467
  if (!Array.isArray(lakes)) {
2368
- throw new Error(
2468
+ throw new TypeError(
2369
2469
  `docs plugin ${label} must contain a "lakes" array: ${path}.`,
2370
2470
  );
2371
2471
  }
@@ -2435,7 +2535,7 @@ function lakeRootOf(
2435
2535
  return join(location, "docs");
2436
2536
  }
2437
2537
  if (typeof entry.root !== "string") {
2438
- throw new Error(
2538
+ throw new TypeError(
2439
2539
  `docs plugin ${lakeClass} lake root must be a string, ` +
2440
2540
  `got ${JSON.stringify(entry.root)}.`,
2441
2541
  );
@@ -2456,12 +2556,14 @@ function lakeNameOf(
2456
2556
  lakeClass: LakeClass,
2457
2557
  ): string {
2458
2558
  const derived = slugify(basename(root)) || lakeClass;
2459
- const name =
2460
- entry.name === undefined
2461
- ? derived
2462
- : typeof entry.name === "string"
2463
- ? entry.name.trim()
2464
- : "";
2559
+ let name: string;
2560
+ if (entry.name === undefined) {
2561
+ name = derived;
2562
+ } else if (typeof entry.name === "string") {
2563
+ name = entry.name.trim();
2564
+ } else {
2565
+ name = "";
2566
+ }
2465
2567
  if (!LAKE_NAME.test(name)) {
2466
2568
  throw new Error(
2467
2569
  `docs plugin lake name must match ${LAKE_NAME}, ` +
@@ -2488,8 +2590,7 @@ function lakeWritableOf(
2488
2590
  `got ${JSON.stringify(entry.writable)}.`,
2489
2591
  );
2490
2592
  }
2491
- const writable =
2492
- entry.writable === undefined ? lakeClass !== "wiki" : entry.writable;
2593
+ const writable = entry.writable ?? lakeClass !== "wiki";
2493
2594
  if (lakeClass === "wiki" && writable) {
2494
2595
  throw new Error(
2495
2596
  `docs plugin lake '${name}' is a wiki lake and cannot be writable.`,
@@ -2547,6 +2648,12 @@ function assertLakesOptions(options: DocsPluginOptions): void {
2547
2648
  "use 'root' for one repo lake or 'lakes' for the full list.",
2548
2649
  );
2549
2650
  }
2651
+ if (options.name !== undefined && options.root === undefined) {
2652
+ throw new Error(
2653
+ "docs plugin option 'name' names the 'root' lake; " +
2654
+ "set 'name' inside a 'lakes' entry instead.",
2655
+ );
2656
+ }
2550
2657
  }
2551
2658
 
2552
2659
  /** Map raw lake entries from a file to specs. */
@@ -2623,7 +2730,7 @@ export function resolveLakes(
2623
2730
  if (options.root !== undefined) {
2624
2731
  specs.push(
2625
2732
  lakeSpecFrom(
2626
- { class: "repo", root: options.root },
2733
+ { class: "repo", root: options.root, name: options.name },
2627
2734
  location,
2628
2735
  false,
2629
2736
  ),
@@ -3046,6 +3153,51 @@ const TWIN_DESCRIPTION =
3046
3153
  "docs; `read` returns a matched doc verbatim; `add` writes through a typed " +
3047
3154
  "DTO; `check` validates the lakes.";
3048
3155
 
3156
+ /**
3157
+ * Run one resolved `docs` request against the runtime.
3158
+ * @param runtime The resolved lakes and seams.
3159
+ * @param input The typed tool input.
3160
+ * @returns The tool result.
3161
+ */
3162
+ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3163
+ const request = resolveRequest(input);
3164
+ if (request.verb === "add") {
3165
+ const result = addDoc(runtime, request.add, request.lake);
3166
+ return {
3167
+ content: result.content,
3168
+ metadata: { written: result.written },
3169
+ };
3170
+ }
3171
+ if (request.verb === "check") {
3172
+ const result = checkLakes(runtime, request.lake);
3173
+ return {
3174
+ content: result.content,
3175
+ metadata: { checked: result.checked },
3176
+ };
3177
+ }
3178
+ if (request.verb === "read") {
3179
+ const result = readDoc(runtime, request.read, request.lake);
3180
+ return {
3181
+ content: result.content,
3182
+ metadata: { read: result.read },
3183
+ };
3184
+ }
3185
+ if (request.verb === "help") {
3186
+ return {
3187
+ content: helpText(lakeInfo(runtime.lakes)),
3188
+ metadata: { count: 0, omitted: 0 },
3189
+ };
3190
+ }
3191
+ const result = searchDocs(runtime, request.search, request.lake);
3192
+ return {
3193
+ content: result.content,
3194
+ metadata: {
3195
+ count: result.count,
3196
+ omitted: result.omitted,
3197
+ },
3198
+ };
3199
+ }
3200
+
3049
3201
  /**
3050
3202
  * Build the `docs` tool from its dependencies.
3051
3203
  *
@@ -3087,43 +3239,8 @@ export function createDocsTool(
3087
3239
  options: codemode
3088
3240
  ? { namespace: "docs", codemode: true }
3089
3241
  : { codemode: false },
3090
- async execute(input) {
3091
- const request = resolveRequest(input);
3092
- if (request.verb === "add") {
3093
- const result = addDoc(runtime, request.add, request.lake);
3094
- return {
3095
- content: result.content,
3096
- metadata: { written: result.written },
3097
- };
3098
- }
3099
- if (request.verb === "check") {
3100
- const result = checkLakes(runtime, request.lake);
3101
- return {
3102
- content: result.content,
3103
- metadata: { checked: result.checked },
3104
- };
3105
- }
3106
- if (request.verb === "read") {
3107
- const result = readDoc(runtime, request.read, request.lake);
3108
- return {
3109
- content: result.content,
3110
- metadata: { read: result.read },
3111
- };
3112
- }
3113
- if (request.verb === "help") {
3114
- return {
3115
- content: helpText(lakeInfo(runtime.lakes)),
3116
- metadata: { count: 0, omitted: 0 },
3117
- };
3118
- }
3119
- const result = searchDocs(runtime, request.search, request.lake);
3120
- return {
3121
- content: result.content,
3122
- metadata: {
3123
- count: result.count,
3124
- omitted: result.omitted,
3125
- },
3126
- };
3242
+ execute(input) {
3243
+ return Promise.resolve(executeRequest(runtime, input));
3127
3244
  },
3128
3245
  };
3129
3246
  }
@@ -3213,7 +3330,7 @@ export function resolveRoot(
3213
3330
  return join(locationDirectory, "docs");
3214
3331
  }
3215
3332
  if (typeof configured !== "string") {
3216
- throw new Error(
3333
+ throw new TypeError(
3217
3334
  `docs plugin option 'root' must be a non-empty string path, ` +
3218
3335
  `got ${JSON.stringify(configured)}.`,
3219
3336
  );
@@ -3264,9 +3381,11 @@ export function unversionedNote(
3264
3381
  if (!existsSync(root)) {
3265
3382
  return [missingRootNote(root, lake)];
3266
3383
  }
3267
- const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
3268
- cwd: root,
3269
- });
3384
+ const probe = spawnSync(
3385
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config.
3386
+ ["rev-parse", "--show-toplevel"],
3387
+ { cwd: root },
3388
+ );
3270
3389
  if (probe.status === 0) {
3271
3390
  return [];
3272
3391
  }