From 379463563a4027835f07dd61866d1984b8bd5be2 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 05:35:50 -0700 Subject: [PATCH 01/12] kb|feat: Add the .kb/taxonomy.yaml loader and comment-preserving writer A knowledge base can now declare its intended assertion structure in `.kb/taxonomy.yaml`: domain paths with one-line descriptions, split between a reviewed `domains` block and a `provisional` block for domains not yet reviewed. Keys are relative to the assertions root and nest to any depth. The new `@williamthorsen/kb/taxonomy` entry point exposes `loadTaxonomy` and `writeTaxonomy`. Declaring domains preserves the file's existing comments, key order, and formatting, and leaves a path either block already declares as it stands. A taxonomy that is absent or declares nothing loads as empty rather than failing. A malformed key fails the load and names the file: a restated `content/assertions/` prefix, a leading or trailing slash, an empty segment, or a `.` or `..` segment. --- packages/kb/package.json | 5 + packages/kb/src/layout/index.ts | 1 + packages/kb/src/layout/store-layout.ts | 3 + .../__tests__/load-taxonomy.unit.test.ts | 104 ++++++++++ .../__tests__/write-taxonomy.unit.test.ts | 192 ++++++++++++++++++ packages/kb/src/taxonomy/index.ts | 9 + packages/kb/src/taxonomy/load-taxonomy.ts | 82 ++++++++ packages/kb/src/taxonomy/taxonomy-schema.ts | 53 +++++ packages/kb/src/taxonomy/write-taxonomy.ts | 143 +++++++++++++ packages/kb/src/test-utils/scaffolding.ts | 9 +- 10 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts create mode 100644 packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts create mode 100644 packages/kb/src/taxonomy/index.ts create mode 100644 packages/kb/src/taxonomy/load-taxonomy.ts create mode 100644 packages/kb/src/taxonomy/taxonomy-schema.ts create mode 100644 packages/kb/src/taxonomy/write-taxonomy.ts diff --git a/packages/kb/package.json b/packages/kb/package.json index 7ce52663..e72e76b8 100644 --- a/packages/kb/package.json +++ b/packages/kb/package.json @@ -76,6 +76,11 @@ "types": "./dist/esm/tags/index.d.ts", "import": "./dist/esm/tags/index.js" }, + "./taxonomy": { + "source": "./src/taxonomy/index.ts", + "types": "./dist/esm/taxonomy/index.d.ts", + "import": "./dist/esm/taxonomy/index.js" + }, "./vault-integrity": { "source": "./src/vault-integrity/index.ts", "types": "./dist/esm/vault-integrity/index.d.ts", diff --git a/packages/kb/src/layout/index.ts b/packages/kb/src/layout/index.ts index 8ba37be3..f5537c17 100644 --- a/packages/kb/src/layout/index.ts +++ b/packages/kb/src/layout/index.ts @@ -16,4 +16,5 @@ export { resolveEventPath, resolveEventsDir, resolveKbDir, + TAXONOMY_FILE, } from './store-layout.ts'; diff --git a/packages/kb/src/layout/store-layout.ts b/packages/kb/src/layout/store-layout.ts index 380ecd2c..ca9fef9a 100644 --- a/packages/kb/src/layout/store-layout.ts +++ b/packages/kb/src/layout/store-layout.ts @@ -28,6 +28,9 @@ export const CONFIG_FILE = `${KB_DIR}/config.yaml`; /** The directory holding the store's event records. */ export const EVENTS_DIR = `${CONTENT_DIR}/events`; +/** The declared assertion taxonomy. */ +export const TAXONOMY_FILE = `${KB_DIR}/taxonomy.yaml`; + /** * Builds an event record's store-relative path. Posix-separated, so it serves as the path half of a git object spec * (`@{upstream}:content/events/.md`) as well as an argument to `join`. diff --git a/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts b/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts new file mode 100644 index 00000000..b5e7cf0d --- /dev/null +++ b/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { isKbLoaderError, KbLoaderError } from '../../config/kb-loader-error.ts'; +import { makeKbRoot } from '../../test-utils/scaffolding.ts'; +import { loadTaxonomy } from '../load-taxonomy.ts'; + +describe(loadTaxonomy, () => { + it('returns an empty taxonomy when no taxonomy.yaml exists', async () => { + const kbRoot = await makeKbRoot(); + + expect(await loadTaxonomy({ kbRoot })).toEqual(new Map()); + }); + + it('returns an empty taxonomy for a file that declares nothing', async () => { + const kbRoot = await makeKbRoot({ taxonomy: '# a commented-out stub declares no domains\n' }); + + expect(await loadTaxonomy({ kbRoot })).toEqual(new Map()); + }); + + it('loads both blocks into one map, marking which block each entry came from', async () => { + const kbRoot = await makeKbRoot({ + taxonomy: 'domains:\n engineering: Software engineering practice\nprovisional:\n tools/vim: Vim\n', + }); + + expect(await loadTaxonomy({ kbRoot })).toEqual( + new Map([ + ['engineering', { description: 'Software engineering practice', provisional: false }], + ['tools/vim', { description: 'Vim', provisional: true }], + ]), + ); + }); + + it('loads a domain declared at any depth', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\n engineering/tooling/versioning: Releases\n' }); + + const taxonomy = await loadTaxonomy({ kbRoot }); + + expect(taxonomy.get('engineering/tooling/versioning')).toEqual({ description: 'Releases', provisional: false }); + }); + + it('reads a bare key as a domain declared without a description', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'provisional:\n engineering:\n' }); + + const taxonomy = await loadTaxonomy({ kbRoot }); + + expect(taxonomy.get('engineering')).toEqual({ description: '', provisional: true }); + }); + + it('loads a file declaring only one of the two blocks', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'provisional:\n tools: Tooling\n' }); + + expect(await loadTaxonomy({ kbRoot })).toEqual(new Map([['tools', { description: 'Tooling', provisional: true }]])); + }); + + it('throws a KbLoaderError naming the file when the YAML is malformed', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains: [unterminated\n' }); + + await expect(loadTaxonomy({ kbRoot })).rejects.toBeInstanceOf(KbLoaderError); + await expect(loadTaxonomy({ kbRoot })).rejects.toThrow(/taxonomy\.yaml/); + }); + + it('throws a KbLoaderError when a description is not a string', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\n engineering: 42\n' }); + + await expect(loadTaxonomy({ kbRoot })).rejects.toBeInstanceOf(KbLoaderError); + }); + + it('throws a KbLoaderError when the top level is not a mapping', async () => { + const kbRoot = await makeKbRoot({ taxonomy: '- engineering\n' }); + + await expect(loadTaxonomy({ kbRoot })).rejects.toBeInstanceOf(KbLoaderError); + }); + + it('throws a KbLoaderError naming the path declared in both blocks', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\n engineering: Practice\nprovisional:\n engineering:\n' }); + + await expect(loadTaxonomy({ kbRoot })).rejects.toThrow(/"engineering" is declared in both/); + }); + + it.each([ + ['""', 'is empty'], + ['"/engineering"', 'leading or trailing slash'], + ['"engineering/"', 'leading or trailing slash'], + ['"engineering//tooling"', 'empty segment'], + ['"engineering/./tooling"', '"." or ".." segment'], + ['"engineering/../tooling"', '"." or ".." segment'], + ['"assertions/engineering"', 'restates'], + ['"content/assertions/engineering"', 'restates'], + [String.raw`"engineering\\tooling"`, 'backslash'], + ])('rejects the malformed key %s', async (key, reason) => { + const kbRoot = await makeKbRoot({ taxonomy: `domains:\n ${key}: Practice\n` }); + + const error = await loadTaxonomy({ kbRoot }).catch((error_: unknown) => error_); + + expect(isKbLoaderError(error)).toBe(true); + expect(String(error)).toContain(reason); + }); + + it('rejects a malformed key in the provisional block too', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'provisional:\n "assertions/engineering":\n' }); + + await expect(loadTaxonomy({ kbRoot })).rejects.toBeInstanceOf(KbLoaderError); + }); +}); diff --git a/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts b/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts new file mode 100644 index 00000000..3d4bbbd2 --- /dev/null +++ b/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts @@ -0,0 +1,192 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { KbLoaderError } from '../../config/kb-loader-error.ts'; +import { TAXONOMY_FILE } from '../../layout/index.ts'; +import { makeKbRoot } from '../../test-utils/scaffolding.ts'; +import type { KbRoot } from '../../types.ts'; +import { loadTaxonomy } from '../load-taxonomy.ts'; +import { writeTaxonomy } from '../write-taxonomy.ts'; + +const SEEDED = `# Taxonomy for this store. +domains: + # the engineering spine + engineering: Software engineering practice + engineering/tooling: Build, test, and development tooling +`; + +describe(writeTaxonomy, () => { + it('creates the file and the requested block when none exists', async () => { + const kbRoot = await makeKbRoot(); + + const { added } = await writeTaxonomy({ + kbRoot, + declarations: [{ path: 'engineering', description: 'Practice', provisional: false }], + }); + + expect(added).toEqual(['engineering']); + expect(await readTaxonomy(kbRoot)).toBe('domains:\n engineering: Practice\n'); + }); + + it('preserves comments and existing key order when appending', async () => { + const kbRoot = await makeKbRoot({ taxonomy: SEEDED }); + + await writeTaxonomy({ + kbRoot, + declarations: [{ path: 'languages', description: 'Programming languages', provisional: false }], + }); + + expect(await readTaxonomy(kbRoot)).toBe(`${SEEDED} languages: Programming languages\n`); + }); + + it('creates a missing block beside an existing one', async () => { + const kbRoot = await makeKbRoot({ taxonomy: SEEDED }); + + await writeTaxonomy({ kbRoot, declarations: [{ path: 'tools/vim', provisional: true }] }); + + expect(await readTaxonomy(kbRoot)).toBe(`${SEEDED}provisional:\n tools/vim:\n`); + }); + + it('appends a batch in path order rather than call order', async () => { + const kbRoot = await makeKbRoot(); + + const { added } = await writeTaxonomy({ + kbRoot, + declarations: [ + { path: 'tools', provisional: true }, + { path: 'engineering', provisional: true }, + { path: 'languages', provisional: true }, + ], + }); + + expect(added).toEqual(['engineering', 'languages', 'tools']); + expect(await readTaxonomy(kbRoot)).toBe('provisional:\n engineering:\n languages:\n tools:\n'); + }); + + it('routes each declaration to the block its provisional flag names', async () => { + const kbRoot = await makeKbRoot(); + + await writeTaxonomy({ + kbRoot, + declarations: [ + { path: 'engineering', description: 'Practice', provisional: false }, + { path: 'scratch', provisional: true }, + ], + }); + + expect(await readTaxonomy(kbRoot)).toBe('domains:\n engineering: Practice\nprovisional:\n scratch:\n'); + }); + + it('writes a description-less domain as a bare key', async () => { + const kbRoot = await makeKbRoot(); + + await writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', description: '', provisional: true }] }); + + expect(await readTaxonomy(kbRoot)).toBe('provisional:\n engineering:\n'); + }); + + it('leaves the file untouched when every path is already declared', async () => { + const kbRoot = await makeKbRoot({ taxonomy: SEEDED }); + + const { added } = await writeTaxonomy({ + kbRoot, + declarations: [{ path: 'engineering', description: 'A different description', provisional: false }], + }); + + expect(added).toEqual([]); + expect(await readTaxonomy(kbRoot)).toBe(SEEDED); + }); + + it('skips a path the other block declares rather than duplicating it across blocks', async () => { + const kbRoot = await makeKbRoot({ taxonomy: SEEDED }); + + const { added } = await writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: true }] }); + + expect(added).toEqual([]); + expect(await readTaxonomy(kbRoot)).toBe(SEEDED); + }); + + it('adds only the absent paths of a partially-declared batch', async () => { + const kbRoot = await makeKbRoot({ taxonomy: SEEDED }); + + const { added } = await writeTaxonomy({ + kbRoot, + declarations: [ + { path: 'engineering', provisional: false }, + { path: 'languages', provisional: false }, + ], + }); + + expect(added).toEqual(['languages']); + }); + + it('appends to a comment-only stub without disturbing its comments', async () => { + const stub = '# Taxonomy for this store.\n#\n# Keys are relative to content/assertions/.\n'; + const kbRoot = await makeKbRoot({ taxonomy: stub }); + + await writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: true }] }); + + expect(await readTaxonomy(kbRoot)).toBe(`${stub}\nprovisional:\n engineering:\n`); + }); + + it('writes what loadTaxonomy reads back', async () => { + const kbRoot = await makeKbRoot(); + + await writeTaxonomy({ + kbRoot, + declarations: [ + { path: 'engineering', description: 'Practice', provisional: false }, + { path: 'engineering/tooling', provisional: true }, + ], + }); + + expect(await loadTaxonomy({ kbRoot })).toEqual( + new Map([ + ['engineering', { description: 'Practice', provisional: false }], + ['engineering/tooling', { description: '', provisional: true }], + ]), + ); + }); + + it('throws on a malformed key without writing any of the batch', async () => { + const kbRoot = await makeKbRoot(); + + await expect( + writeTaxonomy({ + kbRoot, + declarations: [ + { path: 'engineering', provisional: false }, + { path: 'content/assertions/tools', provisional: false }, + ], + }), + ).rejects.toBeInstanceOf(KbLoaderError); + await expect(readTaxonomy(kbRoot)).rejects.toThrow(/ENOENT/); + }); + + it('refuses a file whose YAML cannot be parsed', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains: [unterminated\n' }); + + await expect( + writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: false }] }), + ).rejects.toBeInstanceOf(KbLoaderError); + }); + + it('refuses a file whose top level is not a mapping', async () => { + const kbRoot = await makeKbRoot({ taxonomy: '- engineering\n' }); + + await expect( + writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: false }] }), + ).rejects.toThrow(/mapping/); + }); +}); + +// region | Helpers + +/** Reads the store's taxonomy file as raw text, so a test can assert on formatting rather than parsed content. */ +function readTaxonomy(kbRoot: KbRoot): Promise { + return readFile(join(kbRoot.path, TAXONOMY_FILE), 'utf8'); +} + +// endregion | Helpers diff --git a/packages/kb/src/taxonomy/index.ts b/packages/kb/src/taxonomy/index.ts new file mode 100644 index 00000000..9c67f2b9 --- /dev/null +++ b/packages/kb/src/taxonomy/index.ts @@ -0,0 +1,9 @@ +// Subpath barrel for @williamthorsen/kb/taxonomy. +// +// Owns `.kb/taxonomy.yaml` end to end: the schema, the loader, and the comment-preserving writer. The writer sits +// beside the loader because its callers span a package boundary: this package's back-fill command, and the `kb-add` +// helper in `packages/agents`. + +export { loadTaxonomy } from './load-taxonomy.ts'; +export { describeKeyDefect, type Taxonomy, type TaxonomyEntry, taxonomyFileShape } from './taxonomy-schema.ts'; +export { type TaxonomyDeclaration, writeTaxonomy } from './write-taxonomy.ts'; diff --git a/packages/kb/src/taxonomy/load-taxonomy.ts b/packages/kb/src/taxonomy/load-taxonomy.ts new file mode 100644 index 00000000..f28a8960 --- /dev/null +++ b/packages/kb/src/taxonomy/load-taxonomy.ts @@ -0,0 +1,82 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { parse } from 'yaml'; + +import { KbLoaderError } from '../config/kb-loader-error.ts'; +import { TAXONOMY_FILE } from '../layout/index.ts'; +import { isEnoent } from '../type-guards.ts'; +import type { KbRoot } from '../types.ts'; +import { describeKeyDefect, type Taxonomy, type TaxonomyEntry, taxonomyFileShape } from './taxonomy-schema.ts'; + +/** + * Loads `.kb/taxonomy.yaml` into a single keyed map, returning an empty taxonomy when the file is absent or declares + * nothing. The two on-disk blocks are a file-format concern: a consumer looks a domain up once and reads `provisional` + * off the entry it finds. + * + * Mirrors {@link loadKbConfig}: structural defects (malformed YAML, a wrong type, a malformed key, a path declared in + * both blocks) throw a {@link KbLoaderError} naming the file. I/O errors other than a missing file propagate. + */ +export async function loadTaxonomy(input: { kbRoot: KbRoot }): Promise { + const path = join(input.kbRoot.path, TAXONOMY_FILE); + + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (isEnoent(error)) { + return new Map(); + } + throw error; + } + + let parsed: unknown; + try { + parsed = parse(text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KbLoaderError(`${path}: malformed YAML — ${message}`); + } + + // A comment-only file parses to null, which is a taxonomy declaring nothing rather than a defect. + const result = taxonomyFileShape.safeParse(parsed ?? {}); + if (!result.success) { + throw new KbLoaderError(`${path}: invalid taxonomy.yaml — ${result.error.issues[0]?.message ?? 'unknown error'}`); + } + + const entries = new Map(); + collectBlock({ entries, block: result.data.domains, provisional: false, path }); + collectBlock({ entries, block: result.data.provisional, provisional: true, path }); + return entries; +} + +// region | Helpers + +/** + * Adds one on-disk block's declarations to the accumulating map, rejecting a malformed key and a path the other block + * already declared. Called for `domains` first, so a collision always names a path `provisional` redeclares. + */ +function collectBlock(input: { + entries: Map; + block: Record | undefined; + provisional: boolean; + path: string; +}): void { + const { entries, block, provisional, path } = input; + if (block === undefined) { + return; + } + + for (const [key, description] of Object.entries(block)) { + const defect = describeKeyDefect(key); + if (defect !== undefined) { + throw new KbLoaderError(`${path}: domain "${key}" ${defect}`); + } + if (entries.has(key)) { + throw new KbLoaderError(`${path}: domain "${key}" is declared in both domains and provisional`); + } + entries.set(key, { description: description ?? '', provisional }); + } +} + +// endregion | Helpers diff --git a/packages/kb/src/taxonomy/taxonomy-schema.ts b/packages/kb/src/taxonomy/taxonomy-schema.ts new file mode 100644 index 00000000..1055137c --- /dev/null +++ b/packages/kb/src/taxonomy/taxonomy-schema.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +import { ASSERTIONS_DIR, ASSERTIONS_SEGMENT, CONTENT_DIR } from '../layout/index.ts'; + +/** + * Describes why a domain key cannot be used, or returns `undefined` when the key is well-formed. Keys are relative to + * the assertions root, so a restated `content/assertions/` prefix would declare the domain a level deeper than the + * author meant; `kb-add` refuses the same mistake on the note-write path. + */ +export function describeKeyDefect(key: string): string | undefined { + if (key === '') { + return 'is empty'; + } + if (key.includes('\\')) { + return 'contains a backslash; domain paths are slash-separated'; + } + if (key.startsWith('/') || key.endsWith('/')) { + return 'has a leading or trailing slash'; + } + + const segments = key.split('/'); + if (segments.some((segment) => segment.trim() === '')) { + return 'has an empty segment'; + } + if (segments.some((segment) => segment === '.' || segment === '..')) { + return 'has a "." or ".." segment'; + } + if (segments[0] === ASSERTIONS_SEGMENT || (segments[0] === CONTENT_DIR && segments[1] === ASSERTIONS_SEGMENT)) { + return `restates the ${ASSERTIONS_DIR}/ prefix, which every key implies`; + } + return undefined; +} + +/** A knowledge base's declared assertion structure, keyed by assertions-root-relative slash-path. */ +export type Taxonomy = ReadonlyMap; + +/** A single declared domain. */ +export interface TaxonomyEntry { + /** The domain's one-line description; empty when it was declared without one. */ + description: string; + /** Whether the domain was declared under `provisional:` rather than `domains:`. */ + provisional: boolean; +} + +/** + * The on-disk `.kb/taxonomy.yaml` shape. Both blocks are optional so a file may declare only one, and a description may + * be null so a key written bare (`engineering/tooling:`) loads rather than failing: that is what a hand editor types + * and what a back-filled entry round-trips to. + */ +export const taxonomyFileShape = z.object({ + domains: z.record(z.string(), z.string().nullable()).optional(), + provisional: z.record(z.string(), z.string().nullable()).optional(), +}); diff --git a/packages/kb/src/taxonomy/write-taxonomy.ts b/packages/kb/src/taxonomy/write-taxonomy.ts new file mode 100644 index 00000000..8b69c136 --- /dev/null +++ b/packages/kb/src/taxonomy/write-taxonomy.ts @@ -0,0 +1,143 @@ +import { randomBytes } from 'node:crypto'; +import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { type Document, isMap, parseDocument } from 'yaml'; + +import { KbLoaderError } from '../config/kb-loader-error.ts'; +import { TAXONOMY_FILE } from '../layout/index.ts'; +import { isEnoent, isRecord } from '../type-guards.ts'; +import type { KbRoot } from '../types.ts'; +import { describeKeyDefect } from './taxonomy-schema.ts'; + +/** A domain to declare. */ +export interface TaxonomyDeclaration { + /** The domain's assertions-root-relative slash-path. */ + path: string; + /** The one-line description; absent or empty writes the key bare, with no description. */ + description?: string; + /** Whether to declare under `provisional:` rather than `domains:`. */ + provisional: boolean; +} + +/** + * Declares domains in `.kb/taxonomy.yaml`, creating the file and either block as needed, and returns the paths added. + * + * Edits the parsed document rather than re-serializing a plain object, so existing comments, key order, and formatting + * survive; a plain parse-and-stringify round trip would discard every comment in a hand-curated file. New keys append + * to the end of their block in path order. + * + * A path either block already declares is skipped rather than overwritten, so a repeat call adds nothing; when nothing + * is left to add, the file is not opened for writing at all. The write is atomic (temp file plus rename, matching + * `note-io`), so an interrupted call cannot truncate the taxonomy. + * + * Throws a {@link KbLoaderError} on a malformed key, or on an existing file that cannot be safely appended to. Other + * I/O errors propagate. + */ +export async function writeTaxonomy(input: { + kbRoot: KbRoot; + declarations: readonly TaxonomyDeclaration[]; +}): Promise<{ added: string[] }> { + const path = join(input.kbRoot.path, TAXONOMY_FILE); + + // Validate every key before opening the file, so a bad batch cannot write half of itself. + for (const declaration of input.declarations) { + const defect = describeKeyDefect(declaration.path); + if (defect !== undefined) { + throw new KbLoaderError(`${path}: domain "${declaration.path}" ${defect}`); + } + } + + const document = await readDocument(path); + const declared = readDeclaredPaths(document); + + const added: string[] = []; + for (const declaration of sortByPath(input.declarations)) { + if (declared.has(declaration.path)) { + continue; + } + const description = declaration.description ?? ''; + const block = declaration.provisional ? 'provisional' : 'domains'; + document.setIn([block, declaration.path], description === '' ? null : description); + declared.add(declaration.path); + added.push(declaration.path); + } + + if (added.length === 0) { + return { added }; + } + + // `nullStr` renders a description-less domain as a bare `engineering/tooling:` rather than an explicit `null`, which + // is what a hand editor writes and what keeps a back-filled file readable. + await writeAtomic(path, document.toString({ nullStr: '' })); + return { added }; +} + +// region | Helpers + +/** + * Reads the taxonomy into an editable document, treating an absent file as an empty one. Refuses a file this cannot + * safely append to: rewriting a file with a parse error would discard whatever the parser could not read, and a + * non-mapping top level has no block to append to. + */ +async function readDocument(path: string): Promise { + let text = ''; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } + + const document = parseDocument(text); + const firstError = document.errors[0]; + if (firstError !== undefined) { + throw new KbLoaderError(`${path}: malformed YAML — ${firstError.message}`); + } + if (document.contents !== null && !isMap(document.contents)) { + throw new KbLoaderError(`${path}: top-level must be a mapping`); + } + return document; +} + +/** Collects the paths both blocks already declare, so an existing declaration is skipped rather than overwritten. */ +function readDeclaredPaths(document: Document): Set { + const paths = new Set(); + const contents: unknown = document.toJS(); + if (!isRecord(contents)) { + return paths; + } + + for (const block of ['domains', 'provisional']) { + const declarations = contents[block]; + if (isRecord(declarations)) { + for (const path of Object.keys(declarations)) { + paths.add(path); + } + } + } + return paths; +} + +/** Orders a batch by path, so appended keys read alphabetically rather than in call order. */ +function sortByPath(declarations: readonly TaxonomyDeclaration[]): TaxonomyDeclaration[] { + return declarations.toSorted((a, b) => { + if (a.path === b.path) return 0; + return a.path < b.path ? -1 : 1; + }); +} + +/** Writes `content` to `path` via a same-directory temp file plus rename, so a reader never sees a partial write. */ +async function writeAtomic(path: string, content: string): Promise { + const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp`; + await writeFile(tempPath, content, 'utf8'); + try { + await rename(tempPath, path); + } catch (error) { + await unlink(tempPath).catch(() => {}); + throw error; + } +} + +// endregion | Helpers diff --git a/packages/kb/src/test-utils/scaffolding.ts b/packages/kb/src/test-utils/scaffolding.ts index 7a9085e5..0dadcce7 100644 --- a/packages/kb/src/test-utils/scaffolding.ts +++ b/packages/kb/src/test-utils/scaffolding.ts @@ -9,7 +9,7 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { ALIASES_FILE, CONFIG_FILE, resolveKbDir } from '../layout/index.ts'; +import { ALIASES_FILE, CONFIG_FILE, resolveKbDir, TAXONOMY_FILE } from '../layout/index.ts'; import type { Finding, KbRoot } from '../types.ts'; /** Stages everything under `dir` and commits it with `message`, returning the new commit SHA. */ @@ -38,11 +38,14 @@ export function kbRootAt(path: string): KbRoot { } /** Stands up a temp KB root with an initialized `.kb/`, writes any supplied seed files into it, and returns its `KbRoot`. */ -export async function makeKbRoot(seeds: { config?: string; aliases?: string } = {}): Promise { +export async function makeKbRoot( + seeds: { aliases?: string; config?: string; taxonomy?: string } = {}, +): Promise { const path = await makeTempDir('kb-root-'); await mkdir(resolveKbDir(path), { recursive: true }); - if (seeds.config !== undefined) await writeFile(join(path, CONFIG_FILE), seeds.config, 'utf8'); if (seeds.aliases !== undefined) await writeFile(join(path, ALIASES_FILE), seeds.aliases, 'utf8'); + if (seeds.config !== undefined) await writeFile(join(path, CONFIG_FILE), seeds.config, 'utf8'); + if (seeds.taxonomy !== undefined) await writeFile(join(path, TAXONOMY_FILE), seeds.taxonomy, 'utf8'); return kbRootAt(path); } From 78dde3668ff812d2d40d9a1a128336daa5af79c7 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 05:39:06 -0700 Subject: [PATCH 02/12] kb|feat: Let a check finding describe the store rather than a note A `kb check` finding can now describe the store itself. Such a finding is reported under every run, including one narrowed to selected paths, one narrowed by `--vs`, and one that matched no notes at all, where it follows the line explaining why nothing was checked. Findings about a note keep being reported only when the run covers that note. The `--json` report carries a `scope` field on every finding, absent for a finding about a note. --- .../kb/src/cli/__tests__/check.tool.test.ts | 93 +++++++++++++++++++ packages/kb/src/cli/commands/check.ts | 9 +- packages/kb/src/cli/format.ts | 13 ++- packages/kb/src/test-utils/scaffolding.ts | 11 ++- packages/kb/src/types.ts | 8 +- 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/packages/kb/src/cli/__tests__/check.tool.test.ts b/packages/kb/src/cli/__tests__/check.tool.test.ts index 607308fb..0c248628 100644 --- a/packages/kb/src/cli/__tests__/check.tool.test.ts +++ b/packages/kb/src/cli/__tests__/check.tool.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { check } from '../../check/check.ts'; +import { TAXONOMY_FILE } from '../../layout/index.ts'; import { commitAll, getRegistryPathFor, @@ -15,6 +16,8 @@ import { } from '../../test-utils/scaffolding.ts'; import { run } from '../run.ts'; +const { check: realCheck } = await vi.importActual('../../check/check.ts'); + // Mock `check` with a passthrough to the real implementation so most tests run // against real stores; the non-loader-error pass-through test overrides it per-call. vi.mock('../../check/check.ts', async () => { @@ -325,6 +328,73 @@ describe('kb check targeting', () => { }); }); +describe('kb check vault-scoped findings', () => { + it('reports a vault-scoped finding on a whole-vault run', async () => { + const store = await makeStore({ 'content/Clean.md': VALID }); + stubVaultFinding(); + + const result = await run({ argv: ['check'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('taxonomy.undeclared'); + }); + + it('keeps a vault-scoped finding under a path-targeted run', async () => { + const store = await makeStore({ 'content/Clean.md': VALID, 'content/Other.md': VALID }); + stubVaultFinding(); + + const result = await run({ argv: ['check', 'content/Clean.md'], cwd: store }); + + expect(result.stdout).toContain('taxonomy.undeclared'); + expect(result.stdout).toContain('in 1 notes'); + }); + + it('keeps a vault-scoped finding under a --vs run', async () => { + const store = await makeStore({ 'content/Clean.md': VALID }); + initGitRepo(store); + const base = commitAll(store, 'base'); + await writeFile(join(store, 'content', 'Added.md'), VALID, 'utf8'); + commitAll(store, 'add a note'); + stubVaultFinding(); + + const result = await run({ argv: ['check', `--vs=${base}`], cwd: store }); + + expect(result.stdout).toContain('taxonomy.undeclared'); + }); + + it('reports a vault-scoped finding after the zero-match line when no notes were checked', async () => { + const store = await makeStore({ 'Loose.md': VALID }); + stubVaultFinding(); + + const result = await run({ argv: ['check'], cwd: store }); + + expect(result.stdout).toContain('no notes matched content/**/*.md (0 checked)'); + expect(result.stdout).toContain('taxonomy.undeclared'); + expect(result.stdout).toContain('in 0 notes'); + }); + + it('drops an unselected note-scoped finding while keeping the vault-scoped one', async () => { + const store = await makeStore({ 'content/Clean.md': VALID, 'content/Bad.md': UNRESOLVED_LINK }); + stubVaultFinding(); + + const result = await run({ argv: ['check', 'content/Clean.md'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('wikilinks.unresolved'); + expect(result.stdout).toContain('taxonomy.undeclared'); + }); + + it('carries the scope through the JSON report', async () => { + const store = await makeStore({ 'content/Clean.md': VALID }); + stubVaultFinding(); + + const result = await run({ argv: ['check', '--json'], cwd: store }); + const payload: unknown = JSON.parse(result.stdout); + + expect(payload).toMatchObject({ findings: [{ rule: 'taxonomy.undeclared', scope: 'vault' }] }); + }); +}); + // region | Helpers /** Stands up an isolated home registering `name → storePath` in `~/.agents/kb.yaml`; returns the home dir. */ @@ -334,4 +404,27 @@ async function makeHome(name: string, storePath: string): Promise { return home; } +/** + * Makes the next `check` call return its real result plus one vault-scoped finding, so the CLI's handling of a finding + * that describes the store rather than a note can be exercised independently of the rules that produce them. + */ +function stubVaultFinding(): void { + vi.mocked(check).mockImplementationOnce(async (input) => { + const result = await realCheck(input); + return { + ...result, + findings: [ + ...result.findings, + { + path: join(input.kbRoot, TAXONOMY_FILE), + scope: 'vault', + rule: 'taxonomy.undeclared', + severity: 'warning', + message: 'folder "engineering" holds notes but no domain declares it', + }, + ], + }; + }); +} + // endregion | Helpers diff --git a/packages/kb/src/cli/commands/check.ts b/packages/kb/src/cli/commands/check.ts index 6b96698a..709ef921 100644 --- a/packages/kb/src/cli/commands/check.ts +++ b/packages/kb/src/cli/commands/check.ts @@ -185,9 +185,10 @@ type SelectionOutcome = /** * Narrows a whole-vault `CheckResult` to the notes the run targets. A bare run passes through unchanged; `--vs` * resolves changed paths via git (a bad ref fails for exit 2), and pattern selection drops non-notes while reporting - * a path that matches nothing real as a usage error. Findings are filtered to the selected notes by their absolute - * path, so cross-references stay resolved against the whole vault while the report and exit code cover only the - * selection. + * a path that matches nothing real as a usage error. Note-scoped findings are filtered to the selected notes by their + * absolute path, so cross-references stay resolved against the whole vault while the report and exit code cover only + * the selection. Vault-scoped findings describe the store rather than any one note, so they bypass the filter and + * appear under every selection. */ async function resolveSelection(input: { options: CheckOptions; @@ -217,7 +218,7 @@ async function resolveSelection(input: { } const selectedPaths = new Set(selection.selected.map((entry) => entry.path)); - const findings = result.findings.filter((finding) => selectedPaths.has(finding.path)); + const findings = result.findings.filter((finding) => finding.scope === 'vault' || selectedPaths.has(finding.path)); return { ok: true, scope, notes: selection.selected, findings }; } diff --git a/packages/kb/src/cli/format.ts b/packages/kb/src/cli/format.ts index 426e68ac..841f5545 100644 --- a/packages/kb/src/cli/format.ts +++ b/packages/kb/src/cli/format.ts @@ -26,8 +26,12 @@ export type CheckScope = 'vault' | 'patterns' | 'vs'; /** * Renders the default human output. Findings are grouped by file in path order, each line reading * ` (line N): message`. A clean run (notes checked, no findings) prints `✓ no findings (N notes - * checked)`; a run that checked nothing prints a zero-match line worded for its `scope` — naming the config targets - * for a whole-vault run, and a scope-appropriate line for a targeted one — without the `✓`, since no check ran. + * checked)`; a run that checked nothing prints a zero-match line worded for its `scope` (naming the config targets for + * a whole-vault run, and a scope-appropriate line for a targeted one) without the `✓`, since no check ran. + * + * A run can check no notes and still carry vault-scoped findings, which describe the store rather than any note. The + * zero-match line then heads the report instead of replacing it: it explains why no note was checked, and the findings + * follow. */ export function formatHuman(input: { summary: CheckSummary; @@ -37,7 +41,7 @@ export function formatHuman(input: { }): string { const { summary, findings, targets, scope } = input; - if (summary.notes === 0) { + if (summary.notes === 0 && findings.length === 0) { return `${zeroMatchLine(scope, targets)}\n`; } if (findings.length === 0) { @@ -45,6 +49,9 @@ export function formatHuman(input: { } const lines: string[] = []; + if (summary.notes === 0) { + lines.push(zeroMatchLine(scope, targets), ''); + } for (const [path, group] of groupByPath(findings)) { lines.push(path); for (const finding of group) { diff --git a/packages/kb/src/test-utils/scaffolding.ts b/packages/kb/src/test-utils/scaffolding.ts index 0dadcce7..d385e71a 100644 --- a/packages/kb/src/test-utils/scaffolding.ts +++ b/packages/kb/src/test-utils/scaffolding.ts @@ -79,15 +79,20 @@ export async function makeTree(files: Record): Promise { return root; } -/** Sorts findings by path, then line, then rule, into a canonical order for order-independent comparison. */ +/** + * Sorts findings by path, then line, then rule, then message, into a canonical order for order-independent + * comparison. Message breaks the tie because a vault-scoped rule reports every one of its findings against the same + * file with no line, so path, line, and rule alone leave them indistinguishable. + */ export function normalizeFindings(findings: readonly Finding[]): Finding[] { return findings.toSorted((a, b) => { if (a.path !== b.path) return a.path < b.path ? -1 : 1; const lineA = a.line ?? 0; const lineB = b.line ?? 0; if (lineA !== lineB) return lineA - lineB; - if (a.rule === b.rule) return 0; - return a.rule < b.rule ? -1 : 1; + if (a.rule !== b.rule) return a.rule < b.rule ? -1 : 1; + if (a.message === b.message) return 0; + return a.message < b.message ? -1 : 1; }); } diff --git a/packages/kb/src/types.ts b/packages/kb/src/types.ts index 2554f6a8..68785037 100644 --- a/packages/kb/src/types.ts +++ b/packages/kb/src/types.ts @@ -86,8 +86,14 @@ export type FindingSeverity = 'error' | 'warning'; /** A single validation finding produced by a rule. */ export interface Finding { - /** Path or label of the note the finding applies to. */ + /** Path or label the finding applies to: a note, or the store file that declares the rule's subject. */ path: string; + /** + * What the finding describes, defaulting to `note` when absent. A note-scoped finding is dropped when a run's + * selection excludes its note; a vault-scoped one describes the store itself, so it survives every selection, + * including one matching no notes at all. + */ + scope?: 'note' | 'vault'; /** 1-based source line number, when known. */ line?: number; /** Rule code, e.g. `frontmatter.required`. */ From dfa4ec4ad5169b71659411e2ec17439590dde60a Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 05:42:32 -0700 Subject: [PATCH 03/12] kb|feat: Report taxonomy drift from kb check `kb check` now reports where a store's assertion folders and its declared taxonomy disagree. `taxonomy.undeclared` names a folder that holds notes but that no domain declares, `taxonomy.unused` a declared domain with no note at or beneath it, and `taxonomy.orphan` a declared domain whose parent is undeclared. All three are warnings, so drift is reported without failing the run, and all three describe the store rather than any one note, so a run narrowed to selected paths or to `--vs` reports them too. A store whose taxonomy is absent or declares nothing reports none of them, so the rules apply only to a store that has adopted a taxonomy. --- packages/kb/src/check/check.ts | 29 +++- .../kb/src/cli/__tests__/check.tool.test.ts | 22 +++ .../src/lints/__tests__/taxonomy.unit.test.ts | 147 ++++++++++++++++++ packages/kb/src/lints/index.ts | 1 + packages/kb/src/lints/taxonomy.ts | 119 ++++++++++++++ 5 files changed, 310 insertions(+), 8 deletions(-) create mode 100644 packages/kb/src/lints/__tests__/taxonomy.unit.test.ts create mode 100644 packages/kb/src/lints/taxonomy.ts diff --git a/packages/kb/src/check/check.ts b/packages/kb/src/check/check.ts index f79657ea..036193a6 100644 --- a/packages/kb/src/check/check.ts +++ b/packages/kb/src/check/check.ts @@ -1,9 +1,13 @@ +import { join } from 'node:path'; + import type { KbConfig } from '../config/config-schema.ts'; import { loadKbConfig } from '../config/load-config.ts'; -import { resolveKbDir } from '../layout/index.ts'; +import { resolveKbDir, TAXONOMY_FILE } from '../layout/index.ts'; import { pathsFindings } from '../lints/paths.ts'; import { tagAliasFindings } from '../lints/tag-alias.ts'; +import { taxonomyFindings } from '../lints/taxonomy.ts'; import { loadAliases } from '../tags/load-aliases.ts'; +import { loadTaxonomy } from '../taxonomy/load-taxonomy.ts'; import type { Finding, KbRoot } from '../types.ts'; import { checkVaultIntegrity } from '../vault-integrity/check-vault-integrity.ts'; import { type EnumeratedNote, enumerateNotes } from './enumerate.ts'; @@ -14,32 +18,41 @@ export interface CheckResult { config: KbConfig; /** Every note enumerated under the store's `config.targets`, in walk order. */ notes: readonly EnumeratedNote[]; - /** Findings from whole-vault integrity (unresolved links, basename collisions) and the tag-alias and paths lints. */ + /** + * Findings from whole-vault integrity (unresolved links, basename collisions), taxonomy drift, and the tag-alias and + * paths lints. + */ findings: readonly Finding[]; } /** - * Runs the store's config-driven check: load `.kb/config.yaml` and `.kb/tag-aliases.yaml`, enumerate notes under the - * config's `targets`/`exclude`, and compose whole-vault integrity with the type-blind per-note lints across them. - * Frontmatter validity is owned by the record types at write time, so no frontmatter re-validation runs here. + * Runs the store's config-driven check: load `.kb/config.yaml`, `.kb/tag-aliases.yaml`, and `.kb/taxonomy.yaml`, + * enumerate notes under the config's `targets`/`exclude`, and compose whole-vault integrity and taxonomy drift with + * the type-blind per-note lints across them. Frontmatter validity is owned by the record types at write time, so no + * frontmatter re-validation runs here. * * Returns the effective config alongside the enumerated notes and findings, so a consumer (e.g. `kb-curate`) can layer * its own detectors over the same enumeration without walking the tree twice, and can read the resolved * `targets`/`exclude` without re-loading `.kb/config.yaml`. * - * A structural defect in either loaded file throws a `KbLoaderError` (the loaders' own contract); the caller decides - * how to surface it. Any other error from enumeration or the checks propagates unchanged — it is never relabeled as a + * A structural defect in any loaded file throws a `KbLoaderError` (the loaders' own contract); the caller decides how + * to surface it. Any other error from enumeration or the checks propagates unchanged — it is never relabeled as a * config defect. */ export async function check(input: { kbRoot: string }): Promise { const kbRoot: KbRoot = { path: input.kbRoot, kbDir: resolveKbDir(input.kbRoot) }; - const [config, aliases] = await Promise.all([loadKbConfig({ kbRoot }), loadAliases({ kbRoot })]); + const [config, aliases, taxonomy] = await Promise.all([ + loadKbConfig({ kbRoot }), + loadAliases({ kbRoot }), + loadTaxonomy({ kbRoot }), + ]); const notes = await enumerateNotes({ kbRoot: input.kbRoot, config }); const findings: Finding[] = [ ...checkVaultIntegrity(notes), + ...taxonomyFindings({ notes, taxonomy, config, taxonomyPath: join(input.kbRoot, TAXONOMY_FILE) }), ...notes.flatMap((note) => [...tagAliasFindings(note, aliases), ...pathsFindings(note)]), ]; diff --git a/packages/kb/src/cli/__tests__/check.tool.test.ts b/packages/kb/src/cli/__tests__/check.tool.test.ts index 0c248628..d467261a 100644 --- a/packages/kb/src/cli/__tests__/check.tool.test.ts +++ b/packages/kb/src/cli/__tests__/check.tool.test.ts @@ -384,6 +384,28 @@ describe('kb check vault-scoped findings', () => { expect(result.stdout).toContain('taxonomy.undeclared'); }); + it('reports real taxonomy drift under a run targeting an unrelated note', async () => { + const store = await makeStore({ + '.kb/taxonomy.yaml': 'domains:\n languages: Programming languages\n', + 'content/assertions/engineering/Note.md': VALID, + 'content/assertions/languages/Other.md': VALID, + }); + + const result = await run({ argv: ['check', 'content/assertions/languages/Other.md'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('taxonomy.undeclared'); + expect(result.stdout).toContain('"engineering"'); + }); + + it('stays silent on a store that declares no taxonomy', async () => { + const store = await makeStore({ 'content/assertions/engineering/Note.md': VALID }); + + const result = await run({ argv: ['check'], cwd: store }); + + expect(result.stdout).toBe('✓ no findings (1 notes checked)\n'); + }); + it('carries the scope through the JSON report', async () => { const store = await makeStore({ 'content/Clean.md': VALID }); stubVaultFinding(); diff --git a/packages/kb/src/lints/__tests__/taxonomy.unit.test.ts b/packages/kb/src/lints/__tests__/taxonomy.unit.test.ts new file mode 100644 index 00000000..8400a60a --- /dev/null +++ b/packages/kb/src/lints/__tests__/taxonomy.unit.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultKbConfig, type KbConfig } from '../../config/config-schema.ts'; +import { ASSERTIONS_DIR, EVENTS_DIR } from '../../layout/index.ts'; +import type { Taxonomy } from '../../taxonomy/taxonomy-schema.ts'; +import { taxonomyFindings, type TaxonomyNote } from '../taxonomy.ts'; + +const TAXONOMY_PATH = '/store/.kb/taxonomy.yaml'; + +describe(taxonomyFindings, () => { + it('reports nothing when the taxonomy declares no domains', () => { + const findings = run({ notes: ['engineering/note.md'], declared: [] }); + + expect(findings).toEqual([]); + }); + + it('reports nothing when every folder holding notes is declared', () => { + const findings = run({ + notes: ['engineering/note.md', 'engineering/tooling/note.md'], + declared: ['engineering', 'engineering/tooling'], + }); + + expect(findings).toEqual([]); + }); + + it('reports a folder holding notes that no domain declares', () => { + const findings = run({ notes: ['engineering/note.md'], declared: ['languages'] }); + + expect(rulesIn(findings)).toContain('taxonomy.undeclared'); + expect(messageFor(findings, 'taxonomy.undeclared')).toContain('"engineering"'); + }); + + it('reports a declared domain that holds no notes', () => { + const findings = run({ notes: ['engineering/note.md'], declared: ['engineering', 'languages'] }); + + expect(messageFor(findings, 'taxonomy.unused')).toContain('"languages"'); + }); + + it('does not report a grouping domain whose descendants hold notes', () => { + const findings = run({ + notes: ['engineering/tooling/versioning/note.md'], + declared: ['engineering', 'engineering/tooling', 'engineering/tooling/versioning'], + }); + + expect(findings).toEqual([]); + }); + + it('reports a declared domain whose parent is undeclared', () => { + const findings = run({ notes: ['engineering/tooling/note.md'], declared: ['engineering/tooling'] }); + + expect(messageFor(findings, 'taxonomy.orphan')).toBe( + 'domain "engineering/tooling" is declared but its parent "engineering" is not', + ); + }); + + it('does not report a top-level domain as an orphan', () => { + const findings = run({ notes: ['engineering/note.md'], declared: ['engineering'] }); + + expect(rulesIn(findings)).not.toContain('taxonomy.orphan'); + }); + + it('ignores event records when deriving observed domains', () => { + const findings = taxonomyFindings({ + notes: [{ relativePath: `${EVENTS_DIR}/01JABC.md` }, { relativePath: `${ASSERTIONS_DIR}/engineering/note.md` }], + taxonomy: buildTaxonomy(['engineering']), + config: defaultKbConfig, + taxonomyPath: TAXONOMY_PATH, + }); + + expect(findings).toEqual([]); + }); + + it('ignores a note sitting directly in the assertions root', () => { + const findings = run({ notes: ['Loose.md'], declared: ['engineering'], extraNotes: ['engineering/note.md'] }); + + expect(findings).toEqual([]); + }); + + it('does not report a declared domain inside an excluded subtree as unused', () => { + const config: KbConfig = { ...defaultKbConfig, exclude: [`${ASSERTIONS_DIR}/drafts/**`] }; + + const findings = taxonomyFindings({ + notes: [{ relativePath: `${ASSERTIONS_DIR}/engineering/note.md` }], + taxonomy: buildTaxonomy(['engineering', 'drafts']), + config, + taxonomyPath: TAXONOMY_PATH, + }); + + expect(findings).toEqual([]); + }); + + it('reports every finding against the taxonomy file, vault-scoped and as a warning', () => { + const findings = run({ notes: ['engineering/note.md'], declared: ['languages/rust'] }); + + expect(findings.length).toBeGreaterThan(0); + for (const finding of findings) { + expect(finding).toMatchObject({ path: TAXONOMY_PATH, scope: 'vault', severity: 'warning' }); + expect(finding.line).toBeUndefined(); + } + }); + + it('orders each rule by domain, so the report is stable across runs', () => { + const findings = run({ + notes: ['tools/note.md', 'engineering/note.md', 'languages/note.md'], + declared: ['other'], + }); + + expect( + findings.filter((finding) => finding.rule === 'taxonomy.undeclared').map((finding) => finding.message), + ).toEqual([ + 'folder "engineering" holds notes but no domain declares it', + 'folder "languages" holds notes but no domain declares it', + 'folder "tools" holds notes but no domain declares it', + ]); + }); +}); + +// region | Helpers + +/** Builds a taxonomy declaring each path under `domains:` with no description. */ +function buildTaxonomy(paths: readonly string[]): Taxonomy { + return new Map(paths.map((path) => [path, { description: '', provisional: false }])); +} + +/** Reads the message of the single finding carrying `rule`. */ +function messageFor(findings: readonly { rule: string; message: string }[], rule: string): string | undefined { + return findings.find((finding) => finding.rule === rule)?.message; +} + +/** Runs the rules over assertions-root-relative note paths and declared domains. */ +function run(input: { notes: readonly string[]; declared: readonly string[]; extraNotes?: readonly string[] }) { + const paths = [...input.notes, ...(input.extraNotes ?? [])]; + const notes: TaxonomyNote[] = paths.map((path) => ({ relativePath: `${ASSERTIONS_DIR}/${path}` })); + return taxonomyFindings({ + notes, + taxonomy: buildTaxonomy(input.declared), + config: defaultKbConfig, + taxonomyPath: TAXONOMY_PATH, + }); +} + +/** Lists the rule codes present in a finding set. */ +function rulesIn(findings: readonly { rule: string }[]): string[] { + return findings.map((finding) => finding.rule); +} + +// endregion | Helpers diff --git a/packages/kb/src/lints/index.ts b/packages/kb/src/lints/index.ts index 5693bc96..2f2aee8d 100644 --- a/packages/kb/src/lints/index.ts +++ b/packages/kb/src/lints/index.ts @@ -1,2 +1,3 @@ export { pathsFindings, type PathsNote } from './paths.ts'; export { tagAliasFindings, type TagAliasNote } from './tag-alias.ts'; +export { taxonomyFindings, type TaxonomyNote } from './taxonomy.ts'; diff --git a/packages/kb/src/lints/taxonomy.ts b/packages/kb/src/lints/taxonomy.ts new file mode 100644 index 00000000..32ab0ba0 --- /dev/null +++ b/packages/kb/src/lints/taxonomy.ts @@ -0,0 +1,119 @@ +import type { KbConfig } from '../config/config-schema.ts'; +import { createNoteScopeMatcher } from '../config/note-scope.ts'; +import { ASSERTIONS_DIR } from '../layout/index.ts'; +import type { Taxonomy } from '../taxonomy/taxonomy-schema.ts'; +import type { Finding } from '../types.ts'; + +/** The note fields the taxonomy rules read. */ +export interface TaxonomyNote { + /** The note's path relative to the KB root, slash-separated. */ + relativePath: string; +} + +/** + * Reports where a store's assertion folders and its declared taxonomy disagree: `taxonomy.undeclared` for a folder + * holding notes that no domain declares, `taxonomy.unused` for a declared domain holding no note at or beneath it, and + * `taxonomy.orphan` for a declared domain whose parent is undeclared. All are warnings, so drift is reported without + * failing the run, and all are vault-scoped, so a run narrowed to selected notes still sees them. + * + * A taxonomy declaring nothing disables all three, whether because the file is absent or because it declares no + * domains. A store that has not adopted a taxonomy is therefore silent rather than reporting every folder it owns. + * + * The observed structure comes from the enumerated notes' own paths rather than a directory listing, so the rules add + * no filesystem traversal and see exactly the notes the run's `targets` and `exclude` admitted. + */ +export function taxonomyFindings(input: { + notes: readonly TaxonomyNote[]; + taxonomy: Taxonomy; + config: KbConfig; + /** Absolute path of `.kb/taxonomy.yaml`, which every finding is reported against. */ + taxonomyPath: string; +}): Finding[] { + const { notes, taxonomy, config, taxonomyPath } = input; + + const declared = taxonomy.keys().toArray().toSorted(); + if (declared.length === 0) { + return []; + } + + const observed = new Set(); + for (const note of notes) { + const domain = resolveDomain(note.relativePath); + if (domain !== undefined) { + observed.add(domain); + } + } + + const matcher = createNoteScopeMatcher(config); + const findings: Finding[] = []; + + for (const domain of [...observed].toSorted()) { + if (!taxonomy.has(domain)) { + findings.push( + buildFinding(taxonomyPath, 'undeclared', `folder "${domain}" holds notes but no domain declares it`), + ); + } + } + + for (const domain of declared) { + // An excluded subtree is pruned during the walk, so its notes never enumerate and every domain inside it would + // otherwise report unused forever. The exemption is needed here only: with no notes to observe, an excluded + // subtree cannot produce an undeclared folder in the first place. + if (holdsNote(domain, observed) || matcher.isExcluded(`${ASSERTIONS_DIR}/${domain}`)) { + continue; + } + findings.push(buildFinding(taxonomyPath, 'unused', `domain "${domain}" is declared but holds no notes`)); + } + + for (const domain of declared) { + const parent = resolveParent(domain); + if (parent !== undefined && !taxonomy.has(parent)) { + findings.push( + buildFinding(taxonomyPath, 'orphan', `domain "${domain}" is declared but its parent "${parent}" is not`), + ); + } + } + + return findings; +} + +// region | Helpers + +/** Builds one vault-scoped warning against the taxonomy file, where every one of these findings is remedied. */ +function buildFinding(taxonomyPath: string, rule: string, message: string): Finding { + return { path: taxonomyPath, scope: 'vault', rule: `taxonomy.${rule}`, severity: 'warning', message }; +} + +/** Reports whether any observed domain is `domain` itself or sits beneath it. */ +function holdsNote(domain: string, observed: ReadonlySet): boolean { + if (observed.has(domain)) { + return true; + } + const prefix = `${domain}/`; + for (const candidate of observed) { + if (candidate.startsWith(prefix)) { + return true; + } + } + return false; +} + +/** + * Derives the domain a note sits in, or `undefined` when the note is not an assertion or sits at the assertions root. + * Scoping to the assertions root is what keeps event records from registering as undeclared domains. + */ +function resolveDomain(relativePath: string): string | undefined { + const prefix = `${ASSERTIONS_DIR}/`; + if (!relativePath.startsWith(prefix)) { + return undefined; + } + return resolveParent(relativePath.slice(prefix.length)); +} + +/** Derives a slash-path's parent, or `undefined` when it has no separator and so sits at the top level. */ +function resolveParent(path: string): string | undefined { + const lastSlash = path.lastIndexOf('/'); + return lastSlash === -1 ? undefined : path.slice(0, lastSlash); +} + +// endregion | Helpers From 33fb6d4677999f4db5ae3c0fc3f989a79144e3cc Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 05:48:24 -0700 Subject: [PATCH 04/12] kb|refactor: Share store resolution, flag parsing, and domain derivation Store resolution and flag-value parsing each move out of the `check` command into a module of their own, so a second command consumes them rather than restating them. The mapping from a note's path to its domain moves beside the taxonomy loader. The drift rules ask which domains a note set observes and back-fill asks which domains it implies; deriving both from one mapping is what keeps a back-filled taxonomy from reporting drift against the notes it came from. --- packages/kb/src/cli/commands/check.ts | 54 +----------------------- packages/kb/src/cli/parse-flag-value.ts | 22 ++++++++++ packages/kb/src/cli/resolve-store.ts | 39 +++++++++++++++++ packages/kb/src/lints/taxonomy.ts | 19 +-------- packages/kb/src/taxonomy/domain-paths.ts | 42 ++++++++++++++++++ 5 files changed, 106 insertions(+), 70 deletions(-) create mode 100644 packages/kb/src/cli/parse-flag-value.ts create mode 100644 packages/kb/src/cli/resolve-store.ts create mode 100644 packages/kb/src/taxonomy/domain-paths.ts diff --git a/packages/kb/src/cli/commands/check.ts b/packages/kb/src/cli/commands/check.ts index 709ef921..ec0cb9fd 100644 --- a/packages/kb/src/cli/commands/check.ts +++ b/packages/kb/src/cli/commands/check.ts @@ -1,10 +1,10 @@ import { check, type CheckResult } from '../../check/check.ts'; import type { EnumeratedNote } from '../../check/enumerate.ts'; import { isKbLoaderError } from '../../config/kb-loader-error.ts'; -import { findKbRoot } from '../../discovery/find-kb-root.ts'; -import { tryLoadKbRegistry } from '../../discovery/load-registry.ts'; import type { Finding } from '../../types.ts'; import { type CheckScope, formatHuman, formatJson, type StoreRef, summarize } from '../format.ts'; +import { takeInlineValue, takeValue } from '../parse-flag-value.ts'; +import { resolveStore } from '../resolve-store.ts'; import { resolveChangedPaths } from '../targeting/resolve-changed-paths.ts'; import { selectNotes } from '../targeting/select-notes.ts'; @@ -222,54 +222,4 @@ async function resolveSelection(input: { return { ok: true, scope, notes: selection.selected, findings }; } -/** The store-resolution outcome: a resolved store, or a categorical failure message for exit 2. */ -type ResolveStoreOutcome = { ok: true; store: StoreRef } | { ok: false; message: string }; - -/** - * Resolves the store to check. An explicit `--kb ` is looked up in the merged registry (`tryLoadKbRegistry` - * with `projectDir: cwd`, so project-local `.agents/kb.yaml` entries join the user-global registry); without a flag, - * the nearest ancestor `.kb/` directory is used. An unknown `--kb` name or a missing `.kb/` fails for exit 2. - */ -async function resolveStore(input: { - explicitKb: string | null; - cwd: string; - home?: string; -}): Promise { - if (input.explicitKb !== null) { - const { config } = await tryLoadKbRegistry({ - projectDir: input.cwd, - ...(input.home !== undefined && { home: input.home }), - }); - const match = config.entries.find((entry) => entry.name === input.explicitKb); - if (match === undefined) { - return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` }; - } - return { ok: true, store: { name: match.name, path: match.path } }; - } - - const discovered = await findKbRoot({ startDir: input.cwd }); - if (discovered === null) { - return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' }; - } - return { ok: true, store: { name: null, path: discovered.path } }; -} - -/** Reads the value from an inline flag (`--kb=x`), throwing when it is empty. */ -function takeInlineValue(arg: string, prefix: string): string { - const value = arg.slice(prefix.length); - if (value === '') { - throw new Error(`${prefix.replace(/=$/, '')} requires a value`); - } - return value; -} - -/** Reads the value after a space-form flag (`--kb x`), throwing when it is missing or looks like another flag. */ -function takeValue(argv: readonly string[], index: number, flag: string): string { - const next = argv[index + 1] ?? null; - if (next === null || next.startsWith('--')) { - throw new Error(`${flag} requires a value`); - } - return next; -} - // endregion | Helpers diff --git a/packages/kb/src/cli/parse-flag-value.ts b/packages/kb/src/cli/parse-flag-value.ts new file mode 100644 index 00000000..5e91676a --- /dev/null +++ b/packages/kb/src/cli/parse-flag-value.ts @@ -0,0 +1,22 @@ +// The `kb` CLI's flag-value grammar, shared so every command accepts a value the same way. +// +// Each value-taking flag supports both the space form (`--kb x`) and the equals form (`--kb=x`). A command's own +// parser decides which flags exist; these two decide what a value looks like once one is found. + +/** Reads the value from an inline flag (`--kb=x`), throwing when it is empty. */ +export function takeInlineValue(arg: string, prefix: string): string { + const value = arg.slice(prefix.length); + if (value === '') { + throw new Error(`${prefix.replace(/=$/, '')} requires a value`); + } + return value; +} + +/** Reads the value after a space-form flag (`--kb x`), throwing when it is missing or looks like another flag. */ +export function takeValue(argv: readonly string[], index: number, flag: string): string { + const next = argv[index + 1] ?? null; + if (next === null || next.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return next; +} diff --git a/packages/kb/src/cli/resolve-store.ts b/packages/kb/src/cli/resolve-store.ts new file mode 100644 index 00000000..f3f2b0ea --- /dev/null +++ b/packages/kb/src/cli/resolve-store.ts @@ -0,0 +1,39 @@ +import { findKbRoot } from '../discovery/find-kb-root.ts'; +import { tryLoadKbRegistry } from '../discovery/load-registry.ts'; +import type { StoreRef } from './format.ts'; + +/** The store-resolution outcome: a resolved store, or a categorical failure message for exit 2. */ +export type ResolveStoreOutcome = { ok: true; store: StoreRef } | { ok: false; message: string }; + +/** + * Resolves the store a command runs against. An explicit `--kb ` is looked up in the merged registry + * (`tryLoadKbRegistry` with `projectDir: cwd`, so project-local `.agents/kb.yaml` entries join the user-global + * registry); without a flag, the nearest ancestor `.kb/` directory is used. An unknown `--kb` name or a missing `.kb/` + * fails for exit 2. + * + * The lookup itself is read-only, so a store's registry `readonly` flag is ignored here; a command that writes checks + * it separately. + */ +export async function resolveStore(input: { + explicitKb: string | null; + cwd: string; + home?: string; +}): Promise { + if (input.explicitKb !== null) { + const { config } = await tryLoadKbRegistry({ + projectDir: input.cwd, + ...(input.home !== undefined && { home: input.home }), + }); + const match = config.entries.find((entry) => entry.name === input.explicitKb); + if (match === undefined) { + return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` }; + } + return { ok: true, store: { name: match.name, path: match.path } }; + } + + const discovered = await findKbRoot({ startDir: input.cwd }); + if (discovered === null) { + return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' }; + } + return { ok: true, store: { name: null, path: discovered.path } }; +} diff --git a/packages/kb/src/lints/taxonomy.ts b/packages/kb/src/lints/taxonomy.ts index 32ab0ba0..213aaf17 100644 --- a/packages/kb/src/lints/taxonomy.ts +++ b/packages/kb/src/lints/taxonomy.ts @@ -1,6 +1,7 @@ import type { KbConfig } from '../config/config-schema.ts'; import { createNoteScopeMatcher } from '../config/note-scope.ts'; import { ASSERTIONS_DIR } from '../layout/index.ts'; +import { resolveDomain, resolveParent } from '../taxonomy/domain-paths.ts'; import type { Taxonomy } from '../taxonomy/taxonomy-schema.ts'; import type { Finding } from '../types.ts'; @@ -98,22 +99,4 @@ function holdsNote(domain: string, observed: ReadonlySet): boolean { return false; } -/** - * Derives the domain a note sits in, or `undefined` when the note is not an assertion or sits at the assertions root. - * Scoping to the assertions root is what keeps event records from registering as undeclared domains. - */ -function resolveDomain(relativePath: string): string | undefined { - const prefix = `${ASSERTIONS_DIR}/`; - if (!relativePath.startsWith(prefix)) { - return undefined; - } - return resolveParent(relativePath.slice(prefix.length)); -} - -/** Derives a slash-path's parent, or `undefined` when it has no separator and so sits at the top level. */ -function resolveParent(path: string): string | undefined { - const lastSlash = path.lastIndexOf('/'); - return lastSlash === -1 ? undefined : path.slice(0, lastSlash); -} - // endregion | Helpers diff --git a/packages/kb/src/taxonomy/domain-paths.ts b/packages/kb/src/taxonomy/domain-paths.ts new file mode 100644 index 00000000..b3fdb15a --- /dev/null +++ b/packages/kb/src/taxonomy/domain-paths.ts @@ -0,0 +1,42 @@ +import { ASSERTIONS_DIR } from '../layout/index.ts'; + +// How a note's path maps onto a domain path, defined once. +// +// The drift rules ask which domains a note set observes; back-fill asks which domains a note set implies. Both answers +// have to come from the same mapping, or a back-filled taxonomy would report drift against the notes it was derived +// from. + +/** + * Derives every domain a note set implies: the domain each note sits in, plus each of that domain's ancestors, sorted. + * A grouping domain that holds only subfolders is included, because the rules treat it as in use and would otherwise + * report a back-filled taxonomy's own entries as unused. + */ +export function deriveDomains(relativePaths: Iterable): string[] { + const domains = new Set(); + for (const relativePath of relativePaths) { + let domain = resolveDomain(relativePath); + while (domain !== undefined) { + domains.add(domain); + domain = resolveParent(domain); + } + } + return domains.values().toArray().toSorted(); +} + +/** + * Derives the domain a note sits in, or `undefined` when the note is not an assertion or sits at the assertions root. + * Scoping to the assertions root is what keeps event records from registering as domains. + */ +export function resolveDomain(relativePath: string): string | undefined { + const prefix = `${ASSERTIONS_DIR}/`; + if (!relativePath.startsWith(prefix)) { + return undefined; + } + return resolveParent(relativePath.slice(prefix.length)); +} + +/** Derives a slash-path's parent, or `undefined` when it has no separator and so sits at the top level. */ +export function resolveParent(path: string): string | undefined { + const lastSlash = path.lastIndexOf('/'); + return lastSlash === -1 ? undefined : path.slice(0, lastSlash); +} From b1eb5c3525ebd82a649331617a522a10d05af348 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 05:48:38 -0700 Subject: [PATCH 05/12] kb|feat: Add kb taxonomy init to derive a taxonomy from existing notes `kb taxonomy init` derives a starting taxonomy from the notes a knowledge base already holds, so a taxonomy can be introduced to a populated store without every folder reporting as undeclared. Every folder holding notes is declared, along with each of its ancestors, under `provisional:` with no description, and a store back-filled this way reports no taxonomy drift. The command leaves an existing taxonomy untouched and exits 2 unless `--merge` is given, which adds only the domains that taxonomy does not already declare. `--kb ` selects a registered store; without it the nearest ancestor `.kb/` directory is used. --- .../src/cli/__tests__/taxonomy.unit.test.ts | 158 ++++++++++++++++ packages/kb/src/cli/commands/taxonomy.ts | 175 ++++++++++++++++++ packages/kb/src/cli/run.ts | 14 +- 3 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 packages/kb/src/cli/__tests__/taxonomy.unit.test.ts create mode 100644 packages/kb/src/cli/commands/taxonomy.ts diff --git a/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts b/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts new file mode 100644 index 00000000..0cfa8846 --- /dev/null +++ b/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts @@ -0,0 +1,158 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { check } from '../../check/check.ts'; +import { TAXONOMY_FILE } from '../../layout/index.ts'; +import { makeStore, makeTempDir } from '../../test-utils/scaffolding.ts'; +import { run } from '../run.ts'; + +const VALID = + '---\ntitle: A\nrecordType: assertion\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [x]\n---\n\nBody.\n'; + +const POPULATED = { + 'content/assertions/engineering/tooling/versioning/Releases.md': VALID, + 'content/assertions/languages/Rust.md': VALID, + 'content/events/01JABC.md': VALID, +}; + +describe('kb taxonomy init', () => { + it('declares every folder holding notes and each of its ancestors', async () => { + const store = await makeStore(POPULATED); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(await readTaxonomy(store)).toBe( + 'provisional:\n engineering:\n engineering/tooling:\n engineering/tooling/versioning:\n languages:\n', + ); + }); + + it('leaves the back-filled store with no taxonomy findings', async () => { + const store = await makeStore(POPULATED); + + await run({ argv: ['taxonomy', 'init'], cwd: store }); + const { findings } = await check({ kbRoot: store }); + + expect(findings.filter((finding) => finding.rule.startsWith('taxonomy.'))).toEqual([]); + }); + + it('reports the domain count it declared', async () => { + const store = await makeStore(POPULATED); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store }); + + expect(result.stdout).toBe(`declared 4 domains in ${TAXONOMY_FILE}\n`); + }); + + it('refuses a store that already declares a taxonomy', async () => { + const store = await makeStore({ ...POPULATED, [TAXONOMY_FILE]: 'domains:\n languages: Languages\n' }); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('--merge'); + expect(await readTaxonomy(store)).toBe('domains:\n languages: Languages\n'); + }); + + it('adds only the absent domains under --merge', async () => { + const store = await makeStore({ ...POPULATED, [TAXONOMY_FILE]: 'domains:\n languages: Languages\n' }); + + const result = await run({ argv: ['taxonomy', 'init', '--merge'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(await readTaxonomy(store)).toBe( + 'domains:\n languages: Languages\nprovisional:\n engineering:\n engineering/tooling:\n engineering/tooling/versioning:\n', + ); + }); + + it('is a no-op on a second --merge run', async () => { + const store = await makeStore(POPULATED); + + await run({ argv: ['taxonomy', 'init'], cwd: store }); + const first = await readTaxonomy(store); + const result = await run({ argv: ['taxonomy', 'init', '--merge'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('already declares every derived domain'); + expect(await readTaxonomy(store)).toBe(first); + }); + + it('writes nothing for a store whose assertion folders hold no notes', async () => { + const store = await makeStore({ 'content/assertions/Loose.md': VALID }); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('no assertion folders hold notes'); + await expect(readTaxonomy(store)).rejects.toThrow(/ENOENT/); + }); + + it('exits 2 when the existing taxonomy is malformed', async () => { + const store = await makeStore({ ...POPULATED, [TAXONOMY_FILE]: 'domains: [unterminated\n' }); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('taxonomy.yaml'); + }); + + it('exits 2 when no .kb/ directory is found', async () => { + const empty = await makeTempDir('kb-taxonomy-empty-'); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: empty }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('no .kb/'); + }); + + it('exits 2 for an unknown subcommand', async () => { + const store = await makeStore(POPULATED); + + const result = await run({ argv: ['taxonomy', 'backfill'], cwd: store }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('unknown subcommand'); + }); + + it('exits 2 when no subcommand is given', async () => { + const store = await makeStore(POPULATED); + + const result = await run({ argv: ['taxonomy'], cwd: store }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('no subcommand'); + }); + + it('exits 2 for an unknown flag', async () => { + const store = await makeStore(POPULATED); + + const result = await run({ argv: ['taxonomy', 'init', '--force'], cwd: store }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('unknown flag'); + }); + + it('prints help for taxonomy --help', async () => { + const result = await run({ argv: ['taxonomy', '--help'], cwd: process.cwd() }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Usage: kb taxonomy init'); + }); + + it('lists taxonomy in the top-level help', async () => { + const result = await run({ argv: [], cwd: process.cwd() }); + + expect(result.stdout).toContain('taxonomy'); + }); +}); + +// region | Helpers + +/** Reads the store's taxonomy file as raw text, so a test can assert on formatting rather than parsed content. */ +function readTaxonomy(storePath: string): Promise { + return readFile(join(storePath, TAXONOMY_FILE), 'utf8'); +} + +// endregion | Helpers diff --git a/packages/kb/src/cli/commands/taxonomy.ts b/packages/kb/src/cli/commands/taxonomy.ts new file mode 100644 index 00000000..c95fb348 --- /dev/null +++ b/packages/kb/src/cli/commands/taxonomy.ts @@ -0,0 +1,175 @@ +import { enumerateNotes } from '../../check/enumerate.ts'; +import { isKbLoaderError } from '../../config/kb-loader-error.ts'; +import { loadKbConfig } from '../../config/load-config.ts'; +import { resolveKbDir, TAXONOMY_FILE } from '../../layout/index.ts'; +import { deriveDomains } from '../../taxonomy/domain-paths.ts'; +import { loadTaxonomy } from '../../taxonomy/load-taxonomy.ts'; +import { writeTaxonomy } from '../../taxonomy/write-taxonomy.ts'; +import type { KbRoot } from '../../types.ts'; +import { takeInlineValue, takeValue } from '../parse-flag-value.ts'; +import { resolveStore } from '../resolve-store.ts'; +import type { CommandOutput } from './check.ts'; + +/** Usage text for `kb taxonomy`. */ +export const TAXONOMY_HELP = `Usage: kb taxonomy init [options] + +Derive a starting taxonomy from the notes a knowledge base already holds, so a +taxonomy can be introduced to a populated store without every folder reporting +as undeclared. Every folder holding notes is declared, along with each of its +ancestors, under "provisional:" with no description: the command cannot invent +descriptions, and provisional already means "declared, not yet reviewed". + +Options: + --kb Use the named store from the kb.yaml registry. Without it, the + nearest ancestor .kb/ directory is used. + --merge Add only the domains an existing taxonomy does not declare. + Without it, a store that already has a taxonomy is left + untouched. + -h, --help Show this help. + +Exit codes: + 0 the taxonomy was written, or already declared every derived domain + 2 usage error, unresolvable store, malformed config or taxonomy, or an + existing taxonomy without --merge +`; + +/** + * Runs `kb taxonomy`: parses options, resolves the store, derives the domains its notes imply, and declares them. + * + * The derivation reads the same enumeration `kb check` does, so a store back-filled by this command reports no + * taxonomy drift. A malformed `.kb/config.yaml` or `.kb/taxonomy.yaml` surfaces as a `KbLoaderError` and maps to exit + * 2; any other error propagates to the caller as a real crash. + */ +export async function runTaxonomy(input: { + argv: readonly string[]; + cwd: string; + home?: string; +}): Promise { + let options: TaxonomyOptions; + try { + options = parseTaxonomyArgs(input.argv); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n${TAXONOMY_HELP}` }; + } + + if (options.help) { + return { exitCode: 0, stdout: TAXONOMY_HELP, stderr: '' }; + } + if (options.subcommand === null) { + return { exitCode: 2, stdout: '', stderr: `kb taxonomy: no subcommand given\n${TAXONOMY_HELP}` }; + } + + const resolved = await resolveStore({ + explicitKb: options.kb, + cwd: input.cwd, + ...(input.home !== undefined && { home: input.home }), + }); + if (!resolved.ok) { + return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${resolved.message}\n` }; + } + const kbRoot: KbRoot = { path: resolved.store.path, kbDir: resolveKbDir(resolved.store.path) }; + + try { + return await initTaxonomy({ kbRoot, merge: options.merge }); + } catch (error) { + if (isKbLoaderError(error)) { + return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${error.message}\n` }; + } + throw error; + } +} + +/** Parsed `kb taxonomy` options. */ +interface TaxonomyOptions { + /** The subcommand to run, or `null` when none was given. */ + subcommand: 'init' | null; + /** Explicit store name from `--kb`, or `null` for ancestor-walk discovery. */ + kb: string | null; + /** Whether `--merge` was supplied. */ + merge: boolean; + /** Whether `--help`/`-h` was supplied. */ + help: boolean; +} + +/** + * Parses `kb taxonomy` options. `--kb` accepts both the space (`--kb x`) and equals (`--kb=x`) forms. An unknown flag, + * an unknown subcommand, a missing `--kb` value, or a second subcommand throws with a usage-style message. + */ +export function parseTaxonomyArgs(argv: readonly string[]): TaxonomyOptions { + let subcommand: 'init' | null = null; + let kb: string | null = null; + let merge = false; + let help = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) continue; + + if (arg === '--help' || arg === '-h') { + help = true; + continue; + } + if (arg === '--merge') { + merge = true; + continue; + } + if (arg === '--kb') { + kb = takeValue(argv, index, '--kb'); + index += 1; + continue; + } + if (arg.startsWith('--kb=')) { + kb = takeInlineValue(arg, '--kb='); + continue; + } + if (arg.startsWith('-')) { + throw new Error(`unknown flag: ${arg}`); + } + if (arg !== 'init') { + throw new Error(`unknown subcommand: ${arg}`); + } + if (subcommand !== null) { + throw new Error('only one subcommand may be given'); + } + subcommand = arg; + } + + return { subcommand, kb, merge, help }; +} + +// region | Helpers + +/** + * Declares every domain the store's notes imply. Refuses a store that already declares domains unless `merge` is set, + * in which case only the absent ones are added. + */ +async function initTaxonomy(input: { kbRoot: KbRoot; merge: boolean }): Promise { + const { kbRoot, merge } = input; + + const existing = await loadTaxonomy({ kbRoot }); + if (existing.size > 0 && !merge) { + const message = `${TAXONOMY_FILE} already declares ${existing.size} domains; pass --merge to add the missing ones`; + return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n` }; + } + + const config = await loadKbConfig({ kbRoot }); + const notes = await enumerateNotes({ kbRoot: kbRoot.path, config }); + const domains = deriveDomains(notes.map((note) => note.relativePath)); + + if (domains.length === 0) { + return { exitCode: 0, stdout: `no assertion folders hold notes; ${TAXONOMY_FILE} not written\n`, stderr: '' }; + } + + const { added } = await writeTaxonomy({ + kbRoot, + declarations: domains.map((path) => ({ path, provisional: true })), + }); + + if (added.length === 0) { + return { exitCode: 0, stdout: `${TAXONOMY_FILE} already declares every derived domain\n`, stderr: '' }; + } + return { exitCode: 0, stdout: `declared ${added.length} domains in ${TAXONOMY_FILE}\n`, stderr: '' }; +} + +// endregion | Helpers diff --git a/packages/kb/src/cli/run.ts b/packages/kb/src/cli/run.ts index 58fa61e8..fe5f8aa7 100644 --- a/packages/kb/src/cli/run.ts +++ b/packages/kb/src/cli/run.ts @@ -1,6 +1,7 @@ import { type CommandOutput, runCheck } from './commands/check.ts'; import { runCreate } from './commands/create.ts'; import { runSetDefault } from './commands/set-default.ts'; +import { runTaxonomy } from './commands/taxonomy.ts'; import type { SelectKbPrompt } from './select-kb-prompt.ts'; /** Top-level usage text for the `kb` bin. */ @@ -10,16 +11,17 @@ Commands: check Validate a knowledge base, optionally scoped to selected notes. create Scaffold a new knowledge base and register it in the kb.yaml registry. set-default Set, clear, or choose the default knowledge base. + taxonomy Derive a knowledge base's taxonomy from the notes it already holds. Run "kb --help" for command options. `; /** * Dispatches a `kb` subcommand and returns its {@link CommandOutput} without touching `process`, so tests drive the - * command directly. `check`, `create`, and `set-default` are the subcommands; a bare invocation or `--help`/`-h` prints - * top-level usage (exit 0), and an unknown command prints usage to stderr (exit 2). The optional `selectKb` picker is - * forwarded to `set-default`'s interactive form and to `create`'s ambiguous default-KB prompt; `cli/index.ts` supplies - * it only when stdin is a TTY. + * command directly. `check`, `create`, `set-default`, and `taxonomy` are the subcommands; a bare invocation or + * `--help`/`-h` prints top-level usage (exit 0), and an unknown command prints usage to stderr (exit 2). The optional + * `selectKb` picker is forwarded to `set-default`'s interactive form and to `create`'s ambiguous default-KB prompt; + * `cli/index.ts` supplies it only when stdin is a TTY. */ export async function run(input: { argv: readonly string[]; @@ -46,6 +48,10 @@ export async function run(input: { }); } + if (command === 'taxonomy') { + return runTaxonomy({ argv: rest, cwd: input.cwd, ...(input.home !== undefined && { home: input.home }) }); + } + if (command === 'set-default') { return runSetDefault({ argv: rest, From fb7b31d26966becdce12ebcbcf0fcaed1fd8cf24 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 05:52:31 -0700 Subject: [PATCH 06/12] docs: Document .kb/taxonomy.yaml and the taxonomy rules Readers can now find `.kb/taxonomy.yaml` documented in the `@williamthorsen/kb` README: its two blocks, the key format and what fails to load, what provisional means, the three drift rules, and `kb taxonomy init`. The `kb-curate` skill names those rules in its rule-code table and points at `kb taxonomy init` as their follow-up. The README's export table also lists `./layout`, which it had omitted, and states the right number of entries. --- .../agents/content/skills/kb-curate/SKILL.md | 12 +++- packages/kb/README.md | 70 ++++++++++++++++--- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/agents/content/skills/kb-curate/SKILL.md b/packages/agents/content/skills/kb-curate/SKILL.md index 13aecb86..dac218f9 100644 --- a/packages/agents/content/skills/kb-curate/SKILL.md +++ b/packages/agents/content/skills/kb-curate/SKILL.md @@ -28,7 +28,7 @@ A value-bearing flag accepts both `--kb coding` and `--kb=coding`. With no flags The knowledge base is resolved the same way as `kb-add`: a concrete `--kb ` beats a discovered `.kb/` folder, and the registry's `default_kb` is reachable only via `--kb @default`. When no `--kb` is given and no `.kb/` is discoverable, the run is refused rather than defaulting. A read-only report run accepts a KB marked `readonly: true` in `kb.yaml`; `--apply` against a readonly KB is refused with `readonly-kb`. Curating spans a single KB per run — wikilink resolution and supersede chains are only valid within one vault, so curating several vaults is a shell loop over `--kb`. -Which notes are curated is governed by the store's `.kb/config.yaml`: by default, only notes under `content/` are enumerated. A store with a different layout overrides the `targets` glob in its `config.yaml`. A malformed `config.yaml` or `tag-aliases.yaml` fails the run with `invalid-config` rather than being silently ignored. +Which notes are curated is governed by the store's `.kb/config.yaml`: by default, only notes under `content/` are enumerated. A store with a different layout overrides the `targets` glob in its `config.yaml`. A malformed `config.yaml`, `tag-aliases.yaml`, or `taxonomy.yaml` fails the run with `invalid-config` rather than being silently ignored. ## Runtime dependencies @@ -37,7 +37,7 @@ Which notes are curated is governed by the store's `.kb/config.yaml`: by default ## Detection categories -The helper reports findings across five categories. Each finding carries a rule code and a severity. +The helper reports findings across six categories. Each finding carries a rule code and a severity. | Rule code | Severity | Meaning | | ----------------------- | -------- | -------------------------------------------------------------------------------------- | @@ -45,12 +45,17 @@ The helper reports findings across five categories. Each finding carries a rule | `wikilinks.basename` | warning | Two or more notes share a basename (reported once for the vault). | | `paths.user-home` | error | A hardcoded `/Users/{name}/` path; use `~/` instead. | | `tag-alias` | warning | A `tags` entry is a known alias of a canonical tag. | +| `taxonomy.undeclared` | warning | A folder holds notes but `.kb/taxonomy.yaml` declares no domain for it. | +| `taxonomy.unused` | warning | A declared domain has no note at or beneath it. | +| `taxonomy.orphan` | warning | A declared domain's parent is undeclared. | | `verification.unmarked` | warning | The note has no `last-verified` field; reported only when the vault uses verification. | | `verification.stale` | warning | `last-verified` is older than `--stale-after` days. | | `supersede.dangling` | error | A `superseded-by`/`supersedes` target is not a vault note. | | `supersede.cycle` | error | The note participates in a `superseded-by` loop. | | `supersede.asymmetric` | warning | `A.superseded-by → B` without the matching `B.supersedes → A`. | +The three `taxonomy.*` rules describe the vault rather than a note, so each is reported once against `.kb/taxonomy.yaml` with the domain named in the message. They are self-configuring in the same way `verification.unmarked` is: a vault whose `.kb/taxonomy.yaml` is absent, or present but declaring nothing, reports none of them. + `verification.unmarked` is self-configuring: it is reported only when the vault actually uses verification — that is, when at least one note carries a well-formed `last-verified` value. In a vault that has not adopted verification stamps, an unmarked note is not a finding. A malformed `last-verified` value does not count as adoption, so a vault whose only verification-ish value is unparseable reports no unmarked findings. `verification.stale` is unaffected: a note with a stale `last-verified` is always flagged. ## Remediation under `--apply` @@ -68,6 +73,7 @@ The remaining findings name the operator's next step: - **Stale or unmarked verification** → re-confirm the note, then `kb-edit --verify`. - **Supersede defects** → repair with `kb-edit --supersede-with `, or correct the offending frontmatter field. +- **Taxonomy drift** → declare the folder in `.kb/taxonomy.yaml`, or move the notes to a declared domain. On a vault adopting a taxonomy for the first time, `kb taxonomy init` declares every folder that already holds notes in one pass. - **Unresolved wikilinks, basename collisions, hardcoded paths** → resolve manually; these are too context-dependent to auto-fix. ## Process @@ -96,7 +102,7 @@ On failure, `ok: false` plus a categorical `error` code: | Code | What it means | What to do | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `invalid-args` | Unknown flag, missing value, or a non-positive-integer `--stale-after`. | Correct the invocation. The message names the specific defect. | -| `invalid-config` | A malformed `.kb/config.yaml` or `.kb/tag-aliases.yaml` in the store. | Fix the named file. The message names the offending file. | +| `invalid-config` | A malformed `.kb/config.yaml`, `.kb/tag-aliases.yaml`, or `.kb/taxonomy.yaml` in the store. | Fix the named file. The message names the offending file. | | `no-kb-resolvable` | A KB could not be resolved: `--kb` matched no entry, or no `--kb` and no discoverable `.kb/`, or `--kb @default` with no configured default. | Confirm the `--kb` name, run from inside the vault, or pass `--kb @default`. | | `readonly-kb` | `--apply` was used against a KB marked `readonly: true` in `kb.yaml`. | Drop `--apply` for a read-only report, or use a writable KB. | diff --git a/packages/kb/README.md b/packages/kb/README.md index 3b4324f8..a6b72fbe 100644 --- a/packages/kb/README.md +++ b/packages/kb/README.md @@ -8,7 +8,7 @@ It underpins the knowledge-base skills — among them `kb-retrieve` (assertion r ## Exports -The package exposes ten subpath entries plus a root barrel: +The package exposes twelve subpath entries plus a root barrel: | Entry | Description | | ------------------- | ------------------------------------------------------------------------------ | @@ -19,9 +19,11 @@ The package exposes ten subpath entries plus a root barrel: | `./discovery` | KB root discovery and `kb.yaml` registry loading, merging, and writing | | `./filesystem` | Filesystem-existence helpers with an explicit absence policy | | `./frontmatter` | Note parsing into typed frontmatter and writing it back to YAML | +| `./layout` | The store's on-disk layout: every path inside a `.kb/` store derives from here | | `./note-io` | Type-blind note read/write as an ordered frontmatter field map | | `./records` | The typed `assertion`/`event` record parsers and renderers | | `./tags` | `.kb/tag-aliases.yaml` loading and tag canonicalization | +| `./taxonomy` | `.kb/taxonomy.yaml` loading and comment-preserving domain declaration | | `./vault-integrity` | Type-blind `[[link]]` resolution and basename-uniqueness over a note set | Every public function takes a single plain-object input so a future MCP wrapper can mechanically bind Zod-validated payloads. @@ -112,6 +114,8 @@ missing files (when a path is given) throw. 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. +`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. + ```ts import { checkVaultIntegrity } from '@williamthorsen/kb/vault-integrity'; @@ -120,7 +124,7 @@ const findings = checkVaultIntegrity(notes); ## Checking a store -`check({ kbRoot })` runs a store's full check in one call: it loads `.kb/config.yaml` and `.kb/tag-aliases.yaml`, enumerates the notes the config selects, and composes whole-vault integrity 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. +`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. ```ts import { check } from '@williamthorsen/kb/check'; @@ -128,7 +132,7 @@ import { check } from '@williamthorsen/kb/check'; const { notes, findings } = await check({ kbRoot }); ``` -A structural defect in either loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged. +A structural defect in any loaded file throws a `KbLoaderError` (see below). Any other error from enumeration or the checks propagates unchanged. ### Which notes are checked: `.kb/config.yaml` @@ -149,9 +153,39 @@ exclude: 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`. +### The declared structure: `.kb/taxonomy.yaml` + +`.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. + +```yaml +# .kb/taxonomy.yaml +domains: + engineering: Software engineering practice + engineering/tooling: Build, test, and development tooling +provisional: + engineering/tooling/versioning: Release and version management + languages: +``` + +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. + +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. + +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: + +| Rule | Meaning | +| --------------------- | ----------------------------------------------- | +| `taxonomy.undeclared` | A folder holds notes but no domain declares it. | +| `taxonomy.unused` | A declared domain has no note at or beneath it. | +| `taxonomy.orphan` | A declared domain's parent is undeclared. | + +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. + +`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. Both are exported from `@williamthorsen/kb/taxonomy`. + ## The `kb` command -The package ships a `kb` bin with three subcommands: `create`, `set-default`, and `check`. +The package ships a `kb` bin with four subcommands: `check`, `create`, `set-default`, and `taxonomy`. ### kb create @@ -213,15 +247,31 @@ Because the exit code reflects only the selected notes, a per-batch or pre-commi Exit codes: -| Code | Meaning | -| ---- | --------------------------------------------------------------------------------------------------------------------------- | -| `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. | -| `1` | One or more error-severity findings in the checked notes. | -| `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config` or `tag-aliases` file. | +| Code | Meaning | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | No error-severity findings in the checked notes (warnings are allowed). A run that selects no notes also exits 0. | +| `1` | One or more error-severity findings in the checked notes. | +| `2` | A usage error, an unresolvable store or `--vs` ref, a path matching no note, or a malformed `config`, `tag-aliases`, or `taxonomy` file. | + +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. + +### kb taxonomy + +`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. + +```bash +kb taxonomy init # declare every folder holding notes, and its ancestors +kb taxonomy init --kb coding # back-fill the named store from the kb.yaml registry +kb taxonomy init --merge # add only the domains an existing taxonomy omits +``` + +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. + +Without `--merge`, a store that already declares a taxonomy is left untouched and the command exits 2. ## Error and exception model -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. +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. ## MCP wrappability From 4756ab6bfb4111005ccb9a8c983a84dac0157b0a Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 06:21:53 -0700 Subject: [PATCH 07/12] kb|fix: Handle a taxonomy block header left with nothing under it A `.kb/taxonomy.yaml` whose `domains:` or `provisional:` header has nothing under it now loads as declaring nothing, matching an absent block. Promoting the last entry out of a block leaves exactly that shape, and it previously failed the load, so `kb check` exited 2 with every other check unrun and `kb-curate` reported `invalid-config`. `kb taxonomy init --merge` fills such a block in place, keeping it where it sits and preserving any comment attached to it. A load failure caused by a malformed block now names the key at fault. --- .../__tests__/load-taxonomy.unit.test.ts | 20 ++++++ .../__tests__/write-taxonomy.unit.test.ts | 36 +++++++++++ packages/kb/src/taxonomy/load-taxonomy.ts | 18 +++++- packages/kb/src/taxonomy/taxonomy-schema.ts | 15 +++-- packages/kb/src/taxonomy/write-taxonomy.ts | 63 +++++++++++++++++-- 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts b/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts index b5e7cf0d..bc28c31e 100644 --- a/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts +++ b/packages/kb/src/taxonomy/__tests__/load-taxonomy.unit.test.ts @@ -46,6 +46,26 @@ describe(loadTaxonomy, () => { expect(taxonomy.get('engineering')).toEqual({ description: '', provisional: true }); }); + it('reads a block header with nothing under it as declaring nothing', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\n engineering: Practice\nprovisional:\n' }); + + expect(await loadTaxonomy({ kbRoot })).toEqual( + new Map([['engineering', { description: 'Practice', provisional: false }]]), + ); + }); + + it('returns an empty taxonomy when both block headers have nothing under them', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\nprovisional:\n' }); + + expect(await loadTaxonomy({ kbRoot })).toEqual(new Map()); + }); + + it('names the offending key when a block is the wrong type', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains: not-a-mapping\n' }); + + await expect(loadTaxonomy({ kbRoot })).rejects.toThrow(/at domains/); + }); + it('loads a file declaring only one of the two blocks', async () => { const kbRoot = await makeKbRoot({ taxonomy: 'provisional:\n tools: Tooling\n' }); diff --git a/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts b/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts index 3d4bbbd2..00431e1b 100644 --- a/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts +++ b/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts @@ -131,6 +131,42 @@ describe(writeTaxonomy, () => { expect(await readTaxonomy(kbRoot)).toBe(`${stub}\nprovisional:\n engineering:\n`); }); + it('fills a block header that has nothing under it, in place', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\nprovisional:\n tools: Tooling\n' }); + + const { added } = await writeTaxonomy({ + kbRoot, + declarations: [{ path: 'engineering', description: 'Practice', provisional: false }], + }); + + expect(added).toEqual(['engineering']); + expect(await readTaxonomy(kbRoot)).toBe('domains:\n engineering: Practice\nprovisional:\n tools: Tooling\n'); + }); + + it('writes a description-less domain into an empty block as a bare key', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains:\nprovisional:\n' }); + + await writeTaxonomy({ + kbRoot, + declarations: [ + { path: 'engineering', provisional: true }, + { path: 'tools', provisional: true }, + ], + }); + + expect(await readTaxonomy(kbRoot)).toBe('domains:\nprovisional:\n engineering:\n tools:\n'); + }); + + it('keeps a comment attached to an empty block', async () => { + const kbRoot = await makeKbRoot({ taxonomy: '# header\ndomains:\n# between the blocks\nprovisional:\n t: T\n' }); + + await writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: false }] }); + + const written = await readTaxonomy(kbRoot); + expect(written).toContain('# header'); + expect(written).toContain('# between the blocks'); + }); + it('writes what loadTaxonomy reads back', async () => { const kbRoot = await makeKbRoot(); diff --git a/packages/kb/src/taxonomy/load-taxonomy.ts b/packages/kb/src/taxonomy/load-taxonomy.ts index f28a8960..8956b76a 100644 --- a/packages/kb/src/taxonomy/load-taxonomy.ts +++ b/packages/kb/src/taxonomy/load-taxonomy.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { parse } from 'yaml'; +import type { ZodError } from 'zod'; import { KbLoaderError } from '../config/kb-loader-error.ts'; import { TAXONOMY_FILE } from '../layout/index.ts'; @@ -41,12 +42,13 @@ export async function loadTaxonomy(input: { kbRoot: KbRoot }): Promise // A comment-only file parses to null, which is a taxonomy declaring nothing rather than a defect. const result = taxonomyFileShape.safeParse(parsed ?? {}); if (!result.success) { - throw new KbLoaderError(`${path}: invalid taxonomy.yaml — ${result.error.issues[0]?.message ?? 'unknown error'}`); + throw new KbLoaderError(`${path}: invalid taxonomy.yaml${describeIssueLocation(result.error)}`); } + // A block header with nothing under it parses to null, which declares nothing, exactly as an absent block does. const entries = new Map(); - collectBlock({ entries, block: result.data.domains, provisional: false, path }); - collectBlock({ entries, block: result.data.provisional, provisional: true, path }); + collectBlock({ entries, block: result.data.domains ?? undefined, provisional: false, path }); + collectBlock({ entries, block: result.data.provisional ?? undefined, provisional: true, path }); return entries; } @@ -79,4 +81,14 @@ function collectBlock(input: { } } +/** Renders a schema failure's first issue, naming the key at fault so a malformed block is identifiable. */ +function describeIssueLocation(error: ZodError): string { + const issue = error.issues[0]; + if (issue === undefined) { + return ' — unknown error'; + } + const location = issue.path.length > 0 ? ` at ${issue.path.join('.')}` : ''; + return `${location} — ${issue.message}`; +} + // endregion | Helpers diff --git a/packages/kb/src/taxonomy/taxonomy-schema.ts b/packages/kb/src/taxonomy/taxonomy-schema.ts index 1055137c..8b9fb4b6 100644 --- a/packages/kb/src/taxonomy/taxonomy-schema.ts +++ b/packages/kb/src/taxonomy/taxonomy-schema.ts @@ -43,11 +43,16 @@ export interface TaxonomyEntry { } /** - * The on-disk `.kb/taxonomy.yaml` shape. Both blocks are optional so a file may declare only one, and a description may - * be null so a key written bare (`engineering/tooling:`) loads rather than failing: that is what a hand editor types - * and what a back-filled entry round-trips to. + * The on-disk `.kb/taxonomy.yaml` shape. + * + * Both blocks are optional and nullable, so a file may declare only one, and a block header left with nothing under it + * loads as declaring nothing rather than failing. YAML reads such a header as null, and it is the state promoting the + * last entry out of a block leaves behind. + * + * A description may likewise be null, so a key written bare (`engineering/tooling:`) loads: that is what a hand editor + * types and what a domain declared without a description round-trips to. */ export const taxonomyFileShape = z.object({ - domains: z.record(z.string(), z.string().nullable()).optional(), - provisional: z.record(z.string(), z.string().nullable()).optional(), + domains: z.record(z.string(), z.string().nullable()).nullable().optional(), + provisional: z.record(z.string(), z.string().nullable()).nullable().optional(), }); diff --git a/packages/kb/src/taxonomy/write-taxonomy.ts b/packages/kb/src/taxonomy/write-taxonomy.ts index 8b69c136..fc53a3e0 100644 --- a/packages/kb/src/taxonomy/write-taxonomy.ts +++ b/packages/kb/src/taxonomy/write-taxonomy.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { type Document, isMap, parseDocument } from 'yaml'; +import { type Document, isMap, isPair, isScalar, type Pair, parseDocument } from 'yaml'; import { KbLoaderError } from '../config/kb-loader-error.ts'; import { TAXONOMY_FILE } from '../layout/index.ts'; @@ -25,7 +25,8 @@ export interface TaxonomyDeclaration { * * Edits the parsed document rather than re-serializing a plain object, so existing comments, key order, and formatting * survive; a plain parse-and-stringify round trip would discard every comment in a hand-curated file. New keys append - * to the end of their block in path order. + * to the end of their block in path order, and a block header left with nothing under it is filled in place rather + * than moved. * * A path either block already declares is skipped rather than overwritten, so a repeat call adds nothing; when nothing * is left to add, the file is not opened for writing at all. The write is atomic (temp file plus rename, matching @@ -52,21 +53,29 @@ export async function writeTaxonomy(input: { const declared = readDeclaredPaths(document); const added: string[] = []; + const byBlock = new Map>(); for (const declaration of sortByPath(input.declarations)) { if (declared.has(declaration.path)) { continue; } - const description = declaration.description ?? ''; - const block = declaration.provisional ? 'provisional' : 'domains'; - document.setIn([block, declaration.path], description === '' ? null : description); declared.add(declaration.path); added.push(declaration.path); + + const block = declaration.provisional ? 'provisional' : 'domains'; + const description = declaration.description ?? ''; + const entries = byBlock.get(block) ?? []; + entries.push([declaration.path, description === '' ? null : description]); + byBlock.set(block, entries); } if (added.length === 0) { return { added }; } + for (const [block, entries] of byBlock) { + declareInBlock({ document, block, entries }); + } + // `nullStr` renders a description-less domain as a bare `engineering/tooling:` rather than an explicit `null`, which // is what a hand editor writes and what keeps a back-filled file readable. await writeAtomic(path, document.toString({ nullStr: '' })); @@ -75,6 +84,50 @@ export async function writeTaxonomy(input: { // region | Helpers +/** + * Adds a block's entries to the document. + * + * A block header left with nothing under it parses to a null value that cannot be descended into, so its value is + * rebuilt from the entries, keeping the block where it already sits and carrying over any comment attached to the + * value being replaced. An absent or already-populated block is appended to instead. + */ +function declareInBlock(input: { + document: Document; + block: string; + entries: readonly (readonly [string, string | null])[]; +}): void { + const { document, block, entries } = input; + + const emptied = findEmptyBlock(document, block); + if (emptied === undefined) { + for (const [path, description] of entries) { + document.setIn([block, path], description); + } + return; + } + + const comment = isScalar(emptied.value) ? emptied.value.comment : undefined; + const replacement = document.createNode(Object.fromEntries(entries)); + if (typeof comment === 'string') { + replacement.comment = comment; + } + emptied.value = replacement; +} + +/** Finds a block declared with no entries under it, whose null value an append cannot descend into. */ +function findEmptyBlock(document: Document, block: string): Pair | undefined { + const contents = document.contents; + if (!isMap(contents)) { + return undefined; + } + for (const pair of contents.items) { + if (isPair(pair) && isScalar(pair.key) && pair.key.value === block && !isMap(pair.value)) { + return pair; + } + } + return undefined; +} + /** * Reads the taxonomy into an editable document, treating an absent file as an empty one. Refuses a file this cannot * safely append to: rewriting a file with a parse error would discard whatever the parser could not read, and a From 793824fe70f49f05535be8fad8a25225ebca8038 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 06:22:08 -0700 Subject: [PATCH 08/12] kb|fix: Refuse kb taxonomy init on a store marked readonly `kb taxonomy init` now refuses a knowledge base the `kb.yaml` registry marks `readonly`, exiting 2 rather than writing `.kb/taxonomy.yaml` into it. A user who marks a vault they mirror or do not own now gets the same guard from it that `kb-curate --apply` already gives. --- .../src/cli/__tests__/taxonomy.unit.test.ts | 28 ++++++++++++++++++- packages/kb/src/cli/commands/taxonomy.ts | 14 +++++++--- packages/kb/src/cli/resolve-store.ts | 11 ++++---- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts b/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts index 0cfa8846..e8f99361 100644 --- a/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts +++ b/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { check } from '../../check/check.ts'; import { TAXONOMY_FILE } from '../../layout/index.ts'; -import { makeStore, makeTempDir } from '../../test-utils/scaffolding.ts'; +import { getRegistryPathFor, makeStore, makeTempDir, seedRegistry } from '../../test-utils/scaffolding.ts'; import { run } from '../run.ts'; const VALID = @@ -79,6 +79,32 @@ describe('kb taxonomy init', () => { expect(await readTaxonomy(store)).toBe(first); }); + it('merges into a taxonomy whose block header has nothing under it', async () => { + const store = await makeStore({ + ...POPULATED, + [TAXONOMY_FILE]: 'domains:\n languages: Languages\nprovisional:\n', + }); + + const result = await run({ argv: ['taxonomy', 'init', '--merge'], cwd: store }); + + expect(result.exitCode).toBe(0); + expect(await readTaxonomy(store)).toBe( + 'domains:\n languages: Languages\nprovisional:\n engineering:\n engineering/tooling:\n engineering/tooling/versioning:\n', + ); + }); + + it('refuses a store the registry marks readonly', async () => { + const store = await makeStore(POPULATED); + const home = await makeTempDir('kb-taxonomy-home-'); + await seedRegistry(getRegistryPathFor(home), `kbs:\n mirror:\n path: ${store}\n readonly: true\n`); + + const result = await run({ argv: ['taxonomy', 'init', '--kb', 'mirror'], cwd: store, home }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('readonly'); + await expect(readTaxonomy(store)).rejects.toThrow(/ENOENT/); + }); + it('writes nothing for a store whose assertion folders hold no notes', async () => { const store = await makeStore({ 'content/assertions/Loose.md': VALID }); diff --git a/packages/kb/src/cli/commands/taxonomy.ts b/packages/kb/src/cli/commands/taxonomy.ts index c95fb348..51a7cdfc 100644 --- a/packages/kb/src/cli/commands/taxonomy.ts +++ b/packages/kb/src/cli/commands/taxonomy.ts @@ -29,16 +29,17 @@ Options: Exit codes: 0 the taxonomy was written, or already declared every derived domain - 2 usage error, unresolvable store, malformed config or taxonomy, or an - existing taxonomy without --merge + 2 usage error, unresolvable store, a store marked readonly in kb.yaml, + malformed config or taxonomy, or an existing taxonomy without --merge `; /** * Runs `kb taxonomy`: parses options, resolves the store, derives the domains its notes imply, and declares them. * * The derivation reads the same enumeration `kb check` does, so a store back-filled by this command reports no - * taxonomy drift. A malformed `.kb/config.yaml` or `.kb/taxonomy.yaml` surfaces as a `KbLoaderError` and maps to exit - * 2; any other error propagates to the caller as a real crash. + * taxonomy drift. A store the registry marks `readonly` is refused, matching `kb-curate --apply`. A malformed + * `.kb/config.yaml` or `.kb/taxonomy.yaml` surfaces as a `KbLoaderError` and maps to exit 2; any other error + * propagates to the caller as a real crash. */ export async function runTaxonomy(input: { argv: readonly string[]; @@ -68,6 +69,11 @@ export async function runTaxonomy(input: { if (!resolved.ok) { return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${resolved.message}\n` }; } + if (resolved.readonly) { + const name = resolved.store.name ?? resolved.store.path; + const message = `knowledge base "${name}" is marked readonly in kb.yaml; taxonomy init is refused`; + return { exitCode: 2, stdout: '', stderr: `kb taxonomy: ${message}\n` }; + } const kbRoot: KbRoot = { path: resolved.store.path, kbDir: resolveKbDir(resolved.store.path) }; try { diff --git a/packages/kb/src/cli/resolve-store.ts b/packages/kb/src/cli/resolve-store.ts index f3f2b0ea..a5481c92 100644 --- a/packages/kb/src/cli/resolve-store.ts +++ b/packages/kb/src/cli/resolve-store.ts @@ -3,7 +3,7 @@ import { tryLoadKbRegistry } from '../discovery/load-registry.ts'; import type { StoreRef } from './format.ts'; /** The store-resolution outcome: a resolved store, or a categorical failure message for exit 2. */ -export type ResolveStoreOutcome = { ok: true; store: StoreRef } | { ok: false; message: string }; +export type ResolveStoreOutcome = { ok: true; store: StoreRef; readonly: boolean } | { ok: false; message: string }; /** * Resolves the store a command runs against. An explicit `--kb ` is looked up in the merged registry @@ -11,8 +11,9 @@ export type ResolveStoreOutcome = { ok: true; store: StoreRef } | { ok: false; m * registry); without a flag, the nearest ancestor `.kb/` directory is used. An unknown `--kb` name or a missing `.kb/` * fails for exit 2. * - * The lookup itself is read-only, so a store's registry `readonly` flag is ignored here; a command that writes checks - * it separately. + * The registry's `readonly` flag is reported rather than enforced: a command that writes into the store refuses on it, + * and a read-only command ignores it. It is kept off {@link StoreRef}, which carries the identity a report renders. A + * `.kb/`-discovered store has no registry entry to mark it, so it is never readonly. */ export async function resolveStore(input: { explicitKb: string | null; @@ -28,12 +29,12 @@ export async function resolveStore(input: { if (match === undefined) { return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` }; } - return { ok: true, store: { name: match.name, path: match.path } }; + return { ok: true, store: { name: match.name, path: match.path }, readonly: match.readonly ?? false }; } const discovered = await findKbRoot({ startDir: input.cwd }); if (discovered === null) { return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' }; } - return { ok: true, store: { name: null, path: discovered.path } }; + return { ok: true, store: { name: null, path: discovered.path }, readonly: false }; } From 3a4fc850e93c53460e38487127ded45d3ccd5a20 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 06:22:09 -0700 Subject: [PATCH 09/12] agents|docs: Align the kb-curate helper docstrings with its rule table The helper prose now counts six detection categories and names `taxonomy.yaml` among the files whose defects surface as `invalid-config`, matching what the skill already documents. --- packages/agents/src/kb-curate/cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/kb-curate/cli.ts b/packages/agents/src/kb-curate/cli.ts index 267877af..54d6d327 100644 --- a/packages/agents/src/kb-curate/cli.ts +++ b/packages/agents/src/kb-curate/cli.ts @@ -85,7 +85,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { /** * Runs the helper end to end: parses args, resolves a single KB, enumerates and parses every note, runs detection - * across all five categories, and (under `--apply`) performs the two safe fixes before re-reporting residual + * across all six categories, and (under `--apply`) performs the two safe fixes before re-reporting residual * findings. Recoverable failures (invalid args, no resolvable KB, a readonly KB under `--apply`) become structured * `{ ok: false, ... }` results. System failures propagate to `main`'s try/catch. * @@ -153,7 +153,7 @@ type GuardedCheck = { ok: true; value: { notes: readonly EnumeratedNote[]; findings: Finding[] } } | { ok: false; failure: CurateResult }; /** - * Runs {@link curateCheck} and maps a `KbLoaderError` (malformed config or aliases) to a structured + * Runs {@link curateCheck} and maps a `KbLoaderError` (malformed config, aliases, or taxonomy) to a structured * `invalid-config` failure. Any other throw — an enumeration or detection crash — propagates as a real failure rather * than being relabeled as a config error. Both `runCurate` check calls route through here so the guard cannot drift. */ From 3c1b88a07bf7a3494aa8e6934aa51d90befd816f Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 06:43:33 -0700 Subject: [PATCH 10/12] kb|fix: Refuse a taxonomy block holding something other than a mapping Declaring a domain into a `.kb/taxonomy.yaml` whose `domains:` or `provisional:` block holds a scalar or a sequence now fails with an error naming the block, and leaves the file as it stands. Such a block previously had its contents replaced by the new declarations and reported as a clean write, so a domain list written as a YAML sequence was lost without a message. A comment written on a block header stays on that header line when the block is filled, rather than moving below the first declaration written into it. --- .../__tests__/write-taxonomy.unit.test.ts | 46 ++++++++++++++-- packages/kb/src/taxonomy/write-taxonomy.ts | 55 +++++++++++++++---- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts b/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts index 00431e1b..51c2c274 100644 --- a/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts +++ b/packages/kb/src/taxonomy/__tests__/write-taxonomy.unit.test.ts @@ -157,14 +157,50 @@ describe(writeTaxonomy, () => { expect(await readTaxonomy(kbRoot)).toBe('domains:\nprovisional:\n engineering:\n tools:\n'); }); - it('keeps a comment attached to an empty block', async () => { - const kbRoot = await makeKbRoot({ taxonomy: '# header\ndomains:\n# between the blocks\nprovisional:\n t: T\n' }); + it('keeps a comment on an empty block header on that header line', async () => { + const kbRoot = await makeKbRoot({ taxonomy: 'domains: # nothing yet\nprovisional:\n t: T\n' }); await writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: false }] }); - const written = await readTaxonomy(kbRoot); - expect(written).toContain('# header'); - expect(written).toContain('# between the blocks'); + expect(await readTaxonomy(kbRoot)).toBe('domains: # nothing yet\n engineering:\nprovisional:\n t: T\n'); + }); + + it('keeps a comment written above an empty block', async () => { + const kbRoot = await makeKbRoot({ taxonomy: '# header\ndomains:\nprovisional:\n t: T\n' }); + + await writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', provisional: false }] }); + + expect(await readTaxonomy(kbRoot)).toBe('# header\ndomains:\n engineering:\nprovisional:\n t: T\n'); + }); + + it('refuses a block holding a scalar, leaving the file untouched', async () => { + const seeded = 'domains: not-a-mapping\nprovisional:\n t: T\n'; + const kbRoot = await makeKbRoot({ taxonomy: seeded }); + + await expect( + writeTaxonomy({ kbRoot, declarations: [{ path: 'engineering', description: 'Practice', provisional: false }] }), + ).rejects.toBeInstanceOf(KbLoaderError); + expect(await readTaxonomy(kbRoot)).toBe(seeded); + }); + + it('refuses a block holding a sequence, leaving the file untouched', async () => { + const seeded = 'domains:\n - engineering\n - tools\n'; + const kbRoot = await makeKbRoot({ taxonomy: seeded }); + + await expect( + writeTaxonomy({ kbRoot, declarations: [{ path: 'languages', description: 'Langs', provisional: false }] }), + ).rejects.toThrow(/"domains" must be a mapping/); + expect(await readTaxonomy(kbRoot)).toBe(seeded); + }); + + it('refuses a malformed block even when writing to the other one', async () => { + const seeded = 'domains: not-a-mapping\n'; + const kbRoot = await makeKbRoot({ taxonomy: seeded }); + + await expect( + writeTaxonomy({ kbRoot, declarations: [{ path: 'tools', provisional: true }] }), + ).rejects.toBeInstanceOf(KbLoaderError); + expect(await readTaxonomy(kbRoot)).toBe(seeded); }); it('writes what loadTaxonomy reads back', async () => { diff --git a/packages/kb/src/taxonomy/write-taxonomy.ts b/packages/kb/src/taxonomy/write-taxonomy.ts index fc53a3e0..ed746723 100644 --- a/packages/kb/src/taxonomy/write-taxonomy.ts +++ b/packages/kb/src/taxonomy/write-taxonomy.ts @@ -10,6 +10,9 @@ import { isEnoent, isRecord } from '../type-guards.ts'; import type { KbRoot } from '../types.ts'; import { describeKeyDefect } from './taxonomy-schema.ts'; +/** The blocks a taxonomy declares domains under. */ +const BLOCKS: ReadonlySet = new Set(['domains', 'provisional']); + /** A domain to declare. */ export interface TaxonomyDeclaration { /** The domain's assertions-root-relative slash-path. */ @@ -32,8 +35,9 @@ export interface TaxonomyDeclaration { * is left to add, the file is not opened for writing at all. The write is atomic (temp file plus rename, matching * `note-io`), so an interrupted call cannot truncate the taxonomy. * - * Throws a {@link KbLoaderError} on a malformed key, or on an existing file that cannot be safely appended to. Other - * I/O errors propagate. + * Throws a {@link KbLoaderError} on a malformed key, or on an existing file that cannot be safely appended to: one + * that fails to parse, one whose top level is not a mapping, and one whose `domains` or `provisional` block holds + * something other than a mapping. Other I/O errors propagate. */ export async function writeTaxonomy(input: { kbRoot: KbRoot; @@ -84,6 +88,27 @@ export async function writeTaxonomy(input: { // region | Helpers +/** + * Refuses a block holding a value an append can neither extend nor safely replace. A block is either a mapping of + * declarations or a header with nothing under it; a scalar or a sequence is content of the wrong type, and rebuilding + * the block from the entries would discard whatever the author put there. + */ +function assertBlocksAppendable(document: Document, path: string): void { + const contents = document.contents; + if (!isMap(contents)) { + return; + } + for (const pair of contents.items) { + if (!isPair(pair) || !isScalar(pair.key) || typeof pair.key.value !== 'string') { + continue; + } + if (!BLOCKS.has(pair.key.value) || isMap(pair.value) || isEmptyBlockValue(pair.value)) { + continue; + } + throw new KbLoaderError(`${path}: "${pair.key.value}" must be a mapping of domain paths to descriptions`); + } +} + /** * Adds a block's entries to the document. * @@ -106,32 +131,39 @@ function declareInBlock(input: { return; } + // The comment moves to the block's key so it stays on the header line; on the map it would render below the first + // entry, reading as an annotation of that entry rather than of the block. const comment = isScalar(emptied.value) ? emptied.value.comment : undefined; - const replacement = document.createNode(Object.fromEntries(entries)); - if (typeof comment === 'string') { - replacement.comment = comment; + if (typeof comment === 'string' && isScalar(emptied.key)) { + emptied.key.comment = comment; } - emptied.value = replacement; + emptied.value = document.createNode(Object.fromEntries(entries)); } -/** Finds a block declared with no entries under it, whose null value an append cannot descend into. */ +/** Finds a block header declared with nothing under it, whose null value an append cannot descend into. */ function findEmptyBlock(document: Document, block: string): Pair | undefined { const contents = document.contents; if (!isMap(contents)) { return undefined; } for (const pair of contents.items) { - if (isPair(pair) && isScalar(pair.key) && pair.key.value === block && !isMap(pair.value)) { + if (isPair(pair) && isScalar(pair.key) && pair.key.value === block && isEmptyBlockValue(pair.value)) { return pair; } } return undefined; } +/** Reports whether a block's value is a header with nothing under it rather than a mapping of declarations. */ +function isEmptyBlockValue(value: unknown): boolean { + return value === null || (isScalar(value) && value.value === null); +} + /** * Reads the taxonomy into an editable document, treating an absent file as an empty one. Refuses a file this cannot - * safely append to: rewriting a file with a parse error would discard whatever the parser could not read, and a - * non-mapping top level has no block to append to. + * safely append to: rewriting a file with a parse error would discard whatever the parser could not read, a + * non-mapping top level has no block to append to, and a block holding a scalar or a sequence holds content that + * appending would destroy. */ async function readDocument(path: string): Promise { let text = ''; @@ -151,6 +183,7 @@ async function readDocument(path: string): Promise { if (document.contents !== null && !isMap(document.contents)) { throw new KbLoaderError(`${path}: top-level must be a mapping`); } + assertBlocksAppendable(document, path); return document; } @@ -162,7 +195,7 @@ function readDeclaredPaths(document: Document): Set { return paths; } - for (const block of ['domains', 'provisional']) { + for (const block of BLOCKS) { const declarations = contents[block]; if (isRecord(declarations)) { for (const path of Object.keys(declarations)) { From 49919bfded30c7fb7db1f895c39c5f20c924510f Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 06:43:34 -0700 Subject: [PATCH 11/12] kb|fix: Apply the readonly guard to a discovered store `kb taxonomy init` now refuses a knowledge base marked `readonly` in `kb.yaml` when it is discovered from the working directory, not only when named with `--kb`. Running it from inside a vault marked readonly is the likelier invocation of the two, and it previously wrote. A `kb check --json` report of a discovered store now carries that store its registry name, where it previously reported `null` for every discovered store. --- .../src/cli/__tests__/taxonomy.unit.test.ts | 31 +++++++++++++++++-- packages/kb/src/cli/resolve-store.ts | 19 ++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts b/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts index e8f99361..f6ea577f 100644 --- a/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts +++ b/packages/kb/src/cli/__tests__/taxonomy.unit.test.ts @@ -95,8 +95,7 @@ describe('kb taxonomy init', () => { it('refuses a store the registry marks readonly', async () => { const store = await makeStore(POPULATED); - const home = await makeTempDir('kb-taxonomy-home-'); - await seedRegistry(getRegistryPathFor(home), `kbs:\n mirror:\n path: ${store}\n readonly: true\n`); + const home = await makeReadonlyHome(store); const result = await run({ argv: ['taxonomy', 'init', '--kb', 'mirror'], cwd: store, home }); @@ -105,6 +104,27 @@ describe('kb taxonomy init', () => { await expect(readTaxonomy(store)).rejects.toThrow(/ENOENT/); }); + it('refuses a readonly store discovered from inside it', async () => { + const store = await makeStore(POPULATED); + const home = await makeReadonlyHome(store); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store, home }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('readonly'); + await expect(readTaxonomy(store)).rejects.toThrow(/ENOENT/); + }); + + it('writes to a discovered store the registry does not mark readonly', async () => { + const store = await makeStore(POPULATED); + const home = await makeTempDir('kb-taxonomy-home-'); + await seedRegistry(getRegistryPathFor(home), `kbs:\n mirror:\n path: ${store}\n`); + + const result = await run({ argv: ['taxonomy', 'init'], cwd: store, home }); + + expect(result.exitCode).toBe(0); + }); + it('writes nothing for a store whose assertion folders hold no notes', async () => { const store = await makeStore({ 'content/assertions/Loose.md': VALID }); @@ -176,6 +196,13 @@ describe('kb taxonomy init', () => { // region | Helpers +/** Stands up an isolated home registering `storePath` as a readonly KB named `mirror`; returns the home dir. */ +async function makeReadonlyHome(storePath: string): Promise { + const home = await makeTempDir('kb-taxonomy-home-'); + await seedRegistry(getRegistryPathFor(home), `kbs:\n mirror:\n path: ${storePath}\n readonly: true\n`); + return home; +} + /** Reads the store's taxonomy file as raw text, so a test can assert on formatting rather than parsed content. */ function readTaxonomy(storePath: string): Promise { return readFile(join(storePath, TAXONOMY_FILE), 'utf8'); diff --git a/packages/kb/src/cli/resolve-store.ts b/packages/kb/src/cli/resolve-store.ts index a5481c92..f4f3bd69 100644 --- a/packages/kb/src/cli/resolve-store.ts +++ b/packages/kb/src/cli/resolve-store.ts @@ -13,18 +13,20 @@ export type ResolveStoreOutcome = { ok: true; store: StoreRef; readonly: boolean * * The registry's `readonly` flag is reported rather than enforced: a command that writes into the store refuses on it, * and a read-only command ignores it. It is kept off {@link StoreRef}, which carries the identity a report renders. A - * `.kb/`-discovered store has no registry entry to mark it, so it is never readonly. + * discovered store is cross-referenced against the registry by path, so a vault marked readonly is reported as such + * however it was named; one with no registry entry has no metadata to consult and is reported writable. */ export async function resolveStore(input: { explicitKb: string | null; cwd: string; home?: string; }): Promise { + const { config } = await tryLoadKbRegistry({ + projectDir: input.cwd, + ...(input.home !== undefined && { home: input.home }), + }); + if (input.explicitKb !== null) { - const { config } = await tryLoadKbRegistry({ - projectDir: input.cwd, - ...(input.home !== undefined && { home: input.home }), - }); const match = config.entries.find((entry) => entry.name === input.explicitKb); if (match === undefined) { return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` }; @@ -36,5 +38,10 @@ export async function resolveStore(input: { if (discovered === null) { return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' }; } - return { ok: true, store: { name: null, path: discovered.path }, readonly: false }; + const registered = config.entries.find((entry) => entry.path === discovered.path); + return { + ok: true, + store: { name: registered?.name ?? null, path: discovered.path }, + readonly: registered?.readonly ?? false, + }; } From f54942224d4d1d2b8881c7bc0c16c0eabae2a609 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 5 Aug 2026 09:16:38 -0700 Subject: [PATCH 12/12] kb|tests: Pin the store name reported for a discovered knowledge base `kb check --json` reports a discovered knowledge base's registry name, and no name when the directory is unregistered. Neither case was asserted, so a change collapsing the reported name would have left the suite green. --- .../kb/src/cli/__tests__/check.tool.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/kb/src/cli/__tests__/check.tool.test.ts b/packages/kb/src/cli/__tests__/check.tool.test.ts index d467261a..70fe1f3f 100644 --- a/packages/kb/src/cli/__tests__/check.tool.test.ts +++ b/packages/kb/src/cli/__tests__/check.tool.test.ts @@ -140,6 +140,26 @@ describe(run, () => { }); }); + it('names a discovered store from its registry entry in the JSON report', async () => { + const store = await makeStore({ 'content/Clean.md': VALID }); + const home = await makeHome('coding', store); + + const result = await run({ argv: ['check', '--json'], cwd: store, home }); + + const payload: unknown = JSON.parse(result.stdout); + expect(payload).toMatchObject({ store: { name: 'coding', path: store } }); + }); + + it('reports a discovered store with no registry entry as unnamed', async () => { + const store = await makeStore({ 'content/Clean.md': VALID }); + const home = await makeTempDir('kb-cli-home-'); + + const result = await run({ argv: ['check', '--json'], cwd: store, home }); + + const payload: unknown = JSON.parse(result.stdout); + expect(payload).toMatchObject({ store: { name: null, path: store } }); + }); + it('resolves a store from a project-local registry entry', async () => { const store = await makeStore({ 'content/Clean.md': VALID }); const project = await makeTempDir('kb-cli-project-');