@williamthorsen/kb 0.7.0 → 0.8.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.
Files changed (77) hide show
  1. package/README.md +101 -96
  2. package/bin/kb.js +2 -4
  3. package/dist/esm/check/check.d.ts +14 -12
  4. package/dist/esm/check/check.js +41 -1
  5. package/dist/esm/check/enumerate.d.ts +13 -16
  6. package/dist/esm/check/glob-segments.d.ts +1 -1
  7. package/dist/esm/check/resolve-foreign-stores.d.ts +25 -0
  8. package/dist/esm/check/resolve-foreign-stores.js +50 -0
  9. package/dist/esm/cli/commands/check.d.ts +4 -10
  10. package/dist/esm/cli/commands/check.js +8 -3
  11. package/dist/esm/cli/commands/create.d.ts +3 -7
  12. package/dist/esm/cli/commands/create.js +1 -1
  13. package/dist/esm/cli/commands/scaffold.d.ts +5 -9
  14. package/dist/esm/cli/commands/scaffold.js +5 -5
  15. package/dist/esm/cli/commands/set-default.d.ts +1 -5
  16. package/dist/esm/cli/commands/set-default.js +1 -1
  17. package/dist/esm/cli/commands/taxonomy.d.ts +5 -10
  18. package/dist/esm/cli/commands/taxonomy.js +10 -9
  19. package/dist/esm/cli/format.d.ts +8 -10
  20. package/dist/esm/cli/resolve-store.d.ts +8 -8
  21. package/dist/esm/cli/run.d.ts +4 -6
  22. package/dist/esm/cli/run.js +1 -1
  23. package/dist/esm/cli/select-kb-prompt.d.ts +6 -5
  24. package/dist/esm/cli/select-kb-prompt.js +1 -1
  25. package/dist/esm/cli/targeting/resolve-changed-paths.d.ts +3 -3
  26. package/dist/esm/cli/targeting/select-notes.d.ts +2 -2
  27. package/dist/esm/config/config-schema.d.ts +24 -6
  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 +3 -3
  32. package/dist/esm/config/load-config.d.ts +3 -3
  33. package/dist/esm/config/load-config.js +2 -1
  34. package/dist/esm/config/note-scope.d.ts +7 -7
  35. package/dist/esm/create/create.d.ts +8 -8
  36. package/dist/esm/discovery/kb-registry-schema.d.ts +0 -2
  37. package/dist/esm/discovery/load-registry.d.ts +7 -9
  38. package/dist/esm/discovery/load-registry.js +1 -1
  39. package/dist/esm/discovery/register-store.d.ts +3 -4
  40. package/dist/esm/discovery/register-store.js +2 -2
  41. package/dist/esm/discovery/registry-document.d.ts +2 -2
  42. package/dist/esm/discovery/set-default-kb.d.ts +5 -5
  43. package/dist/esm/discovery/set-default-kb.js +3 -3
  44. package/dist/esm/filesystem/exists.d.ts +4 -4
  45. package/dist/esm/frontmatter/parse-note.d.ts +3 -3
  46. package/dist/esm/git/list-git-scope.d.ts +7 -7
  47. package/dist/esm/git/run-git.d.ts +2 -2
  48. package/dist/esm/layout/store-layout.d.ts +5 -5
  49. package/dist/esm/lints/paths.d.ts +1 -1
  50. package/dist/esm/lints/paths.js +1 -1
  51. package/dist/esm/lints/tag-alias.d.ts +1 -1
  52. package/dist/esm/lints/tag-alias.js +1 -1
  53. package/dist/esm/lints/taxonomy.d.ts +9 -8
  54. package/dist/esm/lints/taxonomy.js +2 -2
  55. package/dist/esm/note-io/field-validators.d.ts +1 -1
  56. package/dist/esm/note-io/read-note.d.ts +2 -2
  57. package/dist/esm/note-io/write-note.d.ts +3 -6
  58. package/dist/esm/records/event.d.ts +3 -3
  59. package/dist/esm/scaffold/render-seeds.d.ts +3 -3
  60. package/dist/esm/scaffold/render-seeds.js +5 -3
  61. package/dist/esm/scaffold/scaffold.d.ts +7 -8
  62. package/dist/esm/tags/load-aliases.d.ts +3 -4
  63. package/dist/esm/taxonomy/domain-paths.d.ts +6 -6
  64. package/dist/esm/taxonomy/load-taxonomy.d.ts +3 -3
  65. package/dist/esm/taxonomy/load-taxonomy.js +2 -2
  66. package/dist/esm/taxonomy/taxonomy-schema.d.ts +5 -6
  67. package/dist/esm/taxonomy/write-taxonomy.d.ts +7 -8
  68. package/dist/esm/type-guards.d.ts +1 -1
  69. package/dist/esm/types.d.ts +6 -9
  70. package/dist/esm/vault-integrity/build-vault-index.d.ts +3 -4
  71. package/dist/esm/vault-integrity/check-vault-integrity.d.ts +46 -12
  72. package/dist/esm/vault-integrity/check-vault-integrity.js +59 -20
  73. package/dist/esm/vault-integrity/index.d.ts +2 -2
  74. package/dist/esm/vault-integrity/index.js +2 -2
  75. package/dist/esm/vault-integrity/wikilink-parse.d.ts +45 -19
  76. package/dist/esm/vault-integrity/wikilink-parse.js +37 -9
  77. package/package.json +6 -6
@@ -1,6 +1,6 @@
1
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
2
+ * The formatting option that every store sets and that `.editorconfig` cannot express. Defined here rather than in
3
+ * the seed prose, so that the file that a store receives cannot diverge from the value that kb documents. See
4
4
  * {@link renderPrettierSeed}'s header for what it prevents.
5
5
  */
6
6
  export declare const canonicalPrettierConfig: {
@@ -10,7 +10,7 @@ export declare const canonicalPrettierConfig: {
10
10
  export declare function renderAliasesSeed(): string;
11
11
  /** Renders `.kb/config.yaml`: a fully-commented stub whose example values are the live `defaultKbConfig`. */
12
12
  export declare function renderConfigSeed(): string;
13
- /** Renders `.editorconfig`: the editor and formatter settings every store shares. */
13
+ /** Renders `.editorconfig`: the editor and formatter settings that every store shares. */
14
14
  export declare function renderEditorconfigSeed(): string;
15
15
  /** Renders `.prettierrc.yaml`: the canonical formatting option under a header explaining why it is set. */
16
16
  export declare function renderPrettierSeed(): string;
@@ -9,9 +9,11 @@ const ALIASES_HEADER = `# Tag aliases for this knowledge store.
9
9
  `;
10
10
  const CONFIG_HEADER = `# Check configuration for this knowledge store.
11
11
  #
12
- # Both keys are optional and fall back to the defaults shown below; an absent file uses these defaults too. Uncomment
12
+ # Every key is optional and falls back to the default shown below; an absent file uses these defaults too. Uncomment
13
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.
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.
15
17
  #
16
18
  `;
17
19
  const EDITORCONFIG_SEED = `root = true
@@ -34,7 +36,7 @@ const PRETTIER_HEADER = `# Formatting configuration for this knowledge store.
34
36
  #
35
37
  # \`embeddedLanguageFormatting: off\` leaves a note's YAML frontmatter unformatted. Formatted, a long \`tags\` or
36
38
  # \`addressed-by\` list breaks across several lines, which the note writer puts back onto one the next time it writes
37
- # the note, so the formatter and the writer would rewrite each other's output without end.
39
+ # the note. The formatter and the writer would rewrite each other's output without end.
38
40
  #
39
41
  # Width, indentation, and line endings are set in \`.editorconfig\`, which Prettier reads and every editor reads too.
40
42
  #
@@ -1,21 +1,20 @@
1
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.
2
+ * What {@link scaffold} did about one canonical path: `created` when it was absent, `present` when it was left as
3
+ * found, `replaced` when `force` overwrote it.
4
4
  */
5
5
  export type ScaffoldAction = 'created' | 'present' | 'replaced';
6
6
  /** One canonical path and what the scaffold did about it. */
7
7
  export interface ScaffoldEntry {
8
- /** The store-relative path, directories carrying a trailing slash. */
8
+ /** The store-relative path, directories with a trailing slash. */
9
9
  path: string;
10
10
  action: ScaffoldAction;
11
11
  }
12
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.
13
+ * Writes the canonical files and directories that every knowledge-base store contains, leaving an existing file as it
14
+ * found it unless `force` is set. Directories are ensured either way, since a directory has no content to replace.
15
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.
16
+ * The function asserts nothing about the store: A caller that requires one to exist, or requires one not to, checks
17
+ * that itself.
19
18
  */
20
19
  export declare function scaffold(input: {
21
20
  storePath: string;
@@ -1,15 +1,14 @@
1
1
  import type { AliasMap, KbRoot } from '../types.js';
2
2
  /**
3
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.
4
+ * The thin I/O wrapper around {@link parseAliases}; on a structural defect it throws, naming the file path.
5
5
  */
6
6
  export declare function loadAliases(input: {
7
7
  kbRoot: KbRoot;
8
8
  }): Promise<AliasMap>;
9
9
  /**
10
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.
11
+ * Aliases are lowercased on insertion so that callers can look up case-insensitively.
12
+ * Throws on malformed YAML and on any structural defect, with `contextLabel` prefixed onto every message.
14
13
  */
15
14
  export declare function parseAliases(text: string, contextLabel?: string): AliasMap;
@@ -1,13 +1,13 @@
1
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.
2
+ * Derives every domain implied by a note set: each note's domain, plus each of that domain's ancestors, sorted.
3
+ * A grouping domain that contains only subfolders is included, because the rules treat it as in use and would
4
+ * otherwise report a back-filled taxonomy's own entries as unused.
5
5
  */
6
6
  export declare function deriveDomains(relativePaths: Iterable<string>): string[];
7
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.
8
+ * Derives a note's domain, or `undefined` when the note is not an assertion or is at the assertions root.
9
+ * Scoping to the assertions root keeps event records from registering as domains.
10
10
  */
11
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. */
12
+ /** Derives a slash-path's parent, or `undefined` when it has no separator and so is at the top level. */
13
13
  export declare function resolveParent(path: string): string | undefined;
@@ -3,10 +3,10 @@ import { type Taxonomy } from './taxonomy-schema.js';
3
3
  /**
4
4
  * Loads `.kb/taxonomy.yaml` into a single keyed map, returning an empty taxonomy when the file is absent or declares
5
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.
6
+ * off the entry that it finds.
7
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.
8
+ * Structural defects (malformed YAML, a wrong type, a malformed key, a path declared in both blocks) throw a
9
+ * {@link KbLoaderError} naming the file. I/O errors other than a missing file propagate.
10
10
  */
11
11
  export declare function loadTaxonomy(input: {
12
12
  kbRoot: KbRoot;
@@ -53,8 +53,8 @@ function collectBlock(input) {
53
53
  function describeIssueLocation(error) {
54
54
  const issue = error.issues[0];
55
55
  if (issue === undefined) {
56
- return ' unknown error';
56
+ return ': unknown error';
57
57
  }
58
58
  const location = issue.path.length > 0 ? ` at ${issue.path.join('.')}` : '';
59
- return `${location} ${issue.message}`;
59
+ return `${location}: ${issue.message}`;
60
60
  }
@@ -2,12 +2,11 @@ import { z } from 'zod';
2
2
  /**
3
3
  * Describes why a domain key cannot be used, or returns `undefined` when the key is well-formed. Keys are relative to
4
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.
5
+ * author meant.
6
6
  */
7
7
  export declare function describeKeyDefect(key: string): string | undefined;
8
8
  /** A knowledge base's declared assertion structure, keyed by assertions-root-relative slash-path. */
9
9
  export type Taxonomy = ReadonlyMap<string, TaxonomyEntry>;
10
- /** A single declared domain. */
11
10
  export interface TaxonomyEntry {
12
11
  /** The domain's one-line description; empty when it was declared without one. */
13
12
  description: string;
@@ -18,11 +17,11 @@ export interface TaxonomyEntry {
18
17
  * The on-disk `.kb/taxonomy.yaml` shape.
19
18
  *
20
19
  * 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.
20
+ * loads as declaring nothing rather than failing. YAML reads such a header as null, and it is the state left behind by
21
+ * promoting the last entry out of a block.
23
22
  *
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.
23
+ * Because a description may likewise be null, a key written bare (`engineering/tooling:`) loads: That is what a hand
24
+ * editor types and what a domain declared without a description round-trips to.
26
25
  */
27
26
  export declare const taxonomyFileShape: z.ZodObject<{
28
27
  domains: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>>;
@@ -1,9 +1,8 @@
1
1
  import type { KbRoot } from '../types.js';
2
- /** A domain to declare. */
3
2
  export interface TaxonomyDeclaration {
4
3
  /** The domain's assertions-root-relative slash-path. */
5
4
  path: string;
6
- /** The one-line description; absent or empty writes the key bare, with no description. */
5
+ /** The one-line description; when absent or empty, `writeTaxonomy` writes the key bare, with no description. */
7
6
  description?: string;
8
7
  /** Whether to declare under `provisional:` rather than `domains:`. */
9
8
  provisional: boolean;
@@ -12,13 +11,13 @@ export interface TaxonomyDeclaration {
12
11
  * Declares domains in `.kb/taxonomy.yaml`, creating the file and either block as needed, and returns the paths added.
13
12
  *
14
13
  * 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.
14
+ * survive; a plain parse-and-stringify round trip would discard every comment in a hand-curated file. New keys
15
+ * are appended to the end of their block in path order, and a block header left with nothing under it is filled in
16
+ * place rather than moved.
18
17
  *
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.
18
+ * A path already declared by either block is skipped rather than overwritten. A repeat call adds nothing, and when
19
+ * nothing is left to add, the file is not opened for writing at all. The write goes through the shared `writeAtomic`
20
+ * helper, so an interrupted call cannot truncate the taxonomy.
22
21
  *
23
22
  * Throws a {@link KbLoaderError} on a malformed key, or on an existing file that cannot be safely appended to: one
24
23
  * that fails to parse, one whose top level is not a mapping, and one whose `domains` or `provisional` block holds
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export declare function isEnoent(error: unknown): boolean;
6
6
  /**
7
- * Returns true when `error` carries the given Node `code` string (e.g. `'ENOENT'`, `'EACCES'`).
7
+ * Returns true when `error` has the given Node `code` string (e.g. `'ENOENT'`, `'EACCES'`).
8
8
  * @internal
9
9
  */
10
10
  export declare function isErrorCode(error: unknown, code: string): boolean;
@@ -1,4 +1,4 @@
1
- /** A knowledge-base root: the directory holding a `.kb/`, and that `.kb/` itself. */
1
+ /** A knowledge-base root: the directory containing a `.kb/`, and that `.kb/` itself. */
2
2
  export interface KbRoot {
3
3
  /** Absolute path to the directory containing the `.kb/` folder. */
4
4
  path: string;
@@ -7,13 +7,11 @@ export interface KbRoot {
7
7
  }
8
8
  /** A single knowledge base declared in a `kb.yaml` registry, normalized in memory. */
9
9
  export interface KbRegistryEntry {
10
- /** The KB's name, lifted from the `kbs` map key. */
10
+ /** The KB's name, taken from the `kbs` map key. */
11
11
  name: string;
12
12
  /** Absolute path to the KB's root directory (tilde expanded, relative resolved). */
13
13
  path: string;
14
- /** Optional human-readable description. */
15
14
  description?: string;
16
- /** Whether the KB is read-only. */
17
15
  readonly?: boolean;
18
16
  /** Which registry the entry came from. */
19
17
  source: 'user' | 'project';
@@ -43,7 +41,7 @@ export interface Frontmatter {
43
41
  /** Optional and unknown fields, preserved through round-trip in insertion order. */
44
42
  extra: Record<string, unknown>;
45
43
  }
46
- /** The raw frontmatter slice plus parse diagnostics, kept so rules can report at source line numbers. */
44
+ /** The raw frontmatter slice plus parse diagnostics, kept so that rules can report at source line numbers. */
47
45
  export interface FrontmatterRaw {
48
46
  /** The YAML text between the opening and closing `---` fences. */
49
47
  text: string;
@@ -56,7 +54,7 @@ export interface FrontmatterRaw {
56
54
  }
57
55
  /** A note parsed into frontmatter and body. */
58
56
  export interface ParsedNote {
59
- /** Path or label the note was parsed from. */
57
+ /** Path or label from which the note was parsed. */
60
58
  path: string;
61
59
  /** The full original note content. */
62
60
  content: string;
@@ -69,11 +67,10 @@ export interface ParsedNote {
69
67
  /** 1-based line where the body begins. */
70
68
  bodyStartLine: number;
71
69
  }
72
- /** Severity of a validation finding. */
73
70
  export type FindingSeverity = 'error' | 'warning';
74
71
  /** A single validation finding produced by a rule. */
75
72
  export interface Finding {
76
- /** Path or label the finding applies to: a note, or the store file that declares the rule's subject. */
73
+ /** Path or label to which the finding applies: a note, or the store file that declares the rule's subject. */
77
74
  path: string;
78
75
  /**
79
76
  * What the finding describes, defaulting to `note` when absent. A note-scoped finding is dropped when a run's
@@ -83,7 +80,7 @@ export interface Finding {
83
80
  scope?: 'note' | 'vault';
84
81
  /** 1-based source line number, when known. */
85
82
  line?: number;
86
- /** Rule code, e.g. `frontmatter.required`. */
83
+ /** Rule code, e.g. `wikilinks.basename`. */
87
84
  rule: string;
88
85
  severity: FindingSeverity;
89
86
  message: string;
@@ -1,13 +1,12 @@
1
1
  /**
2
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.
3
+ * single-path entry resolves a wikilink unambiguously; a multi-path entry is a basename collision.
5
4
  */
6
5
  export type VaultIndex = ReadonlyMap<string, ReadonlySet<string>>;
7
6
  /**
8
7
  * 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
8
+ * the note paths that share it. Reads only each note's `path`, so it is type-blind: It needs no frontmatter or record
9
+ * projection. Because a note's `path` is used verbatim as the index value, callers control whether entries are
11
10
  * vault-relative or absolute.
12
11
  */
13
12
  export declare function buildVaultIndex(notes: readonly {
@@ -1,20 +1,54 @@
1
+ import type { StoreVisibility } from '../config/config-schema.js';
1
2
  import type { Finding } from '../types.js';
2
- /** A note reduced to what vault integrity inspects: its path, its body, and the file line the body begins on. */
3
- export interface VaultIntegrityNote {
4
- /** Path or label the note was read from; used as the index value and the finding path. */
5
- path: string;
6
- /** The note body (everything after the frontmatter block). */
7
- body: string;
8
- /** 1-based file line where the body begins, so link findings report file-absolute lines. */
9
- bodyStartLine: number;
10
- }
3
+ import { type VaultIndex } from './build-vault-index.js';
11
4
  /**
12
5
  * Checks whole-vault integrity over a type-blind note set: unresolved `[[link]]` targets and basename collisions.
13
- * Projects no records and reads no frontmatter a note is just its path and body.
6
+ * Projects no records and reads no frontmatter: A note is just its path and body.
14
7
  *
15
8
  * A `[[Target]]` whose basename resolves to zero notes is an error (`wikilinks.unresolved`), reported at its
16
9
  * file-absolute line. A basename shared by two or more notes is a single vault-wide warning (`wikilinks.basename`),
17
10
  * reported once per basename independent of whether any link references it. An ambiguous link (a basename that several
18
- * notes share) is not flagged per-link the vault-wide basename warning subsumes it.
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 is never added to this store's own
14
+ * basename index, so the basename warning stays store-scoped. Without `options`, a qualified target is treated as a
15
+ * bare one.
19
16
  */
20
- export declare function checkVaultIntegrity(notes: readonly VaultIntegrityNote[]): Finding[];
17
+ export declare function checkVaultIntegrity(notes: readonly VaultIntegrityNote[], options?: VaultIntegrityOptions): Finding[];
18
+ /** What a check run found when it looked up a store named by one of its links. */
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
+ /** Because the store is less shareable than the source, a link into it would widen disclosure. */
30
+ | {
31
+ status: 'disallowed';
32
+ visibility: StoreVisibility;
33
+ }
34
+ /** The store was read; `index` contains its basenames. */
35
+ | {
36
+ status: 'resolved';
37
+ index: VaultIndex;
38
+ };
39
+ /** A note reduced to what vault integrity inspects. */
40
+ export interface VaultIntegrityNote {
41
+ /** Path or label from which the note was read; used as the index value and the finding path. */
42
+ path: string;
43
+ /** The note body (everything after the frontmatter block). */
44
+ body: string;
45
+ /** 1-based file line where the body begins, so that link findings report file-absolute lines. */
46
+ bodyStartLine: number;
47
+ }
48
+ /** What a run needs to evaluate a store-qualified link. */
49
+ export interface VaultIntegrityOptions {
50
+ /** What the run found for each store name that qualifies one of its links, keyed by that name. */
51
+ foreignStores: ReadonlyMap<string, ForeignStore>;
52
+ /** The visibility of the store being checked, which decides the direction that 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';
@@ -1,3 +1,3 @@
1
1
  export { buildVaultIndex } from "./build-vault-index.js";
2
- export { checkVaultIntegrity } from "./check-vault-integrity.js";
3
- export { countNewlines, extractTarget, hasNonMarkdownExtension, lookupKey, maskFencedCode, maskInlineCode, WIKILINK, } from "./wikilink-parse.js";
2
+ export { checkVaultIntegrity, } from "./check-vault-integrity.js";
3
+ export { lookupKey, scanWikilinks } from "./wikilink-parse.js";
@@ -1,29 +1,55 @@
1
- /**
2
- * Match `[[Target]]` and `![[Target]]` (embeds). A backslash-escaped `\[[…]]` is excluded. Scan the body so that
3
- * frontmatter wikilink-looking text (e.g. inside a description) is not flagged.
4
- */
5
- export declare const WIKILINK: RegExp;
6
- /** Counts the newlines in `text` before byte offset `upTo`, used to locate a match's source line. */
1
+ /** Counts the newlines in `text` before string index `upTo`, used to locate a match's source line. */
7
2
  export declare function countNewlines(text: string, upTo: number): number;
8
3
  /**
9
4
  * Strips `|alias` and `#anchor` from a wikilink inner string and returns the target. Returns `null` for intra-doc
10
- * links like `[[#heading]]`, which carry no target.
5
+ * links like `[[#heading]]`, which have no target.
11
6
  */
12
7
  export declare function extractTarget(inner: string): string | null;
13
- /** Whether a target carries a known non-Markdown extension (an embed the vault index cannot resolve). */
14
- export declare function hasNonMarkdownExtension(target: string): boolean;
15
- /** Reduces a wikilink target to the basename key the vault index is keyed on (drops any directory prefix and `.md`). */
8
+ /**
9
+ * Reduces a wikilink target to the basename key on which the vault index is keyed (drops any directory prefix and
10
+ * `.md`).
11
+ */
16
12
  export declare function lookupKey(target: string): string;
13
+ /** A wikilink target separated into the store that it names, when it names one, and the target within that store. */
14
+ export interface QualifiedTarget {
15
+ /** The store named by the link, or `undefined` when the target is store-local. */
16
+ store?: string;
17
+ /** The target with any store qualifier removed. */
18
+ target: string;
19
+ }
20
+ /**
21
+ * One wikilink accepted by a body scan, with its target already split into an optional store qualifier and a target.
22
+ */
23
+ export interface ScannedWikilink {
24
+ /** The whole matched link, including any `!` embed prefix. */
25
+ match: string;
26
+ /** The text between the brackets, with alias and anchor intact. */
27
+ inner: string;
28
+ /** Offset of the match within the body. */
29
+ offset: number;
30
+ /** The store named by the link, or `undefined` when the target is store-local. */
31
+ store?: string;
32
+ /** The target within that store, with alias, anchor, and any store qualifier stripped. */
33
+ target: string;
34
+ }
17
35
  /**
18
- * Replaces the content of fenced code blocks with spaces so wikilink-shaped text inside code (e.g., a bash
19
- * `[[ -n "$x" ]]` conditional) is not flagged. Offsets and line counts are preserved by substituting same-length
20
- * whitespace.
36
+ * Scans a note body and yields every wikilink that has a resolvable target: fenced and inline code are masked
37
+ * first, and backslash-escaped links, intra-doc anchors, and non-Markdown embeds are skipped. This is the single
38
+ * definition of what counts as a link and what its target is, so a consumer that detects links and one that rewrites
39
+ * them cannot drift apart on either question.
40
+ *
41
+ * `offset` indexes the body as passed in. Because masking substitutes same-length whitespace, which this function
42
+ * asserts, a consumer may slice the unmasked body at the offsets yielded here. It also leaves every newline where it
43
+ * was, which lets a consumer count lines in the unmasked body at those offsets. Nothing asserts that at runtime, so a
44
+ * masker that moved a newline while keeping the length would shift reported line numbers rather than fail.
21
45
  */
22
- export declare function maskFencedCode(body: string): string;
46
+ export declare function scanWikilinks(body: string): Generator<ScannedWikilink>;
23
47
  /**
24
- * Replaces inline backtick spans (e.g., TOML `[[plugins]]` mentioned in prose) with same-length whitespace so
25
- * wikilink-shaped text inside inline code is not flagged. Matches single or multi-backtick runs whose content
26
- * contains no backticks or newlines the common case; complex spans with embedded backticks fall through and are
27
- * still parsed for wikilinks.
48
+ * Separates a leading `store:` qualifier from a wikilink target: `fde:Note title` names the note `Note title` in the
49
+ * store `fde`. A qualifier is recognized only when the text before the first colon is non-empty and contains no
50
+ * whitespace and no `/`, and something follows the colon. Every other target stays store-local: A title such as
51
+ * `Release notes: v2` resolves within this store.
52
+ *
53
+ * Call it on the output of {@link extractTarget}, which has already stripped any alias and anchor.
28
54
  */
29
- export declare function maskInlineCode(body: string): string;
55
+ export declare function splitStoreQualifier(target: string): QualifiedTarget;
@@ -1,4 +1,4 @@
1
- export const WIKILINK = /(?<!\\)!?\[\[([^\]\n]+?)\]\]/g;
1
+ const WIKILINK = /(?<!\\)!?\[\[([^\]\n]+?)\]\]/g;
2
2
  const NON_MD_EXTENSIONS = new Set([
3
3
  '.png',
4
4
  '.jpg',
@@ -26,7 +26,40 @@ export function extractTarget(inner) {
26
26
  const trimmed = beforeAnchor.trim();
27
27
  return trimmed === '' ? null : trimmed;
28
28
  }
29
- export function hasNonMarkdownExtension(target) {
29
+ export function lookupKey(target) {
30
+ const withoutExtension = target.endsWith('.md') ? target.slice(0, -3) : target;
31
+ const segments = withoutExtension.split('/');
32
+ return segments.at(-1) ?? withoutExtension;
33
+ }
34
+ export function* scanWikilinks(body) {
35
+ const masked = maskInlineCode(maskFencedCode(body));
36
+ if (masked.length !== body.length) {
37
+ throw new Error('code masking changed body length; wikilink offsets would be invalid');
38
+ }
39
+ for (const match of masked.matchAll(WIKILINK)) {
40
+ const inner = match[1];
41
+ if (inner === undefined)
42
+ continue;
43
+ const extracted = extractTarget(inner);
44
+ if (extracted === null)
45
+ continue;
46
+ if (hasNonMarkdownExtension(extracted))
47
+ continue;
48
+ const { store, target } = splitStoreQualifier(extracted);
49
+ yield { match: match[0], inner, offset: match.index, ...(store !== undefined && { store }), target };
50
+ }
51
+ }
52
+ export function splitStoreQualifier(target) {
53
+ const colonIndex = target.indexOf(':');
54
+ if (colonIndex <= 0)
55
+ return { target };
56
+ const store = target.slice(0, colonIndex);
57
+ const remainder = target.slice(colonIndex + 1);
58
+ if (remainder === '' || /[\s/]/.test(store))
59
+ return { target };
60
+ return { store, target: remainder };
61
+ }
62
+ function hasNonMarkdownExtension(target) {
30
63
  const dotIndex = target.lastIndexOf('.');
31
64
  if (dotIndex === -1)
32
65
  return false;
@@ -35,12 +68,7 @@ export function hasNonMarkdownExtension(target) {
35
68
  return false;
36
69
  return NON_MD_EXTENSIONS.has(ext);
37
70
  }
38
- export function lookupKey(target) {
39
- const withoutExtension = target.endsWith('.md') ? target.slice(0, -3) : target;
40
- const segments = withoutExtension.split('/');
41
- return segments.at(-1) ?? withoutExtension;
42
- }
43
- export function maskFencedCode(body) {
71
+ function maskFencedCode(body) {
44
72
  const lines = body.split('\n');
45
73
  let inFence = false;
46
74
  let fenceChar = '';
@@ -69,7 +97,7 @@ export function maskFencedCode(body) {
69
97
  }
70
98
  return lines.join('\n');
71
99
  }
72
- export function maskInlineCode(body) {
100
+ function maskInlineCode(body) {
73
101
  return body.replace(/`+[^`\n]+?`+/g, (match) => ' '.repeat(match.length));
74
102
  }
75
103
  const FENCE_LINE = /^\s{0,3}(`{3,}|~{3,})/;