@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 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.6.2",
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
- /** Environment notes for `check` (missing root, unversioned root). */
2231
- notes: readonly string[];
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
- return specs.map((spec) =>
2355
- createLake(spec, {
2356
- skip: specs
2357
- .filter((other) => other !== spec)
2358
- .map((other) => other.root),
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
- return (
2832
- lakes.find((lake) => lake.class === "repo" && lake.writable) ??
2833
- lakes.find((lake) => lake.profile === "knowledge" && lake.writable) ?? {
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
- !existsSync(lake.root),
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.notes, {
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.notes, {
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 > 1) {
3060
- return {
3061
- reason:
3062
- `${REFUSE} ${path} exists in multiple lakes ` +
3063
- `(${holders.map((lake) => lake.name).join(", ")}); pass lake.`,
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 holders[0] as Lake;
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: lake.root !== "" && !existsSync(lake.root),
3206
+ missing: lakeMissing(lake),
3118
3207
  }));
3119
3208
  }
3120
3209