@williamthorsen/kb 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -15
- package/dist/esm/check/check.js +10 -2
- package/dist/esm/check/enumerate.d.ts +4 -0
- package/dist/esm/check/enumerate.js +32 -20
- package/dist/esm/check/index.d.ts +1 -1
- package/dist/esm/check/index.js +1 -1
- package/dist/esm/cli/commands/check.js +3 -35
- package/dist/esm/cli/commands/create.d.ts +2 -1
- package/dist/esm/cli/commands/create.js +32 -15
- package/dist/esm/cli/commands/taxonomy.d.ts +15 -0
- package/dist/esm/cli/commands/taxonomy.js +130 -0
- package/dist/esm/cli/format.js +4 -1
- package/dist/esm/cli/parse-flag-value.d.ts +2 -0
- package/dist/esm/cli/parse-flag-value.js +14 -0
- package/dist/esm/cli/resolve-store.d.ts +14 -0
- package/dist/esm/cli/resolve-store.js +25 -0
- package/dist/esm/cli/run.d.ts +1 -1
- package/dist/esm/cli/run.js +5 -0
- package/dist/esm/create/create.d.ts +2 -0
- package/dist/esm/create/create.js +3 -2
- package/dist/esm/discovery/register-store.js +23 -4
- package/dist/esm/layout/index.d.ts +1 -1
- package/dist/esm/layout/index.js +1 -1
- package/dist/esm/layout/store-layout.d.ts +1 -0
- package/dist/esm/layout/store-layout.js +1 -0
- package/dist/esm/lints/index.d.ts +1 -0
- package/dist/esm/lints/index.js +1 -0
- package/dist/esm/lints/taxonomy.d.ts +12 -0
- package/dist/esm/lints/taxonomy.js +52 -0
- package/dist/esm/taxonomy/domain-paths.d.ts +3 -0
- package/dist/esm/taxonomy/domain-paths.js +23 -0
- package/dist/esm/taxonomy/index.d.ts +4 -0
- package/dist/esm/taxonomy/index.js +4 -0
- package/dist/esm/taxonomy/load-taxonomy.d.ts +5 -0
- package/dist/esm/taxonomy/load-taxonomy.js +60 -0
- package/dist/esm/taxonomy/taxonomy-schema.d.ts +11 -0
- package/dist/esm/taxonomy/taxonomy-schema.js +28 -0
- package/dist/esm/taxonomy/write-taxonomy.d.ts +12 -0
- package/dist/esm/taxonomy/write-taxonomy.js +142 -0
- package/dist/esm/types.d.ts +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { StoreRef } from './format.js';
|
|
2
|
+
export type ResolveStoreOutcome = {
|
|
3
|
+
ok: true;
|
|
4
|
+
store: StoreRef;
|
|
5
|
+
readonly: boolean;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveStore(input: {
|
|
11
|
+
explicitKb: string | null;
|
|
12
|
+
cwd: string;
|
|
13
|
+
home?: string;
|
|
14
|
+
}): Promise<ResolveStoreOutcome>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { findKbRoot } from "../discovery/find-kb-root.js";
|
|
2
|
+
import { tryLoadKbRegistry } from "../discovery/load-registry.js";
|
|
3
|
+
export async function resolveStore(input) {
|
|
4
|
+
const { config } = await tryLoadKbRegistry({
|
|
5
|
+
projectDir: input.cwd,
|
|
6
|
+
...(input.home !== undefined && { home: input.home }),
|
|
7
|
+
});
|
|
8
|
+
if (input.explicitKb !== null) {
|
|
9
|
+
const match = config.entries.find((entry) => entry.name === input.explicitKb);
|
|
10
|
+
if (match === undefined) {
|
|
11
|
+
return { ok: false, message: `--kb "${input.explicitKb}" does not match any registered knowledge base` };
|
|
12
|
+
}
|
|
13
|
+
return { ok: true, store: { name: match.name, path: match.path }, readonly: match.readonly ?? false };
|
|
14
|
+
}
|
|
15
|
+
const discovered = await findKbRoot({ startDir: input.cwd });
|
|
16
|
+
if (discovered === null) {
|
|
17
|
+
return { ok: false, message: 'no .kb/ directory found in the current directory or any ancestor' };
|
|
18
|
+
}
|
|
19
|
+
const registered = config.entries.find((entry) => entry.path === discovered.path);
|
|
20
|
+
return {
|
|
21
|
+
ok: true,
|
|
22
|
+
store: { name: registered?.name ?? null, path: discovered.path },
|
|
23
|
+
readonly: registered?.readonly ?? false,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/esm/cli/run.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CommandOutput } from './commands/check.js';
|
|
2
2
|
import type { SelectKbPrompt } from './select-kb-prompt.js';
|
|
3
|
-
export declare const HELP = "Usage: kb <command> [options]\n\nCommands:\n check Validate a knowledge base, optionally scoped to selected notes.\n create Scaffold a new knowledge base and register it in the kb.yaml registry.\n set-default Set, clear, or choose the default knowledge base.\n\nRun \"kb <command> --help\" for command options.\n";
|
|
3
|
+
export declare const HELP = "Usage: kb <command> [options]\n\nCommands:\n check Validate a knowledge base, optionally scoped to selected notes.\n create Scaffold a new knowledge base and register it in the kb.yaml registry.\n set-default Set, clear, or choose the default knowledge base.\n taxonomy Derive a knowledge base's taxonomy from the notes it already holds.\n\nRun \"kb <command> --help\" for command options.\n";
|
|
4
4
|
export declare function run(input: {
|
|
5
5
|
argv: readonly string[];
|
|
6
6
|
cwd: string;
|
package/dist/esm/cli/run.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { runCheck } from "./commands/check.js";
|
|
2
2
|
import { runCreate } from "./commands/create.js";
|
|
3
3
|
import { runSetDefault } from "./commands/set-default.js";
|
|
4
|
+
import { runTaxonomy } from "./commands/taxonomy.js";
|
|
4
5
|
export const HELP = `Usage: kb <command> [options]
|
|
5
6
|
|
|
6
7
|
Commands:
|
|
7
8
|
check Validate a knowledge base, optionally scoped to selected notes.
|
|
8
9
|
create Scaffold a new knowledge base and register it in the kb.yaml registry.
|
|
9
10
|
set-default Set, clear, or choose the default knowledge base.
|
|
11
|
+
taxonomy Derive a knowledge base's taxonomy from the notes it already holds.
|
|
10
12
|
|
|
11
13
|
Run "kb <command> --help" for command options.
|
|
12
14
|
`;
|
|
@@ -26,6 +28,9 @@ export async function run(input) {
|
|
|
26
28
|
...(input.selectKb !== undefined && { selectKb: input.selectKb }),
|
|
27
29
|
});
|
|
28
30
|
}
|
|
31
|
+
if (command === 'taxonomy') {
|
|
32
|
+
return runTaxonomy({ argv: rest, cwd: input.cwd, ...(input.home !== undefined && { home: input.home }) });
|
|
33
|
+
}
|
|
29
34
|
if (command === 'set-default') {
|
|
30
35
|
return runSetDefault({
|
|
31
36
|
argv: rest,
|
|
@@ -2,6 +2,7 @@ export type DefaultKbOutcome = 'set' | 'unchanged' | 'needs-selection';
|
|
|
2
2
|
export interface CreatedStore {
|
|
3
3
|
name: string;
|
|
4
4
|
storePath: string;
|
|
5
|
+
description?: string;
|
|
5
6
|
registered: boolean;
|
|
6
7
|
created: readonly string[];
|
|
7
8
|
defaultKb?: DefaultKbOutcome;
|
|
@@ -14,6 +15,7 @@ export type CreateInput = {
|
|
|
14
15
|
} | {
|
|
15
16
|
register: true;
|
|
16
17
|
registryPath: string;
|
|
18
|
+
description?: string;
|
|
17
19
|
});
|
|
18
20
|
export type CreateOutcome = {
|
|
19
21
|
ok: true;
|
|
@@ -22,12 +22,13 @@ export async function create(input) {
|
|
|
22
22
|
return { ok: false, reason: 'name-registered', message: nameRegisteredMessage(name, registryPath) };
|
|
23
23
|
}
|
|
24
24
|
const created = await scaffold(storePath);
|
|
25
|
-
const
|
|
25
|
+
const described = input.description !== undefined && { description: input.description };
|
|
26
|
+
const result = await registerStore({ registryPath, name, storePath, ...described });
|
|
26
27
|
if (result.status === 'already-present') {
|
|
27
28
|
return { ok: false, reason: 'name-registered', message: nameRegisteredMessage(name, registryPath) };
|
|
28
29
|
}
|
|
29
30
|
const defaultKb = await ensureDefaultKb({ registryPath, name, before });
|
|
30
|
-
return { ok: true, created: { name, storePath, registered: true, created, defaultKb } };
|
|
31
|
+
return { ok: true, created: { name, storePath, ...described, registered: true, created, defaultKb } };
|
|
31
32
|
}
|
|
32
33
|
async function ensureDefaultKb(input) {
|
|
33
34
|
if (input.before.defaultKb !== undefined) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
+
import { isMap, isScalar } from 'yaml';
|
|
3
4
|
import { kbRegistryFileSchema } from "./kb-registry-schema.js";
|
|
4
5
|
import { loadRegistryDocument } from "./registry-document.js";
|
|
5
6
|
export async function registerStore(input) {
|
|
@@ -11,11 +12,12 @@ export async function registerStore(input) {
|
|
|
11
12
|
if (doc.hasIn(['kbs', input.name])) {
|
|
12
13
|
return { status: 'already-present' };
|
|
13
14
|
}
|
|
14
|
-
const entry = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
15
|
+
const entry = {
|
|
16
|
+
...(input.description !== undefined && { description: input.description }),
|
|
17
|
+
path: input.storePath,
|
|
18
|
+
};
|
|
18
19
|
doc.setIn(['kbs', input.name], entry);
|
|
20
|
+
sortRegistryEntries(doc);
|
|
19
21
|
const result = kbRegistryFileSchema.safeParse(doc.toJS());
|
|
20
22
|
if (!result.success) {
|
|
21
23
|
throw new Error(`${input.registryPath}: cannot register "${input.name}" — ${result.error.issues[0]?.message ?? 'invalid entry'}`);
|
|
@@ -24,3 +26,20 @@ export async function registerStore(input) {
|
|
|
24
26
|
await writeFile(input.registryPath, doc.toString(), 'utf8');
|
|
25
27
|
return { status: 'added' };
|
|
26
28
|
}
|
|
29
|
+
function compareRegistryNames(left, right) {
|
|
30
|
+
const caseInsensitive = left.localeCompare(right, 'en', { sensitivity: 'base' });
|
|
31
|
+
return caseInsensitive === 0 ? left.localeCompare(right) : caseInsensitive;
|
|
32
|
+
}
|
|
33
|
+
function sortRegistryEntries(doc) {
|
|
34
|
+
const kbs = doc.getIn(['kbs'], true);
|
|
35
|
+
if (!isMap(kbs)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
kbs.items.sort((left, right) => compareRegistryNames(readName(left.key), readName(right.key)));
|
|
39
|
+
}
|
|
40
|
+
function readName(key) {
|
|
41
|
+
if (isScalar(key)) {
|
|
42
|
+
return String(key.value);
|
|
43
|
+
}
|
|
44
|
+
return typeof key === 'string' ? key : '';
|
|
45
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, } from './store-layout.js';
|
|
1
|
+
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, TAXONOMY_FILE, } from './store-layout.js';
|
package/dist/esm/layout/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, } from "./store-layout.js";
|
|
1
|
+
export { ALIASES_FILE, ASSERTIONS_DIR, ASSERTIONS_SEGMENT, buildEventPath, CONFIG_FILE, CONTENT_DIR, EVENTS_DIR, KB_DIR, resolveAssertionsDir, resolveEventPath, resolveEventsDir, resolveKbDir, TAXONOMY_FILE, } from "./store-layout.js";
|
|
@@ -5,6 +5,7 @@ export declare const ALIASES_FILE = ".kb/tag-aliases.yaml";
|
|
|
5
5
|
export declare const ASSERTIONS_DIR = "content/assertions";
|
|
6
6
|
export declare const CONFIG_FILE = ".kb/config.yaml";
|
|
7
7
|
export declare const EVENTS_DIR = "content/events";
|
|
8
|
+
export declare const TAXONOMY_FILE = ".kb/taxonomy.yaml";
|
|
8
9
|
export declare function buildEventPath(id: string): string;
|
|
9
10
|
export declare function resolveAssertionsDir(storePath: string): string;
|
|
10
11
|
export declare function resolveEventPath(input: {
|
|
@@ -6,6 +6,7 @@ export const ALIASES_FILE = `${KB_DIR}/tag-aliases.yaml`;
|
|
|
6
6
|
export const ASSERTIONS_DIR = `${CONTENT_DIR}/${ASSERTIONS_SEGMENT}`;
|
|
7
7
|
export const CONFIG_FILE = `${KB_DIR}/config.yaml`;
|
|
8
8
|
export const EVENTS_DIR = `${CONTENT_DIR}/events`;
|
|
9
|
+
export const TAXONOMY_FILE = `${KB_DIR}/taxonomy.yaml`;
|
|
9
10
|
export function buildEventPath(id) {
|
|
10
11
|
return `${EVENTS_DIR}/${id}.md`;
|
|
11
12
|
}
|
package/dist/esm/lints/index.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KbConfig } from '../config/config-schema.js';
|
|
2
|
+
import type { Taxonomy } from '../taxonomy/taxonomy-schema.js';
|
|
3
|
+
import type { Finding } from '../types.js';
|
|
4
|
+
export interface TaxonomyNote {
|
|
5
|
+
relativePath: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function taxonomyFindings(input: {
|
|
8
|
+
notes: readonly TaxonomyNote[];
|
|
9
|
+
taxonomy: Taxonomy;
|
|
10
|
+
config: KbConfig;
|
|
11
|
+
taxonomyPath: string;
|
|
12
|
+
}): Finding[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createNoteScopeMatcher } from "../config/note-scope.js";
|
|
2
|
+
import { ASSERTIONS_DIR } from "../layout/index.js";
|
|
3
|
+
import { resolveDomain, resolveParent } from "../taxonomy/domain-paths.js";
|
|
4
|
+
export function taxonomyFindings(input) {
|
|
5
|
+
const { notes, taxonomy, config, taxonomyPath } = input;
|
|
6
|
+
const declared = taxonomy.keys().toArray().toSorted();
|
|
7
|
+
if (declared.length === 0) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const observed = new Set();
|
|
11
|
+
for (const note of notes) {
|
|
12
|
+
const domain = resolveDomain(note.relativePath);
|
|
13
|
+
if (domain !== undefined) {
|
|
14
|
+
observed.add(domain);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const matcher = createNoteScopeMatcher(config);
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const domain of [...observed].toSorted()) {
|
|
20
|
+
if (!taxonomy.has(domain)) {
|
|
21
|
+
findings.push(buildFinding(taxonomyPath, 'undeclared', `folder "${domain}" holds notes but no domain declares it`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const domain of declared) {
|
|
25
|
+
if (holdsNote(domain, observed) || matcher.isExcluded(`${ASSERTIONS_DIR}/${domain}`)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
findings.push(buildFinding(taxonomyPath, 'unused', `domain "${domain}" is declared but holds no notes`));
|
|
29
|
+
}
|
|
30
|
+
for (const domain of declared) {
|
|
31
|
+
const parent = resolveParent(domain);
|
|
32
|
+
if (parent !== undefined && !taxonomy.has(parent)) {
|
|
33
|
+
findings.push(buildFinding(taxonomyPath, 'orphan', `domain "${domain}" is declared but its parent "${parent}" is not`));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
function buildFinding(taxonomyPath, rule, message) {
|
|
39
|
+
return { path: taxonomyPath, scope: 'vault', rule: `taxonomy.${rule}`, severity: 'warning', message };
|
|
40
|
+
}
|
|
41
|
+
function holdsNote(domain, observed) {
|
|
42
|
+
if (observed.has(domain)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
const prefix = `${domain}/`;
|
|
46
|
+
for (const candidate of observed) {
|
|
47
|
+
if (candidate.startsWith(prefix)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ASSERTIONS_DIR } from "../layout/index.js";
|
|
2
|
+
export function deriveDomains(relativePaths) {
|
|
3
|
+
const domains = new Set();
|
|
4
|
+
for (const relativePath of relativePaths) {
|
|
5
|
+
let domain = resolveDomain(relativePath);
|
|
6
|
+
while (domain !== undefined) {
|
|
7
|
+
domains.add(domain);
|
|
8
|
+
domain = resolveParent(domain);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return domains.values().toArray().toSorted();
|
|
12
|
+
}
|
|
13
|
+
export function resolveDomain(relativePath) {
|
|
14
|
+
const prefix = `${ASSERTIONS_DIR}/`;
|
|
15
|
+
if (!relativePath.startsWith(prefix)) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return resolveParent(relativePath.slice(prefix.length));
|
|
19
|
+
}
|
|
20
|
+
export function resolveParent(path) {
|
|
21
|
+
const lastSlash = path.lastIndexOf('/');
|
|
22
|
+
return lastSlash === -1 ? undefined : path.slice(0, lastSlash);
|
|
23
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { resolveDomain, resolveParent } from './domain-paths.js';
|
|
2
|
+
export { loadTaxonomy } from './load-taxonomy.js';
|
|
3
|
+
export { describeKeyDefect, type Taxonomy, type TaxonomyEntry, taxonomyFileShape } from './taxonomy-schema.js';
|
|
4
|
+
export { type TaxonomyDeclaration, writeTaxonomy } from './write-taxonomy.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { KbLoaderError } from "../config/kb-loader-error.js";
|
|
5
|
+
import { TAXONOMY_FILE } from "../layout/index.js";
|
|
6
|
+
import { isEnoent } from "../type-guards.js";
|
|
7
|
+
import { describeKeyDefect, taxonomyFileShape } from "./taxonomy-schema.js";
|
|
8
|
+
export async function loadTaxonomy(input) {
|
|
9
|
+
const path = join(input.kbRoot.path, TAXONOMY_FILE);
|
|
10
|
+
let text;
|
|
11
|
+
try {
|
|
12
|
+
text = await readFile(path, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (isEnoent(error)) {
|
|
16
|
+
return new Map();
|
|
17
|
+
}
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
throw new KbLoaderError(`${path}: malformed YAML — ${message}`);
|
|
27
|
+
}
|
|
28
|
+
const result = taxonomyFileShape.safeParse(parsed ?? {});
|
|
29
|
+
if (!result.success) {
|
|
30
|
+
throw new KbLoaderError(`${path}: invalid taxonomy.yaml${describeIssueLocation(result.error)}`);
|
|
31
|
+
}
|
|
32
|
+
const entries = new Map();
|
|
33
|
+
collectBlock({ entries, block: result.data.domains ?? undefined, provisional: false, path });
|
|
34
|
+
collectBlock({ entries, block: result.data.provisional ?? undefined, provisional: true, path });
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
function collectBlock(input) {
|
|
38
|
+
const { entries, block, provisional, path } = input;
|
|
39
|
+
if (block === undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const [key, description] of Object.entries(block)) {
|
|
43
|
+
const defect = describeKeyDefect(key);
|
|
44
|
+
if (defect !== undefined) {
|
|
45
|
+
throw new KbLoaderError(`${path}: domain "${key}" ${defect}`);
|
|
46
|
+
}
|
|
47
|
+
if (entries.has(key)) {
|
|
48
|
+
throw new KbLoaderError(`${path}: domain "${key}" is declared in both domains and provisional`);
|
|
49
|
+
}
|
|
50
|
+
entries.set(key, { description: description ?? '', provisional });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function describeIssueLocation(error) {
|
|
54
|
+
const issue = error.issues[0];
|
|
55
|
+
if (issue === undefined) {
|
|
56
|
+
return ' — unknown error';
|
|
57
|
+
}
|
|
58
|
+
const location = issue.path.length > 0 ? ` at ${issue.path.join('.')}` : '';
|
|
59
|
+
return `${location} — ${issue.message}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare function describeKeyDefect(key: string): string | undefined;
|
|
3
|
+
export type Taxonomy = ReadonlyMap<string, TaxonomyEntry>;
|
|
4
|
+
export interface TaxonomyEntry {
|
|
5
|
+
description: string;
|
|
6
|
+
provisional: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const taxonomyFileShape: z.ZodObject<{
|
|
9
|
+
domains: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
|
|
10
|
+
provisional: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
|
|
11
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ASSERTIONS_DIR, ASSERTIONS_SEGMENT, CONTENT_DIR } from "../layout/index.js";
|
|
3
|
+
export function describeKeyDefect(key) {
|
|
4
|
+
if (key === '') {
|
|
5
|
+
return 'is empty';
|
|
6
|
+
}
|
|
7
|
+
if (key.includes('\\')) {
|
|
8
|
+
return 'contains a backslash; domain paths are slash-separated';
|
|
9
|
+
}
|
|
10
|
+
if (key.startsWith('/') || key.endsWith('/')) {
|
|
11
|
+
return 'has a leading or trailing slash';
|
|
12
|
+
}
|
|
13
|
+
const segments = key.split('/');
|
|
14
|
+
if (segments.some((segment) => segment.trim() === '')) {
|
|
15
|
+
return 'has an empty segment';
|
|
16
|
+
}
|
|
17
|
+
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
|
18
|
+
return 'has a "." or ".." segment';
|
|
19
|
+
}
|
|
20
|
+
if (segments[0] === ASSERTIONS_SEGMENT || (segments[0] === CONTENT_DIR && segments[1] === ASSERTIONS_SEGMENT)) {
|
|
21
|
+
return `restates the ${ASSERTIONS_DIR}/ prefix, which every key implies`;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export const taxonomyFileShape = z.object({
|
|
26
|
+
domains: z.record(z.string(), z.string().nullable()).nullable().optional(),
|
|
27
|
+
provisional: z.record(z.string(), z.string().nullable()).nullable().optional(),
|
|
28
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KbRoot } from '../types.js';
|
|
2
|
+
export interface TaxonomyDeclaration {
|
|
3
|
+
path: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
provisional: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeTaxonomy(input: {
|
|
8
|
+
kbRoot: KbRoot;
|
|
9
|
+
declarations: readonly TaxonomyDeclaration[];
|
|
10
|
+
}): Promise<{
|
|
11
|
+
added: string[];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isMap, isPair, isScalar, parseDocument } from 'yaml';
|
|
5
|
+
import { KbLoaderError } from "../config/kb-loader-error.js";
|
|
6
|
+
import { TAXONOMY_FILE } from "../layout/index.js";
|
|
7
|
+
import { isEnoent, isRecord } from "../type-guards.js";
|
|
8
|
+
import { describeKeyDefect } from "./taxonomy-schema.js";
|
|
9
|
+
const BLOCKS = new Set(['domains', 'provisional']);
|
|
10
|
+
export async function writeTaxonomy(input) {
|
|
11
|
+
const path = join(input.kbRoot.path, TAXONOMY_FILE);
|
|
12
|
+
for (const declaration of input.declarations) {
|
|
13
|
+
const defect = describeKeyDefect(declaration.path);
|
|
14
|
+
if (defect !== undefined) {
|
|
15
|
+
throw new KbLoaderError(`${path}: domain "${declaration.path}" ${defect}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const document = await readDocument(path);
|
|
19
|
+
const declared = readDeclaredPaths(document);
|
|
20
|
+
const added = [];
|
|
21
|
+
const byBlock = new Map();
|
|
22
|
+
for (const declaration of sortByPath(input.declarations)) {
|
|
23
|
+
if (declared.has(declaration.path)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
declared.add(declaration.path);
|
|
27
|
+
added.push(declaration.path);
|
|
28
|
+
const block = declaration.provisional ? 'provisional' : 'domains';
|
|
29
|
+
const description = declaration.description ?? '';
|
|
30
|
+
const entries = byBlock.get(block) ?? [];
|
|
31
|
+
entries.push([declaration.path, description === '' ? null : description]);
|
|
32
|
+
byBlock.set(block, entries);
|
|
33
|
+
}
|
|
34
|
+
if (added.length === 0) {
|
|
35
|
+
return { added };
|
|
36
|
+
}
|
|
37
|
+
for (const [block, entries] of byBlock) {
|
|
38
|
+
declareInBlock({ document, block, entries });
|
|
39
|
+
}
|
|
40
|
+
await writeAtomic(path, document.toString({ nullStr: '' }));
|
|
41
|
+
return { added };
|
|
42
|
+
}
|
|
43
|
+
function assertBlocksAppendable(document, path) {
|
|
44
|
+
const contents = document.contents;
|
|
45
|
+
if (!isMap(contents)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const pair of contents.items) {
|
|
49
|
+
if (!isPair(pair) || !isScalar(pair.key) || typeof pair.key.value !== 'string') {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!BLOCKS.has(pair.key.value) || isMap(pair.value) || isEmptyBlockValue(pair.value)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw new KbLoaderError(`${path}: "${pair.key.value}" must be a mapping of domain paths to descriptions`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function declareInBlock(input) {
|
|
59
|
+
const { document, block, entries } = input;
|
|
60
|
+
const emptied = findEmptyBlock(document, block);
|
|
61
|
+
if (emptied === undefined) {
|
|
62
|
+
for (const [path, description] of entries) {
|
|
63
|
+
document.setIn([block, path], description);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const comment = isScalar(emptied.value) ? emptied.value.comment : undefined;
|
|
68
|
+
if (typeof comment === 'string' && isScalar(emptied.key)) {
|
|
69
|
+
emptied.key.comment = comment;
|
|
70
|
+
}
|
|
71
|
+
emptied.value = document.createNode(Object.fromEntries(entries));
|
|
72
|
+
}
|
|
73
|
+
function findEmptyBlock(document, block) {
|
|
74
|
+
const contents = document.contents;
|
|
75
|
+
if (!isMap(contents)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
for (const pair of contents.items) {
|
|
79
|
+
if (isPair(pair) && isScalar(pair.key) && pair.key.value === block && isEmptyBlockValue(pair.value)) {
|
|
80
|
+
return pair;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function isEmptyBlockValue(value) {
|
|
86
|
+
return value === null || (isScalar(value) && value.value === null);
|
|
87
|
+
}
|
|
88
|
+
async function readDocument(path) {
|
|
89
|
+
let text = '';
|
|
90
|
+
try {
|
|
91
|
+
text = await readFile(path, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!isEnoent(error)) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const document = parseDocument(text);
|
|
99
|
+
const firstError = document.errors[0];
|
|
100
|
+
if (firstError !== undefined) {
|
|
101
|
+
throw new KbLoaderError(`${path}: malformed YAML — ${firstError.message}`);
|
|
102
|
+
}
|
|
103
|
+
if (document.contents !== null && !isMap(document.contents)) {
|
|
104
|
+
throw new KbLoaderError(`${path}: top-level must be a mapping`);
|
|
105
|
+
}
|
|
106
|
+
assertBlocksAppendable(document, path);
|
|
107
|
+
return document;
|
|
108
|
+
}
|
|
109
|
+
function readDeclaredPaths(document) {
|
|
110
|
+
const paths = new Set();
|
|
111
|
+
const contents = document.toJS();
|
|
112
|
+
if (!isRecord(contents)) {
|
|
113
|
+
return paths;
|
|
114
|
+
}
|
|
115
|
+
for (const block of BLOCKS) {
|
|
116
|
+
const declarations = contents[block];
|
|
117
|
+
if (isRecord(declarations)) {
|
|
118
|
+
for (const path of Object.keys(declarations)) {
|
|
119
|
+
paths.add(path);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return paths;
|
|
124
|
+
}
|
|
125
|
+
function sortByPath(declarations) {
|
|
126
|
+
return declarations.toSorted((a, b) => {
|
|
127
|
+
if (a.path === b.path)
|
|
128
|
+
return 0;
|
|
129
|
+
return a.path < b.path ? -1 : 1;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function writeAtomic(path, content) {
|
|
133
|
+
const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
134
|
+
await writeFile(tempPath, content, 'utf8');
|
|
135
|
+
try {
|
|
136
|
+
await rename(tempPath, path);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
await unlink(tempPath).catch(() => { });
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/esm/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/kb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Knowledge-base foundation: discovery, config, frontmatter parsing, records, tags, and vault-integrity checks",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontmatter",
|
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
"types": "./dist/esm/tags/index.d.ts",
|
|
77
77
|
"import": "./dist/esm/tags/index.js"
|
|
78
78
|
},
|
|
79
|
+
"./taxonomy": {
|
|
80
|
+
"source": "./src/taxonomy/index.ts",
|
|
81
|
+
"types": "./dist/esm/taxonomy/index.d.ts",
|
|
82
|
+
"import": "./dist/esm/taxonomy/index.js"
|
|
83
|
+
},
|
|
79
84
|
"./vault-integrity": {
|
|
80
85
|
"source": "./src/vault-integrity/index.ts",
|
|
81
86
|
"types": "./dist/esm/vault-integrity/index.d.ts",
|