@markstanden/opencode-docs-plugin 0.6.0 → 0.6.2

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/SKILL.md +6 -4
  3. package/src/index.ts +229 -118
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.6.2",
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": {
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. */
@@ -2359,13 +2450,20 @@ function readLakesJson(
2359
2450
  { cause: error },
2360
2451
  );
2361
2452
  }
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;
2453
+ let lakes: unknown;
2454
+ if (Array.isArray(parsed)) {
2455
+ lakes = parsed;
2456
+ } else if (
2457
+ typeof parsed === "object" &&
2458
+ parsed !== null &&
2459
+ "lakes" in parsed
2460
+ ) {
2461
+ lakes = (parsed as { lakes: unknown }).lakes;
2462
+ } else {
2463
+ lakes = undefined;
2464
+ }
2367
2465
  if (!Array.isArray(lakes)) {
2368
- throw new Error(
2466
+ throw new TypeError(
2369
2467
  `docs plugin ${label} must contain a "lakes" array: ${path}.`,
2370
2468
  );
2371
2469
  }
@@ -2435,7 +2533,7 @@ function lakeRootOf(
2435
2533
  return join(location, "docs");
2436
2534
  }
2437
2535
  if (typeof entry.root !== "string") {
2438
- throw new Error(
2536
+ throw new TypeError(
2439
2537
  `docs plugin ${lakeClass} lake root must be a string, ` +
2440
2538
  `got ${JSON.stringify(entry.root)}.`,
2441
2539
  );
@@ -2456,12 +2554,14 @@ function lakeNameOf(
2456
2554
  lakeClass: LakeClass,
2457
2555
  ): string {
2458
2556
  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
- : "";
2557
+ let name: string;
2558
+ if (entry.name === undefined) {
2559
+ name = derived;
2560
+ } else if (typeof entry.name === "string") {
2561
+ name = entry.name.trim();
2562
+ } else {
2563
+ name = "";
2564
+ }
2465
2565
  if (!LAKE_NAME.test(name)) {
2466
2566
  throw new Error(
2467
2567
  `docs plugin lake name must match ${LAKE_NAME}, ` +
@@ -2488,8 +2588,7 @@ function lakeWritableOf(
2488
2588
  `got ${JSON.stringify(entry.writable)}.`,
2489
2589
  );
2490
2590
  }
2491
- const writable =
2492
- entry.writable === undefined ? lakeClass !== "wiki" : entry.writable;
2591
+ const writable = entry.writable ?? lakeClass !== "wiki";
2493
2592
  if (lakeClass === "wiki" && writable) {
2494
2593
  throw new Error(
2495
2594
  `docs plugin lake '${name}' is a wiki lake and cannot be writable.`,
@@ -3046,6 +3145,51 @@ const TWIN_DESCRIPTION =
3046
3145
  "docs; `read` returns a matched doc verbatim; `add` writes through a typed " +
3047
3146
  "DTO; `check` validates the lakes.";
3048
3147
 
3148
+ /**
3149
+ * Run one resolved `docs` request against the runtime.
3150
+ * @param runtime The resolved lakes and seams.
3151
+ * @param input The typed tool input.
3152
+ * @returns The tool result.
3153
+ */
3154
+ function executeRequest(runtime: Runtime, input: DocsInput): Result {
3155
+ const request = resolveRequest(input);
3156
+ if (request.verb === "add") {
3157
+ const result = addDoc(runtime, request.add, request.lake);
3158
+ return {
3159
+ content: result.content,
3160
+ metadata: { written: result.written },
3161
+ };
3162
+ }
3163
+ if (request.verb === "check") {
3164
+ const result = checkLakes(runtime, request.lake);
3165
+ return {
3166
+ content: result.content,
3167
+ metadata: { checked: result.checked },
3168
+ };
3169
+ }
3170
+ if (request.verb === "read") {
3171
+ const result = readDoc(runtime, request.read, request.lake);
3172
+ return {
3173
+ content: result.content,
3174
+ metadata: { read: result.read },
3175
+ };
3176
+ }
3177
+ if (request.verb === "help") {
3178
+ return {
3179
+ content: helpText(lakeInfo(runtime.lakes)),
3180
+ metadata: { count: 0, omitted: 0 },
3181
+ };
3182
+ }
3183
+ const result = searchDocs(runtime, request.search, request.lake);
3184
+ return {
3185
+ content: result.content,
3186
+ metadata: {
3187
+ count: result.count,
3188
+ omitted: result.omitted,
3189
+ },
3190
+ };
3191
+ }
3192
+
3049
3193
  /**
3050
3194
  * Build the `docs` tool from its dependencies.
3051
3195
  *
@@ -3087,43 +3231,8 @@ export function createDocsTool(
3087
3231
  options: codemode
3088
3232
  ? { namespace: "docs", codemode: true }
3089
3233
  : { 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
- };
3234
+ execute(input) {
3235
+ return Promise.resolve(executeRequest(runtime, input));
3127
3236
  },
3128
3237
  };
3129
3238
  }
@@ -3213,7 +3322,7 @@ export function resolveRoot(
3213
3322
  return join(locationDirectory, "docs");
3214
3323
  }
3215
3324
  if (typeof configured !== "string") {
3216
- throw new Error(
3325
+ throw new TypeError(
3217
3326
  `docs plugin option 'root' must be a non-empty string path, ` +
3218
3327
  `got ${JSON.stringify(configured)}.`,
3219
3328
  );
@@ -3264,9 +3373,11 @@ export function unversionedNote(
3264
3373
  if (!existsSync(root)) {
3265
3374
  return [missingRootNote(root, lake)];
3266
3375
  }
3267
- const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
3268
- cwd: root,
3269
- });
3376
+ const probe = spawnSync(
3377
+ "git", // NOSONAR: portable git lookup; cwd is trusted local config.
3378
+ ["rev-parse", "--show-toplevel"],
3379
+ { cwd: root },
3380
+ );
3270
3381
  if (probe.status === 0) {
3271
3382
  return [];
3272
3383
  }