@markstanden/opencode-docs-plugin 0.8.0 → 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
@@ -181,10 +181,17 @@ writable repo lake may sit inside a larger read-only wiki lake, and search,
181
181
  only decides its own profile and defaults; `repo`, `machine`, `global` and
182
182
  `wiki` all skip a nested root the same way.
183
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.
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.
188
195
 
189
196
  ```jsonc
190
197
  {
@@ -220,6 +227,10 @@ it `— missing`, and `add` refuses with the path rather than bootstrapping a
220
227
  tree. Create the directory deliberately. Area folders beneath an existing root
221
228
  are still created on write.
222
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
+
223
234
  `maxDocLines` (default 200, `0` disables) is a **soft** note on a long doc, not a
224
235
  refusal. `perLakeQuota` (default 3, `0` disables) is the per-source result
225
236
  reservation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.8.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": {
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. */
@@ -2819,6 +2831,44 @@ function findLake(lakes: readonly Lake[], name: string): Lake | undefined {
2819
2831
  return lakes.find((lake) => lake.name === name);
2820
2832
  }
2821
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
+
2822
2872
  /** The lake `add` writes to, or the refusal reason. */
2823
2873
  function writableLake(
2824
2874
  lakes: readonly Lake[],
@@ -2836,14 +2886,34 @@ function writableLake(
2836
2886
  }
2837
2887
  return lake;
2838
2888
  }
2839
- return (
2840
- lakes.find((lake) => lake.class === "repo" && lake.writable) ??
2841
- 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 {
2842
2902
  reason:
2843
2903
  `${REFUSE} no writable knowledge lake is configured; ` +
2844
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;
2845
2914
  }
2846
- );
2915
+ }
2916
+ return target;
2847
2917
  }
2848
2918
 
2849
2919
  /** Qualify a path with its lake when more than one lake is configured. */
@@ -2887,9 +2957,8 @@ function searchDocs(
2887
2957
  const missing = runtime.lakes
2888
2958
  .filter(
2889
2959
  (lake) =>
2890
- lake.root !== "" &&
2891
2960
  (lakeName === undefined || lakeName === lake.name) &&
2892
- !existsSync(lake.root),
2961
+ lakeMissing(lake),
2893
2962
  )
2894
2963
  .map((lake) => missingRootNote(lake.root, lake.name));
2895
2964
  return {
@@ -2922,7 +2991,7 @@ function checkLakes(
2922
2991
  }
2923
2992
  const entries = lake.collect();
2924
2993
  return {
2925
- content: checkDocs(entries, lake.read, lake.notes, {
2994
+ content: checkDocs(entries, lake.read, lakeNotes(lake), {
2926
2995
  maxDocLines: runtime.maxDocLines,
2927
2996
  ...(lake.linkCheck ? { exists: lake.exists } : {}),
2928
2997
  }),
@@ -2936,7 +3005,7 @@ function checkLakes(
2936
3005
  const reports = knowledge.map((lake) => {
2937
3006
  const entries = lake.collect();
2938
3007
  checked += entries.length;
2939
- const report = checkDocs(entries, lake.read, lake.notes, {
3008
+ const report = checkDocs(entries, lake.read, lakeNotes(lake), {
2940
3009
  maxDocLines: runtime.maxDocLines,
2941
3010
  ...(lake.linkCheck ? { exists: lake.exists } : {}),
2942
3011
  });
@@ -3064,14 +3133,26 @@ function uniqueHolder(
3064
3133
  if (holders.length === 0) {
3065
3134
  return { reason: `No doc at ${path}.` };
3066
3135
  }
3067
- if (holders.length > 1) {
3068
- return {
3069
- reason:
3070
- `${REFUSE} ${path} exists in multiple lakes ` +
3071
- `(${holders.map((lake) => lake.name).join(", ")}); pass lake.`,
3072
- };
3136
+ if (holders.length === 1) {
3137
+ return holders[0] as Lake;
3073
3138
  }
3074
- return holders[0] as Lake;
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;
3150
+ }
3151
+ return {
3152
+ reason:
3153
+ `${REFUSE} ${path} exists in multiple lakes ` +
3154
+ `(${holders.map((lake) => lake.name).join(", ")}); pass lake.`,
3155
+ };
3075
3156
  }
3076
3157
 
3077
3158
  /** Read one doc, resolving a `lake:path` prefix or the unique match. */
@@ -3122,7 +3203,7 @@ function lakeInfo(lakes: readonly Lake[]): LakeInfo[] {
3122
3203
  root: lake.root,
3123
3204
  writable: lake.writable,
3124
3205
  validated: lake.profile === "knowledge",
3125
- missing: lake.root !== "" && !existsSync(lake.root),
3206
+ missing: lakeMissing(lake),
3126
3207
  }));
3127
3208
  }
3128
3209