@williamthorsen/kb 0.0.0 → 0.3.1
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/LICENSE +7 -0
- package/README.md +228 -1
- package/bin/kb.js +26 -0
- package/dist/esm/check/check.d.ts +11 -0
- package/dist/esm/check/check.js +17 -0
- package/dist/esm/check/enumerate.d.ts +14 -0
- package/dist/esm/check/enumerate.js +71 -0
- package/dist/esm/check/glob-segments.d.ts +2 -0
- package/dist/esm/check/glob-segments.js +12 -0
- package/dist/esm/check/index.d.ts +2 -0
- package/dist/esm/check/index.js +2 -0
- package/dist/esm/cli/commands/check.d.ts +20 -0
- package/dist/esm/cli/commands/check.js +179 -0
- package/dist/esm/cli/commands/create.d.ts +16 -0
- package/dist/esm/cli/commands/create.js +111 -0
- package/dist/esm/cli/commands/set-default.d.ts +15 -0
- package/dist/esm/cli/commands/set-default.js +123 -0
- package/dist/esm/cli/format.d.ts +24 -0
- package/dist/esm/cli/format.js +55 -0
- package/dist/esm/cli/index.d.ts +1 -0
- package/dist/esm/cli/index.js +25 -0
- package/dist/esm/cli/run.d.ts +9 -0
- package/dist/esm/cli/run.js +37 -0
- package/dist/esm/cli/select-kb-prompt.d.ts +16 -0
- package/dist/esm/cli/select-kb-prompt.js +44 -0
- package/dist/esm/cli/targeting/resolve-changed-paths.d.ts +11 -0
- package/dist/esm/cli/targeting/resolve-changed-paths.js +53 -0
- package/dist/esm/cli/targeting/select-notes.d.ts +10 -0
- package/dist/esm/cli/targeting/select-notes.js +60 -0
- package/dist/esm/config/config-schema.d.ts +10 -0
- package/dist/esm/config/config-schema.js +10 -0
- package/dist/esm/config/index.d.ts +4 -0
- package/dist/esm/config/index.js +4 -0
- package/dist/esm/config/kb-loader-error.d.ts +5 -0
- package/dist/esm/config/kb-loader-error.js +14 -0
- package/dist/esm/config/load-config.d.ts +5 -0
- package/dist/esm/config/load-config.js +36 -0
- package/dist/esm/config/note-scope.d.ts +7 -0
- package/dist/esm/config/note-scope.js +10 -0
- package/dist/esm/create/create.d.ts +26 -0
- package/dist/esm/create/create.js +51 -0
- package/dist/esm/create/index.d.ts +3 -0
- package/dist/esm/create/index.js +2 -0
- package/dist/esm/create/render-seeds.d.ts +2 -0
- package/dist/esm/create/render-seeds.js +29 -0
- package/dist/esm/discovery/find-kb-root.d.ts +4 -0
- package/dist/esm/discovery/find-kb-root.js +22 -0
- package/dist/esm/discovery/index.d.ts +9 -0
- package/dist/esm/discovery/index.js +5 -0
- package/dist/esm/discovery/kb-registry-schema.d.ts +16 -0
- package/dist/esm/discovery/kb-registry-schema.js +10 -0
- package/dist/esm/discovery/load-registry.d.ts +15 -0
- package/dist/esm/discovery/load-registry.js +114 -0
- package/dist/esm/discovery/register-store.d.ts +9 -0
- package/dist/esm/discovery/register-store.js +26 -0
- package/dist/esm/discovery/registry-document.d.ts +2 -0
- package/dist/esm/discovery/registry-document.js +17 -0
- package/dist/esm/discovery/set-default-kb.d.ts +7 -0
- package/dist/esm/discovery/set-default-kb.js +53 -0
- package/dist/esm/filesystem/exists.d.ts +6 -0
- package/dist/esm/filesystem/exists.js +24 -0
- package/dist/esm/filesystem/index.d.ts +1 -0
- package/dist/esm/filesystem/index.js +1 -0
- package/dist/esm/frontmatter/index.d.ts +2 -0
- package/dist/esm/frontmatter/index.js +1 -0
- package/dist/esm/frontmatter/parse-note.d.ts +8 -0
- package/dist/esm/frontmatter/parse-note.js +98 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/layout/index.d.ts +1 -0
- package/dist/esm/layout/index.js +1 -0
- package/dist/esm/layout/store-layout.d.ts +15 -0
- package/dist/esm/layout/store-layout.js +23 -0
- package/dist/esm/lints/index.d.ts +2 -0
- package/dist/esm/lints/index.js +2 -0
- package/dist/esm/lints/paths.d.ts +6 -0
- package/dist/esm/lints/paths.js +12 -0
- package/dist/esm/lints/tag-alias.d.ts +6 -0
- package/dist/esm/lints/tag-alias.js +21 -0
- package/dist/esm/note-io/field-validators.d.ts +2 -0
- package/dist/esm/note-io/field-validators.js +30 -0
- package/dist/esm/note-io/index.d.ts +4 -0
- package/dist/esm/note-io/index.js +4 -0
- package/dist/esm/note-io/read-note.d.ts +8 -0
- package/dist/esm/note-io/read-note.js +22 -0
- package/dist/esm/note-io/write-note.d.ts +2 -0
- package/dist/esm/note-io/write-note.js +21 -0
- package/dist/esm/note-io/yaml-fields.d.ts +5 -0
- package/dist/esm/note-io/yaml-fields.js +49 -0
- package/dist/esm/records/assertion.d.ts +25 -0
- package/dist/esm/records/assertion.js +136 -0
- package/dist/esm/records/event.d.ts +28 -0
- package/dist/esm/records/event.js +128 -0
- package/dist/esm/records/index.d.ts +2 -0
- package/dist/esm/records/index.js +2 -0
- package/dist/esm/tags/canonicalize.d.ts +3 -0
- package/dist/esm/tags/canonicalize.js +6 -0
- package/dist/esm/tags/index.d.ts +3 -0
- package/dist/esm/tags/index.js +2 -0
- package/dist/esm/tags/load-aliases.d.ts +5 -0
- package/dist/esm/tags/load-aliases.js +72 -0
- package/dist/esm/type-guards.d.ts +3 -0
- package/dist/esm/type-guards.js +12 -0
- package/dist/esm/types.d.ts +50 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/vault-integrity/build-vault-index.d.ts +4 -0
- package/dist/esm/vault-integrity/build-vault-index.js +14 -0
- package/dist/esm/vault-integrity/check-vault-integrity.d.ts +7 -0
- package/dist/esm/vault-integrity/check-vault-integrity.js +49 -0
- package/dist/esm/vault-integrity/index.d.ts +3 -0
- package/dist/esm/vault-integrity/index.js +3 -0
- package/dist/esm/vault-integrity/wikilink-parse.d.ts +7 -0
- package/dist/esm/vault-integrity/wikilink-parse.js +75 -0
- package/package.json +108 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isMap, isPair, isScalar, isSeq, parseDocument } from 'yaml';
|
|
3
|
+
import { isRecord } from "../type-guards.js";
|
|
4
|
+
const FENCE = '---';
|
|
5
|
+
export async function parseNote(input) {
|
|
6
|
+
const content = await readFile(input.path, 'utf8');
|
|
7
|
+
return parseNoteContent({ content, path: input.path });
|
|
8
|
+
}
|
|
9
|
+
export function parseNoteContent(input) {
|
|
10
|
+
const { content, path = '<string>' } = input;
|
|
11
|
+
const lines = content.split('\n');
|
|
12
|
+
if (lines[0] !== FENCE) {
|
|
13
|
+
return { path, content, frontmatter: null, frontmatterRaw: null, body: content, bodyStartLine: 1 };
|
|
14
|
+
}
|
|
15
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && line === FENCE);
|
|
16
|
+
if (endIndex === -1) {
|
|
17
|
+
return { path, content, frontmatter: null, frontmatterRaw: null, body: content, bodyStartLine: 1 };
|
|
18
|
+
}
|
|
19
|
+
const text = lines.slice(1, endIndex).join('\n');
|
|
20
|
+
const doc = parseDocument(text, { schema: 'core' });
|
|
21
|
+
const parseError = doc.errors[0]?.message;
|
|
22
|
+
const frontmatterRaw = {
|
|
23
|
+
text,
|
|
24
|
+
startLine: 1,
|
|
25
|
+
endLine: endIndex + 1,
|
|
26
|
+
...(parseError !== undefined && { parseError }),
|
|
27
|
+
};
|
|
28
|
+
const body = lines.slice(endIndex + 1).join('\n');
|
|
29
|
+
const bodyStartLine = endIndex + 2;
|
|
30
|
+
const frontmatter = parseError === undefined ? toFrontmatter(doc) : null;
|
|
31
|
+
return { path, content, frontmatter, frontmatterRaw, body, bodyStartLine };
|
|
32
|
+
}
|
|
33
|
+
function stringList(value) {
|
|
34
|
+
if (!isSeq(value)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const result = [];
|
|
38
|
+
for (const item of value.items) {
|
|
39
|
+
if (isScalar(item) && typeof item.value === 'string') {
|
|
40
|
+
result.push(item.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
function stringValue(value) {
|
|
46
|
+
if (!isScalar(value)) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
const raw = value.value;
|
|
50
|
+
if (typeof raw === 'string') {
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
if (typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'bigint') {
|
|
54
|
+
return String(raw);
|
|
55
|
+
}
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
function toFrontmatter(doc) {
|
|
59
|
+
const contents = doc.contents;
|
|
60
|
+
if (!isMap(contents)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const plain = doc.toJS();
|
|
64
|
+
const plainRecord = isRecord(plain) ? plain : {};
|
|
65
|
+
const extra = {};
|
|
66
|
+
let title = '';
|
|
67
|
+
let recordType = '';
|
|
68
|
+
let created = '';
|
|
69
|
+
let updated = '';
|
|
70
|
+
let tags = [];
|
|
71
|
+
for (const item of contents.items) {
|
|
72
|
+
if (!isPair(item) || !isScalar(item.key))
|
|
73
|
+
continue;
|
|
74
|
+
const key = item.key.value;
|
|
75
|
+
if (typeof key !== 'string')
|
|
76
|
+
continue;
|
|
77
|
+
switch (key) {
|
|
78
|
+
case 'title':
|
|
79
|
+
title = stringValue(item.value);
|
|
80
|
+
break;
|
|
81
|
+
case 'recordType':
|
|
82
|
+
recordType = stringValue(item.value);
|
|
83
|
+
break;
|
|
84
|
+
case 'created':
|
|
85
|
+
created = stringValue(item.value);
|
|
86
|
+
break;
|
|
87
|
+
case 'updated':
|
|
88
|
+
updated = stringValue(item.value);
|
|
89
|
+
break;
|
|
90
|
+
case 'tags':
|
|
91
|
+
tags = stringList(item.value);
|
|
92
|
+
break;
|
|
93
|
+
default:
|
|
94
|
+
extra[key] = key in plainRecord ? plainRecord[key] : null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { title, recordType, created, updated, tags, extra };
|
|
98
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildVaultIndex } from "./vault-integrity/build-vault-index.js";
|
|
@@ -0,0 +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';
|
|
@@ -0,0 +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";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const KB_DIR = ".kb";
|
|
2
|
+
export declare const CONTENT_DIR = "content";
|
|
3
|
+
export declare const ASSERTIONS_SEGMENT = "assertions";
|
|
4
|
+
export declare const ALIASES_FILE = ".kb/tag-aliases.yaml";
|
|
5
|
+
export declare const ASSERTIONS_DIR = "content/assertions";
|
|
6
|
+
export declare const CONFIG_FILE = ".kb/config.yaml";
|
|
7
|
+
export declare const EVENTS_DIR = "content/events";
|
|
8
|
+
export declare function buildEventPath(id: string): string;
|
|
9
|
+
export declare function resolveAssertionsDir(storePath: string): string;
|
|
10
|
+
export declare function resolveEventPath(input: {
|
|
11
|
+
storePath: string;
|
|
12
|
+
id: string;
|
|
13
|
+
}): string;
|
|
14
|
+
export declare function resolveEventsDir(storePath: string): string;
|
|
15
|
+
export declare function resolveKbDir(storePath: string): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
export const KB_DIR = '.kb';
|
|
3
|
+
export const CONTENT_DIR = 'content';
|
|
4
|
+
export const ASSERTIONS_SEGMENT = 'assertions';
|
|
5
|
+
export const ALIASES_FILE = `${KB_DIR}/tag-aliases.yaml`;
|
|
6
|
+
export const ASSERTIONS_DIR = `${CONTENT_DIR}/${ASSERTIONS_SEGMENT}`;
|
|
7
|
+
export const CONFIG_FILE = `${KB_DIR}/config.yaml`;
|
|
8
|
+
export const EVENTS_DIR = `${CONTENT_DIR}/events`;
|
|
9
|
+
export function buildEventPath(id) {
|
|
10
|
+
return `${EVENTS_DIR}/${id}.md`;
|
|
11
|
+
}
|
|
12
|
+
export function resolveAssertionsDir(storePath) {
|
|
13
|
+
return join(storePath, ASSERTIONS_DIR);
|
|
14
|
+
}
|
|
15
|
+
export function resolveEventPath(input) {
|
|
16
|
+
return join(input.storePath, buildEventPath(input.id));
|
|
17
|
+
}
|
|
18
|
+
export function resolveEventsDir(storePath) {
|
|
19
|
+
return join(storePath, EVENTS_DIR);
|
|
20
|
+
}
|
|
21
|
+
export function resolveKbDir(storePath) {
|
|
22
|
+
return join(storePath, KB_DIR);
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { countNewlines } from "../vault-integrity/wikilink-parse.js";
|
|
2
|
+
export function pathsFindings(note) {
|
|
3
|
+
const findings = Array.from(note.content.matchAll(USER_HOME), (match) => ({
|
|
4
|
+
path: note.path,
|
|
5
|
+
line: countNewlines(note.content, match.index) + 1,
|
|
6
|
+
rule: 'paths.user-home',
|
|
7
|
+
severity: 'error',
|
|
8
|
+
message: `hardcoded "${match[0]}" — replace with ~/`,
|
|
9
|
+
}));
|
|
10
|
+
return findings;
|
|
11
|
+
}
|
|
12
|
+
const USER_HOME = /\/Users\/[A-Za-z0-9_.-]+\//g;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { asStringList } from "../note-io/field-validators.js";
|
|
2
|
+
import { findAliasFor } from "../tags/canonicalize.js";
|
|
3
|
+
export function tagAliasFindings(note, aliases) {
|
|
4
|
+
const tags = asStringList(note.fields.tags);
|
|
5
|
+
if (tags === null) {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
const findings = [];
|
|
9
|
+
for (const tag of tags) {
|
|
10
|
+
const canonical = findAliasFor(tag, aliases);
|
|
11
|
+
if (canonical === null)
|
|
12
|
+
continue;
|
|
13
|
+
findings.push({
|
|
14
|
+
path: note.path,
|
|
15
|
+
rule: 'tag-alias',
|
|
16
|
+
severity: 'warning',
|
|
17
|
+
message: `tag "${tag}" is an alias — use canonical form "${canonical}"`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return findings;
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
2
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
3
|
+
export function asStringList(value) {
|
|
4
|
+
if (value === undefined || value === null) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
if (!Array.isArray(value)) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
return value.filter((item) => typeof item === 'string');
|
|
11
|
+
}
|
|
12
|
+
export function isValidDate(value) {
|
|
13
|
+
const dateOnly = DATE_ONLY.test(value);
|
|
14
|
+
if (!dateOnly && !TIMESTAMP.test(value)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const year = Number(value.slice(0, 4));
|
|
18
|
+
const month = Number(value.slice(5, 7));
|
|
19
|
+
const day = Number(value.slice(8, 10));
|
|
20
|
+
const hour = dateOnly ? 0 : Number(value.slice(11, 13));
|
|
21
|
+
const minute = dateOnly ? 0 : Number(value.slice(14, 16));
|
|
22
|
+
const second = dateOnly ? 0 : Number(value.slice(17, 19));
|
|
23
|
+
const roundTrip = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
|
24
|
+
return (roundTrip.getUTCFullYear() === year &&
|
|
25
|
+
roundTrip.getUTCMonth() === month - 1 &&
|
|
26
|
+
roundTrip.getUTCDate() === day &&
|
|
27
|
+
roundTrip.getUTCHours() === hour &&
|
|
28
|
+
roundTrip.getUTCMinutes() === minute &&
|
|
29
|
+
roundTrip.getUTCSeconds() === second);
|
|
30
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { asStringList, isValidDate } from './field-validators.js';
|
|
2
|
+
export { type ReadNote, readNote, readNoteContent } from './read-note.js';
|
|
3
|
+
export { renderNote, writeNote } from './write-note.js';
|
|
4
|
+
export { parseFrontmatterFields, renderFrontmatterFields } from './yaml-fields.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parseFrontmatterFields } from "./yaml-fields.js";
|
|
3
|
+
const FENCE = '---';
|
|
4
|
+
export async function readNote(path) {
|
|
5
|
+
const content = await readFile(path, 'utf8');
|
|
6
|
+
return readNoteContent(content);
|
|
7
|
+
}
|
|
8
|
+
export function readNoteContent(content) {
|
|
9
|
+
const lines = content.split('\n');
|
|
10
|
+
if (lines[0] !== FENCE) {
|
|
11
|
+
return { fields: {}, body: content, bodyStartLine: 1, error: 'no frontmatter block found' };
|
|
12
|
+
}
|
|
13
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && line === FENCE);
|
|
14
|
+
if (endIndex === -1) {
|
|
15
|
+
return { fields: {}, body: content, bodyStartLine: 1, error: 'no frontmatter block found' };
|
|
16
|
+
}
|
|
17
|
+
const text = lines.slice(1, endIndex).join('\n');
|
|
18
|
+
const body = lines.slice(endIndex + 1).join('\n');
|
|
19
|
+
const bodyStartLine = endIndex + 2;
|
|
20
|
+
const { fields, error } = parseFrontmatterFields(text);
|
|
21
|
+
return { fields, body, bodyStartLine, ...(error !== undefined && { error }) };
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { renderFrontmatterFields } from "./yaml-fields.js";
|
|
4
|
+
const FENCE = '---';
|
|
5
|
+
export function renderNote(fields, body) {
|
|
6
|
+
const frontmatter = renderFrontmatterFields(fields);
|
|
7
|
+
const normalizedBody = body.startsWith('\n') ? body.slice(1) : body;
|
|
8
|
+
return `${FENCE}\n${frontmatter}\n${FENCE}\n\n${normalizedBody}`;
|
|
9
|
+
}
|
|
10
|
+
export async function writeNote(path, fields, body) {
|
|
11
|
+
const content = renderNote(fields, body);
|
|
12
|
+
const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
13
|
+
await writeFile(tempPath, content, 'utf8');
|
|
14
|
+
try {
|
|
15
|
+
await rename(tempPath, path);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
await unlink(tempPath).catch(() => { });
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Document, parseDocument, stringify } from 'yaml';
|
|
2
|
+
import { isRecord } from "../type-guards.js";
|
|
3
|
+
const SCALAR_STRINGIFY_OPTIONS = {
|
|
4
|
+
schema: 'core',
|
|
5
|
+
defaultStringType: 'PLAIN',
|
|
6
|
+
defaultKeyType: 'PLAIN',
|
|
7
|
+
singleQuote: true,
|
|
8
|
+
lineWidth: 0,
|
|
9
|
+
};
|
|
10
|
+
export function parseFrontmatterFields(text) {
|
|
11
|
+
const doc = parseDocument(text, { schema: 'core' });
|
|
12
|
+
const error = doc.errors[0]?.message;
|
|
13
|
+
if (error !== undefined) {
|
|
14
|
+
return { fields: {}, error };
|
|
15
|
+
}
|
|
16
|
+
const plain = doc.toJS();
|
|
17
|
+
return { fields: isRecord(plain) ? plain : {} };
|
|
18
|
+
}
|
|
19
|
+
export function renderFrontmatterFields(fields) {
|
|
20
|
+
return Object.entries(fields)
|
|
21
|
+
.flatMap(([key, value]) => renderEntry(key, value))
|
|
22
|
+
.join('\n');
|
|
23
|
+
}
|
|
24
|
+
function renderEntry(key, value) {
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
return [`${key}: ${renderScalar(value)}`];
|
|
27
|
+
}
|
|
28
|
+
if (value === null || value === undefined) {
|
|
29
|
+
return [`${key}:`];
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
32
|
+
return [`${key}: ${stringify(value, SCALAR_STRINGIFY_OPTIONS).replace(/\n$/, '')}`];
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
35
|
+
return [`${key}: ${renderFlowList(value)}`];
|
|
36
|
+
}
|
|
37
|
+
const doc = new Document({ [key]: value });
|
|
38
|
+
return doc.toString({ lineWidth: 0 }).trimEnd().split('\n');
|
|
39
|
+
}
|
|
40
|
+
function renderFlowList(values) {
|
|
41
|
+
return `[${values.map((value) => renderScalar(value)).join(', ')}]`;
|
|
42
|
+
}
|
|
43
|
+
function renderScalar(value) {
|
|
44
|
+
const rendered = stringify(value, SCALAR_STRINGIFY_OPTIONS).replace(/\n$/, '');
|
|
45
|
+
if (rendered.includes('\n')) {
|
|
46
|
+
return stringify(value, { ...SCALAR_STRINGIFY_OPTIONS, defaultStringType: 'QUOTE_DOUBLE' }).replace(/\n$/, '');
|
|
47
|
+
}
|
|
48
|
+
return rendered;
|
|
49
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface KbAssertion {
|
|
2
|
+
recordType: 'assertion';
|
|
3
|
+
title: string;
|
|
4
|
+
created: string;
|
|
5
|
+
updated: string;
|
|
6
|
+
tags: string[];
|
|
7
|
+
addressedBy: string[];
|
|
8
|
+
lastVerified?: string;
|
|
9
|
+
supersedes?: string;
|
|
10
|
+
supersededBy?: string;
|
|
11
|
+
extra: Record<string, unknown>;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
export type ParseAssertionResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
record: KbAssertion;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
errors: string[];
|
|
20
|
+
};
|
|
21
|
+
export declare function parseAssertion(fields: Record<string, unknown>, body: string): ParseAssertionResult;
|
|
22
|
+
export declare function renderAssertion(record: KbAssertion): {
|
|
23
|
+
fields: Record<string, unknown>;
|
|
24
|
+
body: string;
|
|
25
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { asStringList, isValidDate } from "../note-io/field-validators.js";
|
|
2
|
+
const TYPED_FIELDS = new Set([
|
|
3
|
+
'recordType',
|
|
4
|
+
'title',
|
|
5
|
+
'created',
|
|
6
|
+
'updated',
|
|
7
|
+
'tags',
|
|
8
|
+
'addressed-by',
|
|
9
|
+
'last-verified',
|
|
10
|
+
'supersedes',
|
|
11
|
+
'superseded-by',
|
|
12
|
+
]);
|
|
13
|
+
export function parseAssertion(fields, body) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
if (fields.recordType !== 'assertion') {
|
|
16
|
+
errors.push('recordType: expected "assertion"');
|
|
17
|
+
}
|
|
18
|
+
let title;
|
|
19
|
+
if (typeof fields.title === 'string' && fields.title.length > 0) {
|
|
20
|
+
title = fields.title;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
errors.push('missing required field: title');
|
|
24
|
+
}
|
|
25
|
+
const created = requireDate(fields.created, 'created', errors);
|
|
26
|
+
const updated = requireDate(fields.updated, 'updated', errors);
|
|
27
|
+
let tags;
|
|
28
|
+
if (fields.tags === undefined) {
|
|
29
|
+
errors.push('missing required field: tags');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const list = asStringList(fields.tags);
|
|
33
|
+
if (list === null) {
|
|
34
|
+
errors.push('tags: must be a list');
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
tags = list;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const addressedBy = readListField(fields['addressed-by'], 'addressed-by', errors);
|
|
41
|
+
const lastVerified = readOptionalDate(fields['last-verified'], 'last-verified', errors);
|
|
42
|
+
const supersedes = readOptionalString(fields.supersedes, 'supersedes', errors);
|
|
43
|
+
const supersededBy = readOptionalString(fields['superseded-by'], 'superseded-by', errors);
|
|
44
|
+
if (errors.length > 0 ||
|
|
45
|
+
title === undefined ||
|
|
46
|
+
created === undefined ||
|
|
47
|
+
updated === undefined ||
|
|
48
|
+
tags === undefined ||
|
|
49
|
+
addressedBy === undefined) {
|
|
50
|
+
return { ok: false, errors };
|
|
51
|
+
}
|
|
52
|
+
const extra = {};
|
|
53
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
54
|
+
if (!TYPED_FIELDS.has(key)) {
|
|
55
|
+
extra[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
record: {
|
|
61
|
+
recordType: 'assertion',
|
|
62
|
+
title,
|
|
63
|
+
created,
|
|
64
|
+
updated,
|
|
65
|
+
tags,
|
|
66
|
+
addressedBy,
|
|
67
|
+
...(lastVerified !== undefined && { lastVerified }),
|
|
68
|
+
...(supersedes !== undefined && { supersedes }),
|
|
69
|
+
...(supersededBy !== undefined && { supersededBy }),
|
|
70
|
+
extra,
|
|
71
|
+
body,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function renderAssertion(record) {
|
|
76
|
+
const fields = {
|
|
77
|
+
recordType: record.recordType,
|
|
78
|
+
title: record.title,
|
|
79
|
+
created: record.created,
|
|
80
|
+
updated: record.updated,
|
|
81
|
+
tags: record.tags,
|
|
82
|
+
};
|
|
83
|
+
if (record.addressedBy.length > 0) {
|
|
84
|
+
fields['addressed-by'] = record.addressedBy;
|
|
85
|
+
}
|
|
86
|
+
if (record.lastVerified !== undefined) {
|
|
87
|
+
fields['last-verified'] = record.lastVerified;
|
|
88
|
+
}
|
|
89
|
+
if (record.supersedes !== undefined) {
|
|
90
|
+
fields.supersedes = record.supersedes;
|
|
91
|
+
}
|
|
92
|
+
if (record.supersededBy !== undefined) {
|
|
93
|
+
fields['superseded-by'] = record.supersededBy;
|
|
94
|
+
}
|
|
95
|
+
Object.assign(fields, record.extra);
|
|
96
|
+
return { fields, body: record.body };
|
|
97
|
+
}
|
|
98
|
+
function requireDate(value, field, errors) {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
errors.push(`missing required field: ${field}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
if (!isValidDate(value)) {
|
|
104
|
+
errors.push(`${field}: expected a YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ date`);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function readListField(value, field, errors) {
|
|
110
|
+
const list = asStringList(value);
|
|
111
|
+
if (list === null) {
|
|
112
|
+
errors.push(`${field}: must be a list`);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
return list;
|
|
116
|
+
}
|
|
117
|
+
function readOptionalDate(value, field, errors) {
|
|
118
|
+
if (value === undefined) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === 'string' && isValidDate(value)) {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
errors.push(`${field}: expected a YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ date`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
function readOptionalString(value, field, errors) {
|
|
128
|
+
if (value === undefined) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value === 'string') {
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
errors.push(`${field}: expected a string`);
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const EVENT_IMPACT_LEVELS: readonly ["low", "medium", "high", "critical"];
|
|
2
|
+
export type EventImpact = (typeof EVENT_IMPACT_LEVELS)[number];
|
|
3
|
+
export declare function isEventImpact(value: unknown): value is EventImpact;
|
|
4
|
+
export interface KbEvent {
|
|
5
|
+
recordType: 'event';
|
|
6
|
+
id: string;
|
|
7
|
+
capturedAt: string;
|
|
8
|
+
session?: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
tags: string[];
|
|
12
|
+
addressedBy: string[];
|
|
13
|
+
impact?: EventImpact;
|
|
14
|
+
extra: Record<string, unknown>;
|
|
15
|
+
body: string;
|
|
16
|
+
}
|
|
17
|
+
export type ParseEventResult = {
|
|
18
|
+
ok: true;
|
|
19
|
+
record: KbEvent;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
errors: string[];
|
|
23
|
+
};
|
|
24
|
+
export declare function parseEvent(fields: Record<string, unknown>, body: string): ParseEventResult;
|
|
25
|
+
export declare function renderEvent(record: KbEvent): {
|
|
26
|
+
fields: Record<string, unknown>;
|
|
27
|
+
body: string;
|
|
28
|
+
};
|