@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 +56 -1
- package/package.json +2 -1
- package/src/SKILL.md +6 -4
- package/src/index.ts +238 -119
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.
|
|
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
|
|
25
|
-
`help` lists the
|
|
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.
|
|
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
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
|
786
|
-
|
|
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
|
|
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 =
|
|
1694
|
+
const claims = new Set(
|
|
1695
|
+
CLAIM_SECTIONS.map((section) => section.toLowerCase()),
|
|
1696
|
+
);
|
|
1620
1697
|
return scanBody(body).headings.some((heading) =>
|
|
1621
|
-
claims.
|
|
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
|
|
1742
|
-
if (
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
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
|
-
|
|
3091
|
-
|
|
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
|
|
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(
|
|
3268
|
-
|
|
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
|
}
|