@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 +62 -41
- package/dist/esm/check/check.d.ts +12 -2
- package/dist/esm/check/check.js +41 -1
- package/dist/esm/check/enumerate.d.ts +3 -3
- package/dist/esm/check/resolve-foreign-stores.d.ts +24 -0
- package/dist/esm/check/resolve-foreign-stores.js +50 -0
- package/dist/esm/cli/commands/check.d.ts +1 -1
- package/dist/esm/cli/commands/check.js +7 -2
- package/dist/esm/config/config-schema.d.ts +26 -9
- package/dist/esm/config/config-schema.js +9 -0
- package/dist/esm/config/index.d.ts +1 -1
- package/dist/esm/config/index.js +1 -1
- package/dist/esm/config/load-config.d.ts +1 -1
- package/dist/esm/config/load-config.js +1 -0
- package/dist/esm/config/note-scope.d.ts +1 -1
- package/dist/esm/git/list-git-scope.d.ts +2 -2
- package/dist/esm/scaffold/render-seeds.js +4 -2
- package/dist/esm/vault-integrity/check-vault-integrity.d.ts +44 -10
- package/dist/esm/vault-integrity/check-vault-integrity.js +59 -20
- package/dist/esm/vault-integrity/index.d.ts +2 -2
- package/dist/esm/vault-integrity/index.js +2 -2
- package/dist/esm/vault-integrity/wikilink-parse.d.ts +37 -16
- package/dist/esm/vault-integrity/wikilink-parse.js +37 -9
- package/package.json +2 -2
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.
|
|
10
|
+
## Release notes — v0.8.0 (2026-09-13)
|
|
9
11
|
|
|
10
12
|
### 🎉 Features
|
|
11
13
|
|
|
12
|
-
-
|
|
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
|
-
|
|
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
|
|
20
|
+
- Add a second-person rule to the writing preferences (#1634)
|
|
20
21
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
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
|
-
###
|
|
26
|
+
### 🪦 Removed
|
|
26
27
|
|
|
27
|
-
-
|
|
28
|
+
- 🚨 **Breaking:** Remove the wikilink-parse primitives from the vault-integrity subpath (#1680)
|
|
28
29
|
|
|
29
|
-
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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 (
|
|
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.
|
|
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
|
|
180
|
-
|
|
|
181
|
-
| `targets`
|
|
182
|
-
| `exclude`
|
|
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
|
-
####
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
/**
|
|
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
|
|
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>;
|
package/dist/esm/check/check.js
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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.
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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';
|
package/dist/esm/config/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
@@ -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
|
|
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
|
|
3
|
-
*
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
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 [...
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
42
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
41
|
+
export declare function scanWikilinks(body: string): Generator<ScannedWikilink>;
|
|
23
42
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
50
|
+
export declare function splitStoreQualifier(target: string): QualifiedTarget;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
113
|
+
"eslint": "10.10.0",
|
|
114
114
|
"prettier": "3.9.6",
|
|
115
115
|
"vitest": "4.1.11"
|
|
116
116
|
},
|