@markstanden/opencode-docs-plugin 0.6.2 → 0.9.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/CONVENTION.md +13 -0
- package/README.md +67 -1
- package/package.json +2 -1
- package/src/index.ts +117 -28
package/CONVENTION.md
CHANGED
|
@@ -36,6 +36,19 @@ are RepoContext. A repo adds other local roots in
|
|
|
36
36
|
`.opencode/docs-lakes.json`; a personal or managed base file lists the
|
|
37
37
|
machine, global and wiki lakes.
|
|
38
38
|
|
|
39
|
+
Lakes are named, and several per class are supported. A root nested inside
|
|
40
|
+
another lake's root belongs to the **narrowest enclosing lake**: the outer lake
|
|
41
|
+
skips it, search returns the inner lake's entry, a bare `read` resolves there,
|
|
42
|
+
and `add` writes to the deepest writable knowledge lake. Nesting is decided on
|
|
43
|
+
real paths, so reaching one root through a symlink cannot defeat containment.
|
|
44
|
+
Two lakes that do not nest holding the same relative path stay a refusal —
|
|
45
|
+
pass `lake`. Near-twin checks apply inside one lake and never pair across the
|
|
46
|
+
nesting boundary.
|
|
47
|
+
|
|
48
|
+
Lake notes — a missing root, or a root outside version control — are computed
|
|
49
|
+
per call, not captured at startup: create, remove or version a root and the
|
|
50
|
+
next `check`, `search` and help reflect it without a restart.
|
|
51
|
+
|
|
39
52
|
The excluded areas — never scanned, never validated in a knowledge lake — are
|
|
40
53
|
`plans/` (scratch, gitignored, deleted when the work lands), `requirements/`
|
|
41
54
|
and `wiki/` (vendored mirrors). The wiki profile has no such exclusions; only
|
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,12 +173,64 @@ 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 is decided on real paths: every configured root is resolved through
|
|
185
|
+
symlinks, junctions and mapped drives before the comparison, so the two
|
|
186
|
+
declarations may reach the tree by different routes and the nested doc is
|
|
187
|
+
still indexed once.
|
|
188
|
+
|
|
189
|
+
A doc belongs to the **narrowest enclosing lake**. With nested roots, `search`
|
|
190
|
+
returns the inner lake's entry and a bare `read` resolves to it; `add` writes
|
|
191
|
+
to the deepest writable knowledge lake, so an outer read-only wrapper never
|
|
192
|
+
hijacks the write. Two lakes that do not nest holding the same relative path
|
|
193
|
+
stay a refusal — pass `lake`. Near-twin checks apply inside one lake and never
|
|
194
|
+
pair across the nesting boundary.
|
|
195
|
+
|
|
196
|
+
```jsonc
|
|
197
|
+
{
|
|
198
|
+
"plugins": [
|
|
199
|
+
{
|
|
200
|
+
"package": "@markstanden/opencode-docs-plugin",
|
|
201
|
+
"options": {
|
|
202
|
+
"lakes": [
|
|
203
|
+
{
|
|
204
|
+
"class": "wiki",
|
|
205
|
+
"root": "~/reference-wiki",
|
|
206
|
+
"name": "wiki",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"class": "repo",
|
|
210
|
+
"root": "~/reference-wiki/work/docs",
|
|
211
|
+
"name": "work",
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`class` is one of `repo`, `machine`, `global` or `wiki`; `root` and `name` are
|
|
221
|
+
strings, and `writable` is a boolean that is fixed `false` for `wiki`. Do not
|
|
222
|
+
add `root` alongside `lakes` — they are mutually exclusive.
|
|
223
|
+
|
|
162
224
|
A missing lake root is flagged, never created. The lake stays dormant until the
|
|
163
225
|
directory exists: `search` appends a note naming the lake and path, `help` marks
|
|
164
226
|
it `— missing`, and `add` refuses with the path rather than bootstrapping a
|
|
165
227
|
tree. Create the directory deliberately. Area folders beneath an existing root
|
|
166
228
|
are still created on write.
|
|
167
229
|
|
|
230
|
+
Root and git notes are computed per call, not captured at startup: a root that
|
|
231
|
+
appears, disappears or gains a git checkout is reflected by the next `check`,
|
|
232
|
+
`search` and help without a restart.
|
|
233
|
+
|
|
168
234
|
`maxDocLines` (default 200, `0` disables) is a **soft** note on a long doc, not a
|
|
169
235
|
refusal. `perLakeQuota` (default 3, `0` disables) is the per-source result
|
|
170
236
|
reservation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markstanden/opencode-docs-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/index.ts
CHANGED
|
@@ -2227,8 +2227,12 @@ export interface Lake extends LakeSpec {
|
|
|
2227
2227
|
* existing root; throws when the root is absent rather than creating it.
|
|
2228
2228
|
*/
|
|
2229
2229
|
write: (path: string, contents: string) => void;
|
|
2230
|
-
/**
|
|
2231
|
-
|
|
2230
|
+
/**
|
|
2231
|
+
* Legacy seam notes only. Real lakes compute their environment notes at
|
|
2232
|
+
* call time — a root that appears, disappears or gains a git checkout is
|
|
2233
|
+
* re-probed on the next `check`, `search` and help, never captured here.
|
|
2234
|
+
*/
|
|
2235
|
+
notes?: readonly string[];
|
|
2232
2236
|
/** Whether `check` should verify relative links; false for the legacy seam. */
|
|
2233
2237
|
linkCheck: boolean;
|
|
2234
2238
|
}
|
|
@@ -2314,7 +2318,6 @@ export function createLake(
|
|
|
2314
2318
|
};
|
|
2315
2319
|
return {
|
|
2316
2320
|
...spec,
|
|
2317
|
-
notes: unversionedNote(spec.root, spec.name),
|
|
2318
2321
|
linkCheck: true,
|
|
2319
2322
|
collect: () =>
|
|
2320
2323
|
collectDocs(spec.root, (paths) => fallbackDates(spec.root, paths), {
|
|
@@ -2346,18 +2349,27 @@ export function createLake(
|
|
|
2346
2349
|
|
|
2347
2350
|
/**
|
|
2348
2351
|
* Build every configured lake, each aware of the others so a nested root is
|
|
2349
|
-
* collected once.
|
|
2352
|
+
* collected once. Nesting is decided on real paths, so a symlinked root cannot
|
|
2353
|
+
* defeat containment: the skip paths handed to the walker are re-anchored
|
|
2354
|
+
* under the collecting lake's configured root.
|
|
2350
2355
|
* @param specs The configured lakes.
|
|
2351
2356
|
* @returns The runtime lakes.
|
|
2352
2357
|
*/
|
|
2353
2358
|
export function createLakes(specs: readonly LakeSpec[]): Lake[] {
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2359
|
+
const realRoots = specs.map((spec) => realPathOf(spec.root));
|
|
2360
|
+
return specs.map((spec, index) => {
|
|
2361
|
+
const own = realRoots[index] ?? spec.root;
|
|
2362
|
+
const skip = specs.flatMap((other, otherIndex) => {
|
|
2363
|
+
if (otherIndex === index) {
|
|
2364
|
+
return [];
|
|
2365
|
+
}
|
|
2366
|
+
const otherRoot = realRoots[otherIndex] ?? other.root;
|
|
2367
|
+
return otherRoot !== own && isUnder(otherRoot, own)
|
|
2368
|
+
? [join(spec.root, relative(own, otherRoot))]
|
|
2369
|
+
: [];
|
|
2370
|
+
});
|
|
2371
|
+
return createLake(spec, { skip });
|
|
2372
|
+
});
|
|
2361
2373
|
}
|
|
2362
2374
|
|
|
2363
2375
|
/** The lake classes the configuration accepts. */
|
|
@@ -2370,6 +2382,8 @@ export const REPO_LAKES_FILE = ".opencode/docs-lakes.json";
|
|
|
2370
2382
|
export interface DocsPluginOptions {
|
|
2371
2383
|
/** The single repo-lake override; mutually exclusive with `lakes`. */
|
|
2372
2384
|
root?: unknown;
|
|
2385
|
+
/** The `root` lake's name; defaults to the root folder name. Needs `root`. */
|
|
2386
|
+
name?: unknown;
|
|
2373
2387
|
/** Base lake declarations, merged after `lakesFile` and the repo file. */
|
|
2374
2388
|
lakes?: unknown;
|
|
2375
2389
|
/** A JSON file of base lakes (`{ "lakes": [...] }`). */
|
|
@@ -2646,6 +2660,12 @@ function assertLakesOptions(options: DocsPluginOptions): void {
|
|
|
2646
2660
|
"use 'root' for one repo lake or 'lakes' for the full list.",
|
|
2647
2661
|
);
|
|
2648
2662
|
}
|
|
2663
|
+
if (options.name !== undefined && options.root === undefined) {
|
|
2664
|
+
throw new Error(
|
|
2665
|
+
"docs plugin option 'name' names the 'root' lake; " +
|
|
2666
|
+
"set 'name' inside a 'lakes' entry instead.",
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2649
2669
|
}
|
|
2650
2670
|
|
|
2651
2671
|
/** Map raw lake entries from a file to specs. */
|
|
@@ -2722,7 +2742,7 @@ export function resolveLakes(
|
|
|
2722
2742
|
if (options.root !== undefined) {
|
|
2723
2743
|
specs.push(
|
|
2724
2744
|
lakeSpecFrom(
|
|
2725
|
-
{ class: "repo", root: options.root },
|
|
2745
|
+
{ class: "repo", root: options.root, name: options.name },
|
|
2726
2746
|
location,
|
|
2727
2747
|
false,
|
|
2728
2748
|
),
|
|
@@ -2811,6 +2831,44 @@ function findLake(lakes: readonly Lake[], name: string): Lake | undefined {
|
|
|
2811
2831
|
return lakes.find((lake) => lake.name === name);
|
|
2812
2832
|
}
|
|
2813
2833
|
|
|
2834
|
+
/**
|
|
2835
|
+
* The environment notes for one lake, probed at call time. Legacy seams keep
|
|
2836
|
+
* their captured notes; every real lake re-probes, so a root that appears,
|
|
2837
|
+
* disappears or gains a git checkout is reported by the next surface that
|
|
2838
|
+
* looks — `check`, `search` and help never disagree with a stale capture.
|
|
2839
|
+
* @param lake The lake to describe.
|
|
2840
|
+
* @returns The note lines.
|
|
2841
|
+
*/
|
|
2842
|
+
function lakeNotes(lake: Lake): readonly string[] {
|
|
2843
|
+
return lake.root === ""
|
|
2844
|
+
? (lake.notes ?? [])
|
|
2845
|
+
: unversionedNote(lake.root, lake.name);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/** Whether a lake root is absent, probed at call time. */
|
|
2849
|
+
function lakeMissing(lake: Lake): boolean {
|
|
2850
|
+
return lake.root !== "" && !existsSync(lake.root);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
/**
|
|
2854
|
+
* How many knowledge lakes enclose a lake — the nesting depth used to pick the
|
|
2855
|
+
* narrowest owner of a path and the deepest writable target for `add`. Wiki
|
|
2856
|
+
* lakes never enclose anything.
|
|
2857
|
+
* @param lake The lake to measure.
|
|
2858
|
+
* @param lakes The configured lakes.
|
|
2859
|
+
* @returns The number of enclosing knowledge lakes.
|
|
2860
|
+
*/
|
|
2861
|
+
function nestingDepth(lake: Lake, lakes: readonly Lake[]): number {
|
|
2862
|
+
const root = realPathOf(lake.root);
|
|
2863
|
+
return lakes.filter((other) => {
|
|
2864
|
+
if (other === lake || other.profile === "wiki") {
|
|
2865
|
+
return false;
|
|
2866
|
+
}
|
|
2867
|
+
const otherRoot = realPathOf(other.root);
|
|
2868
|
+
return otherRoot !== root && isUnder(root, otherRoot);
|
|
2869
|
+
}).length;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2814
2872
|
/** The lake `add` writes to, or the refusal reason. */
|
|
2815
2873
|
function writableLake(
|
|
2816
2874
|
lakes: readonly Lake[],
|
|
@@ -2828,14 +2886,34 @@ function writableLake(
|
|
|
2828
2886
|
}
|
|
2829
2887
|
return lake;
|
|
2830
2888
|
}
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2889
|
+
// The narrowest writable knowledge lake wins: with nested roots the inner
|
|
2890
|
+
// lake owns the file, so an outer read-only lake must not hijack the write.
|
|
2891
|
+
const repos = lakes.filter(
|
|
2892
|
+
(lake) => lake.class === "repo" && lake.writable,
|
|
2893
|
+
);
|
|
2894
|
+
const pool =
|
|
2895
|
+
repos.length > 0
|
|
2896
|
+
? repos
|
|
2897
|
+
: lakes.filter(
|
|
2898
|
+
(lake) => lake.profile === "knowledge" && lake.writable,
|
|
2899
|
+
);
|
|
2900
|
+
if (pool.length === 0) {
|
|
2901
|
+
return {
|
|
2834
2902
|
reason:
|
|
2835
2903
|
`${REFUSE} no writable knowledge lake is configured; ` +
|
|
2836
2904
|
"add a repo, machine, or global lake.",
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
let target = pool[0] as Lake;
|
|
2908
|
+
let depth = nestingDepth(target, lakes);
|
|
2909
|
+
for (const candidate of pool.slice(1)) {
|
|
2910
|
+
const candidateDepth = nestingDepth(candidate, lakes);
|
|
2911
|
+
if (candidateDepth > depth) {
|
|
2912
|
+
target = candidate;
|
|
2913
|
+
depth = candidateDepth;
|
|
2837
2914
|
}
|
|
2838
|
-
|
|
2915
|
+
}
|
|
2916
|
+
return target;
|
|
2839
2917
|
}
|
|
2840
2918
|
|
|
2841
2919
|
/** Qualify a path with its lake when more than one lake is configured. */
|
|
@@ -2879,9 +2957,8 @@ function searchDocs(
|
|
|
2879
2957
|
const missing = runtime.lakes
|
|
2880
2958
|
.filter(
|
|
2881
2959
|
(lake) =>
|
|
2882
|
-
lake.root !== "" &&
|
|
2883
2960
|
(lakeName === undefined || lakeName === lake.name) &&
|
|
2884
|
-
|
|
2961
|
+
lakeMissing(lake),
|
|
2885
2962
|
)
|
|
2886
2963
|
.map((lake) => missingRootNote(lake.root, lake.name));
|
|
2887
2964
|
return {
|
|
@@ -2914,7 +2991,7 @@ function checkLakes(
|
|
|
2914
2991
|
}
|
|
2915
2992
|
const entries = lake.collect();
|
|
2916
2993
|
return {
|
|
2917
|
-
content: checkDocs(entries, lake.read, lake
|
|
2994
|
+
content: checkDocs(entries, lake.read, lakeNotes(lake), {
|
|
2918
2995
|
maxDocLines: runtime.maxDocLines,
|
|
2919
2996
|
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2920
2997
|
}),
|
|
@@ -2928,7 +3005,7 @@ function checkLakes(
|
|
|
2928
3005
|
const reports = knowledge.map((lake) => {
|
|
2929
3006
|
const entries = lake.collect();
|
|
2930
3007
|
checked += entries.length;
|
|
2931
|
-
const report = checkDocs(entries, lake.read, lake
|
|
3008
|
+
const report = checkDocs(entries, lake.read, lakeNotes(lake), {
|
|
2932
3009
|
maxDocLines: runtime.maxDocLines,
|
|
2933
3010
|
...(lake.linkCheck ? { exists: lake.exists } : {}),
|
|
2934
3011
|
});
|
|
@@ -3056,14 +3133,26 @@ function uniqueHolder(
|
|
|
3056
3133
|
if (holders.length === 0) {
|
|
3057
3134
|
return { reason: `No doc at ${path}.` };
|
|
3058
3135
|
}
|
|
3059
|
-
if (holders.length
|
|
3060
|
-
return
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3136
|
+
if (holders.length === 1) {
|
|
3137
|
+
return holders[0] as Lake;
|
|
3138
|
+
}
|
|
3139
|
+
// Nested lakes both hold an inner path: the narrowest enclosing lake owns
|
|
3140
|
+
// it. Non-nested holders tie on depth, which stays a documented refusal —
|
|
3141
|
+
// never a silent pick by collection order.
|
|
3142
|
+
const deepest = holders.map((lake) => ({
|
|
3143
|
+
lake,
|
|
3144
|
+
depth: nestingDepth(lake, holders),
|
|
3145
|
+
}));
|
|
3146
|
+
const max = Math.max(...deepest.map((item) => item.depth));
|
|
3147
|
+
const winners = deepest.filter((item) => item.depth === max);
|
|
3148
|
+
if (winners.length === 1) {
|
|
3149
|
+
return winners[0]?.lake as Lake;
|
|
3065
3150
|
}
|
|
3066
|
-
return
|
|
3151
|
+
return {
|
|
3152
|
+
reason:
|
|
3153
|
+
`${REFUSE} ${path} exists in multiple lakes ` +
|
|
3154
|
+
`(${holders.map((lake) => lake.name).join(", ")}); pass lake.`,
|
|
3155
|
+
};
|
|
3067
3156
|
}
|
|
3068
3157
|
|
|
3069
3158
|
/** Read one doc, resolving a `lake:path` prefix or the unique match. */
|
|
@@ -3114,7 +3203,7 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
|
|
|
3114
3203
|
root: lake.root,
|
|
3115
3204
|
writable: lake.writable,
|
|
3116
3205
|
validated: lake.profile === "knowledge",
|
|
3117
|
-
missing:
|
|
3206
|
+
missing: lakeMissing(lake),
|
|
3118
3207
|
}));
|
|
3119
3208
|
}
|
|
3120
3209
|
|