@williamthorsen/kb 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +161 -27
  2. package/dist/esm/check/check.d.ts +34 -0
  3. package/dist/esm/check/check.js +41 -1
  4. package/dist/esm/check/enumerate.d.ts +37 -0
  5. package/dist/esm/check/enumerate.js +12 -4
  6. package/dist/esm/check/glob-segments.d.ts +6 -0
  7. package/dist/esm/check/resolve-foreign-stores.d.ts +24 -0
  8. package/dist/esm/check/resolve-foreign-stores.js +50 -0
  9. package/dist/esm/cli/commands/check.d.ts +23 -1
  10. package/dist/esm/cli/commands/check.js +7 -2
  11. package/dist/esm/cli/commands/create.d.ts +19 -1
  12. package/dist/esm/cli/commands/create.js +2 -0
  13. package/dist/esm/cli/commands/scaffold.d.ts +30 -0
  14. package/dist/esm/cli/commands/scaffold.js +108 -0
  15. package/dist/esm/cli/commands/set-default.d.ts +15 -0
  16. package/dist/esm/cli/commands/taxonomy.d.ts +18 -0
  17. package/dist/esm/cli/format.d.ts +21 -0
  18. package/dist/esm/cli/parse-flag-value.d.ts +5 -0
  19. package/dist/esm/cli/resolve-store.d.ts +12 -0
  20. package/dist/esm/cli/run.d.ts +9 -1
  21. package/dist/esm/cli/run.js +5 -0
  22. package/dist/esm/cli/select-kb-prompt.d.ts +18 -0
  23. package/dist/esm/cli/targeting/resolve-changed-paths.d.ts +10 -0
  24. package/dist/esm/cli/targeting/resolve-changed-paths.js +7 -33
  25. package/dist/esm/cli/targeting/select-notes.d.ts +13 -0
  26. package/dist/esm/cli/targeting/select-notes.js +4 -3
  27. package/dist/esm/config/config-schema.d.ts +30 -1
  28. package/dist/esm/config/config-schema.js +9 -0
  29. package/dist/esm/config/index.d.ts +1 -1
  30. package/dist/esm/config/index.js +1 -1
  31. package/dist/esm/config/kb-loader-error.d.ts +8 -0
  32. package/dist/esm/config/load-config.d.ts +7 -0
  33. package/dist/esm/config/load-config.js +1 -0
  34. package/dist/esm/config/note-scope.d.ts +13 -0
  35. package/dist/esm/create/create.d.ts +25 -0
  36. package/dist/esm/create/create.js +8 -12
  37. package/dist/esm/create/index.d.ts +0 -1
  38. package/dist/esm/create/index.js +0 -1
  39. package/dist/esm/discovery/find-kb-root.d.ts +4 -0
  40. package/dist/esm/discovery/kb-registry-schema.d.ts +4 -0
  41. package/dist/esm/discovery/load-registry.d.ts +23 -0
  42. package/dist/esm/discovery/register-store.d.ts +11 -0
  43. package/dist/esm/discovery/registry-document.d.ts +5 -0
  44. package/dist/esm/discovery/set-default-kb.d.ts +12 -0
  45. package/dist/esm/filesystem/exists.d.ts +13 -0
  46. package/dist/esm/filesystem/write-atomic.d.ts +5 -0
  47. package/dist/esm/frontmatter/parse-note.d.ts +9 -0
  48. package/dist/esm/git/list-git-scope.d.ts +15 -0
  49. package/dist/esm/git/list-git-scope.js +22 -0
  50. package/dist/esm/git/run-git.d.ts +17 -0
  51. package/dist/esm/git/run-git.js +22 -0
  52. package/dist/esm/layout/index.d.ts +1 -1
  53. package/dist/esm/layout/index.js +1 -1
  54. package/dist/esm/layout/store-layout.d.ts +20 -0
  55. package/dist/esm/layout/store-layout.js +2 -0
  56. package/dist/esm/lints/paths.d.ts +7 -0
  57. package/dist/esm/lints/tag-alias.d.ts +6 -0
  58. package/dist/esm/lints/taxonomy.d.ts +15 -0
  59. package/dist/esm/note-io/field-validators.d.ts +9 -0
  60. package/dist/esm/note-io/read-note.d.ts +7 -0
  61. package/dist/esm/note-io/write-note.d.ts +9 -0
  62. package/dist/esm/note-io/yaml-fields.d.ts +5 -0
  63. package/dist/esm/records/assertion.d.ts +4 -0
  64. package/dist/esm/records/event.d.ts +12 -0
  65. package/dist/esm/scaffold/index.d.ts +2 -0
  66. package/dist/esm/scaffold/index.js +2 -0
  67. package/dist/esm/scaffold/render-seeds.d.ts +16 -0
  68. package/dist/esm/scaffold/render-seeds.js +65 -0
  69. package/dist/esm/scaffold/scaffold.d.ts +23 -0
  70. package/dist/esm/scaffold/scaffold.js +35 -0
  71. package/dist/esm/tags/canonicalize.d.ts +8 -0
  72. package/dist/esm/tags/load-aliases.d.ts +10 -0
  73. package/dist/esm/taxonomy/domain-paths.d.ts +10 -0
  74. package/dist/esm/taxonomy/load-taxonomy.d.ts +8 -0
  75. package/dist/esm/taxonomy/taxonomy-schema.d.ts +19 -0
  76. package/dist/esm/taxonomy/write-taxonomy.d.ts +20 -0
  77. package/dist/esm/type-guards.d.ts +13 -0
  78. package/dist/esm/types.d.ts +41 -0
  79. package/dist/esm/vault-integrity/build-vault-index.d.ts +11 -0
  80. package/dist/esm/vault-integrity/check-vault-integrity.d.ts +48 -1
  81. package/dist/esm/vault-integrity/check-vault-integrity.js +59 -20
  82. package/dist/esm/vault-integrity/index.d.ts +2 -2
  83. package/dist/esm/vault-integrity/index.js +2 -2
  84. package/dist/esm/vault-integrity/wikilink-parse.d.ts +47 -4
  85. package/dist/esm/vault-integrity/wikilink-parse.js +37 -9
  86. package/package.json +10 -4
  87. package/dist/esm/create/render-seeds.d.ts +0 -2
  88. package/dist/esm/create/render-seeds.js +0 -29
@@ -1,2 +1,11 @@
1
+ /**
2
+ * Renders a field map and body to note content: the opening fence, the frontmatter, the closing fence, one blank line,
3
+ * then the body. A single leading newline on the body is dropped so the blank line is not doubled, keeping the output
4
+ * stable across read/write cycles.
5
+ */
1
6
  export declare function renderNote(fields: Record<string, unknown>, body: string): string;
7
+ /**
8
+ * Atomically writes a note to `path`, so a concurrent reader never sees a partial write. See {@link writeAtomic} for
9
+ * the guarantee and its failure behavior.
10
+ */
2
11
  export declare function writeNote(path: string, fields: Record<string, unknown>, body: string): Promise<void>;
@@ -1,5 +1,10 @@
1
+ /**
2
+ * Parses frontmatter text (the YAML between the fences) into an insertion-ordered field map. A YAML parse error is
3
+ * returned rather than thrown; a block that is not a map yields an empty field map.
4
+ */
1
5
  export declare function parseFrontmatterFields(text: string): {
2
6
  fields: Record<string, unknown>;
3
7
  error?: string;
4
8
  };
9
+ /** Renders a field map to frontmatter text (no fences), one `key: value` line per entry, in insertion order. */
5
10
  export declare function renderFrontmatterFields(fields: Record<string, unknown>): string;
@@ -1,3 +1,4 @@
1
+ /** A parsed `assertion` record: its typed fields, the body, and any other frontmatter preserved in `extra`. */
1
2
  export interface KbAssertion {
2
3
  recordType: 'assertion';
3
4
  title: string;
@@ -11,6 +12,7 @@ export interface KbAssertion {
11
12
  extra: Record<string, unknown>;
12
13
  body: string;
13
14
  }
15
+ /** The outcome of parsing frontmatter as an assertion: the typed record, or the validation errors that blocked it. */
14
16
  export type ParseAssertionResult = {
15
17
  ok: true;
16
18
  record: KbAssertion;
@@ -18,7 +20,9 @@ export type ParseAssertionResult = {
18
20
  ok: false;
19
21
  errors: string[];
20
22
  };
23
+ /** Validates a frontmatter field map as an assertion and projects it onto a {@link KbAssertion}, accumulating every error. */
21
24
  export declare function parseAssertion(fields: Record<string, unknown>, body: string): ParseAssertionResult;
25
+ /** Projects an assertion back to a frontmatter field map (typed fields first, then preserved `extra`) plus its body. */
22
26
  export declare function renderAssertion(record: KbAssertion): {
23
27
  fields: Record<string, unknown>;
24
28
  body: string;
@@ -1,10 +1,15 @@
1
+ /** The impact levels an event may carry, ordered lowest to highest. */
1
2
  export declare const EVENT_IMPACT_LEVELS: readonly ["low", "medium", "high", "critical"];
3
+ /** An event's impact: the author's subjective, revisable rating of how much addressing the event matters. */
2
4
  export type EventImpact = (typeof EVENT_IMPACT_LEVELS)[number];
5
+ /** Reports whether a value is one of the declared {@link EVENT_IMPACT_LEVELS}. */
3
6
  export declare function isEventImpact(value: unknown): value is EventImpact;
7
+ /** A parsed `event` record: its declared fields, the body, and any other frontmatter preserved in `extra`. */
4
8
  export interface KbEvent {
5
9
  recordType: 'event';
6
10
  id: string;
7
11
  capturedAt: string;
12
+ /** Harness-dependent provenance: absent when the harness that captured the event exposes no session id. */
8
13
  session?: string;
9
14
  cwd: string;
10
15
  summary: string;
@@ -14,6 +19,7 @@ export interface KbEvent {
14
19
  extra: Record<string, unknown>;
15
20
  body: string;
16
21
  }
22
+ /** The outcome of parsing frontmatter as an event: the typed record, or the validation errors that blocked it. */
17
23
  export type ParseEventResult = {
18
24
  ok: true;
19
25
  record: KbEvent;
@@ -21,7 +27,13 @@ export type ParseEventResult = {
21
27
  ok: false;
22
28
  errors: string[];
23
29
  };
30
+ /** Validates a frontmatter field map as an event and projects it onto a {@link KbEvent}, accumulating every error. */
24
31
  export declare function parseEvent(fields: Record<string, unknown>, body: string): ParseEventResult;
32
+ /**
33
+ * Projects an event back to a frontmatter field map (declared fields first, then preserved `extra`) plus its body. An
34
+ * empty `session` is omitted like an absent one, mirroring {@link parseEvent}: the two spellings of "no session" have a
35
+ * single representation on both edges of the module, so no record can reacquire the empty field on a write.
36
+ */
25
37
  export declare function renderEvent(record: KbEvent): {
26
38
  fields: Record<string, unknown>;
27
39
  body: string;
@@ -0,0 +1,2 @@
1
+ export { renderAliasesSeed, renderConfigSeed } from './render-seeds.js';
2
+ export { scaffold, type ScaffoldAction, type ScaffoldEntry } from './scaffold.js';
@@ -0,0 +1,2 @@
1
+ export { renderAliasesSeed, renderConfigSeed } from "./render-seeds.js";
2
+ export { scaffold } from "./scaffold.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The formatting option every store carries that `.editorconfig` cannot express. Held here rather than in the seed
3
+ * prose, so that the file that a store receives cannot diverge from the value that kb documents. See
4
+ * {@link renderPrettierSeed}'s header for what it prevents.
5
+ */
6
+ export declare const canonicalPrettierConfig: {
7
+ embeddedLanguageFormatting: string;
8
+ };
9
+ /** Renders `.kb/tag-aliases.yaml`: an empty `aliases: {}` map under an explanatory header. */
10
+ export declare function renderAliasesSeed(): string;
11
+ /** Renders `.kb/config.yaml`: a fully-commented stub whose example values are the live `defaultKbConfig`. */
12
+ export declare function renderConfigSeed(): string;
13
+ /** Renders `.editorconfig`: the editor and formatter settings every store shares. */
14
+ export declare function renderEditorconfigSeed(): string;
15
+ /** Renders `.prettierrc.yaml`: the canonical formatting option under a header explaining why it is set. */
16
+ export declare function renderPrettierSeed(): string;
@@ -0,0 +1,65 @@
1
+ import { stringify } from 'yaml';
2
+ import { defaultKbConfig } from "../config/config-schema.js";
3
+ const ALIASES_HEADER = `# Tag aliases for this knowledge store.
4
+ #
5
+ # Map each canonical tag to the aliases that should resolve to it, for example:
6
+ # aliases:
7
+ # typescript: [ts, type-script]
8
+ # The \`aliases:\` key is required even when empty.
9
+ `;
10
+ const CONFIG_HEADER = `# Check configuration for this knowledge store.
11
+ #
12
+ # Every key is optional and falls back to the default shown below; an absent file uses these defaults too. Uncomment
13
+ # and edit to override. \`targets\` selects which notes \`kb check\` enumerates and \`exclude\` removes matches;
14
+ # patterns are slash-separated and relative to the store root. \`visibility\` declares how widely the store is
15
+ # published, which decides what may link into it: a \`[[store:Note title]]\` link resolves only into a store as
16
+ # shareable as its own or more so.
17
+ #
18
+ `;
19
+ const EDITORCONFIG_SEED = `root = true
20
+
21
+ [*]
22
+ charset = utf-8
23
+ end_of_line = lf
24
+ indent_size = 2
25
+ indent_style = space
26
+ insert_final_newline = true
27
+ max_line_length = 120
28
+ trim_trailing_whitespace = true
29
+
30
+ [*.md]
31
+ trim_trailing_whitespace = false
32
+ `;
33
+ const PRETTIER_HEADER = `# Formatting configuration for this knowledge store.
34
+ #
35
+ # The single option here is not stylistic. It prevents a specific failure, so read this before removing it.
36
+ #
37
+ # \`embeddedLanguageFormatting: off\` leaves a note's YAML frontmatter unformatted. Formatted, a long \`tags\` or
38
+ # \`addressed-by\` list breaks across several lines, which the note writer puts back onto one the next time it writes
39
+ # the note, so the formatter and the writer would rewrite each other's output without end.
40
+ #
41
+ # Width, indentation, and line endings are set in \`.editorconfig\`, which Prettier reads and every editor reads too.
42
+ #
43
+ `;
44
+ export const canonicalPrettierConfig = {
45
+ embeddedLanguageFormatting: 'off',
46
+ };
47
+ export function renderAliasesSeed() {
48
+ return `${ALIASES_HEADER}aliases: {}\n`;
49
+ }
50
+ export function renderConfigSeed() {
51
+ return `${CONFIG_HEADER}${commentBlock(stringify(defaultKbConfig))}\n`;
52
+ }
53
+ export function renderEditorconfigSeed() {
54
+ return EDITORCONFIG_SEED;
55
+ }
56
+ export function renderPrettierSeed() {
57
+ return `${PRETTIER_HEADER}${stringify(canonicalPrettierConfig)}`;
58
+ }
59
+ function commentBlock(text) {
60
+ return text
61
+ .trimEnd()
62
+ .split('\n')
63
+ .map((line) => (line === '' ? '#' : `# ${line}`))
64
+ .join('\n');
65
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What {@link scaffold} did about one canonical path: `created` where it was absent, `present` where it was left as
3
+ * found, `replaced` where `force` overwrote it.
4
+ */
5
+ export type ScaffoldAction = 'created' | 'present' | 'replaced';
6
+ /** One canonical path and what the scaffold did about it. */
7
+ export interface ScaffoldEntry {
8
+ /** The store-relative path, directories carrying a trailing slash. */
9
+ path: string;
10
+ action: ScaffoldAction;
11
+ }
12
+ /**
13
+ * Writes the canonical files and directories every knowledge-base store holds, leaving an existing file as it found it
14
+ * unless `force` is set. Directories are ensured either way, since a directory has no content to replace.
15
+ *
16
+ * The function asserts nothing about the store: a caller that requires one to exist, or requires one not to, checks
17
+ * that itself. `create` calls it on a directory it has just confirmed holds no store, and `kb scaffold` on one it has
18
+ * just confirmed does.
19
+ */
20
+ export declare function scaffold(input: {
21
+ storePath: string;
22
+ force?: boolean;
23
+ }): Promise<readonly ScaffoldEntry[]>;
@@ -0,0 +1,35 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { pathExists } from "../filesystem/exists.js";
4
+ import { writeAtomic } from "../filesystem/write-atomic.js";
5
+ import { ALIASES_FILE, CONFIG_FILE, CONTENT_DIR, EDITORCONFIG_FILE, EVENTS_DIR, PRETTIER_CONFIG_FILE, } from "../layout/index.js";
6
+ import { renderAliasesSeed, renderConfigSeed, renderEditorconfigSeed, renderPrettierSeed } from "./render-seeds.js";
7
+ export async function scaffold(input) {
8
+ const { storePath, force = false } = input;
9
+ const entries = [];
10
+ for (const file of CANONICAL_FILES) {
11
+ const absolutePath = join(storePath, file.path);
12
+ const exists = await pathExists(absolutePath);
13
+ if (exists && !force) {
14
+ entries.push({ path: file.path, action: 'present' });
15
+ continue;
16
+ }
17
+ await mkdir(dirname(absolutePath), { recursive: true });
18
+ await writeAtomic(absolutePath, file.render());
19
+ entries.push({ path: file.path, action: exists ? 'replaced' : 'created' });
20
+ }
21
+ for (const directory of CANONICAL_DIRECTORIES) {
22
+ const absolutePath = join(storePath, directory);
23
+ const exists = await pathExists(absolutePath);
24
+ await mkdir(absolutePath, { recursive: true });
25
+ entries.push({ path: `${directory}/`, action: exists ? 'present' : 'created' });
26
+ }
27
+ return entries;
28
+ }
29
+ const CANONICAL_DIRECTORIES = [CONTENT_DIR, EVENTS_DIR];
30
+ const CANONICAL_FILES = [
31
+ { path: EDITORCONFIG_FILE, render: renderEditorconfigSeed },
32
+ { path: CONFIG_FILE, render: renderConfigSeed },
33
+ { path: ALIASES_FILE, render: renderAliasesSeed },
34
+ { path: PRETTIER_CONFIG_FILE, render: renderPrettierSeed },
35
+ ];
@@ -1,3 +1,11 @@
1
1
  import type { AliasMap } from '../types.js';
2
+ /**
3
+ * Returns the canonical form of a tag. Lookups are case-insensitive.
4
+ * Unknown tags and tags that are already canonical pass through unchanged.
5
+ */
2
6
  export declare function canonicalize(tag: string, aliases: AliasMap): string;
7
+ /**
8
+ * Returns the canonical form of a tag only when the input is a known alias; otherwise `null`.
9
+ * Distinguishes drift (an alias is present) from canonical or new-vocabulary tags.
10
+ */
3
11
  export declare function findAliasFor(tag: string, aliases: AliasMap): string | null;
@@ -1,5 +1,15 @@
1
1
  import type { AliasMap, KbRoot } from '../types.js';
2
+ /**
3
+ * Loads `.kb/tag-aliases.yaml` from a KB root into a typed `AliasMap`, returning an empty map when the file is absent.
4
+ * The thin I/O wrapper around {@link parseAliases}; structural defects throw with the file path included.
5
+ */
2
6
  export declare function loadAliases(input: {
3
7
  kbRoot: KbRoot;
4
8
  }): Promise<AliasMap>;
9
+ /**
10
+ * Parses a tag-aliases registry from a string into an `AliasMap`.
11
+ * Aliases are lowercased on insertion so callers can look up case-insensitively.
12
+ * Throws on any structural defect — non-object top level, missing `aliases` key, non-string entries, self-aliases,
13
+ * or cross-canonical collisions — with `contextLabel` prefixed onto every message.
14
+ */
5
15
  export declare function parseAliases(text: string, contextLabel?: string): AliasMap;
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Derives every domain a note set implies: the domain each note sits in, plus each of that domain's ancestors, sorted.
3
+ * A grouping domain that holds only subfolders is included, because the rules treat it as in use and would otherwise
4
+ * report a back-filled taxonomy's own entries as unused.
5
+ */
1
6
  export declare function deriveDomains(relativePaths: Iterable<string>): string[];
7
+ /**
8
+ * Derives the domain a note sits in, or `undefined` when the note is not an assertion or sits at the assertions root.
9
+ * Scoping to the assertions root is what keeps event records from registering as domains.
10
+ */
2
11
  export declare function resolveDomain(relativePath: string): string | undefined;
12
+ /** Derives a slash-path's parent, or `undefined` when it has no separator and so sits at the top level. */
3
13
  export declare function resolveParent(path: string): string | undefined;
@@ -1,5 +1,13 @@
1
1
  import type { KbRoot } from '../types.js';
2
2
  import { type Taxonomy } from './taxonomy-schema.js';
3
+ /**
4
+ * Loads `.kb/taxonomy.yaml` into a single keyed map, returning an empty taxonomy when the file is absent or declares
5
+ * nothing. The two on-disk blocks are a file-format concern: a consumer looks a domain up once and reads `provisional`
6
+ * off the entry it finds.
7
+ *
8
+ * Mirrors {@link loadKbConfig}: structural defects (malformed YAML, a wrong type, a malformed key, a path declared in
9
+ * both blocks) throw a {@link KbLoaderError} naming the file. I/O errors other than a missing file propagate.
10
+ */
3
11
  export declare function loadTaxonomy(input: {
4
12
  kbRoot: KbRoot;
5
13
  }): Promise<Taxonomy>;
@@ -1,10 +1,29 @@
1
1
  import { z } from 'zod';
2
+ /**
3
+ * Describes why a domain key cannot be used, or returns `undefined` when the key is well-formed. Keys are relative to
4
+ * the assertions root, so a restated `content/assertions/` prefix would declare the domain a level deeper than the
5
+ * author meant; `kb-add` refuses the same mistake on the note-write path.
6
+ */
2
7
  export declare function describeKeyDefect(key: string): string | undefined;
8
+ /** A knowledge base's declared assertion structure, keyed by assertions-root-relative slash-path. */
3
9
  export type Taxonomy = ReadonlyMap<string, TaxonomyEntry>;
10
+ /** A single declared domain. */
4
11
  export interface TaxonomyEntry {
12
+ /** The domain's one-line description; empty when it was declared without one. */
5
13
  description: string;
14
+ /** Whether the domain was declared under `provisional:` rather than `domains:`. */
6
15
  provisional: boolean;
7
16
  }
17
+ /**
18
+ * The on-disk `.kb/taxonomy.yaml` shape.
19
+ *
20
+ * Both blocks are optional and nullable, so a file may declare only one, and a block header left with nothing under it
21
+ * loads as declaring nothing rather than failing. YAML reads such a header as null, and it is the state promoting the
22
+ * last entry out of a block leaves behind.
23
+ *
24
+ * A description may likewise be null, so a key written bare (`engineering/tooling:`) loads: that is what a hand editor
25
+ * types and what a domain declared without a description round-trips to.
26
+ */
8
27
  export declare const taxonomyFileShape: z.ZodObject<{
9
28
  domains: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
10
29
  provisional: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
@@ -1,9 +1,29 @@
1
1
  import type { KbRoot } from '../types.js';
2
+ /** A domain to declare. */
2
3
  export interface TaxonomyDeclaration {
4
+ /** The domain's assertions-root-relative slash-path. */
3
5
  path: string;
6
+ /** The one-line description; absent or empty writes the key bare, with no description. */
4
7
  description?: string;
8
+ /** Whether to declare under `provisional:` rather than `domains:`. */
5
9
  provisional: boolean;
6
10
  }
11
+ /**
12
+ * Declares domains in `.kb/taxonomy.yaml`, creating the file and either block as needed, and returns the paths added.
13
+ *
14
+ * Edits the parsed document rather than re-serializing a plain object, so existing comments, key order, and formatting
15
+ * survive; a plain parse-and-stringify round trip would discard every comment in a hand-curated file. New keys append
16
+ * to the end of their block in path order, and a block header left with nothing under it is filled in place rather
17
+ * than moved.
18
+ *
19
+ * A path either block already declares is skipped rather than overwritten, so a repeat call adds nothing; when nothing
20
+ * is left to add, the file is not opened for writing at all. The write goes through the shared `writeAtomic` helper,
21
+ * so an interrupted call cannot truncate the taxonomy.
22
+ *
23
+ * Throws a {@link KbLoaderError} on a malformed key, or on an existing file that cannot be safely appended to: one
24
+ * that fails to parse, one whose top level is not a mapping, and one whose `domains` or `provisional` block holds
25
+ * something other than a mapping. Other I/O errors propagate.
26
+ */
7
27
  export declare function writeTaxonomy(input: {
8
28
  kbRoot: KbRoot;
9
29
  declarations: readonly TaxonomyDeclaration[];
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Checks whether an error is an ENOENT (no such file or directory) filesystem error.
3
+ * @internal
4
+ */
1
5
  export declare function isEnoent(error: unknown): boolean;
6
+ /**
7
+ * Returns true when `error` carries the given Node `code` string (e.g. `'ENOENT'`, `'EACCES'`).
8
+ * @internal
9
+ */
2
10
  export declare function isErrorCode(error: unknown, code: string): boolean;
11
+ /**
12
+ * Type guard for a non-null, non-array object.
13
+ *
14
+ * @internal
15
+ */
3
16
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -1,51 +1,92 @@
1
+ /** A knowledge-base root: the directory holding a `.kb/`, and that `.kb/` itself. */
1
2
  export interface KbRoot {
3
+ /** Absolute path to the directory containing the `.kb/` folder. */
2
4
  path: string;
5
+ /** Absolute path to the `.kb/` directory itself. */
3
6
  kbDir: string;
4
7
  }
8
+ /** A single knowledge base declared in a `kb.yaml` registry, normalized in memory. */
5
9
  export interface KbRegistryEntry {
10
+ /** The KB's name, lifted from the `kbs` map key. */
6
11
  name: string;
12
+ /** Absolute path to the KB's root directory (tilde expanded, relative resolved). */
7
13
  path: string;
14
+ /** Optional human-readable description. */
8
15
  description?: string;
16
+ /** Whether the KB is read-only. */
9
17
  readonly?: boolean;
18
+ /** Which registry the entry came from. */
10
19
  source: 'user' | 'project';
11
20
  }
21
+ /** The merged, normalized KB registry. */
12
22
  export interface KbRegistry {
23
+ /** All KB entries, user entries merged with project overrides. */
13
24
  entries: KbRegistryEntry[];
25
+ /** The entry named by `default_kb`, resolved against `entries`; absent when `default_kb` is unset. */
14
26
  defaultKb?: KbRegistryEntry;
27
+ /** Absolute paths of the registry files that contributed entries. */
15
28
  sources: {
16
29
  user?: string;
17
30
  project?: string;
18
31
  };
19
32
  }
33
+ /** Strongly-typed frontmatter for a note. */
20
34
  export interface Frontmatter {
21
35
  title: string;
36
+ /** The stored record-type discriminant (e.g. `assertion`, `event`). */
22
37
  recordType: string;
38
+ /** Second-precision UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`); bare legacy `YYYY-MM-DD` dates remain valid. */
23
39
  created: string;
40
+ /** Second-precision UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`); bare legacy `YYYY-MM-DD` dates remain valid. */
24
41
  updated: string;
25
42
  tags: string[];
43
+ /** Optional and unknown fields, preserved through round-trip in insertion order. */
26
44
  extra: Record<string, unknown>;
27
45
  }
46
+ /** The raw frontmatter slice plus parse diagnostics, kept so rules can report at source line numbers. */
28
47
  export interface FrontmatterRaw {
48
+ /** The YAML text between the opening and closing `---` fences. */
29
49
  text: string;
50
+ /** 1-based line of the opening `---` fence. */
30
51
  startLine: number;
52
+ /** 1-based line of the closing `---` fence. */
31
53
  endLine: number;
54
+ /** YAML parse error message, if the block could not be parsed. */
32
55
  parseError?: string;
33
56
  }
57
+ /** A note parsed into frontmatter and body. */
34
58
  export interface ParsedNote {
59
+ /** Path or label the note was parsed from. */
35
60
  path: string;
61
+ /** The full original note content. */
36
62
  content: string;
63
+ /** Typed frontmatter, or `null` when no frontmatter block is present or it failed to parse. */
37
64
  frontmatter: Frontmatter | null;
65
+ /** Raw frontmatter slice and diagnostics, or `null` when no `---` block is present. */
38
66
  frontmatterRaw: FrontmatterRaw | null;
67
+ /** The note body (everything after the closing `---`). */
39
68
  body: string;
69
+ /** 1-based line where the body begins. */
40
70
  bodyStartLine: number;
41
71
  }
72
+ /** Severity of a validation finding. */
42
73
  export type FindingSeverity = 'error' | 'warning';
74
+ /** A single validation finding produced by a rule. */
43
75
  export interface Finding {
76
+ /** Path or label the finding applies to: a note, or the store file that declares the rule's subject. */
44
77
  path: string;
78
+ /**
79
+ * What the finding describes, defaulting to `note` when absent. A note-scoped finding is dropped when a run's
80
+ * selection excludes its note; a vault-scoped one describes the store itself, so it survives every selection,
81
+ * including one matching no notes at all.
82
+ */
45
83
  scope?: 'note' | 'vault';
84
+ /** 1-based source line number, when known. */
46
85
  line?: number;
86
+ /** Rule code, e.g. `frontmatter.required`. */
47
87
  rule: string;
48
88
  severity: FindingSeverity;
49
89
  message: string;
50
90
  }
91
+ /** A lowercase-keyed map from tag alias to its canonical form. */
51
92
  export type AliasMap = ReadonlyMap<string, string>;
@@ -1,4 +1,15 @@
1
+ /**
2
+ * A vault-wide lookup from a note basename (without the `.md` extension) to the set of note paths that share it. A
3
+ * single-path entry resolves a wikilink unambiguously; a multi-path entry is a basename collision. Consumed by the
4
+ * vault-integrity checks and by curate's wikilink rewriter.
5
+ */
1
6
  export type VaultIndex = ReadonlyMap<string, ReadonlySet<string>>;
7
+ /**
8
+ * Builds a basename → set-of-paths index from a set of notes, mapping each note's `.md` basename (sans extension) to
9
+ * the note paths that share it. Reads only each note's `path`, so it is type-blind — it needs no frontmatter or record
10
+ * projection. A note's `path` is used verbatim as the index value, so callers control whether entries are
11
+ * vault-relative or absolute.
12
+ */
2
13
  export declare function buildVaultIndex(notes: readonly {
3
14
  path: string;
4
15
  }[]): VaultIndex;
@@ -1,7 +1,54 @@
1
+ import type { StoreVisibility } from '../config/config-schema.js';
1
2
  import type { Finding } from '../types.js';
3
+ import { type VaultIndex } from './build-vault-index.js';
4
+ /**
5
+ * Checks whole-vault integrity over a type-blind note set: unresolved `[[link]]` targets and basename collisions.
6
+ * Projects no records and reads no frontmatter — a note is just its path and body.
7
+ *
8
+ * A `[[Target]]` whose basename resolves to zero notes is an error (`wikilinks.unresolved`), reported at its
9
+ * file-absolute line. A basename shared by two or more notes is a single vault-wide warning (`wikilinks.basename`),
10
+ * reported once per basename independent of whether any link references it. An ambiguous link (a basename that several
11
+ * notes share) is not flagged per-link — the vault-wide basename warning subsumes it.
12
+ *
13
+ * A `[[store:Target]]` resolves against `options.foreignStores` instead, and never joins this store's own basename
14
+ * index, so the basename warning stays store-scoped. Without `options`, a qualified target is treated as a bare one,
15
+ * which is the behavior every caller had before cross-store resolution existed.
16
+ */
17
+ export declare function checkVaultIntegrity(notes: readonly VaultIntegrityNote[], options?: VaultIntegrityOptions): Finding[];
18
+ /** What a check run found when it looked up a store one of its links names. */
19
+ export type ForeignStore =
20
+ /** The name matches no entry in the merged registry. */
21
+ {
22
+ status: 'unknown';
23
+ }
24
+ /** The store is registered but cannot be read on this machine, so its links are unverifiable rather than broken. */
25
+ | {
26
+ status: 'unavailable';
27
+ reason: string;
28
+ }
29
+ /** The store is less shareable than the source, so a link into it would widen disclosure. */
30
+ | {
31
+ status: 'disallowed';
32
+ visibility: StoreVisibility;
33
+ }
34
+ /** The store was read; `index` holds its basenames. */
35
+ | {
36
+ status: 'resolved';
37
+ index: VaultIndex;
38
+ };
39
+ /** A note reduced to what vault integrity inspects: its path, its body, and the file line the body begins on. */
2
40
  export interface VaultIntegrityNote {
41
+ /** Path or label the note was read from; used as the index value and the finding path. */
3
42
  path: string;
43
+ /** The note body (everything after the frontmatter block). */
4
44
  body: string;
45
+ /** 1-based file line where the body begins, so link findings report file-absolute lines. */
5
46
  bodyStartLine: number;
6
47
  }
7
- export declare function checkVaultIntegrity(notes: readonly VaultIntegrityNote[]): Finding[];
48
+ /** What a run needs to evaluate a store-qualified link. */
49
+ export interface VaultIntegrityOptions {
50
+ /** What the run found for each store name its links qualify, keyed by that name. */
51
+ foreignStores: ReadonlyMap<string, ForeignStore>;
52
+ /** The visibility of the store being checked, which decides the direction a qualified link may take. */
53
+ sourceVisibility: StoreVisibility;
54
+ }
@@ -1,8 +1,8 @@
1
1
  import { buildVaultIndex } from "./build-vault-index.js";
2
- import { countNewlines, extractTarget, hasNonMarkdownExtension, lookupKey, maskFencedCode, maskInlineCode, WIKILINK, } from "./wikilink-parse.js";
3
- export function checkVaultIntegrity(notes) {
2
+ import { countNewlines, lookupKey, scanWikilinks } from "./wikilink-parse.js";
3
+ export function checkVaultIntegrity(notes, options) {
4
4
  const vaultIndex = buildVaultIndex(notes);
5
- return [...unresolvedLinkFindings(notes, vaultIndex), ...basenameFindings(vaultIndex)];
5
+ return [...linkFindings(notes, vaultIndex, options), ...basenameFindings(vaultIndex)];
6
6
  }
7
7
  function basenameFindings(vaultIndex) {
8
8
  const findings = [];
@@ -20,28 +20,67 @@ function basenameFindings(vaultIndex) {
20
20
  }
21
21
  return findings;
22
22
  }
23
- function unresolvedLinkFindings(notes, vaultIndex) {
23
+ function describeForeignDefect(store, target, options) {
24
+ const link = `[[${store}:${target}]]`;
25
+ const foreignStore = options.foreignStores.get(store);
26
+ if (foreignStore === undefined)
27
+ return undefined;
28
+ if (foreignStore.status === 'unknown') {
29
+ return {
30
+ rule: 'wikilinks.unknown-store',
31
+ severity: 'error',
32
+ message: `${link} names the store "${store}", which no kb.yaml registry entry declares`,
33
+ };
34
+ }
35
+ if (foreignStore.status === 'unavailable') {
36
+ return {
37
+ rule: 'wikilinks.store-unavailable',
38
+ severity: 'warning',
39
+ message: `${link} targets the store "${store}", which could not be read here: ${foreignStore.reason}`,
40
+ };
41
+ }
42
+ if (foreignStore.status === 'disallowed') {
43
+ return {
44
+ rule: 'wikilinks.disallowed-store',
45
+ severity: 'error',
46
+ message: `${link} targets "${store}", a ${foreignStore.visibility} store, which a ${options.sourceVisibility} store may not link into: the link discloses the target's title`,
47
+ };
48
+ }
49
+ const resolved = foreignStore.index.get(lookupKey(target));
50
+ if (resolved !== undefined && resolved.size > 0)
51
+ return undefined;
52
+ return {
53
+ rule: 'wikilinks.unresolved',
54
+ severity: 'error',
55
+ message: `${link} does not resolve to any note in the store "${store}"`,
56
+ };
57
+ }
58
+ function describeLocalDefect(target, vaultIndex) {
59
+ const resolved = vaultIndex.get(lookupKey(target));
60
+ if (resolved !== undefined && resolved.size > 0)
61
+ return undefined;
62
+ return {
63
+ rule: 'wikilinks.unresolved',
64
+ severity: 'error',
65
+ message: `[[${target}]] does not resolve to any vault note`,
66
+ };
67
+ }
68
+ function joinTarget(link) {
69
+ return link.store === undefined ? link.target : `${link.store}:${link.target}`;
70
+ }
71
+ function linkFindings(notes, vaultIndex, options) {
24
72
  const findings = [];
25
73
  for (const note of notes) {
26
- const body = maskInlineCode(maskFencedCode(note.body));
27
- for (const match of body.matchAll(WIKILINK)) {
28
- const inner = match[1];
29
- if (inner === undefined)
30
- continue;
31
- const target = extractTarget(inner);
32
- if (target === null)
33
- continue;
34
- if (hasNonMarkdownExtension(target))
35
- continue;
36
- const resolved = vaultIndex.get(lookupKey(target));
37
- if (resolved !== undefined && resolved.size > 0)
74
+ for (const link of scanWikilinks(note.body)) {
75
+ const defect = link.store === undefined || options === undefined
76
+ ? describeLocalDefect(joinTarget(link), vaultIndex)
77
+ : describeForeignDefect(link.store, link.target, options);
78
+ if (defect === undefined)
38
79
  continue;
39
80
  findings.push({
40
81
  path: note.path,
41
- line: note.bodyStartLine + countNewlines(body, match.index),
42
- rule: 'wikilinks.unresolved',
43
- severity: 'error',
44
- message: `[[${target}]] does not resolve to any vault note`,
82
+ line: note.bodyStartLine + countNewlines(note.body, link.offset),
83
+ ...defect,
45
84
  });
46
85
  }
47
86
  }
@@ -1,3 +1,3 @@
1
1
  export { buildVaultIndex, type VaultIndex } from './build-vault-index.js';
2
- export { checkVaultIntegrity, type VaultIntegrityNote } from './check-vault-integrity.js';
3
- export { countNewlines, extractTarget, hasNonMarkdownExtension, lookupKey, maskFencedCode, maskInlineCode, WIKILINK, } from './wikilink-parse.js';
2
+ export { checkVaultIntegrity, type ForeignStore, type VaultIntegrityNote, type VaultIntegrityOptions, } from './check-vault-integrity.js';
3
+ export { lookupKey, type ScannedWikilink, scanWikilinks } from './wikilink-parse.js';