@forwardimpact/outpost 3.12.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/fit-outpost.js +3 -2
- package/package.json +6 -5
- package/src/agent-path.js +10 -6
- package/src/agent-runner.js +28 -24
- package/src/index.js +2 -3
- package/src/kb-manager.js +75 -20
- package/src/kb-validator.js +762 -0
- package/src/outpost.js +108 -39
- package/src/posture.js +3 -3
- package/src/privilege.js +11 -11
- package/src/scheduler.js +14 -12
- package/src/socket-server.js +19 -18
- package/src/spawn-env.js +7 -6
- package/src/state-manager.js +2 -2
- package/templates/.claude/agents/chief-of-staff.md +32 -24
- package/templates/.claude/agents/concierge.md +26 -17
- package/templates/.claude/agents/head-hunter.md +34 -26
- package/templates/.claude/agents/librarian.md +26 -18
- package/templates/.claude/agents/postman.md +26 -18
- package/templates/.claude/agents/recruiter.md +38 -27
- package/templates/.claude/skills/anarlog-follow/SKILL.md +91 -66
- package/templates/.claude/skills/anarlog-follow/references/coaching.md +22 -21
- package/templates/.claude/skills/anarlog-follow/references/sessions.md +52 -0
- package/templates/.claude/skills/anarlog-process/SKILL.md +108 -62
- package/templates/.claude/skills/anarlog-process/references/extraction.md +41 -29
- package/templates/.claude/skills/anarlog-process/references/sessions.md +92 -58
- package/templates/.claude/skills/anarlog-process/scripts/scan.mjs +402 -160
- package/templates/.claude/skills/candidate-report/SKILL.md +41 -36
- package/templates/.claude/skills/candidate-report/references/report-template.html +10 -10
- package/templates/.claude/skills/candidate-report/references/report.css +4 -4
- package/templates/.claude/skills/candidate-report/references/rubric.md +15 -14
- package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +2 -2
- package/templates/.claude/skills/changelog/SKILL.md +122 -67
- package/templates/.claude/skills/deck-create/SKILL.md +90 -32
- package/templates/.claude/skills/deck-create/references/slide.css +4 -4
- package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +6 -6
- package/templates/.claude/skills/deck-review/SKILL.md +63 -58
- package/templates/.claude/skills/deck-review/assets/slide-annotator.js +58 -49
- package/templates/.claude/skills/deck-summarize/SKILL.md +55 -45
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +5 -4
- package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +8 -7
- package/templates/.claude/skills/doc-collab/SKILL.md +39 -34
- package/templates/.claude/skills/doc-create/SKILL.md +27 -23
- package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +6 -6
- package/templates/.claude/skills/draft-emails/SKILL.md +63 -53
- package/templates/.claude/skills/draft-emails/references/template.md +2 -2
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +15 -8
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +19 -11
- package/templates/.claude/skills/extract-entities/SKILL.md +76 -75
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +6 -5
- package/templates/.claude/skills/extract-entities/references/conditions.md +20 -18
- package/templates/.claude/skills/extract-entities/references/content.md +10 -9
- package/templates/.claude/skills/extract-entities/references/links.md +37 -13
- package/templates/.claude/skills/extract-entities/references/recruitment.md +30 -24
- package/templates/.claude/skills/extract-entities/references/resolution.md +14 -14
- package/templates/.claude/skills/extract-entities/references/sources.md +9 -9
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +12 -6
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +29 -10
- package/templates/.claude/skills/extract-entities/references/templates-priorities.md +10 -4
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +19 -7
- package/templates/.claude/skills/extract-entities/scripts/state.mjs +9 -8
- package/templates/.claude/skills/meeting-prep/SKILL.md +42 -36
- package/templates/.claude/skills/organize-files/SKILL.md +27 -22
- package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +5 -4
- package/templates/.claude/skills/organize-files/scripts/summarize.mjs +4 -4
- package/templates/.claude/skills/person-identify/SKILL.md +77 -27
- package/templates/.claude/skills/person-identify/scripts/identify.sh +125 -26
- package/templates/.claude/skills/person-lookup/SKILL.md +34 -30
- package/templates/.claude/skills/person-lookup/scripts/lookup.sh +46 -17
- package/templates/.claude/skills/req-assess/SKILL.md +44 -36
- package/templates/.claude/skills/req-assess/references/interview-template.md +7 -2
- package/templates/.claude/skills/req-assess/references/panel-template.md +7 -2
- package/templates/.claude/skills/req-assess/references/rubric.md +13 -13
- package/templates/.claude/skills/req-bundle/SKILL.md +164 -0
- package/templates/.claude/skills/req-bundle/references/matching.md +70 -0
- package/templates/.claude/skills/req-bundle/references/pdf-structure.md +74 -0
- package/templates/.claude/skills/req-bundle/scripts/split-bundle.mjs +377 -0
- package/templates/.claude/skills/req-decide/SKILL.md +52 -41
- package/templates/.claude/skills/req-decide/references/rubric.md +11 -10
- package/templates/.claude/skills/req-decide/references/template.md +2 -2
- package/templates/.claude/skills/req-forget/SKILL.md +58 -39
- package/templates/.claude/skills/req-forget/references/classify.md +15 -13
- package/templates/.claude/skills/req-forget/references/locations.md +27 -22
- package/templates/.claude/skills/req-forget/references/report-template.md +24 -16
- package/templates/.claude/skills/req-scan/SKILL.md +27 -25
- package/templates/.claude/skills/req-scan/references/fallbacks.md +9 -9
- package/templates/.claude/skills/req-scan/references/filters.md +13 -13
- package/templates/.claude/skills/req-scan/references/sources.md +7 -7
- package/templates/.claude/skills/req-scan/references/state.md +1 -1
- package/templates/.claude/skills/req-scan/references/template.md +11 -1
- package/templates/.claude/skills/req-scan/scripts/state.mjs +3 -2
- package/templates/.claude/skills/req-screen/SKILL.md +96 -50
- package/templates/.claude/skills/req-screen/references/rubric.md +46 -50
- package/templates/.claude/skills/req-screen/references/scoring.md +79 -0
- package/templates/.claude/skills/req-screen/references/template.md +29 -17
- package/templates/.claude/skills/req-track/SKILL.md +76 -76
- package/templates/.claude/skills/req-track/references/fields.md +31 -27
- package/templates/.claude/skills/req-track/references/overlays.md +42 -0
- package/templates/.claude/skills/req-track/references/signals.md +12 -12
- package/templates/.claude/skills/req-track/references/statuses.md +3 -2
- package/templates/.claude/skills/req-track/references/templates.md +28 -27
- package/templates/.claude/skills/req-workday/SKILL.md +69 -40
- package/templates/.claude/skills/req-workday/references/brief.md +81 -0
- package/templates/.claude/skills/req-workday/references/status-mapping.md +5 -5
- package/templates/.claude/skills/req-workday/references/templates.md +11 -78
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +26 -7
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +113 -35
- package/templates/.claude/skills/send-chat/SKILL.md +58 -54
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +23 -19
- package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +7 -7
- package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +6 -6
- package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +9 -8
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +20 -14
- package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +10 -10
- package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +9 -9
- package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +16 -13
- package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +3 -3
- package/templates/.claude/skills/sync-teams/SKILL.md +46 -43
- package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +22 -21
- package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +4 -4
- package/templates/.claude/skills/sync-teams/scripts/sync.mjs +4 -4
- package/templates/.claude/skills/upstream-instructions/SKILL.md +43 -41
- package/templates/.claude/skills/upstream-instructions/references/examples.md +5 -5
- package/templates/CLAUDE.md +97 -85
- package/templates/MIGRATION.md +359 -0
- package/templates/registry.yaml +25 -0
- package/templates/.claude/skills/anarlog-follow/scripts/follow.mjs +0 -243
- package/templates/.claude/skills/anarlog-trim/SKILL.md +0 -183
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kb-validator — pure knowledge checks for one Outpost knowledge base.
|
|
3
|
+
*
|
|
4
|
+
* The module takes a KB root (the Obsidian vault that holds the numbered
|
|
5
|
+
* tier directories) and an injected async `fs` surface. It returns findings.
|
|
6
|
+
* It never logs, never exits, and never reads scheduler config. The only
|
|
7
|
+
* files it consults besides the vault content are the vault-local
|
|
8
|
+
* `registry.yaml` and `validation-baseline.json`.
|
|
9
|
+
*
|
|
10
|
+
* The frontmatter block-splitter is deliberately local. Reusing libdoc's
|
|
11
|
+
* helper would pull a site-generator package into an end-user CLI for ten
|
|
12
|
+
* lines of code.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Finding
|
|
20
|
+
* @property {string} kind - One of the link, directory, or frontmatter kinds.
|
|
21
|
+
* @property {boolean} baselined - True when a baseline entry matches.
|
|
22
|
+
* @property {string} [file] - Source note, relative to the KB root.
|
|
23
|
+
* @property {number} [line] - 1-based line of the link or property.
|
|
24
|
+
* @property {string} [link] - The link target or path string as written.
|
|
25
|
+
* @property {string|null} [sourceTier] - Tier directory name of the source.
|
|
26
|
+
* @property {string|null} [targetTier] - Tier directory name of the target.
|
|
27
|
+
* @property {string} [path] - Root entry a directory finding names.
|
|
28
|
+
* @property {string} [property] - Frontmatter property a finding names.
|
|
29
|
+
* @property {string|null} [value] - Offending frontmatter value.
|
|
30
|
+
* @property {string} [message] - Human hint; legacy kinds name MIGRATION.md.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} Ctx
|
|
35
|
+
* @property {object} fs - Injected async fs surface.
|
|
36
|
+
* @property {string} kbRoot - Absolute vault root.
|
|
37
|
+
* @property {Finding[]} findings - Accumulator.
|
|
38
|
+
* @property {{name: string, rank: number}[]} tiers
|
|
39
|
+
* @property {Map<string, {name: string, rank: number}>} tierByName
|
|
40
|
+
* @property {string[]} personalNames - Non-tier root entries.
|
|
41
|
+
* @property {Map<string, {rel: string, tier: object}>} index
|
|
42
|
+
* @property {Map<string, string[]>} byBase - Resolution name to paths.
|
|
43
|
+
* @property {object|null} registry - Parsed registry.yaml.
|
|
44
|
+
* @property {Set<string>|null} typeVocab - Registry type values.
|
|
45
|
+
* @property {Map<string, object>} tagRows - Registry tag rows by tag.
|
|
46
|
+
* @property {(rel: string) => Promise<boolean>} exists
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const TIER_RE = /^[0-9]-/;
|
|
50
|
+
const NEAR_MISS_RE = /^[0-9]{2,3}-/;
|
|
51
|
+
const PERSONAL_DIGITS_RE = /^[0-9]{4,}-/;
|
|
52
|
+
/** The legacy layout markers; kb-manager keys the MIGRATION.md install on them. */
|
|
53
|
+
export const LEGACY_ROOTS = ["Knowledge", "Drafts"];
|
|
54
|
+
const LEGACY_ENTITIES = [
|
|
55
|
+
"People",
|
|
56
|
+
"Organizations",
|
|
57
|
+
"Projects",
|
|
58
|
+
"Topics",
|
|
59
|
+
"Candidates",
|
|
60
|
+
"Priorities",
|
|
61
|
+
"Conditions",
|
|
62
|
+
"Roles",
|
|
63
|
+
"Prospects",
|
|
64
|
+
"Erasure",
|
|
65
|
+
"Tasks",
|
|
66
|
+
"Goals",
|
|
67
|
+
];
|
|
68
|
+
const REGISTRY_FILE = "registry.yaml";
|
|
69
|
+
const BASELINE_FILE = "validation-baseline.json";
|
|
70
|
+
const CORE_KEYS = ["type", "created", "updated"];
|
|
71
|
+
const DATE_KEYS = ["created", "updated"];
|
|
72
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
73
|
+
const ALIAS_TYPES = ["person", "candidate", "organization"];
|
|
74
|
+
const STATUS_TYPES = ["candidate", "prospect"];
|
|
75
|
+
const WIKI_LINK_RE = /!?\[\[([^[\]]+)\]\]/g;
|
|
76
|
+
const MD_LINK_RE = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
|
77
|
+
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
78
|
+
// Deliberately narrower than "any #token": the tag taxonomy is a closed,
|
|
79
|
+
// namespaced `topic/` vocabulary, and the field study found most bare-hash
|
|
80
|
+
// tokens are noise (hex colors, ticket ids, UUID fragments). Only a
|
|
81
|
+
// namespaced token counts as an inline tag.
|
|
82
|
+
const INLINE_TAG_RE = /(?:^|[\s(])#([A-Za-z][\w-]*(?:\/[\w-]+)+)/g;
|
|
83
|
+
|
|
84
|
+
/** @param {string} raw - A wiki-link inner text. @returns {string} The bare target. */
|
|
85
|
+
function wikiTarget(raw) {
|
|
86
|
+
return raw
|
|
87
|
+
.split(/\\\||\|/)[0]
|
|
88
|
+
.replace(/#.*$/, "")
|
|
89
|
+
.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {*} v @returns {boolean} True for a scalar frontmatter value. */
|
|
93
|
+
function isScalar(v) {
|
|
94
|
+
return (
|
|
95
|
+
v === null ||
|
|
96
|
+
typeof v === "string" ||
|
|
97
|
+
typeof v === "number" ||
|
|
98
|
+
typeof v === "boolean"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split a note into its frontmatter block and body. The block only counts
|
|
104
|
+
* when line 1 opens it.
|
|
105
|
+
* @param {string[]} lines - The note's lines.
|
|
106
|
+
* @returns {{blockLines: string[]|null, bodyStart: number}}
|
|
107
|
+
*/
|
|
108
|
+
function splitFrontmatter(lines) {
|
|
109
|
+
if (lines[0] !== "---") return { blockLines: null, bodyStart: 0 };
|
|
110
|
+
const end = lines.indexOf("---", 1);
|
|
111
|
+
if (end === -1) return { blockLines: null, bodyStart: 0 };
|
|
112
|
+
return { blockLines: lines.slice(1, end), bodyStart: end + 1 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the baseline key of a finding or a baseline entry. Vocabulary kinds
|
|
117
|
+
* include the value; no key includes a line number, so edits elsewhere in a
|
|
118
|
+
* note never resurface a grandfathered finding.
|
|
119
|
+
* @param {object} f - A finding or a baseline entry.
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function baselineKey(f) {
|
|
123
|
+
if (f.path !== undefined) return ["d", f.kind, f.path].join(" ");
|
|
124
|
+
if (f.property !== undefined) {
|
|
125
|
+
const value = f.kind === "frontmatter-vocabulary" ? (f.value ?? "") : "";
|
|
126
|
+
return ["p", f.kind, f.file, f.property, value].join(" ");
|
|
127
|
+
}
|
|
128
|
+
return ["l", f.kind, f.file, f.link].join(" ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pass 1: read the root and collect tiers, near misses, and personal names.
|
|
133
|
+
* @param {Ctx} ctx
|
|
134
|
+
* @returns {Promise<string[]>} The sorted root directory names.
|
|
135
|
+
*/
|
|
136
|
+
async function collectTiers(ctx) {
|
|
137
|
+
const rootNames = (
|
|
138
|
+
await ctx.fs.readdir(ctx.kbRoot, { withFileTypes: true })
|
|
139
|
+
).map((d) => d.name);
|
|
140
|
+
rootNames.sort();
|
|
141
|
+
const rootDirs = [];
|
|
142
|
+
for (const name of rootNames) {
|
|
143
|
+
const stat = await ctx.fs.stat(join(ctx.kbRoot, name)).catch(() => null);
|
|
144
|
+
if (stat?.isDirectory()) rootDirs.push(name);
|
|
145
|
+
if (!stat?.isDirectory() || PERSONAL_DIGITS_RE.test(name)) {
|
|
146
|
+
ctx.personalNames.push(name);
|
|
147
|
+
} else if (NEAR_MISS_RE.test(name)) {
|
|
148
|
+
ctx.findings.push({ kind: "out-of-grammar-rank", path: name });
|
|
149
|
+
} else if (TIER_RE.test(name)) {
|
|
150
|
+
const rank = Number(name[0]);
|
|
151
|
+
const twin = ctx.tiers.find((t) => t.rank === rank);
|
|
152
|
+
if (twin) {
|
|
153
|
+
ctx.findings.push({
|
|
154
|
+
kind: "duplicate-rank",
|
|
155
|
+
path: name,
|
|
156
|
+
message: `rank ${rank} is claimed by both ${twin.name}/ and ${name}/`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
ctx.tiers.push({ name, rank });
|
|
160
|
+
} else {
|
|
161
|
+
ctx.personalNames.push(name);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return rootDirs;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Pass 2: flag the legacy layout. `Knowledge/` and `Drafts/` fail at any
|
|
169
|
+
* time; the historical entity directories fail only at a tier-less root;
|
|
170
|
+
* a root with no tiers and no legacy markers fails no-tiers. Only
|
|
171
|
+
* directories count — a root file with a legacy name is personal.
|
|
172
|
+
* @param {Ctx} ctx
|
|
173
|
+
* @param {string[]} rootDirs
|
|
174
|
+
* @returns {void}
|
|
175
|
+
*/
|
|
176
|
+
function detectLegacy(ctx, rootDirs) {
|
|
177
|
+
const markers = LEGACY_ROOTS.filter((name) => rootDirs.includes(name));
|
|
178
|
+
if (ctx.tiers.length === 0) {
|
|
179
|
+
markers.push(...LEGACY_ENTITIES.filter((n) => rootDirs.includes(n)));
|
|
180
|
+
}
|
|
181
|
+
for (const name of markers) {
|
|
182
|
+
ctx.findings.push({
|
|
183
|
+
kind: "legacy-layout",
|
|
184
|
+
path: name,
|
|
185
|
+
message: `legacy layout: ${name}/ at the KB root — see MIGRATION.md`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (ctx.tiers.length === 0 && markers.length === 0) {
|
|
189
|
+
ctx.findings.push({
|
|
190
|
+
kind: "no-tiers",
|
|
191
|
+
path: ".",
|
|
192
|
+
message: "no tier directories at the KB root — see MIGRATION.md",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Pass 3: index every file under the tiers (through symlinks), keyed by
|
|
199
|
+
* root-relative path and by resolution name (basename without `.md`).
|
|
200
|
+
* @param {Ctx} ctx
|
|
201
|
+
* @param {string} relDir
|
|
202
|
+
* @param {{name: string, rank: number}} tier
|
|
203
|
+
* @returns {Promise<void>}
|
|
204
|
+
*/
|
|
205
|
+
async function walkTier(ctx, relDir, tier) {
|
|
206
|
+
const names = (
|
|
207
|
+
await ctx.fs.readdir(join(ctx.kbRoot, relDir), { withFileTypes: true })
|
|
208
|
+
).map((d) => d.name);
|
|
209
|
+
names.sort();
|
|
210
|
+
for (const name of names) {
|
|
211
|
+
const rel = `${relDir}/${name}`;
|
|
212
|
+
// A broken symlink inside a synced tier is a vault defect, not a reason
|
|
213
|
+
// to abort the whole run; skip it like the root-level pass does.
|
|
214
|
+
const stat = await ctx.fs.stat(join(ctx.kbRoot, rel)).catch(() => null);
|
|
215
|
+
if (stat === null) continue;
|
|
216
|
+
if (stat.isDirectory()) {
|
|
217
|
+
await walkTier(ctx, rel, tier);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
ctx.index.set(rel, { rel, tier });
|
|
221
|
+
const key = name.endsWith(".md") ? name.slice(0, -3) : name;
|
|
222
|
+
if (!ctx.byBase.has(key)) ctx.byBase.set(key, []);
|
|
223
|
+
ctx.byBase.get(key).push(rel);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Extract links from a frontmatter block. A property wiki link must be
|
|
229
|
+
* double-quoted; an unquoted one is a serialization finding, not a link.
|
|
230
|
+
* @param {Ctx} ctx
|
|
231
|
+
* @param {{rel: string, tier: object}} note
|
|
232
|
+
* @param {string[]} blockLines
|
|
233
|
+
* @param {boolean} shared
|
|
234
|
+
* @param {object[]} links
|
|
235
|
+
* @returns {object|null} The parsed frontmatter, or null when unparseable.
|
|
236
|
+
*/
|
|
237
|
+
function extractFrontmatter(ctx, note, blockLines, shared, links) {
|
|
238
|
+
let frontmatter = null;
|
|
239
|
+
try {
|
|
240
|
+
frontmatter = parseYaml(blockLines.join("\n")) ?? {};
|
|
241
|
+
} catch {
|
|
242
|
+
if (shared) {
|
|
243
|
+
ctx.findings.push({
|
|
244
|
+
kind: "frontmatter-invalid",
|
|
245
|
+
file: note.rel,
|
|
246
|
+
line: 1,
|
|
247
|
+
property: "frontmatter",
|
|
248
|
+
value: "unparseable YAML",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
blockLines.forEach((raw, i) => {
|
|
253
|
+
for (const match of raw.matchAll(WIKI_LINK_RE)) {
|
|
254
|
+
const quoted =
|
|
255
|
+
raw[match.index - 1] === '"' &&
|
|
256
|
+
raw[match.index + match[0].length] === '"';
|
|
257
|
+
if (quoted) {
|
|
258
|
+
links.push({ line: i + 2, target: wikiTarget(match[1]) });
|
|
259
|
+
} else if (shared) {
|
|
260
|
+
ctx.findings.push({
|
|
261
|
+
kind: "frontmatter-invalid",
|
|
262
|
+
file: note.rel,
|
|
263
|
+
line: i + 2,
|
|
264
|
+
property: raw.split(":")[0].trim().replace(/^- /, ""),
|
|
265
|
+
value: match[0],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
return frontmatter;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Flag literal path strings that name a narrower tier or a personal surface.
|
|
275
|
+
* @param {Ctx} ctx
|
|
276
|
+
* @param {{rel: string, tier: object}} note
|
|
277
|
+
* @param {string} stripped - The line with link spans blanked.
|
|
278
|
+
* @param {number} lineNo
|
|
279
|
+
* @returns {void}
|
|
280
|
+
*/
|
|
281
|
+
function scanPathStrings(ctx, note, stripped, lineNo) {
|
|
282
|
+
const names = [...ctx.tiers.map((t) => t.name), ...ctx.personalNames];
|
|
283
|
+
for (const name of names) {
|
|
284
|
+
const at = stripped.indexOf(`${name}/`);
|
|
285
|
+
if (at === -1) continue;
|
|
286
|
+
// A word, bracket, or slash before the match means the name sits inside
|
|
287
|
+
// a longer token (a wiki link, or a deeper path like `3-Team/Projects/`
|
|
288
|
+
// when a personal folder shares the entity name).
|
|
289
|
+
if (at > 0 && /[\w[/]/.test(stripped[at - 1])) continue;
|
|
290
|
+
const tier = ctx.tierByName.get(name);
|
|
291
|
+
if (tier && tier.rank >= note.tier.rank) continue;
|
|
292
|
+
ctx.findings.push({
|
|
293
|
+
kind: "path-string",
|
|
294
|
+
file: note.rel,
|
|
295
|
+
line: lineNo,
|
|
296
|
+
link: stripped.slice(at).split(/[\s)\]"'`]/)[0],
|
|
297
|
+
sourceTier: note.tier.name,
|
|
298
|
+
targetTier: tier?.name ?? null,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Extract body links, path strings, and inline tags. Fenced code blocks are
|
|
305
|
+
* opaque.
|
|
306
|
+
* @param {Ctx} ctx
|
|
307
|
+
* @param {{rel: string, tier: object}} note
|
|
308
|
+
* @param {string[]} lines
|
|
309
|
+
* @param {number} bodyStart
|
|
310
|
+
* @param {boolean} shared
|
|
311
|
+
* @param {object[]} links
|
|
312
|
+
* @returns {void}
|
|
313
|
+
*/
|
|
314
|
+
function extractBody(ctx, note, lines, bodyStart, shared, links) {
|
|
315
|
+
let fenced = false;
|
|
316
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
317
|
+
if (/^```/.test(lines[i].trim())) {
|
|
318
|
+
fenced = !fenced;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (fenced) {
|
|
322
|
+
// Fenced blocks carry no links or tags, but embedded commands are
|
|
323
|
+
// exactly where literal narrower-tier paths leak, so the mechanical
|
|
324
|
+
// path-string detection still runs inside them.
|
|
325
|
+
if (shared) scanPathStrings(ctx, note, lines[i], i + 1);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const stripped = extractLineLinks(lines[i], i + 1, links);
|
|
329
|
+
if (!shared) continue;
|
|
330
|
+
scanPathStrings(ctx, note, stripped, i + 1);
|
|
331
|
+
for (const match of stripped.matchAll(INLINE_TAG_RE)) {
|
|
332
|
+
ctx.findings.push({
|
|
333
|
+
kind: "frontmatter-invalid",
|
|
334
|
+
file: note.rel,
|
|
335
|
+
line: i + 1,
|
|
336
|
+
property: "tags",
|
|
337
|
+
value: `#${match[1]}`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Extract the wiki and relative markdown links on one body line.
|
|
345
|
+
* @param {string} line
|
|
346
|
+
* @param {number} lineNo
|
|
347
|
+
* @param {object[]} links - The accumulator.
|
|
348
|
+
* @returns {string} The line with every link span blanked.
|
|
349
|
+
*/
|
|
350
|
+
function extractLineLinks(line, lineNo, links) {
|
|
351
|
+
let stripped = line;
|
|
352
|
+
for (const match of line.matchAll(WIKI_LINK_RE)) {
|
|
353
|
+
links.push({ line: lineNo, target: wikiTarget(match[1]) });
|
|
354
|
+
stripped = stripped.replace(match[0], " ".repeat(match[0].length));
|
|
355
|
+
}
|
|
356
|
+
for (const match of stripped.matchAll(MD_LINK_RE)) {
|
|
357
|
+
const target = mdTarget(match[1]);
|
|
358
|
+
if (target === null) continue;
|
|
359
|
+
links.push({ line: lineNo, target, relative: true });
|
|
360
|
+
stripped = stripped.replace(match[0], " ".repeat(match[0].length));
|
|
361
|
+
}
|
|
362
|
+
return stripped;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Normalize a markdown link target. URLs with a scheme and pure anchors are
|
|
367
|
+
* out of scope.
|
|
368
|
+
* @param {string} raw
|
|
369
|
+
* @returns {string|null}
|
|
370
|
+
*/
|
|
371
|
+
function mdTarget(raw) {
|
|
372
|
+
const target = raw.split(/\s+/)[0];
|
|
373
|
+
if (URL_SCHEME_RE.test(target) || target.startsWith("#")) return null;
|
|
374
|
+
try {
|
|
375
|
+
return decodeURIComponent(target);
|
|
376
|
+
} catch {
|
|
377
|
+
return target;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Whether a path-form target names something outside the tier set that
|
|
383
|
+
* exists on disk (a personal surface).
|
|
384
|
+
* @param {Ctx} ctx @param {string} target
|
|
385
|
+
* @returns {Promise<boolean>}
|
|
386
|
+
*/
|
|
387
|
+
async function isPersonalTarget(ctx, target) {
|
|
388
|
+
const first = target.split("/")[0];
|
|
389
|
+
if (!target.includes("/") || ctx.tierByName.has(first)) return false;
|
|
390
|
+
return (await ctx.exists(target)) || (await ctx.exists(`${target}.md`));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Resolve a wiki target from the KB root, then by unique basename.
|
|
395
|
+
* @param {Ctx} ctx @param {string} target
|
|
396
|
+
* @returns {Promise<{rel: string}|{ambiguous: true}|{personal: true}|null>}
|
|
397
|
+
*/
|
|
398
|
+
async function resolveWikiTarget(ctx, target) {
|
|
399
|
+
const hit = ctx.index.get(target) ?? ctx.index.get(`${target}.md`);
|
|
400
|
+
if (hit) return { rel: hit.rel };
|
|
401
|
+
if (await isPersonalTarget(ctx, target)) return { personal: true };
|
|
402
|
+
// The basename map keys notes without their `.md`, so an explicit
|
|
403
|
+
// `[[Note.md]]` strips before the fallback; assets keep their extension.
|
|
404
|
+
const base = target.split("/").pop();
|
|
405
|
+
const key = base.endsWith(".md") ? base.slice(0, -3) : base;
|
|
406
|
+
const matches = ctx.byBase.get(key) ?? [];
|
|
407
|
+
if (matches.length === 1) return { rel: matches[0] };
|
|
408
|
+
return matches.length > 1 ? { ambiguous: true } : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Normalize a relative markdown target against the source directory.
|
|
413
|
+
* @param {string} sourceRel @param {string} target
|
|
414
|
+
* @returns {string} The KB-root-relative path, or "" when it escapes.
|
|
415
|
+
*/
|
|
416
|
+
function resolveRelative(sourceRel, target) {
|
|
417
|
+
const parts = dirname(sourceRel).split("/");
|
|
418
|
+
for (const seg of target.split("/")) {
|
|
419
|
+
if (seg === "" || seg === ".") continue;
|
|
420
|
+
if (seg === "..") {
|
|
421
|
+
if (parts.length === 0) return "";
|
|
422
|
+
parts.pop();
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
parts.push(seg);
|
|
426
|
+
}
|
|
427
|
+
return parts.join("/");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolve one extracted link to an indexed path, pushing the resolution
|
|
432
|
+
* finding when it fails.
|
|
433
|
+
* @param {Ctx} ctx
|
|
434
|
+
* @param {{rel: string, tier: object}} note
|
|
435
|
+
* @param {object} link
|
|
436
|
+
* @param {object} base - The shared finding fields.
|
|
437
|
+
* @returns {Promise<string|null>} The resolved root-relative path.
|
|
438
|
+
*/
|
|
439
|
+
async function resolveLink(ctx, note, link, base) {
|
|
440
|
+
if (!link.relative) {
|
|
441
|
+
const hit = await resolveWikiTarget(ctx, link.target);
|
|
442
|
+
if (hit?.rel) return hit.rel;
|
|
443
|
+
if (hit?.personal) {
|
|
444
|
+
ctx.findings.push({ kind: "narrower-link", ...base, targetTier: null });
|
|
445
|
+
} else if (hit?.ambiguous) {
|
|
446
|
+
ctx.findings.push({ kind: "ambiguous", ...base, targetTier: null });
|
|
447
|
+
} else {
|
|
448
|
+
ctx.findings.push({ kind: "unresolved", ...base, targetTier: null });
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const rel = resolveRelative(note.rel, link.target);
|
|
453
|
+
if (rel && ctx.index.has(rel)) return rel;
|
|
454
|
+
const outside = rel && !ctx.tierByName.has(rel.split("/")[0]);
|
|
455
|
+
const kind =
|
|
456
|
+
outside && (await ctx.exists(rel)) ? "narrower-link" : "unresolved";
|
|
457
|
+
ctx.findings.push({ kind, ...base, targetTier: null });
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Whether two paths sit inside one entity subdirectory
|
|
463
|
+
* (`<tier>/<entity>/...`), the folder-atomic unit that moves whole.
|
|
464
|
+
* @param {string} a @param {string} b
|
|
465
|
+
* @returns {boolean}
|
|
466
|
+
*/
|
|
467
|
+
function sameEntityDir(a, b) {
|
|
468
|
+
const [aTier, aEntity, ...aRest] = a.split("/");
|
|
469
|
+
const [bTier, bEntity, ...bRest] = b.split("/");
|
|
470
|
+
return (
|
|
471
|
+
aRest.length > 0 &&
|
|
472
|
+
bRest.length > 0 &&
|
|
473
|
+
aTier === bTier &&
|
|
474
|
+
aEntity === bEntity
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Apply the format contract in a shared tier: a wiki link must be
|
|
480
|
+
* tier-prefixed and vault-absolute, with no exemption. Only a relative
|
|
481
|
+
* markdown link whose source and resolved target share one entity
|
|
482
|
+
* subdirectory is exempt, so folder-atomic units move as single units.
|
|
483
|
+
* @param {Ctx} ctx
|
|
484
|
+
* @param {{rel: string, tier: object}} note
|
|
485
|
+
* @param {object} link
|
|
486
|
+
* @param {string} resolvedRel
|
|
487
|
+
* @param {{name: string}} targetTier
|
|
488
|
+
* @param {object} base - The shared finding fields.
|
|
489
|
+
* @returns {void}
|
|
490
|
+
*/
|
|
491
|
+
function checkLinkFormat(ctx, note, link, resolvedRel, targetTier, base) {
|
|
492
|
+
if (note.tier.rank < 1) return;
|
|
493
|
+
if (!link.relative && link.target.split("/")[0] === targetTier.name) return;
|
|
494
|
+
if (link.relative && sameEntityDir(note.rel, resolvedRel)) return;
|
|
495
|
+
ctx.findings.push({
|
|
496
|
+
kind: "bare-basename",
|
|
497
|
+
...base,
|
|
498
|
+
targetTier: targetTier.name,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Passes 5–7 for one link: resolution, legality, format.
|
|
504
|
+
* @param {Ctx} ctx
|
|
505
|
+
* @param {{rel: string, tier: object}} note
|
|
506
|
+
* @param {object} link
|
|
507
|
+
* @returns {Promise<void>}
|
|
508
|
+
*/
|
|
509
|
+
async function checkLink(ctx, note, link) {
|
|
510
|
+
const base = {
|
|
511
|
+
file: note.rel,
|
|
512
|
+
line: link.line,
|
|
513
|
+
link: link.target,
|
|
514
|
+
sourceTier: note.tier.name,
|
|
515
|
+
};
|
|
516
|
+
const resolvedRel = await resolveLink(ctx, note, link, base);
|
|
517
|
+
if (resolvedRel === null) return;
|
|
518
|
+
const targetTier = ctx.index.get(resolvedRel).tier;
|
|
519
|
+
if (targetTier.rank < note.tier.rank) {
|
|
520
|
+
ctx.findings.push({
|
|
521
|
+
kind: "narrower-link",
|
|
522
|
+
...base,
|
|
523
|
+
targetTier: targetTier.name,
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
checkLinkFormat(ctx, note, link, resolvedRel, targetTier, base);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Push one frontmatter finding.
|
|
532
|
+
* @param {Ctx} ctx @param {string} kind @param {string} rel
|
|
533
|
+
* @param {string} property @param {string|null} value
|
|
534
|
+
* @returns {void}
|
|
535
|
+
*/
|
|
536
|
+
function fmFinding(ctx, kind, rel, property, value) {
|
|
537
|
+
ctx.findings.push({ kind, file: rel, line: 1, property, value });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Core keys and validator-decidable conditional triggers.
|
|
542
|
+
* @param {Ctx} ctx @param {{rel: string, tier: object}} note @param {object} fm
|
|
543
|
+
* @returns {void}
|
|
544
|
+
*/
|
|
545
|
+
function checkRequiredKeys(ctx, note, fm) {
|
|
546
|
+
for (const key of CORE_KEYS) {
|
|
547
|
+
if (fm[key] === undefined) {
|
|
548
|
+
fmFinding(ctx, "frontmatter-missing", note.rel, key, null);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (ALIAS_TYPES.includes(fm.type) && fm.aliases === undefined) {
|
|
552
|
+
fmFinding(ctx, "frontmatter-missing", note.rel, "aliases", null);
|
|
553
|
+
}
|
|
554
|
+
if (STATUS_TYPES.includes(fm.type) && fm.status === undefined) {
|
|
555
|
+
fmFinding(ctx, "frontmatter-missing", note.rel, "status", null);
|
|
556
|
+
}
|
|
557
|
+
if (note.tier.rank === 4 && fm.verified === undefined) {
|
|
558
|
+
fmFinding(ctx, "frontmatter-missing", note.rel, "verified", null);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Serialization contract: a flat block and ISO dates.
|
|
564
|
+
* @param {Ctx} ctx @param {{rel: string}} note @param {object} fm
|
|
565
|
+
* @returns {void}
|
|
566
|
+
*/
|
|
567
|
+
function checkSerialization(ctx, note, fm) {
|
|
568
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
569
|
+
const flat =
|
|
570
|
+
isScalar(value) || (Array.isArray(value) && value.every(isScalar));
|
|
571
|
+
if (!flat) {
|
|
572
|
+
fmFinding(ctx, "frontmatter-invalid", note.rel, key, String(value));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
for (const key of DATE_KEYS) {
|
|
576
|
+
const value = fm[key];
|
|
577
|
+
if (typeof value === "string" && !ISO_DATE_RE.test(value)) {
|
|
578
|
+
fmFinding(ctx, "frontmatter-invalid", note.rel, key, value);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Registry-dependent vocabulary checks: type, status, tags, and tag tier
|
|
585
|
+
* bounds. Skipped entirely when no registry file is present.
|
|
586
|
+
* @param {Ctx} ctx @param {{rel: string, tier: object}} note @param {object} fm
|
|
587
|
+
* @returns {void}
|
|
588
|
+
*/
|
|
589
|
+
function checkVocabulary(ctx, note, fm) {
|
|
590
|
+
if (typeof fm.type === "string" && !ctx.typeVocab.has(fm.type)) {
|
|
591
|
+
fmFinding(ctx, "frontmatter-vocabulary", note.rel, "type", fm.type);
|
|
592
|
+
}
|
|
593
|
+
const statusVocab = ctx.registry.status?.[fm.type];
|
|
594
|
+
if (statusVocab && fm.status !== undefined) {
|
|
595
|
+
if (!statusVocab.includes(fm.status)) {
|
|
596
|
+
fmFinding(
|
|
597
|
+
ctx,
|
|
598
|
+
"frontmatter-vocabulary",
|
|
599
|
+
note.rel,
|
|
600
|
+
"status",
|
|
601
|
+
String(fm.status),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const tags = Array.isArray(fm.tags) ? fm.tags : [fm.tags].filter(Boolean);
|
|
606
|
+
for (const tag of tags) {
|
|
607
|
+
const row = ctx.tagRows.get(tag);
|
|
608
|
+
if (!row || note.tier.rank > row.bound) {
|
|
609
|
+
fmFinding(ctx, "frontmatter-vocabulary", note.rel, "tags", String(tag));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Pass 8 for one shared-tier note. A note whose block failed to parse never
|
|
616
|
+
* reaches this pass — the parse failure is already one finding, and the
|
|
617
|
+
* missing-key findings would only restate the same root cause.
|
|
618
|
+
* @param {Ctx} ctx
|
|
619
|
+
* @param {{rel: string, tier: object}} note
|
|
620
|
+
* @param {object|undefined} fm - Parsed frontmatter; undefined means none.
|
|
621
|
+
* @returns {void}
|
|
622
|
+
*/
|
|
623
|
+
function checkNoteFrontmatter(ctx, note, fm) {
|
|
624
|
+
if (fm === undefined) {
|
|
625
|
+
for (const key of CORE_KEYS) {
|
|
626
|
+
fmFinding(ctx, "frontmatter-missing", note.rel, key, null);
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
checkRequiredKeys(ctx, note, fm);
|
|
631
|
+
checkSerialization(ctx, note, fm);
|
|
632
|
+
if (ctx.registry) checkVocabulary(ctx, note, fm);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Overlay declarations: a cross-tier duplicate basename inside the same
|
|
637
|
+
* entity-subdirectory name marks the narrower note as an overlay, and an
|
|
638
|
+
* overlay declares itself through `canonical`.
|
|
639
|
+
* @param {Ctx} ctx
|
|
640
|
+
* @param {{rel: string, tier: object}[]} notes
|
|
641
|
+
* @param {Map<string, object|null>} frontmatterByRel
|
|
642
|
+
* @returns {void}
|
|
643
|
+
*/
|
|
644
|
+
function checkOverlays(ctx, notes, frontmatterByRel) {
|
|
645
|
+
const families = new Map();
|
|
646
|
+
for (const note of notes) {
|
|
647
|
+
const segments = note.rel.split("/");
|
|
648
|
+
if (note.tier.rank < 1 || segments.length < 3) continue;
|
|
649
|
+
const key = `${segments[1]}/${segments[segments.length - 1]}`;
|
|
650
|
+
if (!families.has(key)) families.set(key, []);
|
|
651
|
+
families.get(key).push(note);
|
|
652
|
+
}
|
|
653
|
+
for (const family of families.values()) {
|
|
654
|
+
const widest = Math.max(...family.map((n) => n.tier.rank));
|
|
655
|
+
for (const note of family) {
|
|
656
|
+
const fm = frontmatterByRel.get(note.rel);
|
|
657
|
+
if (note.tier.rank < widest && fm?.canonical === undefined) {
|
|
658
|
+
fmFinding(ctx, "overlay-undeclared", note.rel, "canonical", null);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Validate one knowledge base.
|
|
666
|
+
* @param {string} kbRoot - Absolute path to the KB root (the vault).
|
|
667
|
+
* @param {{fs: object}} runtime - Injected async fs surface.
|
|
668
|
+
* @returns {Promise<{findings: Finding[], tierCount: number}>}
|
|
669
|
+
*/
|
|
670
|
+
export async function validateKnowledgeBase(kbRoot, runtime) {
|
|
671
|
+
const { fs } = runtime;
|
|
672
|
+
/** @type {Ctx} */
|
|
673
|
+
const ctx = {
|
|
674
|
+
fs,
|
|
675
|
+
kbRoot,
|
|
676
|
+
findings: [],
|
|
677
|
+
tiers: [],
|
|
678
|
+
tierByName: new Map(),
|
|
679
|
+
personalNames: [],
|
|
680
|
+
index: new Map(),
|
|
681
|
+
byBase: new Map(),
|
|
682
|
+
registry: null,
|
|
683
|
+
typeVocab: null,
|
|
684
|
+
tagRows: new Map(),
|
|
685
|
+
exists: (rel) =>
|
|
686
|
+
fs.access(join(kbRoot, rel)).then(
|
|
687
|
+
() => true,
|
|
688
|
+
() => false,
|
|
689
|
+
),
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const rootDirs = await collectTiers(ctx);
|
|
693
|
+
ctx.tierByName = new Map(ctx.tiers.map((t) => [t.name, t]));
|
|
694
|
+
detectLegacy(ctx, rootDirs);
|
|
695
|
+
for (const tier of ctx.tiers) await walkTier(ctx, tier.name, tier);
|
|
696
|
+
await loadRegistry(ctx);
|
|
697
|
+
await checkNotes(ctx);
|
|
698
|
+
await applyBaseline(ctx);
|
|
699
|
+
return { findings: ctx.findings, tierCount: ctx.tiers.length };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Read the vault-local registry when present. Vocabulary checks stay off
|
|
704
|
+
* without it, so recipient suffixes still validate.
|
|
705
|
+
* @param {Ctx} ctx
|
|
706
|
+
* @returns {Promise<void>}
|
|
707
|
+
*/
|
|
708
|
+
async function loadRegistry(ctx) {
|
|
709
|
+
if (!(await ctx.exists(REGISTRY_FILE))) return;
|
|
710
|
+
const parsed = parseYaml(
|
|
711
|
+
await ctx.fs.readFile(join(ctx.kbRoot, REGISTRY_FILE), "utf8"),
|
|
712
|
+
);
|
|
713
|
+
// An empty or comment-only registry parses to null; treat it as absent so
|
|
714
|
+
// the registry-dependent checks skip instead of crashing.
|
|
715
|
+
if (!parsed) return;
|
|
716
|
+
ctx.registry = parsed;
|
|
717
|
+
ctx.typeVocab = new Set([
|
|
718
|
+
...Object.values(ctx.registry.types ?? {}),
|
|
719
|
+
...Object.values(ctx.registry.reserved ?? {}),
|
|
720
|
+
]);
|
|
721
|
+
ctx.tagRows = new Map((ctx.registry.tags ?? []).map((r) => [r.tag, r]));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Passes 4–8: extract, resolve, and check every indexed note.
|
|
726
|
+
* @param {Ctx} ctx
|
|
727
|
+
* @returns {Promise<void>}
|
|
728
|
+
*/
|
|
729
|
+
async function checkNotes(ctx) {
|
|
730
|
+
const notes = [...ctx.index.values()].filter((f) => f.rel.endsWith(".md"));
|
|
731
|
+
const frontmatterByRel = new Map();
|
|
732
|
+
for (const note of notes) {
|
|
733
|
+
const shared = note.tier.rank >= 1;
|
|
734
|
+
const text = await ctx.fs.readFile(join(ctx.kbRoot, note.rel), "utf8");
|
|
735
|
+
const lines = text.split("\n");
|
|
736
|
+
const { blockLines, bodyStart } = splitFrontmatter(lines);
|
|
737
|
+
const links = [];
|
|
738
|
+
const fm = blockLines
|
|
739
|
+
? extractFrontmatter(ctx, note, blockLines, shared, links)
|
|
740
|
+
: undefined;
|
|
741
|
+
frontmatterByRel.set(note.rel, fm);
|
|
742
|
+
extractBody(ctx, note, lines, bodyStart, shared, links);
|
|
743
|
+
for (const link of links) await checkLink(ctx, note, link);
|
|
744
|
+
if (shared && fm !== null) checkNoteFrontmatter(ctx, note, fm);
|
|
745
|
+
}
|
|
746
|
+
checkOverlays(ctx, notes, frontmatterByRel);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Pass 9: mark findings the vault-local baseline grandfathers.
|
|
751
|
+
* @param {Ctx} ctx
|
|
752
|
+
* @returns {Promise<void>}
|
|
753
|
+
*/
|
|
754
|
+
async function applyBaseline(ctx) {
|
|
755
|
+
const baseline = (await ctx.exists(BASELINE_FILE))
|
|
756
|
+
? JSON.parse(await ctx.fs.readFile(join(ctx.kbRoot, BASELINE_FILE), "utf8"))
|
|
757
|
+
: null;
|
|
758
|
+
const baselined = new Set((baseline?.findings ?? []).map(baselineKey));
|
|
759
|
+
for (const finding of ctx.findings) {
|
|
760
|
+
finding.baselined = baselined.has(baselineKey(finding));
|
|
761
|
+
}
|
|
762
|
+
}
|