@williamthorsen/kb 0.3.1 → 0.4.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 +75 -11
- package/dist/esm/check/check.js +10 -2
- package/dist/esm/check/enumerate.d.ts +4 -0
- package/dist/esm/check/enumerate.js +32 -20
- package/dist/esm/check/index.d.ts +1 -1
- package/dist/esm/check/index.js +1 -1
- package/dist/esm/cli/commands/check.js +3 -35
- package/dist/esm/cli/commands/taxonomy.d.ts +15 -0
- package/dist/esm/cli/commands/taxonomy.js +130 -0
- package/dist/esm/cli/format.js +4 -1
- package/dist/esm/cli/parse-flag-value.d.ts +2 -0
- package/dist/esm/cli/parse-flag-value.js +14 -0
- package/dist/esm/cli/resolve-store.d.ts +14 -0
- package/dist/esm/cli/resolve-store.js +25 -0
- package/dist/esm/cli/run.d.ts +1 -1
- package/dist/esm/cli/run.js +5 -0
- package/dist/esm/layout/index.d.ts +1 -1
- package/dist/esm/layout/index.js +1 -1
- package/dist/esm/layout/store-layout.d.ts +1 -0
- package/dist/esm/layout/store-layout.js +1 -0
- package/dist/esm/lints/index.d.ts +1 -0
- package/dist/esm/lints/index.js +1 -0
- package/dist/esm/lints/taxonomy.d.ts +12 -0
- package/dist/esm/lints/taxonomy.js +52 -0
- package/dist/esm/taxonomy/domain-paths.d.ts +3 -0
- package/dist/esm/taxonomy/domain-paths.js +23 -0
- package/dist/esm/taxonomy/index.d.ts +4 -0
- package/dist/esm/taxonomy/index.js +4 -0
- package/dist/esm/taxonomy/load-taxonomy.d.ts +5 -0
- package/dist/esm/taxonomy/load-taxonomy.js +60 -0
- package/dist/esm/taxonomy/taxonomy-schema.d.ts +11 -0
- package/dist/esm/taxonomy/taxonomy-schema.js +28 -0
- package/dist/esm/taxonomy/write-taxonomy.d.ts +12 -0
- package/dist/esm/taxonomy/write-taxonomy.js +142 -0
- package/dist/esm/types.d.ts +1 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -4,11 +4,23 @@ Foundation library for knowledge-base tooling.
|
|
|
4
4
|
Provides knowledge-base discovery, registry loading, frontmatter parsing and writing, tag canonicalization, and type-blind vault-integrity checks.
|
|
5
5
|
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
6
|
|
|
7
|
-
<!-- section:release-notes
|
|
7
|
+
<!-- section:release-notes -->
|
|
8
|
+
## Release notes — v0.4.0 (2026-08-07)
|
|
9
|
+
|
|
10
|
+
### 🎉 Features
|
|
11
|
+
|
|
12
|
+
- Add the .kb/taxonomy.yaml format with drift reporting and back-fill (#1210)
|
|
13
|
+
|
|
14
|
+
Introduces `.kb/taxonomy.yaml`, in which a knowledge base declares the structure of its assertions. `kb check` now reports three kinds of drift between that declaration and the folders on disk: a folder that holds notes nothing declares, a declared area that holds no notes, and a declared area whose parent is undeclared. A knowledge base that already holds notes can adopt a declaration in one pass with the new `kb taxonomy init`, and `--merge` adds only what an existing declaration omits.
|
|
15
|
+
|
|
16
|
+
- Guide kb-add note placement with the store's declared taxonomy (#1223)
|
|
17
|
+
|
|
18
|
+
Improves classification of captured knowledge-base notes by aligning with the domains declared by the KB's taxonomy rather than looking to the directory structure. If a note is filed in a folder not covered by a domain, that folder is now added to the base's taxonomy. A domain added without confirmation is recorded as awaiting review.
|
|
19
|
+
<!-- /section:release-notes -->
|
|
8
20
|
|
|
9
21
|
## Exports
|
|
10
22
|
|
|
11
|
-
The package exposes
|
|
23
|
+
The package exposes twelve subpath entries plus a root barrel:
|
|
12
24
|
|
|
13
25
|
| Entry | Description |
|
|
14
26
|
| ------------------- | ------------------------------------------------------------------------------ |
|
|
@@ -19,9 +31,11 @@ The package exposes ten subpath entries plus a root barrel:
|
|
|
19
31
|
| `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing |
|
|
20
32
|
| `./filesystem` | Filesystem-existence helpers with an explicit absence policy |
|
|
21
33
|
| `./frontmatter` | Note parsing into typed frontmatter and writing it back to YAML |
|
|
34
|
+
| `./layout` | The store's on-disk layout: every path inside a `.kb/` store derives from here |
|
|
22
35
|
| `./note-io` | Type-blind note read/write as an ordered frontmatter field map |
|
|
23
36
|
| `./records` | The typed `assertion`/`event` record parsers and renderers |
|
|
24
37
|
| `./tags` | `.kb/tag-aliases.yaml` loading and tag canonicalization |
|
|
38
|
+
| `./taxonomy` | `.kb/taxonomy.yaml` loading, comment-preserving declaration, and path mapping |
|
|
25
39
|
| `./vault-integrity` | Type-blind `[[link]]` resolution and basename-uniqueness over a note set |
|
|
26
40
|
|
|
27
41
|
Every public function takes a single plain-object input so a future MCP wrapper can mechanically bind Zod-validated payloads.
|
|
@@ -112,6 +126,8 @@ missing files (when a path is given) throw.
|
|
|
112
126
|
|
|
113
127
|
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.
|
|
114
128
|
|
|
129
|
+
`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.
|
|
130
|
+
|
|
115
131
|
```ts
|
|
116
132
|
import { checkVaultIntegrity } from '@williamthorsen/kb/vault-integrity';
|
|
117
133
|
|
|
@@ -120,7 +136,7 @@ const findings = checkVaultIntegrity(notes);
|
|
|
120
136
|
|
|
121
137
|
## Checking a store
|
|
122
138
|
|
|
123
|
-
`check({ kbRoot })` runs a store's full check in one call: it loads `.kb/config.yaml
|
|
139
|
+
`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 the config selects, 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.
|
|
124
140
|
|
|
125
141
|
```ts
|
|
126
142
|
import { check } from '@williamthorsen/kb/check';
|
|
@@ -128,7 +144,9 @@ import { check } from '@williamthorsen/kb/check';
|
|
|
128
144
|
const { notes, findings } = await check({ kbRoot });
|
|
129
145
|
```
|
|
130
146
|
|
|
131
|
-
A structural defect in
|
|
147
|
+
A structural defect in any loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged.
|
|
148
|
+
|
|
149
|
+
`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.
|
|
132
150
|
|
|
133
151
|
### Which notes are checked: `.kb/config.yaml`
|
|
134
152
|
|
|
@@ -149,9 +167,39 @@ exclude:
|
|
|
149
167
|
|
|
150
168
|
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`.
|
|
151
169
|
|
|
170
|
+
### The declared structure: `.kb/taxonomy.yaml`
|
|
171
|
+
|
|
172
|
+
`.kb/taxonomy.yaml` states where a store's assertions are meant to live. It is the source of truth for intended structure: folders on disk are derived from it, not the reverse. It governs `content/assertions/` only, since `content/events/` is flat and ULID-keyed.
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
# .kb/taxonomy.yaml
|
|
176
|
+
domains:
|
|
177
|
+
engineering: Software engineering practice
|
|
178
|
+
engineering/tooling: Build, test, and development tooling
|
|
179
|
+
provisional:
|
|
180
|
+
engineering/tooling/versioning: Release and version management
|
|
181
|
+
languages:
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Two disjoint maps of domain path to one-line description. `domains` holds reviewed declarations and `provisional` holds those declared but not yet reviewed; promotion is writing a description and moving the line up. A domain may be declared without a description, as `languages` is above.
|
|
185
|
+
|
|
186
|
+
Keys are relative to `content/assertions/` and may nest to any depth. Parents are not implied: declaring `engineering/tooling` does not declare `engineering`. A path declared in both maps fails the load, as does a malformed key — one restating the `content/assertions/` prefix, or carrying a leading or trailing slash, an empty segment, or a `.`/`..` segment.
|
|
187
|
+
|
|
188
|
+
An absent taxonomy, and one present but declaring nothing, are both valid and report nothing, so the rules apply only to a store that has adopted a taxonomy. Three warnings report drift once one has:
|
|
189
|
+
|
|
190
|
+
| Rule | Meaning |
|
|
191
|
+
| --------------------- | ----------------------------------------------- |
|
|
192
|
+
| `taxonomy.undeclared` | A folder holds notes but no domain declares it. |
|
|
193
|
+
| `taxonomy.unused` | A declared domain has no note at or beneath it. |
|
|
194
|
+
| `taxonomy.orphan` | A declared domain's parent is undeclared. |
|
|
195
|
+
|
|
196
|
+
A domain counts as used when any note lives at or beneath it, so a grouping domain that holds only subfolders is not reported unused. A domain inside a `config.exclude` subtree is exempt from `taxonomy.unused`, since its notes never enumerate.
|
|
197
|
+
|
|
198
|
+
`loadTaxonomy({ kbRoot })` reads both blocks into one map of domain path to `{ description, provisional }`, and `writeTaxonomy({ kbRoot, declarations })` declares domains while preserving the file's existing comments, key order, and formatting. `resolveDomain(relativePath)` maps a store-root-relative note path to the domain it sits in (`undefined` for a non-assertion or a note at the assertions root), and `resolveParent(path)` yields a domain's parent (`undefined` at the top level); the drift rules and the back-fill both derive their answers from this pair, so a consumer that classifies notes against the taxonomy stays in agreement with what `kb check` reports. All four are exported from `@williamthorsen/kb/taxonomy`.
|
|
199
|
+
|
|
152
200
|
## The `kb` command
|
|
153
201
|
|
|
154
|
-
The package ships a `kb` bin with
|
|
202
|
+
The package ships a `kb` bin with four subcommands: `check`, `create`, `set-default`, and `taxonomy`.
|
|
155
203
|
|
|
156
204
|
### kb create
|
|
157
205
|
|
|
@@ -213,15 +261,31 @@ Because the exit code reflects only the selected notes, a per-batch or pre-commi
|
|
|
213
261
|
|
|
214
262
|
Exit codes:
|
|
215
263
|
|
|
216
|
-
| Code | Meaning
|
|
217
|
-
| ---- |
|
|
218
|
-
| `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0.
|
|
219
|
-
| `1` | One or more error-severity findings in the checked notes.
|
|
220
|
-
| `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config
|
|
264
|
+
| Code | Meaning |
|
|
265
|
+
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
266
|
+
| `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. |
|
|
267
|
+
| `1` | One or more error-severity findings in the checked notes. |
|
|
268
|
+
| `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config`, `tag-aliases`, or `taxonomy` file. |
|
|
269
|
+
|
|
270
|
+
A finding carrying `scope: 'vault'` describes the store rather than any one note, so it is reported under every run, including a targeted one, a `--vs` one, and one that matched no notes at all. The taxonomy rules are the ones that produce them.
|
|
271
|
+
|
|
272
|
+
### kb taxonomy
|
|
273
|
+
|
|
274
|
+
`kb taxonomy init` derives a starting taxonomy from the notes a store already holds, so a taxonomy can be introduced to a populated store without every folder reporting as undeclared.
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
kb taxonomy init # declare every folder holding notes, and its ancestors
|
|
278
|
+
kb taxonomy init --kb coding # back-fill the named store from the kb.yaml registry
|
|
279
|
+
kb taxonomy init --merge # add only the domains an existing taxonomy omits
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Every derived domain lands under `provisional:` with no description: the command cannot invent descriptions, and provisional already means "declared, not yet reviewed". Because the derivation reads the same enumeration `kb check` does, a back-filled store reports no taxonomy drift.
|
|
283
|
+
|
|
284
|
+
Without `--merge`, a store that already declares a taxonomy is left untouched and the command exits 2.
|
|
221
285
|
|
|
222
286
|
## Error and exception model
|
|
223
287
|
|
|
224
|
-
The checks **return** findings; they never throw. Loaders (`loadKbConfig`, `loadAliases`) **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. `loadKbRegistry` throws a plain `Error` on its own structural defects. I/O errors other than a missing optional file propagate.
|
|
288
|
+
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. `loadKbRegistry` throws a plain `Error` on its own structural defects. I/O errors other than a missing optional file propagate.
|
|
225
289
|
|
|
226
290
|
## MCP wrappability
|
|
227
291
|
|
package/dist/esm/check/check.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { loadKbConfig } from "../config/load-config.js";
|
|
2
|
-
import { resolveKbDir } from "../layout/index.js";
|
|
3
|
+
import { resolveKbDir, TAXONOMY_FILE } from "../layout/index.js";
|
|
3
4
|
import { pathsFindings } from "../lints/paths.js";
|
|
4
5
|
import { tagAliasFindings } from "../lints/tag-alias.js";
|
|
6
|
+
import { taxonomyFindings } from "../lints/taxonomy.js";
|
|
5
7
|
import { loadAliases } from "../tags/load-aliases.js";
|
|
8
|
+
import { loadTaxonomy } from "../taxonomy/load-taxonomy.js";
|
|
6
9
|
import { checkVaultIntegrity } from "../vault-integrity/check-vault-integrity.js";
|
|
7
10
|
import { enumerateNotes } from "./enumerate.js";
|
|
8
11
|
export async function check(input) {
|
|
9
12
|
const kbRoot = { path: input.kbRoot, kbDir: resolveKbDir(input.kbRoot) };
|
|
10
|
-
const [config, aliases] = await Promise.all([
|
|
13
|
+
const [config, aliases, taxonomy] = await Promise.all([
|
|
14
|
+
loadKbConfig({ kbRoot }),
|
|
15
|
+
loadAliases({ kbRoot }),
|
|
16
|
+
loadTaxonomy({ kbRoot }),
|
|
17
|
+
]);
|
|
11
18
|
const notes = await enumerateNotes({ kbRoot: input.kbRoot, config });
|
|
12
19
|
const findings = [
|
|
13
20
|
...checkVaultIntegrity(notes),
|
|
21
|
+
...taxonomyFindings({ notes, taxonomy, config, taxonomyPath: join(input.kbRoot, TAXONOMY_FILE) }),
|
|
14
22
|
...notes.flatMap((note) => [...tagAliasFindings(note, aliases), ...pathsFindings(note)]),
|
|
15
23
|
];
|
|
16
24
|
return { config, notes, findings };
|
|
@@ -8,6 +8,10 @@ export interface EnumeratedNote {
|
|
|
8
8
|
bodyStartLine: number;
|
|
9
9
|
error?: string;
|
|
10
10
|
}
|
|
11
|
+
export declare function enumerateNotePaths(input: {
|
|
12
|
+
kbRoot: string;
|
|
13
|
+
config: KbConfig;
|
|
14
|
+
}): Promise<string[]>;
|
|
11
15
|
export declare function enumerateNotes(input: {
|
|
12
16
|
kbRoot: string;
|
|
13
17
|
config: KbConfig;
|
|
@@ -4,13 +4,41 @@ import process from 'node:process';
|
|
|
4
4
|
import { createNoteScopeMatcher } from "../config/note-scope.js";
|
|
5
5
|
import { readNoteContent } from "../note-io/read-note.js";
|
|
6
6
|
import { isGlobSegment } from "./glob-segments.js";
|
|
7
|
+
export async function enumerateNotePaths(input) {
|
|
8
|
+
const locations = await collectNoteLocations(input);
|
|
9
|
+
return locations.map((location) => location.relativePath);
|
|
10
|
+
}
|
|
7
11
|
export async function enumerateNotes(input) {
|
|
12
|
+
const locations = await collectNoteLocations(input);
|
|
13
|
+
const notes = [];
|
|
14
|
+
for (const { path, relativePath } of locations) {
|
|
15
|
+
try {
|
|
16
|
+
const content = await readFile(path, 'utf8');
|
|
17
|
+
const { fields, body, bodyStartLine, error: parseError } = readNoteContent(content);
|
|
18
|
+
notes.push({
|
|
19
|
+
path,
|
|
20
|
+
relativePath,
|
|
21
|
+
fields,
|
|
22
|
+
body,
|
|
23
|
+
content,
|
|
24
|
+
bodyStartLine,
|
|
25
|
+
...(parseError !== undefined && { error: parseError }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
process.stderr.write(`kb: warning: could not read note ${path}; skipping: ${message}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return notes;
|
|
34
|
+
}
|
|
35
|
+
async function collectNoteLocations(input) {
|
|
8
36
|
const { kbRoot, config } = input;
|
|
9
37
|
const matcher = createNoteScopeMatcher(config);
|
|
10
38
|
const topLevelDirs = leadingLiteralSegments(config.targets);
|
|
11
|
-
const
|
|
12
|
-
await walk({ root: kbRoot, dir: kbRoot, matcher, topLevelDirs, out:
|
|
13
|
-
return
|
|
39
|
+
const locations = [];
|
|
40
|
+
await walk({ root: kbRoot, dir: kbRoot, matcher, topLevelDirs, out: locations });
|
|
41
|
+
return locations;
|
|
14
42
|
}
|
|
15
43
|
function leadingLiteralSegments(targets) {
|
|
16
44
|
const dirs = new Set();
|
|
@@ -50,22 +78,6 @@ async function walk(input) {
|
|
|
50
78
|
continue;
|
|
51
79
|
if (!matcher.isNote(relativePath))
|
|
52
80
|
continue;
|
|
53
|
-
|
|
54
|
-
const content = await readFile(absolutePath, 'utf8');
|
|
55
|
-
const { fields, body, bodyStartLine, error: parseError } = readNoteContent(content);
|
|
56
|
-
out.push({
|
|
57
|
-
path: absolutePath,
|
|
58
|
-
relativePath,
|
|
59
|
-
fields,
|
|
60
|
-
body,
|
|
61
|
-
content,
|
|
62
|
-
bodyStartLine,
|
|
63
|
-
...(parseError !== undefined && { error: parseError }),
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
-
process.stderr.write(`kb: warning: could not read note ${absolutePath}; skipping: ${message}\n`);
|
|
69
|
-
}
|
|
81
|
+
out.push({ path: absolutePath, relativePath });
|
|
70
82
|
}
|
|
71
83
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { check, type CheckResult } from './check.js';
|
|
2
|
-
export { type EnumeratedNote, enumerateNotes } from './enumerate.js';
|
|
2
|
+
export { type EnumeratedNote, enumerateNotePaths, enumerateNotes } from './enumerate.js';
|
package/dist/esm/check/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { check } from "./check.js";
|
|
2
|
-
export { enumerateNotes } from "./enumerate.js";
|
|
2
|
+
export { enumerateNotePaths, enumerateNotes } from "./enumerate.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { check } from "../../check/check.js";
|
|
2
2
|
import { isKbLoaderError } from "../../config/kb-loader-error.js";
|
|
3
|
-
import { findKbRoot } from "../../discovery/find-kb-root.js";
|
|
4
|
-
import { tryLoadKbRegistry } from "../../discovery/load-registry.js";
|
|
5
3
|
import { formatHuman, formatJson, summarize } from "../format.js";
|
|
4
|
+
import { takeInlineValue, takeValue } from "../parse-flag-value.js";
|
|
5
|
+
import { resolveStore } from "../resolve-store.js";
|
|
6
6
|
import { resolveChangedPaths } from "../targeting/resolve-changed-paths.js";
|
|
7
7
|
import { selectNotes } from "../targeting/select-notes.js";
|
|
8
8
|
export const CHECK_HELP = `Usage: kb check [paths...] [options]
|
|
@@ -142,38 +142,6 @@ async function resolveSelection(input) {
|
|
|
142
142
|
return { ok: false, message: `no notes matched: ${selection.unmatched.join(', ')}` };
|
|
143
143
|
}
|
|
144
144
|
const selectedPaths = new Set(selection.selected.map((entry) => entry.path));
|
|
145
|
-
const findings = result.findings.filter((finding) => selectedPaths.has(finding.path));
|
|
145
|
+
const findings = result.findings.filter((finding) => finding.scope === 'vault' || selectedPaths.has(finding.path));
|
|
146
146
|
return { ok: true, scope, notes: selection.selected, findings };
|
|
147
147
|
}
|
|
148
|
-
async function resolveStore(input) {
|
|
149
|
-
if (input.explicitKb !== null) {
|
|
150
|
-
const { config } = await tryLoadKbRegistry({
|
|
151
|
-
projectDir: input.cwd,
|
|
152
|
-
...(input.home !== undefined && { home: input.home }),
|
|
153
|
-
});
|
|
154
|
-
const match = config.entries.find((entry) => entry.name === input.explicitKb);
|
|
155
|
-
if (match === undefined) {
|
|
156
|
-
return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` };
|
|
157
|
-
}
|
|
158
|
-
return { ok: true, store: { name: match.name, path: match.path } };
|
|
159
|
-
}
|
|
160
|
-
const discovered = await findKbRoot({ startDir: input.cwd });
|
|
161
|
-
if (discovered === null) {
|
|
162
|
-
return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' };
|
|
163
|
-
}
|
|
164
|
-
return { ok: true, store: { name: null, path: discovered.path } };
|
|
165
|
-
}
|
|
166
|
-
function takeInlineValue(arg, prefix) {
|
|
167
|
-
const value = arg.slice(prefix.length);
|
|
168
|
-
if (value === '') {
|
|
169
|
-
throw new Error(`${prefix.replace(/=$/, '')} requires a value`);
|
|
170
|
-
}
|
|
171
|
-
return value;
|
|
172
|
-
}
|
|
173
|
-
function takeValue(argv, index, flag) {
|
|
174
|
-
const next = argv[index + 1] ?? null;
|
|
175
|
-
if (next === null || next.startsWith('--')) {
|
|
176
|
-
throw new Error(`${flag} requires a value`);
|
|
177
|
-
}
|
|
178
|
-
return next;
|
|
179
|
-
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CommandOutput } from './check.js';
|
|
2
|
+
export declare const TAXONOMY_HELP = "Usage: kb taxonomy init [options]\n\nDerive a starting taxonomy from the notes a knowledge base already holds, so a\ntaxonomy can be introduced to a populated store without every folder reporting\nas undeclared. Every folder holding notes is declared, along with each of its\nancestors, under \"provisional:\" with no description: the command cannot invent\ndescriptions, and provisional already means \"declared, not yet reviewed\".\n\nOptions:\n --kb <name> Use the named store from the kb.yaml registry. Without it, the\n nearest ancestor .kb/ directory is used.\n --merge Add only the domains an existing taxonomy does not declare.\n Without it, a store that already has a taxonomy is left\n untouched.\n -h, --help Show this help.\n\nExit codes:\n 0 the taxonomy was written, or already declared every derived domain\n 2 usage error, unresolvable store, a store marked readonly in kb.yaml,\n malformed config or taxonomy, or an existing taxonomy without --merge\n";
|
|
3
|
+
export declare function runTaxonomy(input: {
|
|
4
|
+
argv: readonly string[];
|
|
5
|
+
cwd: string;
|
|
6
|
+
home?: string;
|
|
7
|
+
}): Promise<CommandOutput>;
|
|
8
|
+
interface TaxonomyOptions {
|
|
9
|
+
subcommand: 'init' | null;
|
|
10
|
+
kb: string | null;
|
|
11
|
+
merge: boolean;
|
|
12
|
+
help: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseTaxonomyArgs(argv: readonly string[]): TaxonomyOptions;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { enumerateNotes } from "../../check/enumerate.js";
|
|
2
|
+
import { isKbLoaderError } from "../../config/kb-loader-error.js";
|
|
3
|
+
import { loadKbConfig } from "../../config/load-config.js";
|
|
4
|
+
import { resolveKbDir, TAXONOMY_FILE } from "../../layout/index.js";
|
|
5
|
+
import { deriveDomains } from "../../taxonomy/domain-paths.js";
|
|
6
|
+
import { loadTaxonomy } from "../../taxonomy/load-taxonomy.js";
|
|
7
|
+
import { writeTaxonomy } from "../../taxonomy/write-taxonomy.js";
|
|
8
|
+
import { takeInlineValue, takeValue } from "../parse-flag-value.js";
|
|
9
|
+
import { resolveStore } from "../resolve-store.js";
|
|
10
|
+
export const TAXONOMY_HELP = `Usage: kb taxonomy init [options]
|
|
11
|
+
|
|
12
|
+
Derive a starting taxonomy from the notes a knowledge base already holds, so a
|
|
13
|
+
taxonomy can be introduced to a populated store without every folder reporting
|
|
14
|
+
as undeclared. Every folder holding notes is declared, along with each of its
|
|
15
|
+
ancestors, under "provisional:" with no description: the command cannot invent
|
|
16
|
+
descriptions, and provisional already means "declared, not yet reviewed".
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--kb <name> Use the named store from the kb.yaml registry. Without it, the
|
|
20
|
+
nearest ancestor .kb/ directory is used.
|
|
21
|
+
--merge Add only the domains an existing taxonomy does not declare.
|
|
22
|
+
Without it, a store that already has a taxonomy is left
|
|
23
|
+
untouched.
|
|
24
|
+
-h, --help Show this help.
|
|
25
|
+
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 the taxonomy was written, or already declared every derived domain
|
|
28
|
+
2 usage error, unresolvable store, a store marked readonly in kb.yaml,
|
|
29
|
+
malformed config or taxonomy, or an existing taxonomy without --merge
|
|
30
|
+
`;
|
|
31
|
+
export async function runTaxonomy(input) {
|
|
32
|
+
let options;
|
|
33
|
+
try {
|
|
34
|
+
options = parseTaxonomyArgs(input.argv);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n${TAXONOMY_HELP}` };
|
|
39
|
+
}
|
|
40
|
+
if (options.help) {
|
|
41
|
+
return { exitCode: 0, stdout: TAXONOMY_HELP, stderr: '' };
|
|
42
|
+
}
|
|
43
|
+
if (options.subcommand === null) {
|
|
44
|
+
return { exitCode: 2, stdout: '', stderr: `kb taxonomy: no subcommand given\n${TAXONOMY_HELP}` };
|
|
45
|
+
}
|
|
46
|
+
const resolved = await resolveStore({
|
|
47
|
+
explicitKb: options.kb,
|
|
48
|
+
cwd: input.cwd,
|
|
49
|
+
...(input.home !== undefined && { home: input.home }),
|
|
50
|
+
});
|
|
51
|
+
if (!resolved.ok) {
|
|
52
|
+
return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${resolved.message}\n` };
|
|
53
|
+
}
|
|
54
|
+
if (resolved.readonly) {
|
|
55
|
+
const name = resolved.store.name ?? resolved.store.path;
|
|
56
|
+
const message = `knowledge base "${name}" is marked readonly in kb.yaml; taxonomy init is refused`;
|
|
57
|
+
return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n` };
|
|
58
|
+
}
|
|
59
|
+
const kbRoot = { path: resolved.store.path, kbDir: resolveKbDir(resolved.store.path) };
|
|
60
|
+
try {
|
|
61
|
+
return await initTaxonomy({ kbRoot, merge: options.merge });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (isKbLoaderError(error)) {
|
|
65
|
+
return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${error.message}\n` };
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function parseTaxonomyArgs(argv) {
|
|
71
|
+
let subcommand = null;
|
|
72
|
+
let kb = null;
|
|
73
|
+
let merge = false;
|
|
74
|
+
let help = false;
|
|
75
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
76
|
+
const arg = argv[index];
|
|
77
|
+
if (arg === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
if (arg === '--help' || arg === '-h') {
|
|
80
|
+
help = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg === '--merge') {
|
|
84
|
+
merge = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === '--kb') {
|
|
88
|
+
kb = takeValue(argv, index, '--kb');
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (arg.startsWith('--kb=')) {
|
|
93
|
+
kb = takeInlineValue(arg, '--kb=');
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg.startsWith('-')) {
|
|
97
|
+
throw new Error(`unknown flag: ${arg}`);
|
|
98
|
+
}
|
|
99
|
+
if (arg !== 'init') {
|
|
100
|
+
throw new Error(`unknown subcommand: ${arg}`);
|
|
101
|
+
}
|
|
102
|
+
if (subcommand !== null) {
|
|
103
|
+
throw new Error('only one subcommand may be given');
|
|
104
|
+
}
|
|
105
|
+
subcommand = arg;
|
|
106
|
+
}
|
|
107
|
+
return { subcommand, kb, merge, help };
|
|
108
|
+
}
|
|
109
|
+
async function initTaxonomy(input) {
|
|
110
|
+
const { kbRoot, merge } = input;
|
|
111
|
+
const existing = await loadTaxonomy({ kbRoot });
|
|
112
|
+
if (existing.size > 0 && !merge) {
|
|
113
|
+
const message = `${TAXONOMY_FILE} already declares ${existing.size} domains; pass --merge to add the missing ones`;
|
|
114
|
+
return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n` };
|
|
115
|
+
}
|
|
116
|
+
const config = await loadKbConfig({ kbRoot });
|
|
117
|
+
const notes = await enumerateNotes({ kbRoot: kbRoot.path, config });
|
|
118
|
+
const domains = deriveDomains(notes.map((note) => note.relativePath));
|
|
119
|
+
if (domains.length === 0) {
|
|
120
|
+
return { exitCode: 0, stdout: `no assertion folders hold notes; ${TAXONOMY_FILE} not written\n`, stderr: '' };
|
|
121
|
+
}
|
|
122
|
+
const { added } = await writeTaxonomy({
|
|
123
|
+
kbRoot,
|
|
124
|
+
declarations: domains.map((path) => ({ path, provisional: true })),
|
|
125
|
+
});
|
|
126
|
+
if (added.length === 0) {
|
|
127
|
+
return { exitCode: 0, stdout: `${TAXONOMY_FILE} already declares every derived domain\n`, stderr: '' };
|
|
128
|
+
}
|
|
129
|
+
return { exitCode: 0, stdout: `declared ${added.length} domains in ${TAXONOMY_FILE}\n`, stderr: '' };
|
|
130
|
+
}
|
package/dist/esm/cli/format.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
export function formatHuman(input) {
|
|
2
2
|
const { summary, findings, targets, scope } = input;
|
|
3
|
-
if (summary.notes === 0) {
|
|
3
|
+
if (summary.notes === 0 && findings.length === 0) {
|
|
4
4
|
return `${zeroMatchLine(scope, targets)}\n`;
|
|
5
5
|
}
|
|
6
6
|
if (findings.length === 0) {
|
|
7
7
|
return `✓ no findings (${summary.notes} notes checked)\n`;
|
|
8
8
|
}
|
|
9
9
|
const lines = [];
|
|
10
|
+
if (summary.notes === 0) {
|
|
11
|
+
lines.push(zeroMatchLine(scope, targets), '');
|
|
12
|
+
}
|
|
10
13
|
for (const [path, group] of groupByPath(findings)) {
|
|
11
14
|
lines.push(path);
|
|
12
15
|
for (const finding of group) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function takeInlineValue(arg, prefix) {
|
|
2
|
+
const value = arg.slice(prefix.length);
|
|
3
|
+
if (value === '') {
|
|
4
|
+
throw new Error(`${prefix.replace(/=$/, '')} requires a value`);
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function takeValue(argv, index, flag) {
|
|
9
|
+
const next = argv[index + 1] ?? null;
|
|
10
|
+
if (next === null || next.startsWith('--')) {
|
|
11
|
+
throw new Error(`${flag} requires a value`);
|
|
12
|
+
}
|
|
13
|
+
return next;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { StoreRef } from './format.js';
|
|
2
|
+
export type ResolveStoreOutcome = {
|
|
3
|
+
ok: true;
|
|
4
|
+
store: StoreRef;
|
|
5
|
+
readonly: boolean;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveStore(input: {
|
|
11
|
+
explicitKb: string | null;
|
|
12
|
+
cwd: string;
|
|
13
|
+
home?: string;
|
|
14
|
+
}): Promise<ResolveStoreOutcome>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { findKbRoot } from "../discovery/find-kb-root.js";
|
|
2
|
+
import { tryLoadKbRegistry } from "../discovery/load-registry.js";
|
|
3
|
+
export async function resolveStore(input) {
|
|
4
|
+
const { config } = await tryLoadKbRegistry({
|
|
5
|
+
projectDir: input.cwd,
|
|
6
|
+
...(input.home !== undefined && { home: input.home }),
|
|
7
|
+
});
|
|
8
|
+
if (input.explicitKb !== null) {
|
|
9
|
+
const match = config.entries.find((entry) => entry.name === input.explicitKb);
|
|
10
|
+
if (match === undefined) {
|
|
11
|
+
return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` };
|
|
12
|
+
}
|
|
13
|
+
return { ok: true, store: { name: match.name, path: match.path }, readonly: match.readonly ?? false };
|
|
14
|
+
}
|
|
15
|
+
const discovered = await findKbRoot({ startDir: input.cwd });
|
|
16
|
+
if (discovered === null) {
|
|
17
|
+
return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' };
|
|
18
|
+
}
|
|
19
|
+
const registered = config.entries.find((entry) => entry.path === discovered.path);
|
|
20
|
+
return {
|
|
21
|
+
ok: true,
|
|
22
|
+
store: { name: registered?.name ?? null, path: discovered.path },
|
|
23
|
+
readonly: registered?.readonly ?? false,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/esm/cli/run.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CommandOutput } from './commands/check.js';
|
|
2
2
|
import type { SelectKbPrompt } from './select-kb-prompt.js';
|
|
3
|
-
export declare const HELP = "Usage: kb <command> [options]\n\nCommands:\n check Validate a knowledge base, optionally scoped to selected notes.\n create Scaffold a new knowledge base and register it in the kb.yaml registry.\n set-default Set, clear, or choose the default knowledge base.\n\nRun \"kb <command> --help\" for command options.\n";
|
|
3
|
+
export declare const HELP = "Usage: kb <command> [options]\n\nCommands:\n check Validate a knowledge base, optionally scoped to selected notes.\n create Scaffold a new knowledge base and register it in the kb.yaml registry.\n set-default Set, clear, or choose the default knowledge base.\n taxonomy Derive a knowledge base's taxonomy from the notes it already holds.\n\nRun \"kb <command> --help\" for command options.\n";
|
|
4
4
|
export declare function run(input: {
|
|
5
5
|
argv: readonly string[];
|
|
6
6
|
cwd: string;
|
package/dist/esm/cli/run.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { runCheck } from "./commands/check.js";
|
|
2
2
|
import { runCreate } from "./commands/create.js";
|
|
3
3
|
import { runSetDefault } from "./commands/set-default.js";
|
|
4
|
+
import { runTaxonomy } from "./commands/taxonomy.js";
|
|
4
5
|
export const HELP = `Usage: kb <command> [options]
|
|
5
6
|
|
|
6
7
|
Commands:
|
|
7
8
|
check Validate a knowledge base, optionally scoped to selected notes.
|
|
8
9
|
create Scaffold a new knowledge base and register it in the kb.yaml registry.
|
|
9
10
|
set-default Set, clear, or choose the default knowledge base.
|
|
11
|
+
taxonomy Derive a knowledge base's taxonomy from the notes it already holds.
|
|
10
12
|
|
|
11
13
|
Run "kb <command> --help" for command options.
|
|
12
14
|
`;
|
|
@@ -26,6 +28,9 @@ export async function run(input) {
|
|
|
26
28
|
...(input.selectKb !== undefined && { selectKb: input.selectKb }),
|
|
27
29
|
});
|
|
28
30
|
}
|
|
31
|
+
if (command === 'taxonomy') {
|
|
32
|
+
return runTaxonomy({ argv: rest, cwd: input.cwd, ...(input.home !== undefined && { home: input.home }) });
|
|
33
|
+
}
|
|
29
34
|
if (command === 'set-default') {
|
|
30
35
|
return runSetDefault({
|
|
31
36
|
argv: rest,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, } from './store-layout.js';
|
|
1
|
+
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, TAXONOMY_FILE, } from './store-layout.js';
|
package/dist/esm/layout/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, } from "./store-layout.js";
|
|
1
|
+
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, TAXONOMY_FILE, } from "./store-layout.js";
|
|
@@ -5,6 +5,7 @@ export declare const ALIASES_FILE = ".kb/tag-aliases.yaml";
|
|
|
5
5
|
export declare const ASSERTIONS_DIR = "content/assertions";
|
|
6
6
|
export declare const CONFIG_FILE = ".kb/config.yaml";
|
|
7
7
|
export declare const EVENTS_DIR = "content/events";
|
|
8
|
+
export declare const TAXONOMY_FILE = ".kb/taxonomy.yaml";
|
|
8
9
|
export declare function buildEventPath(id: string): string;
|
|
9
10
|
export declare function resolveAssertionsDir(storePath: string): string;
|
|
10
11
|
export declare function resolveEventPath(input: {
|
|
@@ -6,6 +6,7 @@ export const ALIASES_FILE = `${KB_DIR}/tag-aliases.yaml`;
|
|
|
6
6
|
export const ASSERTIONS_DIR = `${CONTENT_DIR}/${ASSERTIONS_SEGMENT}`;
|
|
7
7
|
export const CONFIG_FILE = `${KB_DIR}/config.yaml`;
|
|
8
8
|
export const EVENTS_DIR = `${CONTENT_DIR}/events`;
|
|
9
|
+
export const TAXONOMY_FILE = `${KB_DIR}/taxonomy.yaml`;
|
|
9
10
|
export function buildEventPath(id) {
|
|
10
11
|
return `${EVENTS_DIR}/${id}.md`;
|
|
11
12
|
}
|
package/dist/esm/lints/index.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KbConfig } from '../config/config-schema.js';
|
|
2
|
+
import type { Taxonomy } from '../taxonomy/taxonomy-schema.js';
|
|
3
|
+
import type { Finding } from '../types.js';
|
|
4
|
+
export interface TaxonomyNote {
|
|
5
|
+
relativePath: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function taxonomyFindings(input: {
|
|
8
|
+
notes: readonly TaxonomyNote[];
|
|
9
|
+
taxonomy: Taxonomy;
|
|
10
|
+
config: KbConfig;
|
|
11
|
+
taxonomyPath: string;
|
|
12
|
+
}): Finding[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createNoteScopeMatcher } from "../config/note-scope.js";
|
|
2
|
+
import { ASSERTIONS_DIR } from "../layout/index.js";
|
|
3
|
+
import { resolveDomain, resolveParent } from "../taxonomy/domain-paths.js";
|
|
4
|
+
export function taxonomyFindings(input) {
|
|
5
|
+
const { notes, taxonomy, config, taxonomyPath } = input;
|
|
6
|
+
const declared = taxonomy.keys().toArray().toSorted();
|
|
7
|
+
if (declared.length === 0) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const observed = new Set();
|
|
11
|
+
for (const note of notes) {
|
|
12
|
+
const domain = resolveDomain(note.relativePath);
|
|
13
|
+
if (domain !== undefined) {
|
|
14
|
+
observed.add(domain);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const matcher = createNoteScopeMatcher(config);
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const domain of [...observed].toSorted()) {
|
|
20
|
+
if (!taxonomy.has(domain)) {
|
|
21
|
+
findings.push(buildFinding(taxonomyPath, 'undeclared', `folder "${domain}" holds notes but no domain declares it`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const domain of declared) {
|
|
25
|
+
if (holdsNote(domain, observed) || matcher.isExcluded(`${ASSERTIONS_DIR}/${domain}`)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
findings.push(buildFinding(taxonomyPath, 'unused', `domain "${domain}" is declared but holds no notes`));
|
|
29
|
+
}
|
|
30
|
+
for (const domain of declared) {
|
|
31
|
+
const parent = resolveParent(domain);
|
|
32
|
+
if (parent !== undefined && !taxonomy.has(parent)) {
|
|
33
|
+
findings.push(buildFinding(taxonomyPath, 'orphan', `domain "${domain}" is declared but its parent "${parent}" is not`));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
function buildFinding(taxonomyPath, rule, message) {
|
|
39
|
+
return { path: taxonomyPath, scope: 'vault', rule: `taxonomy.${rule}`, severity: 'warning', message };
|
|
40
|
+
}
|
|
41
|
+
function holdsNote(domain, observed) {
|
|
42
|
+
if (observed.has(domain)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
const prefix = `${domain}/`;
|
|
46
|
+
for (const candidate of observed) {
|
|
47
|
+
if (candidate.startsWith(prefix)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ASSERTIONS_DIR } from "../layout/index.js";
|
|
2
|
+
export function deriveDomains(relativePaths) {
|
|
3
|
+
const domains = new Set();
|
|
4
|
+
for (const relativePath of relativePaths) {
|
|
5
|
+
let domain = resolveDomain(relativePath);
|
|
6
|
+
while (domain !== undefined) {
|
|
7
|
+
domains.add(domain);
|
|
8
|
+
domain = resolveParent(domain);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return domains.values().toArray().toSorted();
|
|
12
|
+
}
|
|
13
|
+
export function resolveDomain(relativePath) {
|
|
14
|
+
const prefix = `${ASSERTIONS_DIR}/`;
|
|
15
|
+
if (!relativePath.startsWith(prefix)) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return resolveParent(relativePath.slice(prefix.length));
|
|
19
|
+
}
|
|
20
|
+
export function resolveParent(path) {
|
|
21
|
+
const lastSlash = path.lastIndexOf('/');
|
|
22
|
+
return lastSlash === -1 ? undefined : path.slice(0, lastSlash);
|
|
23
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { resolveDomain, resolveParent } from './domain-paths.js';
|
|
2
|
+
export { loadTaxonomy } from './load-taxonomy.js';
|
|
3
|
+
export { describeKeyDefect, type Taxonomy, type TaxonomyEntry, taxonomyFileShape } from './taxonomy-schema.js';
|
|
4
|
+
export { type TaxonomyDeclaration, writeTaxonomy } from './write-taxonomy.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { KbLoaderError } from "../config/kb-loader-error.js";
|
|
5
|
+
import { TAXONOMY_FILE } from "../layout/index.js";
|
|
6
|
+
import { isEnoent } from "../type-guards.js";
|
|
7
|
+
import { describeKeyDefect, taxonomyFileShape } from "./taxonomy-schema.js";
|
|
8
|
+
export async function loadTaxonomy(input) {
|
|
9
|
+
const path = join(input.kbRoot.path, TAXONOMY_FILE);
|
|
10
|
+
let text;
|
|
11
|
+
try {
|
|
12
|
+
text = await readFile(path, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (isEnoent(error)) {
|
|
16
|
+
return new Map();
|
|
17
|
+
}
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
throw new KbLoaderError(`${path}: malformed YAML — ${message}`);
|
|
27
|
+
}
|
|
28
|
+
const result = taxonomyFileShape.safeParse(parsed ?? {});
|
|
29
|
+
if (!result.success) {
|
|
30
|
+
throw new KbLoaderError(`${path}: invalid taxonomy.yaml${describeIssueLocation(result.error)}`);
|
|
31
|
+
}
|
|
32
|
+
const entries = new Map();
|
|
33
|
+
collectBlock({ entries, block: result.data.domains ?? undefined, provisional: false, path });
|
|
34
|
+
collectBlock({ entries, block: result.data.provisional ?? undefined, provisional: true, path });
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
function collectBlock(input) {
|
|
38
|
+
const { entries, block, provisional, path } = input;
|
|
39
|
+
if (block === undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const [key, description] of Object.entries(block)) {
|
|
43
|
+
const defect = describeKeyDefect(key);
|
|
44
|
+
if (defect !== undefined) {
|
|
45
|
+
throw new KbLoaderError(`${path}: domain "${key}" ${defect}`);
|
|
46
|
+
}
|
|
47
|
+
if (entries.has(key)) {
|
|
48
|
+
throw new KbLoaderError(`${path}: domain "${key}" is declared in both domains and provisional`);
|
|
49
|
+
}
|
|
50
|
+
entries.set(key, { description: description ?? '', provisional });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function describeIssueLocation(error) {
|
|
54
|
+
const issue = error.issues[0];
|
|
55
|
+
if (issue === undefined) {
|
|
56
|
+
return ' — unknown error';
|
|
57
|
+
}
|
|
58
|
+
const location = issue.path.length > 0 ? ` at ${issue.path.join('.')}` : '';
|
|
59
|
+
return `${location} — ${issue.message}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare function describeKeyDefect(key: string): string | undefined;
|
|
3
|
+
export type Taxonomy = ReadonlyMap<string, TaxonomyEntry>;
|
|
4
|
+
export interface TaxonomyEntry {
|
|
5
|
+
description: string;
|
|
6
|
+
provisional: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const taxonomyFileShape: z.ZodObject<{
|
|
9
|
+
domains: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
|
|
10
|
+
provisional: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
|
|
11
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ASSERTIONS_DIR, ASSERTIONS_SEGMENT, CONTENT_DIR } from "../layout/index.js";
|
|
3
|
+
export function describeKeyDefect(key) {
|
|
4
|
+
if (key === '') {
|
|
5
|
+
return 'is empty';
|
|
6
|
+
}
|
|
7
|
+
if (key.includes('\\')) {
|
|
8
|
+
return 'contains a backslash; domain paths are slash-separated';
|
|
9
|
+
}
|
|
10
|
+
if (key.startsWith('/') || key.endsWith('/')) {
|
|
11
|
+
return 'has a leading or trailing slash';
|
|
12
|
+
}
|
|
13
|
+
const segments = key.split('/');
|
|
14
|
+
if (segments.some((segment) => segment.trim() === '')) {
|
|
15
|
+
return 'has an empty segment';
|
|
16
|
+
}
|
|
17
|
+
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
|
18
|
+
return 'has a "." or ".." segment';
|
|
19
|
+
}
|
|
20
|
+
if (segments[0] === ASSERTIONS_SEGMENT || (segments[0] === CONTENT_DIR && segments[1] === ASSERTIONS_SEGMENT)) {
|
|
21
|
+
return `restates the ${ASSERTIONS_DIR}/ prefix, which every key implies`;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export const taxonomyFileShape = z.object({
|
|
26
|
+
domains: z.record(z.string(), z.string().nullable()).nullable().optional(),
|
|
27
|
+
provisional: z.record(z.string(), z.string().nullable()).nullable().optional(),
|
|
28
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KbRoot } from '../types.js';
|
|
2
|
+
export interface TaxonomyDeclaration {
|
|
3
|
+
path: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
provisional: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeTaxonomy(input: {
|
|
8
|
+
kbRoot: KbRoot;
|
|
9
|
+
declarations: readonly TaxonomyDeclaration[];
|
|
10
|
+
}): Promise<{
|
|
11
|
+
added: string[];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isMap, isPair, isScalar, parseDocument } from 'yaml';
|
|
5
|
+
import { KbLoaderError } from "../config/kb-loader-error.js";
|
|
6
|
+
import { TAXONOMY_FILE } from "../layout/index.js";
|
|
7
|
+
import { isEnoent, isRecord } from "../type-guards.js";
|
|
8
|
+
import { describeKeyDefect } from "./taxonomy-schema.js";
|
|
9
|
+
const BLOCKS = new Set(['domains', 'provisional']);
|
|
10
|
+
export async function writeTaxonomy(input) {
|
|
11
|
+
const path = join(input.kbRoot.path, TAXONOMY_FILE);
|
|
12
|
+
for (const declaration of input.declarations) {
|
|
13
|
+
const defect = describeKeyDefect(declaration.path);
|
|
14
|
+
if (defect !== undefined) {
|
|
15
|
+
throw new KbLoaderError(`${path}: domain "${declaration.path}" ${defect}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const document = await readDocument(path);
|
|
19
|
+
const declared = readDeclaredPaths(document);
|
|
20
|
+
const added = [];
|
|
21
|
+
const byBlock = new Map();
|
|
22
|
+
for (const declaration of sortByPath(input.declarations)) {
|
|
23
|
+
if (declared.has(declaration.path)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
declared.add(declaration.path);
|
|
27
|
+
added.push(declaration.path);
|
|
28
|
+
const block = declaration.provisional ? 'provisional' : 'domains';
|
|
29
|
+
const description = declaration.description ?? '';
|
|
30
|
+
const entries = byBlock.get(block) ?? [];
|
|
31
|
+
entries.push([declaration.path, description === '' ? null : description]);
|
|
32
|
+
byBlock.set(block, entries);
|
|
33
|
+
}
|
|
34
|
+
if (added.length === 0) {
|
|
35
|
+
return { added };
|
|
36
|
+
}
|
|
37
|
+
for (const [block, entries] of byBlock) {
|
|
38
|
+
declareInBlock({ document, block, entries });
|
|
39
|
+
}
|
|
40
|
+
await writeAtomic(path, document.toString({ nullStr: '' }));
|
|
41
|
+
return { added };
|
|
42
|
+
}
|
|
43
|
+
function assertBlocksAppendable(document, path) {
|
|
44
|
+
const contents = document.contents;
|
|
45
|
+
if (!isMap(contents)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const pair of contents.items) {
|
|
49
|
+
if (!isPair(pair) || !isScalar(pair.key) || typeof pair.key.value !== 'string') {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!BLOCKS.has(pair.key.value) || isMap(pair.value) || isEmptyBlockValue(pair.value)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw new KbLoaderError(`${path}: "${pair.key.value}" must be a mapping of domain paths to descriptions`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function declareInBlock(input) {
|
|
59
|
+
const { document, block, entries } = input;
|
|
60
|
+
const emptied = findEmptyBlock(document, block);
|
|
61
|
+
if (emptied === undefined) {
|
|
62
|
+
for (const [path, description] of entries) {
|
|
63
|
+
document.setIn([block, path], description);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const comment = isScalar(emptied.value) ? emptied.value.comment : undefined;
|
|
68
|
+
if (typeof comment === 'string' && isScalar(emptied.key)) {
|
|
69
|
+
emptied.key.comment = comment;
|
|
70
|
+
}
|
|
71
|
+
emptied.value = document.createNode(Object.fromEntries(entries));
|
|
72
|
+
}
|
|
73
|
+
function findEmptyBlock(document, block) {
|
|
74
|
+
const contents = document.contents;
|
|
75
|
+
if (!isMap(contents)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
for (const pair of contents.items) {
|
|
79
|
+
if (isPair(pair) && isScalar(pair.key) && pair.key.value === block && isEmptyBlockValue(pair.value)) {
|
|
80
|
+
return pair;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function isEmptyBlockValue(value) {
|
|
86
|
+
return value === null || (isScalar(value) && value.value === null);
|
|
87
|
+
}
|
|
88
|
+
async function readDocument(path) {
|
|
89
|
+
let text = '';
|
|
90
|
+
try {
|
|
91
|
+
text = await readFile(path, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!isEnoent(error)) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const document = parseDocument(text);
|
|
99
|
+
const firstError = document.errors[0];
|
|
100
|
+
if (firstError !== undefined) {
|
|
101
|
+
throw new KbLoaderError(`${path}: malformed YAML — ${firstError.message}`);
|
|
102
|
+
}
|
|
103
|
+
if (document.contents !== null && !isMap(document.contents)) {
|
|
104
|
+
throw new KbLoaderError(`${path}: top-level must be a mapping`);
|
|
105
|
+
}
|
|
106
|
+
assertBlocksAppendable(document, path);
|
|
107
|
+
return document;
|
|
108
|
+
}
|
|
109
|
+
function readDeclaredPaths(document) {
|
|
110
|
+
const paths = new Set();
|
|
111
|
+
const contents = document.toJS();
|
|
112
|
+
if (!isRecord(contents)) {
|
|
113
|
+
return paths;
|
|
114
|
+
}
|
|
115
|
+
for (const block of BLOCKS) {
|
|
116
|
+
const declarations = contents[block];
|
|
117
|
+
if (isRecord(declarations)) {
|
|
118
|
+
for (const path of Object.keys(declarations)) {
|
|
119
|
+
paths.add(path);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return paths;
|
|
124
|
+
}
|
|
125
|
+
function sortByPath(declarations) {
|
|
126
|
+
return declarations.toSorted((a, b) => {
|
|
127
|
+
if (a.path === b.path)
|
|
128
|
+
return 0;
|
|
129
|
+
return a.path < b.path ? -1 : 1;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function writeAtomic(path, content) {
|
|
133
|
+
const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
134
|
+
await writeFile(tempPath, content, 'utf8');
|
|
135
|
+
try {
|
|
136
|
+
await rename(tempPath, path);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
await unlink(tempPath).catch(() => { });
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/esm/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/kb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Knowledge-base foundation: discovery, config, frontmatter parsing, records, tags, and vault-integrity checks",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontmatter",
|
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
"types": "./dist/esm/tags/index.d.ts",
|
|
77
77
|
"import": "./dist/esm/tags/index.js"
|
|
78
78
|
},
|
|
79
|
+
"./taxonomy": {
|
|
80
|
+
"source": "./src/taxonomy/index.ts",
|
|
81
|
+
"types": "./dist/esm/taxonomy/index.d.ts",
|
|
82
|
+
"import": "./dist/esm/taxonomy/index.js"
|
|
83
|
+
},
|
|
79
84
|
"./vault-integrity": {
|
|
80
85
|
"source": "./src/vault-integrity/index.ts",
|
|
81
86
|
"types": "./dist/esm/vault-integrity/index.d.ts",
|