@williamthorsen/kb 0.7.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 CHANGED
@@ -1,3 +1,5 @@
1
+ <!-- readme-type: library -->
2
+
1
3
  # @williamthorsen/kb
2
4
 
3
5
  Foundation library for knowledge-base tooling.
@@ -5,32 +7,29 @@ Provides knowledge-base discovery, registry loading, frontmatter parsing and wri
5
7
  It underpins the knowledge-base skills — among them `kb-retrieve` (assertion recall) and `kb-retrieve-events` (event recall), `kb-add`, `kb-curate`, `capture-event`, and `kb-update-events` — and the planned `@williamthorsen/kb-mcp` server.
6
8
 
7
9
  <!-- section:release-notes -->
8
- ## Release notes — v0.7.0 (2026-09-08)
10
+ ## Release notes — v0.8.0 (2026-09-13)
9
11
 
10
12
  ### 🎉 Features
11
13
 
12
- - 🚨 **Breaking:** Add kb scaffold to back-fill canonical files into an existing store (#1584)
13
-
14
- - Adds the `kb scaffold` command, which writes the canonical files (`.kb/config.yaml` and `.kb/tag-aliases.yaml`) into a knowledge base. Files are created if missing, or replaced if `--force` is used.
15
- - Narrows what `kb create` reports to the paths that it actually created.
14
+ - Resolve wikilinks from a private store into a shared one (#1617)
16
15
 
17
- Migration: import `renderAliasesSeed` and `renderConfigSeed` from `@williamthorsen/kb/scaffold` instead of `@williamthorsen/kb/create`.
16
+ - Adds the store-qualified wikilink `[[store:Note title]]`, whose prefix names an entry in the merged `kb.yaml` registry, so that `kb check` resolves the link against that store rather than reporting `wikilinks.unresolved`.
17
+ - Adds `visibility` to `.kb/config.yaml`, taking `shared` or `private` and defaulting to `private`, which sets the permitted direction: a link may point at a store as shareable as its own or more so.
18
+ - Adds three findings for a qualified link that does not resolve, `wikilinks.unknown-store` and `wikilinks.disallowed-store` as errors and `wikilinks.store-unavailable` as a warning, so that a correct link into a store that this machine has not cloned is reported as unverifiable rather than broken.
18
19
 
19
- - Add canonical formatting configs so a store formats without a toolchain (#1586)
20
+ - Add a second-person rule to the writing preferences (#1634)
20
21
 
21
- - Adds `.editorconfig` and `.prettierrc.yaml` to the canonical file set, so that `kb create` writes them into a new store and `kb scaffold` back-fills them into an existing one.
22
- - Puts width, indentation, and line endings in `.editorconfig`, which editors read as well as Prettier.
23
- - Sets `embeddedLanguageFormatting: off` in `.prettierrc.yaml`, which keeps Prettier from reflowing note frontmatter that the kb's note writer would flatten again.
22
+ - Bans the use of the 2nd person in user- and developer-facing documentation prose.
23
+ - Adds `second-person` to the rule names that `revise-prose` and `prose-reviser` accept, alongside `plain-speech`, so a sweep repairs a site or records a rejection under it though no detector reports its sites.
24
+ - Moves the rulebook's `version` to `3`, so that a bound repository re-sweeps and re-adjudicates the rejections recorded under the old version.
24
25
 
25
- ### 🐛 Bug fixes
26
+ ### 🪦 Removed
26
27
 
27
- - Stop checking gitignored notes (#1587)
28
+ - 🚨 **Breaking:** Remove the wikilink-parse primitives from the vault-integrity subpath (#1680)
28
29
 
29
- - Stops `kb check` from checking notes matching patterns in `.gitignore`. It now checks only those files that are tracked by git or new and not gitignored.
30
- - Makes a link pointing at a gitignored note report `wikilinks.unresolved`, since such a link is broken for every clone but the author's.
31
- - Fixes `kb check <path>` and `kb check --vs <ref>` for a note with an accented name, which on macOS matched nothing and could select unrelated files in its place.
30
+ - Removes `countNewlines`, `extractTarget`, `hasNonMarkdownExtension`, `maskFencedCode`, `maskInlineCode`, and `WIKILINK` from `@williamthorsen/kb/vault-integrity`.
32
31
 
33
- Migration: Track a gitignored note that other notes link to, or remove the wikilinks pointing at it.
32
+ Migration: Call `scanWikilinks` to find the wikilinks in a note body and their targets, in place of a scan assembled from the removed primitives.
34
33
  <!-- /section:release-notes -->
35
34
 
36
35
  ## Exports
@@ -45,17 +44,14 @@ The package exposes thirteen subpath entries plus a root barrel:
45
44
  | `./create` | `create`: scaffold a new store and register it in `kb.yaml` |
46
45
  | `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing |
47
46
  | `./filesystem` | Filesystem-existence helpers with an explicit absence policy |
48
- | `./frontmatter` | Note parsing into typed frontmatter and writing it back to YAML |
47
+ | `./frontmatter` | Note parsing into typed frontmatter |
49
48
  | `./layout` | The store's on-disk layout: every path inside a `.kb/` store derives from here |
50
49
  | `./note-io` | Type-blind note read/write as an ordered frontmatter field map |
51
50
  | `./records` | The typed `assertion`/`event` record parsers and renderers |
52
51
  | `./scaffold` | The canonical set held by a store, and the idempotent writer over it |
53
52
  | `./tags` | `.kb/tag-aliases.yaml` loading and tag canonicalization |
54
53
  | `./taxonomy` | `.kb/taxonomy.yaml` loading, comment-preserving declaration, and path mapping |
55
- | `./vault-integrity` | Type-blind `[[link]]` resolution and basename-uniqueness over a note set |
56
-
57
- Every public function takes a single plain-object input so a future MCP wrapper can mechanically bind Zod-validated payloads.
58
- The library throws on errors; success/failure shaping is left to consumers.
54
+ | `./vault-integrity` | Type-blind `[[link]]` scanning, resolution, and basename-uniqueness checks |
59
55
 
60
56
  ## Knowledge-base discovery
61
57
 
@@ -97,6 +93,8 @@ Configuration keys, per KB entry under `kbs.<name>`:
97
93
  | `description` | no | Human-readable description |
98
94
  | `readonly` | no | Marks the KB as read-only |
99
95
 
96
+ An entry's name is also what a [store-qualified wikilink](#linking-into-another-store) names: `[[coding:Note title]]` resolves against the KB registered as `coding`.
97
+
100
98
  ### Merge semantics
101
99
 
102
100
  `loadKbRegistry` merges the two registries by KB name:
@@ -126,23 +124,24 @@ The relation is many-to-many (one response can address many problems, and one pr
126
124
 
127
125
  `parseNote({ path })` (or `parseNoteContent({ content })`) parses a note into a `ParsedNote` carrying typed `Frontmatter`:
128
126
  The `title`, `recordType`, `created`, `updated`, and `tags` fields are strongly typed and any other fields are preserved in an `extra` map.
129
- `writeFrontmatter({ frontmatter, body })` renders it back to a note string with a fixed field order and flow-style tags; the round trip is idempotent.
130
127
 
131
128
  Date fields surface as strings, never JS `Date` objects. YAML parse errors are recorded in `ParsedNote.frontmatterRaw.parseError` rather than thrown;
132
129
  missing files (when a path is given) throw.
133
130
 
131
+ Writing is type-blind and lives in `@williamthorsen/kb/note-io`. `readNote(path)` (or `readNoteContent(content)`) splits a note into an insertion-ordered frontmatter field map and a body, and `writeNote(path, fields, body)` (or `renderNote(fields, body)`) renders them back, with string lists in flow style. A note written this way reads back to the same fields and body.
132
+
134
133
  ## Tags
135
134
 
136
135
  `loadAliases({ kbRoot })` reads `.kb/tag-aliases.yaml` into an `AliasMap`, rejecting collisions and self-aliases at load time; an absent file yields an empty map.
137
136
  `canonicalize(tag, aliases)` resolves a tag to its canonical form; `findAliasFor(tag, aliases)` returns the canonical form only when the input is a known alias.
138
137
 
139
- ## Vault integrity and lints
138
+ ## Vault integrity
140
139
 
141
140
  `checkVaultIntegrity(notes)` runs whole-vault, type-blind checks over a `{ path, body, bodyStartLine }[]` note set: an unresolved `[[link]]` is an error (`wikilinks.unresolved`), and a basename shared by two or more notes is one vault-wide warning (`wikilinks.basename`). `buildVaultIndex(notes)` builds the basename → paths index the layer and curate's wikilink rewriter share.
142
141
 
143
- The type-blind per-note lints — `tagAliasFindings(note, aliases)` (`tag-alias`, warning) and `pathsFindings(note)` (`paths.user-home`, error) catch what write-time record validation can't: alias-vocabulary drift and hardcoded `/Users/{name}/` paths in captured content.
142
+ A second argument, `{ foreignStores, sourceVisibility }`, resolves [store-qualified links](#linking-into-another-store) against the stores they name; supplied none, the layer treats every target as store-local, which is what `buildVaultIndex`'s other consumers get.
144
143
 
145
- `taxonomyFindings({ notes, taxonomy, config, taxonomyPath })` reports where a store's assertion folders and its declared taxonomy disagree (see [`.kb/taxonomy.yaml`](#the-declared-structure-kbtaxonomyyaml)). Its findings carry `scope: 'vault'`: they describe the store rather than any one note, so a consumer that narrows a report to selected notes must keep them rather than filter them out by path.
144
+ `scanWikilinks(body)` yields each link in a note body, with its store qualifier and target separated, and is the single definition of what counts as a link. `lookupKey(target)` reduces a target to the key that `VaultIndex` uses. The subpath exports `checkVaultIntegrity`, `buildVaultIndex`, `scanWikilinks`, and `lookupKey`, and not the parse primitives from which they are built: A consumer that detects or rewrites links calls `scanWikilinks`.
146
145
 
147
146
  ```ts
148
147
  import { checkVaultIntegrity } from '@williamthorsen/kb/vault-integrity';
@@ -152,7 +151,7 @@ const findings = checkVaultIntegrity(notes);
152
151
 
153
152
  ## Checking a store
154
153
 
155
- `check({ kbRoot })` runs a store's full check in one call: it loads `.kb/config.yaml`, `.kb/tag-aliases.yaml`, and `.kb/taxonomy.yaml`, enumerates the notes that the config selects (narrowed by [the git dimension](#the-git-dimension) inside a working tree), and composes whole-vault integrity and taxonomy drift with the `tag-alias` and `paths` lints. It performs no frontmatter validation — record types own that at write time. It returns **both** the enumerated notes and the findings, so a consumer can layer its own detectors over the same enumeration without walking the store twice.
154
+ `check({ kbRoot })` runs a store's full check in one call: it loads `.kb/config.yaml`, `.kb/tag-aliases.yaml`, and `.kb/taxonomy.yaml`, enumerates the notes that the config selects (inside a git working tree, the enumeration drops [ignored notes](#ignored-notes)), and composes whole-vault integrity and taxonomy drift with the `tag-alias` and `paths` lints. It performs no frontmatter validation — record types own that at write time. It returns **both** the enumerated notes and the findings, so a consumer can layer its own detectors over the same enumeration without walking the store twice.
156
155
 
157
156
  ```ts
158
157
  import { check } from '@williamthorsen/kb/check';
@@ -160,13 +159,15 @@ import { check } from '@williamthorsen/kb/check';
160
159
  const { notes, findings } = await check({ kbRoot });
161
160
  ```
162
161
 
162
+ No subpath exports the lints, so `check`, and the `kb check` command built on it, is the only way to run them. Two type-blind per-note lints catch what write-time record validation can't: `tag-alias` (warning) reports alias-vocabulary drift, and `paths.user-home` (error) reports a hardcoded `/Users/{name}/` path in captured content. The `taxonomy.*` rules, all warnings, report where a store's assertion folders and its declared taxonomy disagree (see [`.kb/taxonomy.yaml`](#the-declared-structure-kbtaxonomyyaml)). Their findings carry `scope: 'vault'`: they describe the store rather than any one note, so a consumer that narrows a report to selected notes must keep them rather than filter them out by path.
163
+
163
164
  A structural defect in any loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
164
165
 
165
166
  `enumerateNotes({ kbRoot, config })` performs the enumeration on its own, and `enumerateNotePaths({ kbRoot, config })` returns the same note set as store-root-relative paths without opening a single note. Both are exported from `@williamthorsen/kb/check`; the paths-only variant serves a caller that needs the note set's shape rather than its content.
166
167
 
167
168
  ### Which notes are checked: `.kb/config.yaml`
168
169
 
169
- `.kb/config.yaml` configures which notes a check enumerates. Both keys are optional; an absent file or an omitted key falls back to the default.
170
+ `.kb/config.yaml` configures which notes a check enumerates and how widely the store is published. Every key is optional; an absent file or an omitted key falls back to the default.
170
171
 
171
172
  ```yaml
172
173
  # .kb/config.yaml
@@ -174,20 +175,44 @@ targets:
174
175
  - 'content/**/*.md'
175
176
  exclude:
176
177
  - '**/node_modules/**'
178
+ visibility: private
177
179
  ```
178
180
 
179
- | Key | Default | Meaning |
180
- | --------- | ------------------------ | ---------------------------------------------------------------------------- |
181
- | `targets` | `['content/**/*.md']` | Glob patterns (store-root-relative) selecting which notes a check enumerates |
182
- | `exclude` | `['**/node_modules/**']` | Glob patterns excluded from enumeration even when a target matches |
181
+ | Key | Default | Meaning |
182
+ | ------------ | ------------------------ | ---------------------------------------------------------------------------- |
183
+ | `targets` | `['content/**/*.md']` | Glob patterns (store-root-relative) selecting which notes a check enumerates |
184
+ | `exclude` | `['**/node_modules/**']` | Glob patterns excluded from enumeration even when a target matches |
185
+ | `visibility` | `private` | `shared` or `private`; decides which stores may link into this one |
186
+
187
+ `visibility` belongs here rather than in the registry entry because it is intrinsic to the store: one store cloned on two machines has one visibility, where a per-machine declaration could let the two disagree silently. It governs [linking into another store](#linking-into-another-store).
183
188
 
184
189
  Matching uses dotfile-insensitive globbing, so dot-directories (`.kb`, `.git`, `.agents`) are skipped without naming them. The default targets the `content/`-scoped layout; a store with a different layout overrides `targets` to match. `loadKbConfig({ kbRoot })` returns the effective config and is exported from `@williamthorsen/kb/config`.
185
190
 
186
- #### The git dimension
191
+ #### Ignored notes
192
+
193
+ Where the store sits in a git working tree, the repository's ignore rules narrow the selection further. A note is enumerated when git tracks it, or when no ignore rule covers it. A note that the repository ignores is therefore neither checked nor available as a wikilink target, so a link pointing at one reports `wikilinks.unresolved`. That is the correct reading: such a link is broken for every clone but the author's. This is what lets a store gitignore a scratch area (`local/`, `*.local.md`) and keep uncommitted notes there without the store's lints gating them.
187
194
 
188
- Where the store sits in a git working tree, `targets`/`exclude` is not the whole of note scope: what git accounts for narrows it further. A note is enumerated when git tracks it, or when git would track it, meaning no ignore rule covers it. A note that the repository ignores is therefore neither checked nor available as a wikilink target, so a link pointing at one reports `wikilinks.unresolved`. That is the correct reading: such a link is broken for every clone but the author's. This is what lets a store gitignore a scratch area (`local/`, `*.local.md`) and keep uncommitted notes there without the store's lints gating them.
195
+ A store outside a git working tree, or a machine carrying no git, keeps the filesystem walk alone. Where the repository ignores every note that the walk found, which happens when a parent repository ignores the store's own directory, the run says so on stderr rather than reporting a clean bill over an empty note set.
189
196
 
190
- A store outside a git working tree, or a machine carrying no git, keeps the filesystem walk alone. Where git accounts for none of the notes that the walk found, which happens when a parent repository ignores the store's own directory, the run says so on stderr rather than reporting a clean bill over an empty note set.
197
+ ### Linking into another store
198
+
199
+ A wikilink names a store by qualifying its target: `[[fde:Note title]]` resolves `Note title` in the store registered as `fde`, where a bare `[[Note title]]` stays inside the store being checked. A qualifier is recognized only when the text before the first colon is non-empty and carries no whitespace and no `/`, so a title that happens to contain a colon resolves whole.
200
+
201
+ Direction is decided by `visibility`: a link may point at a store as shareable as its own or more so, never at a less shareable one. A private note may therefore link to the share-safe assertion its detail was stripped from, while the reverse is refused, because a note's title tends to be its claim and a link into a private store discloses that claim through the link itself.
202
+
203
+ Only the stores that a run's own links name are consulted, and each is enumerated under its own `targets`/`exclude` and its own repository's ignore rules, reading note paths alone. A run whose links qualify no store reads no registry.
204
+
205
+ | Rule | Severity | Reported when |
206
+ | ------------------------------- | -------- | ----------------------------------------------------------------------------------- |
207
+ | `wikilinks.disallowed-store` | error | The named store is less shareable than the one being checked |
208
+ | `wikilinks.registry-unloadable` | error | A run with qualified links could not load `kb.yaml`; vault-scoped, reported once |
209
+ | `wikilinks.store-unavailable` | warning | The named store is registered but could not be read here, so the link is unverified |
210
+ | `wikilinks.unknown-store` | error | No `kb.yaml` entry declares the named store |
211
+ | `wikilinks.unresolved` | error | The named store carries no note of that basename |
212
+
213
+ `wikilinks.store-unavailable` is a warning rather than an error because a store absent from this machine leaves its links unverifiable rather than broken: a correct link should not fail a check run on a machine that has not cloned the target.
214
+
215
+ A run that reports `wikilinks.registry-unloadable` reports nothing per link. No store was looked up, so whether one is registered is undetermined, and naming each link would send the reader to fix a registration that may already be correct.
191
216
 
192
217
  ### The declared structure: `.kb/taxonomy.yaml`
193
218
 
@@ -248,7 +273,7 @@ The check config is serialized from the in-package `defaultKbConfig` and the Pre
248
273
 
249
274
  The name defaults to the directory's base name; `--name` overrides it and `--no-register` scaffolds without writing the registry. `--description` sets the new entry's description, and requires registration: combining it with `--no-register` is a usage error. The registry write preserves any existing comments in `kb.yaml` and leaves the `kbs:` entries alphabetically ordered, so a registry that has drifted out of order is tidied as stores are added. `kb create` refuses to clobber: it exits 2 if the directory already contains a `.kb/` store, or if the chosen name is already registered. Use `kb scaffold` to add canonical files to a store that already exists.
250
275
 
251
- `kb create` also keeps a default knowledge base set. When the registry's top-level `default_kb` pointer is unset and the new store is the only registered KB, it becomes the default. When other KBs are already registered with no default, `kb create` prompts you to choose one on an interactive terminal — or, when stdin is not interactive, points you to `kb set-default`. An existing `default_kb` is never overwritten.
276
+ `kb create` also keeps a default knowledge base set. When the registry's top-level `default_kb` pointer is unset and the new store is the only registered KB, it becomes the default. When other KBs are already registered with no default, `kb create` prompts for one on an interactive terminal, and points to `kb set-default` where stdin is not interactive. An existing `default_kb` is never overwritten.
252
277
 
253
278
  ### kb scaffold
254
279
 
@@ -293,7 +318,7 @@ kb check --vs=main # check only the notes changed since a ref
293
318
 
294
319
  `kb check` resolves the store from the nearest ancestor `.kb/` directory, or from a `--kb <name>` entry in the merged `kb.yaml` registry (project-local entries join the user-global registry). The default output groups findings by file; `--json` emits `{ store, summary, findings }`. The command is read-only and never writes to the store.
295
320
 
296
- **Targeting.** Path arguments and `--vs` each scope the run to a subset of notes; they are mutually exclusive, and both compose with `--kb` and `--json`. Cross-note rules always resolve against the whole vault, so a targeted run never false-flags a link to an unselected note; only the report and the exit code narrow to the selection.
321
+ **Targeting.** Path arguments and `--vs` each scope the run to a subset of notes; they are mutually exclusive, and both compose with `--kb` and `--json`. Cross-note rules always resolve against the whole store, and a store-qualified link against the whole store it names, so a targeted run never false-flags a link to an unselected note; only the report and the exit code narrow to the selection.
297
322
 
298
323
  - **`[paths...]`**: one or more glob patterns, files, or directories (store-root-relative). The command expands globs itself, so a quoted glob behaves the same as a shell-expanded one. A directory checks every note beneath it. A path that matches no note is a usage error, unless it names a real non-note (a README, a triage note, or a file that `exclude` or git rules out), which is skipped silently.
299
324
  - **`--vs <ref>`**: the notes changed between the working tree and the merge-base of `<ref>` and HEAD. The diff follows renames (checking the destination), includes uncommitted edits to tracked notes, and excludes deletions, so a `git mv`-heavy migration batch reports the notes it actually touched.
@@ -365,7 +390,7 @@ The `[*.md]` exemption is the one entry that is not self-explanatory. Two traili
365
390
  embeddedLanguageFormatting: off
366
391
  ```
367
392
 
368
- It is not stylistic, and the scaffolded file carries this reasoning in its own header so it travels with the store. `embeddedLanguageFormatting: off` leaves a note's YAML frontmatter unformatted. Formatted, a long `tags` or `addressed-by` list breaks across several lines, which `writeFrontmatter` puts back onto one the next time anything writes the note. Without this option the formatter and the note writer rewrite each other's output without end, and every note carrying a list past the print width churns on each pass.
393
+ It is not stylistic, and the scaffolded file carries this reasoning in its own header so it travels with the store. `embeddedLanguageFormatting: off` leaves a note's YAML frontmatter unformatted. Formatted, a long `tags` or `addressed-by` list breaks across several lines, which `renderNote` puts back onto one the next time anything writes the note. Without this option the formatter and the note writer rewrite each other's output without end, and every note carrying a list past the print width churns on each pass.
369
394
 
370
395
  A store that must keep a file that it cannot format, such as a lockfile or a fixture whose defect is the point, names it in a `.prettierignore`. That file is the escape hatch and is not scaffolded, since a fresh store has nothing to put in it.
371
396
 
@@ -388,7 +413,3 @@ A store with no `package.json` runs steps 1, 2, and 7, and skips 3 through 6: it
388
413
  ## Error and exception model
389
414
 
390
415
  The checks **return** findings; they never throw. Loaders (`loadKbConfig`, `loadAliases`, `loadTaxonomy`) **throw** a typed `KbLoaderError` on structural defects or malformed YAML, with the offending file path named in the message. `KbLoaderError` (exported from `@williamthorsen/kb/config`) carries a `kind: 'KbLoaderError'` discriminant — and an `isKbLoaderError` type guard — so a caller can distinguish a recoverable config or alias defect from any other throw. An underlying failure, such as a YAML parse error, is attached as the thrown error's `cause`. `loadKbRegistry` throws a plain `Error` on its own structural defects. I/O errors other than a missing optional file propagate.
391
-
392
- ## MCP wrappability
393
-
394
- Every public function input is a plain object with primitive or `unknown`-typed fields, and no function takes a callback. A future `kb-mcp` server can bind Zod-validated request payloads directly onto these inputs without refactoring.
@@ -5,7 +5,10 @@ import { type EnumeratedNote } from './enumerate.js';
5
5
  export interface CheckResult {
6
6
  /** The effective `KbConfig` the run used — loaded from `.kb/config.yaml`, or `defaultKbConfig` when absent. */
7
7
  config: KbConfig;
8
- /** Every note that the store's `config.targets` and, inside a git working tree, its git scope admit, in walk order. */
8
+ /**
9
+ * Every note selected by the store's `config.targets` and, inside a git working tree, not ignored by the repository,
10
+ * in walk order.
11
+ */
9
12
  notes: readonly EnumeratedNote[];
10
13
  /**
11
14
  * Findings from whole-vault integrity (unresolved links, basename collisions), taxonomy drift, and the tag-alias and
@@ -19,9 +22,14 @@ export interface CheckResult {
19
22
  * the type-blind per-note lints across them. Frontmatter validity is owned by the record types at write time, so no
20
23
  * frontmatter re-validation runs here.
21
24
  *
22
- * Inside a git working tree the enumeration narrows to what git accounts for, so a note the repository ignores is
25
+ * Inside a git working tree the enumeration also drops the notes that the repository ignores, so such a note is
23
26
  * neither checked nor available as a wikilink target; see {@link enumerateNotes} for the rule.
24
27
  *
28
+ * A `[[store:Target]]` link resolves against the store its prefix names rather than this one. Those stores are looked
29
+ * up in the merged `kb.yaml` registry, which is read from `~/.agents/kb.yaml` and from `cwd`'s project-local registry;
30
+ * `cwd` defaults to the store root, so a caller that supplies none still resolves against the user-global registry.
31
+ * Only the stores this store's own links name are consulted.
32
+ *
25
33
  * Returns the effective config alongside the enumerated notes and findings, so a consumer (e.g. `kb-curate`) can layer
26
34
  * its own detectors over the same enumeration without walking the tree twice, and can read the resolved
27
35
  * `targets`/`exclude` without re-loading `.kb/config.yaml`.
@@ -32,4 +40,6 @@ export interface CheckResult {
32
40
  */
33
41
  export declare function check(input: {
34
42
  kbRoot: string;
43
+ cwd?: string;
44
+ home?: string;
35
45
  }): Promise<CheckResult>;
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { loadKbConfig } from "../config/load-config.js";
3
+ import { tryLoadKbRegistry } from "../discovery/load-registry.js";
3
4
  import { resolveKbDir, TAXONOMY_FILE } from "../layout/index.js";
4
5
  import { pathsFindings } from "../lints/paths.js";
5
6
  import { tagAliasFindings } from "../lints/tag-alias.js";
@@ -8,6 +9,7 @@ import { loadAliases } from "../tags/load-aliases.js";
8
9
  import { loadTaxonomy } from "../taxonomy/load-taxonomy.js";
9
10
  import { checkVaultIntegrity } from "../vault-integrity/check-vault-integrity.js";
10
11
  import { enumerateNotes } from "./enumerate.js";
12
+ import { collectStorePrefixes, resolveForeignStores } from "./resolve-foreign-stores.js";
11
13
  export async function check(input) {
12
14
  const kbRoot = { path: input.kbRoot, kbDir: resolveKbDir(input.kbRoot) };
13
15
  const [config, aliases, taxonomy] = await Promise.all([
@@ -16,10 +18,48 @@ export async function check(input) {
16
18
  loadTaxonomy({ kbRoot }),
17
19
  ]);
18
20
  const notes = await enumerateNotes({ kbRoot: input.kbRoot, config });
21
+ const qualified = await resolveQualifiedStores({
22
+ notes,
23
+ projectDir: input.cwd ?? input.kbRoot,
24
+ sourceVisibility: config.visibility,
25
+ ...(input.home !== undefined && { home: input.home }),
26
+ });
19
27
  const findings = [
20
- ...checkVaultIntegrity(notes),
28
+ ...registryFindings(qualified.registryError),
29
+ ...checkVaultIntegrity(notes, { foreignStores: qualified.foreignStores, sourceVisibility: config.visibility }),
21
30
  ...taxonomyFindings({ notes, taxonomy, config, taxonomyPath: join(input.kbRoot, TAXONOMY_FILE) }),
22
31
  ...notes.flatMap((note) => [...tagAliasFindings(note, aliases), ...pathsFindings(note)]),
23
32
  ];
24
33
  return { config, notes, findings };
25
34
  }
35
+ function registryFindings(error) {
36
+ if (error === undefined)
37
+ return [];
38
+ return [
39
+ {
40
+ path: KB_REGISTRY_LABEL,
41
+ scope: 'vault',
42
+ rule: 'wikilinks.registry-unloadable',
43
+ severity: 'error',
44
+ message: `no store-qualified link can resolve: ${error}`,
45
+ },
46
+ ];
47
+ }
48
+ async function resolveQualifiedStores(input) {
49
+ const prefixes = collectStorePrefixes(input.notes);
50
+ if (prefixes.size === 0)
51
+ return { foreignStores: new Map() };
52
+ const registry = await tryLoadKbRegistry({
53
+ projectDir: input.projectDir,
54
+ ...(input.home !== undefined && { home: input.home }),
55
+ });
56
+ if (registry.error !== undefined)
57
+ return { foreignStores: new Map(), registryError: registry.error };
58
+ const foreignStores = await resolveForeignStores({
59
+ prefixes,
60
+ registry: registry.config,
61
+ sourceVisibility: input.sourceVisibility,
62
+ });
63
+ return { foreignStores };
64
+ }
65
+ const KB_REGISTRY_LABEL = 'kb.yaml';
@@ -39,10 +39,10 @@ export declare function enumerateNotePaths(input: {
39
39
  * (`content/**` descends only into `content/`); a target with no leading literal (e.g. `**\/*.md`) falls back to a
40
40
  * full walk. Excludes are honored during descent so an excluded subtree is never entered.
41
41
  *
42
- * Where the store sits in a git working tree, scope narrows further to what git accounts for: tracked notes plus
43
- * untracked ones that no ignore rule covers. A note that the repository ignores is therefore neither enumerated nor available
42
+ * If the store sits in a git working tree, the enumeration keeps only the notes that git tracks and the untracked
43
+ * notes that no ignore rule covers. A note that the repository ignores is therefore neither enumerated nor available
44
44
  * as a wikilink target, so a link pointing at one resolves to nothing. A store outside a working tree, or a machine
45
- * carrying no git, keeps the walk's own scope.
45
+ * carrying no git, keeps what the walk alone found.
46
46
  *
47
47
  * Notes with malformed or absent frontmatter are kept — `readNoteContent` records the parse error in `error` and
48
48
  * returns an empty field map rather than throwing, so they remain valid wikilink targets. A note that cannot be read,
@@ -0,0 +1,24 @@
1
+ import { type StoreVisibility } from '../config/config-schema.js';
2
+ import type { KbRegistry } from '../types.js';
3
+ import type { ForeignStore } from '../vault-integrity/check-vault-integrity.js';
4
+ /**
5
+ * Collects the distinct store names that a note set's wikilinks qualify, so a check run consults only the stores its
6
+ * own links reach. It reads {@link scanWikilinks}, the same walk that later evaluates the links, so a store-shaped
7
+ * prefix inside a code sample pulls in no store and the two cannot disagree on what counts as a qualified link.
8
+ */
9
+ export declare function collectStorePrefixes(notes: readonly {
10
+ body: string;
11
+ }[]): Set<string>;
12
+ /**
13
+ * Resolves each store name a run's links qualify against the merged registry, reading only note paths and each store's
14
+ * own `.kb/config.yaml`: a foreign store is enumerated under its own `targets`/`exclude` and its own repository's
15
+ * ignore rules, as it would be under its own check run, and no foreign note is opened.
16
+ *
17
+ * A store that cannot be read — absent from this machine, or carrying a config file that will not load — resolves
18
+ * `unavailable` rather than throwing, so one unrelated store cannot fail the run.
19
+ */
20
+ export declare function resolveForeignStores(input: {
21
+ prefixes: Iterable<string>;
22
+ registry: KbRegistry;
23
+ sourceVisibility: StoreVisibility;
24
+ }): Promise<Map<string, ForeignStore>>;
@@ -0,0 +1,50 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { isAtLeastAsShareable } from "../config/config-schema.js";
4
+ import { loadKbConfig } from "../config/load-config.js";
5
+ import { resolveKbDir } from "../layout/index.js";
6
+ import { buildVaultIndex } from "../vault-integrity/build-vault-index.js";
7
+ import { scanWikilinks } from "../vault-integrity/wikilink-parse.js";
8
+ import { enumerateNotePaths } from "./enumerate.js";
9
+ export function collectStorePrefixes(notes) {
10
+ const prefixes = new Set();
11
+ for (const note of notes) {
12
+ for (const { store } of scanWikilinks(note.body)) {
13
+ if (store !== undefined)
14
+ prefixes.add(store);
15
+ }
16
+ }
17
+ return prefixes;
18
+ }
19
+ export async function resolveForeignStores(input) {
20
+ const resolved = new Map();
21
+ for (const prefix of input.prefixes) {
22
+ resolved.set(prefix, await resolveOne(prefix, input.registry, input.sourceVisibility));
23
+ }
24
+ return resolved;
25
+ }
26
+ async function resolveOne(prefix, registry, sourceVisibility) {
27
+ const entry = registry.entries.find((candidate) => candidate.name === prefix);
28
+ if (entry === undefined)
29
+ return { status: 'unknown' };
30
+ try {
31
+ const stats = await stat(entry.path);
32
+ if (!stats.isDirectory())
33
+ return { status: 'unavailable', reason: `${entry.path} is not a directory` };
34
+ }
35
+ catch (error) {
36
+ return { status: 'unavailable', reason: `${entry.path} could not be read: ${describeError(error)}` };
37
+ }
38
+ let config;
39
+ try {
40
+ config = await loadKbConfig({ kbRoot: { path: entry.path, kbDir: resolveKbDir(entry.path) } });
41
+ }
42
+ catch (error) {
43
+ return { status: 'unavailable', reason: describeError(error) };
44
+ }
45
+ if (!isAtLeastAsShareable({ source: sourceVisibility, target: config.visibility })) {
46
+ return { status: 'disallowed', visibility: config.visibility };
47
+ }
48
+ const paths = await enumerateNotePaths({ kbRoot: entry.path, config });
49
+ return { status: 'resolved', index: buildVaultIndex(paths.map((path) => ({ path }))) };
50
+ }
@@ -6,7 +6,7 @@ export interface CommandOutput {
6
6
  stderr: string;
7
7
  }
8
8
  /** Usage text for `kb check`. */
9
- export declare const CHECK_HELP = "Usage: kb check [paths...] [options]\n\nValidate notes in a knowledge base against its tag aliases and cross-note\nlink and path rules. With no path arguments, every note is checked.\nCross-note rules always resolve against the whole vault.\n\nTargeting (mutually exclusive):\n [paths...] Check only the notes matching the given glob patterns, files,\n or directories. Quote globs so kb expands them itself. A\n directory checks every note beneath it. A path that matches no\n note is a usage error unless it names a real non-note.\n --vs <ref> Check only the notes changed between the working tree and the\n merge-base of <ref> and HEAD: follows renames, includes\n uncommitted edits, excludes deletions.\n\nOptions:\n --kb <name> Check the named store from the kb.yaml registry. Without it,\n the nearest ancestor .kb/ directory is used.\n --json Emit a JSON report instead of human-readable output.\n -h, --help Show this help.\n\nExit codes:\n 0 no error-severity findings in the checked notes (warnings allowed)\n 1 one or more error-severity findings in the checked notes\n 2 usage error, unresolvable store or --vs ref, a path matching no note, or\n malformed config or aliases\n";
9
+ export declare const CHECK_HELP = "Usage: kb check [paths...] [options]\n\nValidate notes in a knowledge base against its tag aliases and cross-note\nlink and path rules. With no path arguments, every note is checked.\nCross-note rules always resolve against the whole store; a [[store:Target]]\nlink resolves against the store its prefix names in the kb.yaml registry.\n\nTargeting (mutually exclusive):\n [paths...] Check only the notes matching the given glob patterns, files,\n or directories. Quote globs so kb expands them itself. A\n directory checks every note beneath it. A path that matches no\n note is a usage error unless it names a real non-note.\n --vs <ref> Check only the notes changed between the working tree and the\n merge-base of <ref> and HEAD: follows renames, includes\n uncommitted edits, excludes deletions.\n\nOptions:\n --kb <name> Check the named store from the kb.yaml registry. Without it,\n the nearest ancestor .kb/ directory is used.\n --json Emit a JSON report instead of human-readable output.\n -h, --help Show this help.\n\nExit codes:\n 0 no error-severity findings in the checked notes (warnings allowed)\n 1 one or more error-severity findings in the checked notes\n 2 usage error, unresolvable store or --vs ref, a path matching no note, or\n malformed config or aliases\n";
10
10
  /**
11
11
  * Runs `kb check`: parses options, resolves the store, runs the shared `check`, and formats the report.
12
12
  *
@@ -10,7 +10,8 @@ export const CHECK_HELP = `Usage: kb check [paths...] [options]
10
10
 
11
11
  Validate notes in a knowledge base against its tag aliases and cross-note
12
12
  link and path rules. With no path arguments, every note is checked.
13
- Cross-note rules always resolve against the whole vault.
13
+ Cross-note rules always resolve against the whole store; a [[store:Target]]
14
+ link resolves against the store its prefix names in the kb.yaml registry.
14
15
 
15
16
  Targeting (mutually exclusive):
16
17
  [paths...] Check only the notes matching the given glob patterns, files,
@@ -55,7 +56,11 @@ export async function runCheck(input) {
55
56
  const store = resolved.store;
56
57
  let result;
57
58
  try {
58
- result = await check({ kbRoot: store.path });
59
+ result = await check({
60
+ kbRoot: store.path,
61
+ cwd: input.cwd,
62
+ ...(input.home !== undefined && { home: input.home }),
63
+ });
59
64
  }
60
65
  catch (error) {
61
66
  if (isKbLoaderError(error)) {
@@ -1,22 +1,39 @@
1
1
  import { z } from 'zod';
2
2
  /**
3
- * The on-disk `.kb/config.yaml` shape. Both fields are optional so a file may override only the dimension it cares
4
- * about; an absent field falls back to {@link defaultKbConfig}.
3
+ * The on-disk `.kb/config.yaml` shape. Every field is optional so a file may override only the key it cares about; an
4
+ * absent field falls back to {@link defaultKbConfig}.
5
5
  */
6
6
  export declare const configFileShape: z.ZodObject<{
7
7
  targets: z.ZodOptional<z.ZodArray<z.ZodString>>;
8
8
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
9
+ visibility: z.ZodOptional<z.ZodEnum<{
10
+ private: "private";
11
+ shared: "shared";
12
+ }>>;
9
13
  }, z.core.$strip>;
10
- /** The effective check configuration: the glob target set and the glob exclude set. */
14
+ /**
15
+ * The configuration applied when `.kb/config.yaml` is absent or omits a field. Targets the `content/`-scoped layout
16
+ * the owner's stores use; with `picomatch` `dot:false`, dot-directories (`.kb`, `.git`, `.agents`) are excluded
17
+ * implicitly, so the default exclude only names `node_modules`. Visibility defaults to the safer of the two values, so
18
+ * a store that has not declared itself never widens what may link into it.
19
+ */
20
+ export declare const defaultKbConfig: KbConfig;
21
+ /**
22
+ * Whether a link from a store of `source` visibility may resolve into one of `target` visibility. A link never
23
+ * increases disclosure: it may point at a store as shareable as its own or more so, never at a less shareable one.
24
+ */
25
+ export declare function isAtLeastAsShareable(input: {
26
+ source: StoreVisibility;
27
+ target: StoreVisibility;
28
+ }): boolean;
29
+ /** The effective check configuration: the glob target set, the glob exclude set, and the store's visibility. */
11
30
  export interface KbConfig {
12
31
  /** Glob patterns (slash-separated, kbRoot-relative) selecting which notes `check` enumerates. */
13
32
  targets: readonly string[];
14
33
  /** Glob patterns excluded from enumeration even when a target matches. */
15
34
  exclude: readonly string[];
35
+ /** How widely the store is published, which decides what may link into it. */
36
+ visibility: StoreVisibility;
16
37
  }
17
- /**
18
- * The configuration applied when `.kb/config.yaml` is absent or omits a field. Targets the `content/`-scoped layout
19
- * the owner's stores use; with `picomatch` `dot:false`, dot-directories (`.kb`, `.git`, `.agents`) are excluded
20
- * implicitly, so the default exclude only names `node_modules`.
21
- */
22
- export declare const defaultKbConfig: KbConfig;
38
+ /** How widely a store is published: `shared` reaches collaborators through a remote, `private` reaches nobody else. */
39
+ export type StoreVisibility = NonNullable<z.infer<typeof configFileShape>['visibility']>;
@@ -3,8 +3,17 @@ import { CONTENT_DIR } from "../layout/index.js";
3
3
  export const configFileShape = z.object({
4
4
  targets: z.array(z.string()).optional(),
5
5
  exclude: z.array(z.string()).optional(),
6
+ visibility: z.enum(['private', 'shared']).optional(),
6
7
  });
7
8
  export const defaultKbConfig = {
8
9
  targets: [`${CONTENT_DIR}/**/*.md`],
9
10
  exclude: ['**/node_modules/**'],
11
+ visibility: 'private',
12
+ };
13
+ export function isAtLeastAsShareable(input) {
14
+ return VISIBILITY_RANK[input.target] >= VISIBILITY_RANK[input.source];
15
+ }
16
+ const VISIBILITY_RANK = {
17
+ private: 0,
18
+ shared: 1,
10
19
  };
@@ -1,4 +1,4 @@
1
- export { configFileShape, defaultKbConfig, type KbConfig } from './config-schema.js';
1
+ export { configFileShape, defaultKbConfig, isAtLeastAsShareable, type KbConfig, type StoreVisibility, } from './config-schema.js';
2
2
  export { isKbLoaderError, KbLoaderError } from './kb-loader-error.js';
3
3
  export { loadKbConfig } from './load-config.js';
4
4
  export { createNoteScopeMatcher, type NoteScopeMatcher } from './note-scope.js';
@@ -1,4 +1,4 @@
1
- export { configFileShape, defaultKbConfig } from "./config-schema.js";
1
+ export { configFileShape, defaultKbConfig, isAtLeastAsShareable, } from "./config-schema.js";
2
2
  export { isKbLoaderError, KbLoaderError } from "./kb-loader-error.js";
3
3
  export { loadKbConfig } from "./load-config.js";
4
4
  export { createNoteScopeMatcher } from "./note-scope.js";
@@ -2,7 +2,7 @@ import type { KbRoot } from '../types.js';
2
2
  import { type KbConfig } from './config-schema.js';
3
3
  /**
4
4
  * Loads the effective check configuration for a KB root. Returns {@link defaultKbConfig} verbatim when no
5
- * `.kb/config.yaml` exists; a file present but omitting `targets` or `exclude` inherits that field's default.
5
+ * `.kb/config.yaml` exists; a file present but omitting a field inherits that field's default.
6
6
  *
7
7
  * Mirrors {@link loadAliases}: a single plain-object input, structural defects (malformed YAML, wrong types) throw a
8
8
  * {@link KbLoaderError} naming the file. I/O errors other than a missing file propagate.
@@ -32,5 +32,6 @@ export async function loadKbConfig(input) {
32
32
  return {
33
33
  targets: result.data.targets ?? defaultKbConfig.targets,
34
34
  exclude: result.data.exclude ?? defaultKbConfig.exclude,
35
+ visibility: result.data.visibility ?? defaultKbConfig.visibility,
35
36
  };
36
37
  }
@@ -15,6 +15,6 @@ export interface NoteScopeMatcher {
15
15
  *
16
16
  * The `.md` extension gate is deliberately left to the caller: `enumerateNotes` applies its own `.endsWith('.md')`
17
17
  * during the walk, and `kb-retrieve` constrains ripgrep with `--glob '*.md'`. Keeping it out of `isNote` lets this
18
- * matcher govern only the `targets`/`exclude` dimension, the one place the two tools previously disagreed.
18
+ * matcher govern `targets`/`exclude` alone, the one place the two tools previously disagreed.
19
19
  */
20
20
  export declare function createNoteScopeMatcher(config: KbConfig): NoteScopeMatcher;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Returns every path git accounts for under `root` as an NFC-normalized set of root-relative, slash-separated paths:
3
- * tracked files, plus untracked ones that no ignore rule covers. Returns `undefined` where git holds no opinion,
2
+ * Returns every path that git tracks under `root`, plus every untracked path that no ignore rule covers, as an
3
+ * NFC-normalized set of root-relative, slash-separated paths. Returns `undefined` where git holds no opinion,
4
4
  * because `root` lies outside a working tree or git cannot be run at all, so a caller keeps its own scope instead of
5
5
  * reading an empty set as "git ignores everything".
6
6
  *
@@ -9,9 +9,11 @@ const ALIASES_HEADER = `# Tag aliases for this knowledge store.
9
9
  `;
10
10
  const CONFIG_HEADER = `# Check configuration for this knowledge store.
11
11
  #
12
- # Both keys are optional and fall back to the defaults shown below; an absent file uses these defaults too. Uncomment
12
+ # Every key is optional and falls back to the default shown below; an absent file uses these defaults too. Uncomment
13
13
  # and edit to override. \`targets\` selects which notes \`kb check\` enumerates and \`exclude\` removes matches;
14
- # patterns are slash-separated and relative to the store root.
14
+ # patterns are slash-separated and relative to the store root. \`visibility\` declares how widely the store is
15
+ # published, which decides what may link into it: a \`[[store:Note title]]\` link resolves only into a store as
16
+ # shareable as its own or more so.
15
17
  #
16
18
  `;
17
19
  const EDITORCONFIG_SEED = `root = true
@@ -1,13 +1,6 @@
1
+ import type { StoreVisibility } from '../config/config-schema.js';
1
2
  import type { Finding } from '../types.js';
2
- /** A note reduced to what vault integrity inspects: its path, its body, and the file line the body begins on. */
3
- export interface VaultIntegrityNote {
4
- /** Path or label the note was read from; used as the index value and the finding path. */
5
- path: string;
6
- /** The note body (everything after the frontmatter block). */
7
- body: string;
8
- /** 1-based file line where the body begins, so link findings report file-absolute lines. */
9
- bodyStartLine: number;
10
- }
3
+ import { type VaultIndex } from './build-vault-index.js';
11
4
  /**
12
5
  * Checks whole-vault integrity over a type-blind note set: unresolved `[[link]]` targets and basename collisions.
13
6
  * Projects no records and reads no frontmatter — a note is just its path and body.
@@ -16,5 +9,46 @@ export interface VaultIntegrityNote {
16
9
  * file-absolute line. A basename shared by two or more notes is a single vault-wide warning (`wikilinks.basename`),
17
10
  * reported once per basename independent of whether any link references it. An ambiguous link (a basename that several
18
11
  * notes share) is not flagged per-link — the vault-wide basename warning subsumes it.
12
+ *
13
+ * A `[[store:Target]]` resolves against `options.foreignStores` instead, and never joins this store's own basename
14
+ * index, so the basename warning stays store-scoped. Without `options`, a qualified target is treated as a bare one,
15
+ * which is the behavior every caller had before cross-store resolution existed.
19
16
  */
20
- export declare function checkVaultIntegrity(notes: readonly VaultIntegrityNote[]): Finding[];
17
+ export declare function checkVaultIntegrity(notes: readonly VaultIntegrityNote[], options?: VaultIntegrityOptions): Finding[];
18
+ /** What a check run found when it looked up a store one of its links names. */
19
+ export type ForeignStore =
20
+ /** The name matches no entry in the merged registry. */
21
+ {
22
+ status: 'unknown';
23
+ }
24
+ /** The store is registered but cannot be read on this machine, so its links are unverifiable rather than broken. */
25
+ | {
26
+ status: 'unavailable';
27
+ reason: string;
28
+ }
29
+ /** The store is less shareable than the source, so a link into it would widen disclosure. */
30
+ | {
31
+ status: 'disallowed';
32
+ visibility: StoreVisibility;
33
+ }
34
+ /** The store was read; `index` holds its basenames. */
35
+ | {
36
+ status: 'resolved';
37
+ index: VaultIndex;
38
+ };
39
+ /** A note reduced to what vault integrity inspects: its path, its body, and the file line the body begins on. */
40
+ export interface VaultIntegrityNote {
41
+ /** Path or label the note was read from; used as the index value and the finding path. */
42
+ path: string;
43
+ /** The note body (everything after the frontmatter block). */
44
+ body: string;
45
+ /** 1-based file line where the body begins, so link findings report file-absolute lines. */
46
+ bodyStartLine: number;
47
+ }
48
+ /** What a run needs to evaluate a store-qualified link. */
49
+ export interface VaultIntegrityOptions {
50
+ /** What the run found for each store name its links qualify, keyed by that name. */
51
+ foreignStores: ReadonlyMap<string, ForeignStore>;
52
+ /** The visibility of the store being checked, which decides the direction a qualified link may take. */
53
+ sourceVisibility: StoreVisibility;
54
+ }
@@ -1,8 +1,8 @@
1
1
  import { buildVaultIndex } from "./build-vault-index.js";
2
- import { countNewlines, extractTarget, hasNonMarkdownExtension, lookupKey, maskFencedCode, maskInlineCode, WIKILINK, } from "./wikilink-parse.js";
3
- export function checkVaultIntegrity(notes) {
2
+ import { countNewlines, lookupKey, scanWikilinks } from "./wikilink-parse.js";
3
+ export function checkVaultIntegrity(notes, options) {
4
4
  const vaultIndex = buildVaultIndex(notes);
5
- return [...unresolvedLinkFindings(notes, vaultIndex), ...basenameFindings(vaultIndex)];
5
+ return [...linkFindings(notes, vaultIndex, options), ...basenameFindings(vaultIndex)];
6
6
  }
7
7
  function basenameFindings(vaultIndex) {
8
8
  const findings = [];
@@ -20,28 +20,67 @@ function basenameFindings(vaultIndex) {
20
20
  }
21
21
  return findings;
22
22
  }
23
- function unresolvedLinkFindings(notes, vaultIndex) {
23
+ function describeForeignDefect(store, target, options) {
24
+ const link = `[[${store}:${target}]]`;
25
+ const foreignStore = options.foreignStores.get(store);
26
+ if (foreignStore === undefined)
27
+ return undefined;
28
+ if (foreignStore.status === 'unknown') {
29
+ return {
30
+ rule: 'wikilinks.unknown-store',
31
+ severity: 'error',
32
+ message: `${link} names the store "${store}", which no kb.yaml registry entry declares`,
33
+ };
34
+ }
35
+ if (foreignStore.status === 'unavailable') {
36
+ return {
37
+ rule: 'wikilinks.store-unavailable',
38
+ severity: 'warning',
39
+ message: `${link} targets the store "${store}", which could not be read here: ${foreignStore.reason}`,
40
+ };
41
+ }
42
+ if (foreignStore.status === 'disallowed') {
43
+ return {
44
+ rule: 'wikilinks.disallowed-store',
45
+ severity: 'error',
46
+ message: `${link} targets "${store}", a ${foreignStore.visibility} store, which a ${options.sourceVisibility} store may not link into: the link discloses the target's title`,
47
+ };
48
+ }
49
+ const resolved = foreignStore.index.get(lookupKey(target));
50
+ if (resolved !== undefined && resolved.size > 0)
51
+ return undefined;
52
+ return {
53
+ rule: 'wikilinks.unresolved',
54
+ severity: 'error',
55
+ message: `${link} does not resolve to any note in the store "${store}"`,
56
+ };
57
+ }
58
+ function describeLocalDefect(target, vaultIndex) {
59
+ const resolved = vaultIndex.get(lookupKey(target));
60
+ if (resolved !== undefined && resolved.size > 0)
61
+ return undefined;
62
+ return {
63
+ rule: 'wikilinks.unresolved',
64
+ severity: 'error',
65
+ message: `[[${target}]] does not resolve to any vault note`,
66
+ };
67
+ }
68
+ function joinTarget(link) {
69
+ return link.store === undefined ? link.target : `${link.store}:${link.target}`;
70
+ }
71
+ function linkFindings(notes, vaultIndex, options) {
24
72
  const findings = [];
25
73
  for (const note of notes) {
26
- const body = maskInlineCode(maskFencedCode(note.body));
27
- for (const match of body.matchAll(WIKILINK)) {
28
- const inner = match[1];
29
- if (inner === undefined)
30
- continue;
31
- const target = extractTarget(inner);
32
- if (target === null)
33
- continue;
34
- if (hasNonMarkdownExtension(target))
35
- continue;
36
- const resolved = vaultIndex.get(lookupKey(target));
37
- if (resolved !== undefined && resolved.size > 0)
74
+ for (const link of scanWikilinks(note.body)) {
75
+ const defect = link.store === undefined || options === undefined
76
+ ? describeLocalDefect(joinTarget(link), vaultIndex)
77
+ : describeForeignDefect(link.store, link.target, options);
78
+ if (defect === undefined)
38
79
  continue;
39
80
  findings.push({
40
81
  path: note.path,
41
- line: note.bodyStartLine + countNewlines(body, match.index),
42
- rule: 'wikilinks.unresolved',
43
- severity: 'error',
44
- message: `[[${target}]] does not resolve to any vault note`,
82
+ line: note.bodyStartLine + countNewlines(note.body, link.offset),
83
+ ...defect,
45
84
  });
46
85
  }
47
86
  }
@@ -1,3 +1,3 @@
1
1
  export { buildVaultIndex, type VaultIndex } from './build-vault-index.js';
2
- export { checkVaultIntegrity, type VaultIntegrityNote } from './check-vault-integrity.js';
3
- export { countNewlines, extractTarget, hasNonMarkdownExtension, lookupKey, maskFencedCode, maskInlineCode, WIKILINK, } from './wikilink-parse.js';
2
+ export { checkVaultIntegrity, type ForeignStore, type VaultIntegrityNote, type VaultIntegrityOptions, } from './check-vault-integrity.js';
3
+ export { lookupKey, type ScannedWikilink, scanWikilinks } from './wikilink-parse.js';
@@ -1,3 +1,3 @@
1
1
  export { buildVaultIndex } from "./build-vault-index.js";
2
- export { checkVaultIntegrity } from "./check-vault-integrity.js";
3
- export { countNewlines, extractTarget, hasNonMarkdownExtension, lookupKey, maskFencedCode, maskInlineCode, WIKILINK, } from "./wikilink-parse.js";
2
+ export { checkVaultIntegrity, } from "./check-vault-integrity.js";
3
+ export { lookupKey, scanWikilinks } from "./wikilink-parse.js";
@@ -1,8 +1,3 @@
1
- /**
2
- * Match `[[Target]]` and `![[Target]]` (embeds). A backslash-escaped `\[[…]]` is excluded. Scan the body so that
3
- * frontmatter wikilink-looking text (e.g. inside a description) is not flagged.
4
- */
5
- export declare const WIKILINK: RegExp;
6
1
  /** Counts the newlines in `text` before byte offset `upTo`, used to locate a match's source line. */
7
2
  export declare function countNewlines(text: string, upTo: number): number;
8
3
  /**
@@ -10,20 +5,46 @@ export declare function countNewlines(text: string, upTo: number): number;
10
5
  * links like `[[#heading]]`, which carry no target.
11
6
  */
12
7
  export declare function extractTarget(inner: string): string | null;
13
- /** Whether a target carries a known non-Markdown extension (an embed the vault index cannot resolve). */
14
- export declare function hasNonMarkdownExtension(target: string): boolean;
15
8
  /** Reduces a wikilink target to the basename key the vault index is keyed on (drops any directory prefix and `.md`). */
16
9
  export declare function lookupKey(target: string): string;
10
+ /** A wikilink target separated into the store it names, where it names one, and the target within that store. */
11
+ export interface QualifiedTarget {
12
+ /** The store the link names, or `undefined` when the target is store-local. */
13
+ store?: string;
14
+ /** The target with any store qualifier removed. */
15
+ target: string;
16
+ }
17
+ /** One wikilink a body scan accepted, with its target already split into an optional store qualifier and a target. */
18
+ export interface ScannedWikilink {
19
+ /** The whole matched link, including any `!` embed prefix. */
20
+ match: string;
21
+ /** The text between the brackets, with alias and anchor intact. */
22
+ inner: string;
23
+ /** Offset of the match within the body. */
24
+ offset: number;
25
+ /** The store the link names, or `undefined` when the target is store-local. */
26
+ store?: string;
27
+ /** The target within that store, with alias, anchor, and any store qualifier stripped. */
28
+ target: string;
29
+ }
17
30
  /**
18
- * Replaces the content of fenced code blocks with spaces so wikilink-shaped text inside code (e.g., a bash
19
- * `[[ -n "$x" ]]` conditional) is not flagged. Offsets and line counts are preserved by substituting same-length
20
- * whitespace.
31
+ * Walks a note body and yields every wikilink that carries a resolvable target: fenced and inline code are masked
32
+ * first, and backslash-escaped links, intra-doc anchors, and non-Markdown embeds are skipped. This is the single
33
+ * definition of what counts as a link and what its target is, so a consumer that detects links and one that rewrites
34
+ * them cannot drift apart on either question.
35
+ *
36
+ * `offset` indexes the body as passed in. Masking substitutes same-length whitespace, which this function asserts, so
37
+ * a consumer may slice the unmasked body at the offsets yielded here. It also leaves every newline where it was, so a
38
+ * consumer may count lines in the unmasked body at those offsets; nothing asserts that at runtime, so a masker that
39
+ * moved a newline while keeping the length would shift reported line numbers rather than fail.
21
40
  */
22
- export declare function maskFencedCode(body: string): string;
41
+ export declare function scanWikilinks(body: string): Generator<ScannedWikilink>;
23
42
  /**
24
- * Replaces inline backtick spans (e.g., TOML `[[plugins]]` mentioned in prose) with same-length whitespace so
25
- * wikilink-shaped text inside inline code is not flagged. Matches single or multi-backtick runs whose content
26
- * contains no backticks or newlines the common case; complex spans with embedded backticks fall through and are
27
- * still parsed for wikilinks.
43
+ * Separates a leading `store:` qualifier from a wikilink target, so `fde:Note title` names the note `Note title` in
44
+ * the store `fde`. A qualifier is recognized only when the text before the first colon is non-empty and carries no
45
+ * whitespace and no `/`, and something follows the colon; every other target passes through store-local, which leaves
46
+ * a title that happens to contain a colon resolving as it always has.
47
+ *
48
+ * Call it on the output of {@link extractTarget}, which has already stripped any alias and anchor.
28
49
  */
29
- export declare function maskInlineCode(body: string): string;
50
+ export declare function splitStoreQualifier(target: string): QualifiedTarget;
@@ -1,4 +1,4 @@
1
- export const WIKILINK = /(?<!\\)!?\[\[([^\]\n]+?)\]\]/g;
1
+ const WIKILINK = /(?<!\\)!?\[\[([^\]\n]+?)\]\]/g;
2
2
  const NON_MD_EXTENSIONS = new Set([
3
3
  '.png',
4
4
  '.jpg',
@@ -26,7 +26,40 @@ export function extractTarget(inner) {
26
26
  const trimmed = beforeAnchor.trim();
27
27
  return trimmed === '' ? null : trimmed;
28
28
  }
29
- export function hasNonMarkdownExtension(target) {
29
+ export function lookupKey(target) {
30
+ const withoutExtension = target.endsWith('.md') ? target.slice(0, -3) : target;
31
+ const segments = withoutExtension.split('/');
32
+ return segments.at(-1) ?? withoutExtension;
33
+ }
34
+ export function* scanWikilinks(body) {
35
+ const masked = maskInlineCode(maskFencedCode(body));
36
+ if (masked.length !== body.length) {
37
+ throw new Error('code masking changed body length; wikilink offsets would be invalid');
38
+ }
39
+ for (const match of masked.matchAll(WIKILINK)) {
40
+ const inner = match[1];
41
+ if (inner === undefined)
42
+ continue;
43
+ const extracted = extractTarget(inner);
44
+ if (extracted === null)
45
+ continue;
46
+ if (hasNonMarkdownExtension(extracted))
47
+ continue;
48
+ const { store, target } = splitStoreQualifier(extracted);
49
+ yield { match: match[0], inner, offset: match.index, ...(store !== undefined && { store }), target };
50
+ }
51
+ }
52
+ export function splitStoreQualifier(target) {
53
+ const colonIndex = target.indexOf(':');
54
+ if (colonIndex <= 0)
55
+ return { target };
56
+ const store = target.slice(0, colonIndex);
57
+ const remainder = target.slice(colonIndex + 1);
58
+ if (remainder === '' || /[\s/]/.test(store))
59
+ return { target };
60
+ return { store, target: remainder };
61
+ }
62
+ function hasNonMarkdownExtension(target) {
30
63
  const dotIndex = target.lastIndexOf('.');
31
64
  if (dotIndex === -1)
32
65
  return false;
@@ -35,12 +68,7 @@ export function hasNonMarkdownExtension(target) {
35
68
  return false;
36
69
  return NON_MD_EXTENSIONS.has(ext);
37
70
  }
38
- export function lookupKey(target) {
39
- const withoutExtension = target.endsWith('.md') ? target.slice(0, -3) : target;
40
- const segments = withoutExtension.split('/');
41
- return segments.at(-1) ?? withoutExtension;
42
- }
43
- export function maskFencedCode(body) {
71
+ function maskFencedCode(body) {
44
72
  const lines = body.split('\n');
45
73
  let inFence = false;
46
74
  let fenceChar = '';
@@ -69,7 +97,7 @@ export function maskFencedCode(body) {
69
97
  }
70
98
  return lines.join('\n');
71
99
  }
72
- export function maskInlineCode(body) {
100
+ function maskInlineCode(body) {
73
101
  return body.replace(/`+[^`\n]+?`+/g, (match) => ' '.repeat(match.length));
74
102
  }
75
103
  const FENCE_LINE = /^\s{0,3}(`{3,}|~{3,})/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/kb",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Knowledge-base foundation: discovery, config, frontmatter parsing, records, tags, and vault-integrity checks",
5
5
  "keywords": [
6
6
  "frontmatter",
@@ -110,7 +110,7 @@
110
110
  "devDependencies": {
111
111
  "@types/node": "24.13.3",
112
112
  "@types/picomatch": "4.0.3",
113
- "eslint": "10.9.1",
113
+ "eslint": "10.10.0",
114
114
  "prettier": "3.9.6",
115
115
  "vitest": "4.1.11"
116
116
  },