@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.
- package/README.md +101 -96
- package/bin/kb.js +2 -4
- package/dist/esm/check/check.d.ts +14 -12
- package/dist/esm/check/check.js +41 -1
- package/dist/esm/check/enumerate.d.ts +13 -16
- package/dist/esm/check/glob-segments.d.ts +1 -1
- package/dist/esm/check/resolve-foreign-stores.d.ts +25 -0
- package/dist/esm/check/resolve-foreign-stores.js +50 -0
- package/dist/esm/cli/commands/check.d.ts +4 -10
- package/dist/esm/cli/commands/check.js +8 -3
- package/dist/esm/cli/commands/create.d.ts +3 -7
- package/dist/esm/cli/commands/create.js +1 -1
- package/dist/esm/cli/commands/scaffold.d.ts +5 -9
- package/dist/esm/cli/commands/scaffold.js +5 -5
- package/dist/esm/cli/commands/set-default.d.ts +1 -5
- package/dist/esm/cli/commands/set-default.js +1 -1
- package/dist/esm/cli/commands/taxonomy.d.ts +5 -10
- package/dist/esm/cli/commands/taxonomy.js +10 -9
- package/dist/esm/cli/format.d.ts +8 -10
- package/dist/esm/cli/resolve-store.d.ts +8 -8
- package/dist/esm/cli/run.d.ts +4 -6
- package/dist/esm/cli/run.js +1 -1
- package/dist/esm/cli/select-kb-prompt.d.ts +6 -5
- package/dist/esm/cli/select-kb-prompt.js +1 -1
- package/dist/esm/cli/targeting/resolve-changed-paths.d.ts +3 -3
- package/dist/esm/cli/targeting/select-notes.d.ts +2 -2
- package/dist/esm/config/config-schema.d.ts +24 -6
- package/dist/esm/config/config-schema.js +9 -0
- package/dist/esm/config/index.d.ts +1 -1
- package/dist/esm/config/index.js +1 -1
- package/dist/esm/config/kb-loader-error.d.ts +3 -3
- package/dist/esm/config/load-config.d.ts +3 -3
- package/dist/esm/config/load-config.js +2 -1
- package/dist/esm/config/note-scope.d.ts +7 -7
- package/dist/esm/create/create.d.ts +8 -8
- package/dist/esm/discovery/kb-registry-schema.d.ts +0 -2
- package/dist/esm/discovery/load-registry.d.ts +7 -9
- package/dist/esm/discovery/load-registry.js +1 -1
- package/dist/esm/discovery/register-store.d.ts +3 -4
- package/dist/esm/discovery/register-store.js +2 -2
- package/dist/esm/discovery/registry-document.d.ts +2 -2
- package/dist/esm/discovery/set-default-kb.d.ts +5 -5
- package/dist/esm/discovery/set-default-kb.js +3 -3
- package/dist/esm/filesystem/exists.d.ts +4 -4
- package/dist/esm/frontmatter/parse-note.d.ts +3 -3
- package/dist/esm/git/list-git-scope.d.ts +7 -7
- package/dist/esm/git/run-git.d.ts +2 -2
- package/dist/esm/layout/store-layout.d.ts +5 -5
- package/dist/esm/lints/paths.d.ts +1 -1
- package/dist/esm/lints/paths.js +1 -1
- package/dist/esm/lints/tag-alias.d.ts +1 -1
- package/dist/esm/lints/tag-alias.js +1 -1
- package/dist/esm/lints/taxonomy.d.ts +9 -8
- package/dist/esm/lints/taxonomy.js +2 -2
- package/dist/esm/note-io/field-validators.d.ts +1 -1
- package/dist/esm/note-io/read-note.d.ts +2 -2
- package/dist/esm/note-io/write-note.d.ts +3 -6
- package/dist/esm/records/event.d.ts +3 -3
- package/dist/esm/scaffold/render-seeds.d.ts +3 -3
- package/dist/esm/scaffold/render-seeds.js +5 -3
- package/dist/esm/scaffold/scaffold.d.ts +7 -8
- package/dist/esm/tags/load-aliases.d.ts +3 -4
- package/dist/esm/taxonomy/domain-paths.d.ts +6 -6
- package/dist/esm/taxonomy/load-taxonomy.d.ts +3 -3
- package/dist/esm/taxonomy/load-taxonomy.js +2 -2
- package/dist/esm/taxonomy/taxonomy-schema.d.ts +5 -6
- package/dist/esm/taxonomy/write-taxonomy.d.ts +7 -8
- package/dist/esm/type-guards.d.ts +1 -1
- package/dist/esm/types.d.ts +6 -9
- package/dist/esm/vault-integrity/build-vault-index.d.ts +3 -4
- package/dist/esm/vault-integrity/check-vault-integrity.d.ts +46 -12
- package/dist/esm/vault-integrity/check-vault-integrity.js +59 -20
- package/dist/esm/vault-integrity/index.d.ts +2 -2
- package/dist/esm/vault-integrity/index.js +2 -2
- package/dist/esm/vault-integrity/wikilink-parse.d.ts +45 -19
- package/dist/esm/vault-integrity/wikilink-parse.js +37 -9
- package/package.json +6 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The formatting option every store
|
|
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
|
-
#
|
|
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
|
|
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`
|
|
3
|
-
* found, `replaced`
|
|
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
|
|
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
|
|
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:
|
|
17
|
-
* that itself.
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* A grouping domain that
|
|
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
|
|
9
|
-
* Scoping to the assertions root
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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 '
|
|
56
|
+
return ': unknown error';
|
|
57
57
|
}
|
|
58
58
|
const location = issue.path.length > 0 ? ` at ${issue.path.join('.')}` : '';
|
|
59
|
-
return `${location}
|
|
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
|
|
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
|
|
22
|
-
* last entry out of a block
|
|
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
|
-
*
|
|
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
|
|
16
|
-
* to the end of their block in path order, and a block header left with nothing under it is filled in
|
|
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
|
|
20
|
-
* is left to add, the file is not opened for writing at all. The write goes through the shared `writeAtomic`
|
|
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`
|
|
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;
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** A knowledge-base root: the directory
|
|
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,
|
|
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
|
|
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
|
|
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. `
|
|
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.
|
|
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
|
|
10
|
-
* projection.
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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 [...
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
42
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
5
|
+
* links like `[[#heading]]`, which have no target.
|
|
11
6
|
*/
|
|
12
7
|
export declare function extractTarget(inner: string): string | null;
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
46
|
+
export declare function scanWikilinks(body: string): Generator<ScannedWikilink>;
|
|
23
47
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
55
|
+
export declare function splitStoreQualifier(target: string): QualifiedTarget;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,})/;
|