@staix/vault-mem 0.0.0-bootstrap.0 → 0.1.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.
@@ -0,0 +1,369 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import type { AnalysisSnapshot } from "./analysis-types.ts";
6
+ import type { VaultSpec } from "./config.ts";
7
+ import { parseFrontmatter } from "./frontmatter.ts";
8
+ import { normalizeTarget, noteKey, proseLines, resolveLinks, type LinkNote, type ResolvedLink } from "./links.ts";
9
+
10
+ export const MAX_NOTE_BYTES = 2 * 1024 * 1024;
11
+ export const MAX_RESULTS = 500;
12
+ /** Bump when link resolution changes, so caches written by an older version recompute every link. 2 = path-only. */
13
+ const LINK_RESOLUTION = 2;
14
+ export const MAX_READ_BYTES = 64 * 1024;
15
+ export interface IndexedNote extends LinkNote { hash: string; description: string; type: string; domain: string }
16
+ export interface NoteReadResult {
17
+ path: string;
18
+ hash: string;
19
+ content: string;
20
+ /** Truncation applies to the requested whole note or selected section. */
21
+ truncated: boolean;
22
+ section?: string;
23
+ /** Byte offset within the selected section, or the whole note. Present when explicitly supplied. */
24
+ offset?: number;
25
+ /** Byte offset of the first unreturned character, in the same range. */
26
+ nextOffset?: number;
27
+ /** Real H2 names from the note body; included when content is truncated. */
28
+ headings?: string[];
29
+ headingsTruncated?: boolean;
30
+ }
31
+ export interface SearchOptions { limit?: number; type?: string; domain?: string }
32
+ export interface SearchResult { path: string; title: string; description: string; type: string; domain: string; score: number; snippet: string }
33
+ interface StoredNote extends IndexedNote { metadata: string; source: string }
34
+ function digest(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); }
35
+ function within(root: string, target: string): boolean {
36
+ const rel = relative(root, target);
37
+ return rel === "" || (!rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && rel !== ".." && !isAbsolute(rel));
38
+ }
39
+ function canonicalLocation(location: string): string {
40
+ try { return realpathSync(location); }
41
+ catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
43
+ const parent = dirname(location);
44
+ if (parent === location) throw error;
45
+ return join(canonicalLocation(parent), relative(parent, location));
46
+ }
47
+ }
48
+ export function resultLimit(value = 50): number {
49
+ if (!Number.isInteger(value) || value < 1 || value > MAX_RESULTS) throw new Error(`limit: expected integer 1..${MAX_RESULTS}`);
50
+ return value;
51
+ }
52
+ function text(value: unknown): string { return typeof value === "string" ? value : ""; }
53
+ function aliases(value: unknown): string[] {
54
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : typeof value === "string" ? [value] : [];
55
+ }
56
+ function metadata(stat: NonNullable<ReturnType<typeof lstatSync>>): string {
57
+ return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
58
+ }
59
+ function boundedText(value: string, bytes: number): { content: string; truncated: boolean } {
60
+ const buffer = Buffer.from(value);
61
+ if (buffer.length <= bytes) return { content: value, truncated: false };
62
+ let end = bytes;
63
+ while (end > 0 && (buffer[end]! & 0xc0) === 0x80) end--;
64
+ return { content: buffer.subarray(0, end).toString("utf8"), truncated: true };
65
+ }
66
+ function readPage(prefix: Buffer, maxBytes: number, offset: number, totalBytes: number): Pick<NoteReadResult, "content" | "truncated" | "nextOffset"> {
67
+ if (offset > totalBytes) throw new Error("offset: exceeds requested note or section");
68
+ if (prefix.length && (prefix[0]! & 0xc0) === 0x80) throw new Error("offset: must be a UTF-8 character boundary");
69
+ let end = Math.min(maxBytes, prefix.length);
70
+ while (end > 0 && end < prefix.length && (prefix[end]! & 0xc0) === 0x80) end--;
71
+ if (!end && offset < totalBytes) throw new Error("max-bytes: cannot fit the next UTF-8 character");
72
+ const truncated = offset + end < totalBytes;
73
+ return { content: prefix.subarray(0, end).toString("utf8"), truncated, ...(truncated ? { nextOffset: offset + end } : {}) };
74
+ }
75
+ function h2Sections(body: string): { name: string; start: number; end: number }[] {
76
+ const visible = proseLines(body), lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
77
+ const sections: { name: string; start: number; end: number }[] = [];
78
+ let offset = 0, active: number | undefined;
79
+ for (const [index, line] of lines.entries()) {
80
+ const heading = /^ {0,3}##(?:[ \t]+(.*)|[ \t]*)$/.exec(visible[index] ?? "");
81
+ if (heading) {
82
+ if (active !== undefined) sections[active]!.end = offset;
83
+ sections.push({ name: (heading[1] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim(), start: offset, end: body.length });
84
+ active = sections.length - 1;
85
+ } else if (/^ {0,3}#(?:[ \t]+|[ \t]*$)/.test(visible[index] ?? "") && active !== undefined) {
86
+ sections[active]!.end = offset; active = undefined;
87
+ }
88
+ offset += line.length;
89
+ }
90
+ return sections;
91
+ }
92
+ function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "headings" | "headingsTruncated"> {
93
+ const headings: string[] = [];
94
+ let bytes = 2, truncated = false;
95
+ for (const { name } of sections) {
96
+ const size = Buffer.byteLength(JSON.stringify(name)) + 1;
97
+ if (headings.length >= 64 || Buffer.byteLength(name) > 1024 || bytes + size > 8192) { truncated = true; continue; }
98
+ headings.push(name); bytes += size;
99
+ }
100
+ return { headings, ...(truncated ? { headingsTruncated: true } : {}) };
101
+ }
102
+ /** Derived cache only. No vault or application configuration files are written. */
103
+ export class VaultIndex {
104
+ private readonly db: Database;
105
+ readonly file: string;
106
+ private readonly root: string;
107
+ constructor(home: string, readonly vault: VaultSpec, vaultRoots: string[] = [vault.path], options: { memory?: boolean } = {}) {
108
+ this.root = realpathSync(vault.path);
109
+ if (this.root !== vault.path) throw new Error(`vault ${vault.id}: registered path changed`);
110
+ const notes = join(this.root, "notes");
111
+ if (realpathSync(notes) !== notes || !lstatSync(notes).isDirectory()) throw new Error(`vault ${vault.id}: notes must be a plain directory`);
112
+ const cache = resolve(home, "indexes");
113
+ const canonical = canonicalLocation(cache);
114
+ for (const root of vaultRoots) {
115
+ if (within(canonicalLocation(resolve(root)), canonical)) throw new Error("index: VMEM_HOME cache must be outside every vault");
116
+ }
117
+ // Canonical home resolution is allowed, but a linked cache directory/file is not.
118
+ if (!options.memory) {
119
+ mkdirSync(cache, { recursive: true, mode: 0o700 });
120
+ if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
121
+ this.file = join(cache, `${digest(this.root)}.sqlite`);
122
+ try {
123
+ const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
124
+ closeSync(fd);
125
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
126
+ if (!lstatSync(this.file).isFile() || lstatSync(this.file).isSymbolicLink()) throw new Error("index: expected plain database file");
127
+ } else this.file = ":memory:";
128
+ this.db = new Database(this.file);
129
+ this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
130
+ this.db.run(`CREATE TABLE IF NOT EXISTS notes (
131
+ path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
132
+ title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
133
+ type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
134
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
135
+ CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
136
+ CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
137
+ // Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
138
+ if (options.memory) {
139
+ const file = join(cache, `${digest(this.root)}.sqlite`);
140
+ let exists = false;
141
+ try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("index: expected plain database file"); exists = true; }
142
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
143
+ if (exists) {
144
+ if (realpathSync(cache) !== cache) throw new Error("index: linked cache directory");
145
+ const disk = new Database(file, { readonly: true });
146
+ try {
147
+ disk.transaction(() => {
148
+ const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM notes").get()!;
149
+ if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
150
+ for (const row of disk.query<Omit<StoredNote,"aliases"> & {aliases:string}, []>("SELECT * FROM notes").all()) {
151
+ this.db.query("INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?)").run(row.path,row.hash,row.metadata,row.title,row.description,row.body,row.type,row.domain,row.aliases,row.source);
152
+ this.db.query("INSERT INTO fts VALUES (?,?,?,?)").run(row.path,row.title,row.description,row.body);
153
+ }
154
+ for (const row of disk.query<{source:string;ordinal:number;data:string}, []>("SELECT * FROM links").all()) this.db.query("INSERT INTO links VALUES (?,?,?)").run(row.source,row.ordinal,row.data);
155
+ this.db.run(`PRAGMA user_version = ${Number(disk.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0)}`);
156
+ const exists = disk.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='analysis_notes'").get();
157
+ if (exists) {
158
+ const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM analysis_notes").get()!;
159
+ if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
160
+ for (const row of disk.query<{path:string;source:string;hash:string;metadata:string}, []>("SELECT * FROM analysis_notes").all()) this.db.query("INSERT INTO analysis_notes VALUES (?,?,?,?)").run(row.path,row.source,row.hash,row.metadata);
161
+ }
162
+ })();
163
+ } finally { disk.close(); }
164
+ }
165
+ }
166
+ }
167
+ close(): void { this.db.close(); }
168
+ private stored(): LinkNote[] {
169
+ return this.db.query<Omit<LinkNote, "aliases"> & { aliases: string }, []>("SELECT path,title,aliases,body FROM notes ORDER BY path").all().map(row => ({ ...row, aliases: JSON.parse(row.aliases) as string[] }));
170
+ }
171
+ /** Scan metadata each call; hash only new or metadata-changed files. Atomic cache update. */
172
+ scan(rebuild = false, options: { skipInvalidFrontmatter?: boolean; captureInvalidFrontmatter?: boolean } = {}): { notes: number; changed: number; removed: number; skipped?: string[] } {
173
+ return this.db.transaction(() => {
174
+ const notesDir = join(this.root, "notes");
175
+ if (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory()) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
176
+ const previous = new Map(this.db.query<{ path: string; hash: string; metadata: string }, []>(options.captureInvalidFrontmatter ? "SELECT path,hash,metadata FROM notes UNION SELECT path,hash,metadata FROM analysis_notes" : "SELECT path,hash,metadata FROM notes").all().map(note => [note.path, note]));
177
+ if (rebuild) this.db.run("DELETE FROM fts");
178
+ const live = new Set<string>(), present = new Set<string>();
179
+ let changed = 0, analysisBytes = 0;
180
+ const skipped: string[] = [], touched = new Set<string>(), keys = new Set<string>();
181
+ // Links resolve by path only, so other notes re-resolve only when they target a touched path.
182
+ const touch = (path: string) => { touched.add(path); keys.add(noteKey(path)); };
183
+ for (const entry of readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
184
+ if (!entry.name.endsWith(".md") || !entry.isFile()) continue;
185
+ const path = entry.name, file = join(notesDir, path), before = lstatSync(file);
186
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(`vault ${this.vault.id}: note path changed`);
187
+ if (before.size > MAX_NOTE_BYTES) throw new Error(`vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
188
+ live.add(path); present.add(path);
189
+ if (options.captureInvalidFrontmatter) {
190
+ analysisBytes += before.size;
191
+ if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error("analysis: snapshot exceeds bound");
192
+ }
193
+ const old = previous.get(path), stamp = metadata(before);
194
+ if (!rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
195
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
196
+ let buffer: Buffer;
197
+ try {
198
+ const stat = fstatSync(fd);
199
+ if (!stat.isFile() || stat.size > MAX_NOTE_BYTES || metadata(stat) !== stamp) throw new Error(`vault ${this.vault.id}, ${path}: note changed during scan`);
200
+ // Fixed bound even if a concurrent writer grows the file.
201
+ buffer = Buffer.alloc(MAX_NOTE_BYTES + 1);
202
+ let size = 0;
203
+ for (;;) {
204
+ const count = readSync(fd, buffer, size, buffer.length - size, null);
205
+ size += count;
206
+ if (!count || size === buffer.length) break;
207
+ }
208
+ buffer = buffer.subarray(0, size);
209
+ if (size > MAX_NOTE_BYTES || metadata(fstatSync(fd)) !== stamp) throw new Error(`vault ${this.vault.id}, ${path}: note changed during scan`);
210
+ } finally { closeSync(fd); }
211
+ const hash = digest(buffer);
212
+ this.db.query("INSERT OR REPLACE INTO analysis_notes VALUES (?,?,?,?)").run(path, buffer.toString("utf8"), hash, stamp);
213
+ if (!rebuild && old?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
214
+ let document: ReturnType<typeof parseFrontmatter>;
215
+ try { document = parseFrontmatter(buffer.toString("utf8")); }
216
+ catch {
217
+ if (!options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
218
+ live.delete(path); skipped.push(path); continue;
219
+ }
220
+ const { data, body } = document;
221
+ touch(path);
222
+ this.db.query("INSERT OR REPLACE INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(path, hash, stamp,
223
+ text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)), buffer.toString("utf8"));
224
+ this.db.query("DELETE FROM fts WHERE path=?").run(path);
225
+ this.db.query("INSERT INTO fts(path,title,description,body) SELECT path,title,description,body FROM notes WHERE path=?").run(path);
226
+ changed++;
227
+ }
228
+ for (const row of this.db.query<{path:string}, []>("SELECT path FROM analysis_notes").all()) if (!present.has(row.path)) this.db.query("DELETE FROM analysis_notes WHERE path=?").run(row.path);
229
+ let removed = 0;
230
+ for (const path of previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
231
+ const stale = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version !== LINK_RESOLUTION;
232
+ if (rebuild || stale) this.db.run("DELETE FROM links");
233
+ else if (changed || removed) {
234
+ for (const row of this.db.query<{ source: string; data: string }, []>("SELECT source,data FROM links").all()) {
235
+ if (!touched.has(row.source) && keys.has(noteKey((JSON.parse(row.data) as ResolvedLink).target))) touched.add(row.source);
236
+ }
237
+ const remove = this.db.query("DELETE FROM links WHERE source=?");
238
+ for (const source of touched) remove.run(source);
239
+ }
240
+ if (changed || removed || rebuild || stale) {
241
+ // Ordinals count per source, so a partial refresh orders exactly like a rebuild.
242
+ const insert = this.db.query("INSERT INTO links VALUES (?, ?, ?)"), next = new Map<string, number>();
243
+ for (const link of resolveLinks(this.stored(), undefined, rebuild || stale ? undefined : touched)) {
244
+ const ordinal = next.get(link.source) ?? 0;
245
+ next.set(link.source, ordinal + 1); insert.run(link.source, ordinal, JSON.stringify(link));
246
+ }
247
+ }
248
+ if (stale) this.db.run(`PRAGMA user_version = ${LINK_RESOLUTION}`);
249
+ return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
250
+ }).immediate();
251
+ }
252
+ /** Consume cached note sources only. Auxiliary inputs are captured separately by analysisInput. */
253
+ analysisSnapshot(inputs: Omit<AnalysisSnapshot, "notes">): AnalysisSnapshot {
254
+ const size = this.db.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM analysis_notes").get()!;
255
+ if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: snapshot exceeds bound");
256
+ const notes = this.db.query<{path: string; source: string}, []>("SELECT path,source FROM analysis_notes ORDER BY path").all();
257
+ return { ...inputs, notes };
258
+ }
259
+ search(query: string, options: SearchOptions = {}): SearchResult[] {
260
+ const limit = resultLimit(options.limit);
261
+ if (!query.trim() || Buffer.byteLength(query) > 1024) throw new Error("search: query must contain 1..1024 bytes");
262
+ // Literal AND terms: users cannot inject FTS operators, columns or SQL.
263
+ const terms = query.trim().split(/\s+/).map(term => `"${term.replaceAll('"', '""')}"`).join(" AND ");
264
+ return this.db.query<SearchResult, [string, string | null, string | null, string | null, string | null, number]>(`SELECT n.path, n.title, substr(n.description,1,512) AS description, n.type, n.domain,
265
+ bm25(fts,0,5,2,1) AS score, snippet(fts,3,'','',' ... ',32) AS snippet FROM fts JOIN notes n ON n.path=fts.path
266
+ WHERE fts MATCH ? AND (? IS NULL OR n.type=?) AND (? IS NULL OR n.domain=?) ORDER BY score,n.path LIMIT ?`).all(terms, options.type ?? null, options.type ?? null, options.domain ?? null, options.domain ?? null, limit)
267
+ .map(row => ({ ...row, title: boundedText(row.title, 512).content, snippet: boundedText(row.snippet, 1024).content, type: boundedText(row.type, 256).content, domain: boundedText(row.domain, 256).content }));
268
+ }
269
+ private find(target: string): string {
270
+ if (!target || Buffer.byteLength(target) > 1024) throw new Error("note: expected target <=1024 bytes");
271
+ const key = normalizeTarget(target.replace(/^notes\//i, "").replace(/\.md$/i, ""));
272
+ // Stream only lookup metadata, never source/body; retain at most two candidates.
273
+ // SQLite's lower() does not implement NFC/Unicode case folding, so match in JS.
274
+ const notes: { path: string }[] = [];
275
+ const lookup = this.db.prepare<{ path: string; aliases: string }, []>("SELECT path,aliases FROM notes");
276
+ try {
277
+ for (const note of lookup.iterate()) {
278
+ if (normalizeTarget(note.path.slice(0, -3)) === key || (JSON.parse(note.aliases) as string[]).some(alias => normalizeTarget(alias) === key)) notes.push({ path: note.path });
279
+ if (notes.length === 2) break;
280
+ }
281
+ } finally { lookup.finalize(); }
282
+ if (notes.length !== 1) throw new Error(notes.length ? "note: ambiguous target" : "note: unknown target");
283
+ return notes[0]!.path;
284
+ }
285
+ show(target: string, maxBytes = 16384, section?: string, offset?: number): NoteReadResult {
286
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES) throw new Error(`max-bytes: expected integer 1..${MAX_READ_BYTES}`);
287
+ if (section !== undefined && (typeof section !== "string" || !section.trim() || /[\r\n]/.test(section) || Buffer.byteLength(section) > 1024)) throw new Error("section: expected one exact H2 heading <=1024 bytes");
288
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_NOTE_BYTES)) throw new Error(`offset: expected integer 0..${MAX_NOTE_BYTES}`);
289
+ const start = offset ?? 0, path = this.find(target);
290
+ const note = this.db.query<{ hash: string; prefix: Uint8Array; bytes: number }, [number, number, string]>(
291
+ "SELECT hash,substr(CAST(source AS BLOB),?,?) AS prefix,length(CAST(source AS BLOB)) AS bytes FROM notes WHERE path=?"
292
+ ).get(section === undefined ? start + 1 : 1, maxBytes + 4, path)!;
293
+ if (note.bytes > MAX_NOTE_BYTES) throw new Error("note: indexed source exceeds read bound");
294
+ const metadata = { ...(section !== undefined ? { section } : {}), ...(offset !== undefined ? { offset } : {}) };
295
+ if (section === undefined) {
296
+ const page = readPage(Buffer.from(note.prefix), maxBytes, start, note.bytes);
297
+ if (!page.truncated) return { path, hash: note.hash, ...page, ...metadata };
298
+ }
299
+ // Only the derived source is loaded; section discovery never rescans a note file.
300
+ const source = this.db.query<{ source: string }, [string]>("SELECT source FROM notes WHERE path=?").get(path)!.source;
301
+ const body = parseFrontmatter(source).body, sections = h2Sections(body);
302
+ if (section !== undefined) {
303
+ const found = sections.filter(item => item.name === section);
304
+ if (found.length !== 1) throw new Error(found.length ? "section: ambiguous H2 heading" : "section: unknown H2 heading");
305
+ const selected = Buffer.from(body.slice(found[0]!.start, found[0]!.end));
306
+ const page = readPage(selected.subarray(start, start + maxBytes + 4), maxBytes, start, selected.length);
307
+ return { path, hash: note.hash, ...page, ...metadata, ...(page.truncated ? headingMetadata(sections) : {}) };
308
+ }
309
+ return { path, hash: note.hash, ...readPage(Buffer.from(note.prefix), maxBytes, start, note.bytes), ...metadata, ...headingMetadata(sections) };
310
+ }
311
+ /** Indexed notes (paths relative to notes/) ordered by path, optionally filtered by a literal stem prefix. */
312
+ list(prefix = "", limit = 50): { path: string; title: string; type: string; domain: string; description: string }[] {
313
+ if (Buffer.byteLength(prefix) > 1024) throw new Error("prefix: expected <=1024 bytes");
314
+ const key = prefix.replace(/^notes\//i, "");
315
+ return this.db.query<{ path: string; title: string; type: string; domain: string; description: string }, [number, string, number]>(
316
+ "SELECT path,title,type,domain,substr(description,1,512) AS description FROM notes WHERE substr(path,1,?)=? ORDER BY path LIMIT ?"
317
+ ).all(key.length, key, resultLimit(limit)).map(row => ({ ...row, title: boundedText(row.title, 512).content, type: boundedText(row.type, 256).content, domain: boundedText(row.domain, 256).content, description: boundedText(row.description, 512).content }));
318
+ }
319
+ /** Links that resolve to the target note, ordered by source. */
320
+ backlinks(target: string, limit = 50): ResolvedLink[] {
321
+ const path = this.find(target), max = resultLimit(limit), out: ResolvedLink[] = [];
322
+ const rows = this.db.prepare<{ data: string }, []>("SELECT data FROM links ORDER BY source,ordinal");
323
+ try {
324
+ for (const row of rows.iterate()) {
325
+ const link = JSON.parse(row.data) as ResolvedLink;
326
+ if (link.resolved !== path) continue;
327
+ out.push({ ...link, target: boundedText(link.target, 1024).content, label: link.label === null ? null : boundedText(link.label, 1024).content, heading: null, block: null });
328
+ if (out.length >= max) break;
329
+ }
330
+ } finally { rows.finalize(); }
331
+ return out;
332
+ }
333
+ /** MOC notes with the number of resolved links pointing at each. */
334
+ /** Link keys that name exactly one indexed note path (aliases excluded), as wiki links resolve. */
335
+ linkTargets(): Set<string> {
336
+ const counts = new Map<string, number>();
337
+ for (const row of this.db.query<{ path: string }, []>("SELECT path FROM notes").all()) { const key = noteKey(row.path); counts.set(key, (counts.get(key) ?? 0) + 1); }
338
+ return new Set([...counts].filter(([, count]) => count === 1).map(([key]) => key));
339
+ }
340
+ mocs(limit = 200): { path: string; title: string; description: string; count: number }[] {
341
+ const mocs = this.db.query<{ path: string; title: string; description: string }, [number]>("SELECT path,title,substr(description,1,512) AS description FROM notes WHERE type='moc' ORDER BY path LIMIT ?").all(resultLimit(limit));
342
+ const counts = new Map(mocs.map(moc => [moc.path, 0]));
343
+ const rows = this.db.prepare<{ data: string }, []>("SELECT data FROM links");
344
+ try {
345
+ for (const row of rows.iterate()) {
346
+ const resolved = (JSON.parse(row.data) as ResolvedLink).resolved;
347
+ if (resolved && counts.has(resolved)) counts.set(resolved, counts.get(resolved)! + 1);
348
+ }
349
+ } finally { rows.finalize(); }
350
+ return mocs.map(moc => ({ path: moc.path, title: boundedText(moc.title, 512).content, description: boundedText(moc.description, 512).content, count: counts.get(moc.path) ?? 0 }));
351
+ }
352
+ /** Notes whose frontmatter `promoted_from` equals the value exactly (at most 20). */
353
+ promotedFrom(value: string): string[] {
354
+ if (!/^mem:[a-f0-9]{32}$/.test(value)) throw new Error("promoted_from: expected mem:<32 hex>");
355
+ const out: string[] = [];
356
+ const rows = this.db.prepare<{ path: string; source: string }, [string]>("SELECT path,source FROM notes WHERE instr(source, ?) > 0 ORDER BY path");
357
+ try {
358
+ for (const row of rows.iterate(value)) {
359
+ try { if (parseFrontmatter(row.source).data.promoted_from === value) out.push(row.path); } catch { /* unrelated malformed YAML */ }
360
+ if (out.length >= 20) break;
361
+ }
362
+ } finally { rows.finalize(); }
363
+ return out;
364
+ }
365
+ links(target: string, limit = 50): ResolvedLink[] {
366
+ const path = this.find(target);
367
+ return this.db.query<{ data: string }, [string, number]>("SELECT data FROM links WHERE source=? ORDER BY ordinal LIMIT ?").all(path, resultLimit(limit)).map(row => JSON.parse(row.data) as ResolvedLink).map(link => ({ ...link, target: boundedText(link.target, 1024).content, label: link.label === null ? null : boundedText(link.label, 1024).content, heading: link.heading === null ? null : boundedText(link.heading, 1024).content, block: link.block === null ? null : boundedText(link.block, 1024).content }));
368
+ }
369
+ }
@@ -0,0 +1,135 @@
1
+ import { parseAnalysisYaml, scalarText, yamlTruthy, AnalysisYamlUnsupported } from "./analysis-yaml.ts";
2
+ import type { AnalysisSnapshot, LintCode, LintReport } from "./analysis-types.ts";
3
+ const TYPES = ["decision", "insight", "method", "moc", "observation", "person", "project", "reference"];
4
+ const CONFIDENCE = ["experimental", "likely", "proven"], STATUS = ["active", "archived", "superseded"];
5
+ const FIELDS = ["topics", "project", "projects", "superseded_by", "supersedes"];
6
+ const CANONICAL = new Set("INGEST ROUTE EXTRACT CONNECT DIGEST LEARN LINT TASK GRAPH SYNC RETHINK SOURCE".split(" "));
7
+ const LEGACY = new Set("CREATE UPDATE MIGRATE REFACTOR MERGE MOVE RELOCATE RENAME DRAFT REVIEW RESEARCH REF CLEANUP CLOSE DONE SUPERSEDED DUPLICATE SKIP EDIT CORRECT".split(" "));
8
+ const CODE_TITLES: Record<LintCode, string> = {
9
+ L01: "L01 — dead wiki-link", L02: "L02 — 필수 frontmatter / 허용값", L03: "L03 — orphan",
10
+ L09: "L09 — 로그 포맷", L10: "L10 — project 값", L11: "L11 — graph report staleness", L12: "L12 — island community", L13: "L13 — filename", L14: "L14 — source paths",
11
+ };
12
+ const truth = yamlTruthy;
13
+ /** Python scalar formatting matters to per-code diagnostic parity. */
14
+ function py(x: unknown): string {
15
+ const scalar = scalarText(x); if (scalar !== undefined) return scalar;
16
+ if (x === undefined || x === null) return "None";
17
+ if (x === true) return "True";
18
+ if (x === false) return "False";
19
+ if (Array.isArray(x)) return `[${x.map(v => typeof v === "string" ? `'${v}'` : py(v)).join(", ")}]`;
20
+ if (typeof x === "object") return `{${Object.entries(x).map(([k,v]) => `'${k}': ${typeof v === "string" ? `'${v}'` : py(v)}`).join(", ")}}`;
21
+ return String(x);
22
+ }
23
+ export function legacyDocument(source: string): { fm: Record<string, unknown> | null; body: string } {
24
+ const match = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source);
25
+ let fm: Record<string, unknown> | null = null;
26
+ if (match) {
27
+ try {
28
+ const value: unknown = parseAnalysisYaml(match[1]!);
29
+ fm = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
30
+ } catch (error) { if (error instanceof AnalysisYamlUnsupported) throw error; /* Keep parse failures visible to L02. */ }
31
+ }
32
+ return { fm, body: match ? source.slice(match[0].length) : source };
33
+ }
34
+ function links(value: unknown): string[] {
35
+ const values = Array.isArray(value) ? value : [value];
36
+ return values.flatMap(v => typeof v === "string" ? [...v.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)].map(m => m[1]!.trim()) : []);
37
+ }
38
+ const stem = (s: string) => s.trim().replace(/\.md$/, "").split("/").at(-1)!;
39
+ function graphChecks(snapshot: AnalysisSnapshot): { l11: string[]; l12: string[]; info: string } {
40
+ const l11: string[] = [], l12: string[] = [], names = Object.keys(snapshot.reports).filter(s => /^graph-report-.*\.md$/.test(s)).sort();
41
+ const latest = names.at(-1);
42
+ if (!latest) l11.push("graph report 없음 — `/vault-graph build` 필요");
43
+ else {
44
+ const match = /graph-report-(\d{2})(\d{2})(\d{2})\.md$/.exec(latest);
45
+ let age: number | null = null;
46
+ if (match) {
47
+ const year = Number(match[1]) <= 68 ? 2000 + Number(match[1]) : 1900 + Number(match[1]);
48
+ const date = `${year}-${match[2]}-${match[3]}`, time = Date.parse(date + "T00:00:00Z");
49
+ if (!Number.isFinite(time) || new Date(time).toISOString().slice(0,10) !== date) throw new Error("analysis: invalid graph report date");
50
+ age = Math.round((Date.parse(snapshot.today + "T00:00:00Z") - time) / 86400000);
51
+ }
52
+ if (age === null || age > 7) l11.push(`\`${latest}\`: ${py(age)}일 경과 (> 7) — \`/vault-graph build\` 재실행 권장`);
53
+ }
54
+ const source = snapshot.reports["vault-graph.json"];
55
+ if (!source) return { l11, l12, info: "vault-graph.json 없음 — L12 생략" };
56
+ const graph = JSON.parse(source) as { nodes?: { id: string; community?: number | string | null }[]; links?: {source:string;target:string}[]; edges?: {source:string;target:string}[] };
57
+ if (!graph || !Array.isArray(graph.nodes ?? []) || !Array.isArray(graph.links ?? graph.edges ?? [])) throw new Error("analysis: invalid graph artifact");
58
+ const communities = new Map<string | number, string[]>(), member = new Map<string, string | number>(), cross = new Set<string | number>();
59
+ for (const node of graph.nodes ?? []) if (node.community !== undefined && node.community !== null) {
60
+ member.set(node.id, node.community);
61
+ const members = communities.get(node.community) ?? []; members.push(node.id); communities.set(node.community, members);
62
+ }
63
+ const edges = graph.links?.length ? graph.links : graph.edges ?? [];
64
+ for (const edge of edges) { const a = member.get(edge.source), b = member.get(edge.target); if (a !== undefined && b !== undefined && a !== b) { cross.add(a); cross.add(b); } }
65
+ const singles: string[] = [];
66
+ for (const [id, members] of communities) {
67
+ if (members.length === 1) singles.push(members[0]!);
68
+ else if (!cross.has(id)) l12.push(`island community (${members.length} notes): ${members.sort().slice(0,10).map(s => `\`${s}\``).join(", ")}${members.length > 10 ? ` +${members.length - 10} more` : ""} — \`/vault-connect\` 필요`);
69
+ }
70
+ singles.sort();
71
+ return { l11, l12, info: `nodes ${graph.nodes?.length ?? 0} / edges ${edges.length} / communities ${communities.size}; singleton ${singles.length}개${singles.length ? ": " + singles.slice(0,10).map(s => `\`${s}\``).join(", ") : ""}` };
72
+ }
73
+ export function lintVault(snapshot: AnalysisSnapshot, only?: string): LintReport {
74
+ const findings: Record<LintCode,string[]> = {L01:[],L02:[],L03:[],L09:[],L10:[],L11:[],L12:[],L13:[],L14:[]};
75
+ const notes = [...snapshot.notes].sort((a,b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(n => ({...n,...legacyDocument(n.source),stem: n.path.replace(/^notes\//, "").slice(0,-3)}));
76
+ const stems = new Set(notes.map(n => n.stem)), inlinks = new Map(notes.map(n => [n.stem,0]));
77
+ let superseded = 0;
78
+ for (const note of notes) {
79
+ const rel = `notes/${note.stem}.md`, prefix = `\`${rel}\`: `, fm = note.fm;
80
+ for(const message of snapshot.sourceWarnings?.[note.path] ?? [])findings.L14.push(prefix+message);
81
+ const allLinks: [string,string][] = links(note.body).map(t => ["body",t]);
82
+ if (fm) for (const field of FIELDS) for (const t of links(fm[field])) allLinks.push([field,t]);
83
+ for (const [field,t] of allLinks) {
84
+ const target = stem(t);
85
+ if (stems.has(target)) { if (target !== note.stem) inlinks.set(target,inlinks.get(target)!+1); }
86
+ else findings.L01.push(prefix + `\`[[${t}]]\` (${field}) → 대상 노트 없음`);
87
+ }
88
+ if (fm === null) { findings.L02.push(prefix + "frontmatter 없음/파싱 실패"); continue; }
89
+ if (fm.status === "superseded") superseded++;
90
+ if (!TYPES.includes(String(fm.type))) findings.L02.push(prefix + `\`type: ${py(fm.type)}\` 허용값 아님 (${TYPES.join(" | ")})`);
91
+ if (fm.type === "moc") { if (!truth(fm.description)) findings.L02.push(prefix + "`type: moc` 노트는 `description` 필수"); }
92
+ else if (!truth(fm.topics)) findings.L02.push(prefix + "`topics` 없음/빈 배열");
93
+ for (const [key,allowed] of [["confidence",CONFIDENCE],["status",STATUS]] as const) if (fm[key] !== undefined && fm[key] !== null && !allowed.includes(py(fm[key]))) findings.L02.push(prefix + `\`${key}: ${py(fm[key])}\` 허용값 아님 (${allowed.join(" | ")})${key === "status" ? " (status는 노트 생애주기이지 사업 진행 상태가 아님 — 작업 상태는 본문에)" : ""}`);
94
+ const project = fm.project;
95
+ if (typeof project === "string" && project.trim()) {
96
+ const targets = links(project);
97
+ if (targets.length) { for (const t of targets) if (!stems.has(stem(t))) findings.L10.push(prefix + `\`project: [[${t}]]\` dead wiki-link`); }
98
+ else if (snapshot.registryIds !== null && !snapshot.registryIds.includes(project.trim())) findings.L10.push(prefix + `\`project: ${project}\` registry id 아님`);
99
+ }
100
+ }
101
+ for (const note of notes) {
102
+ if (note.stem !== "index" && note.fm !== null && !truth(note.fm.topics) && inlinks.get(note.stem) === 0) findings.L03.push(`\`notes/${note.stem}.md\`: in-link 0, topics 0`);
103
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(note.stem)) findings.L13.push(`\`notes/${note.stem}.md\`: expected lowercase ASCII kebab-case filename`);
104
+ }
105
+ let legacy = 0, last = "", data = 0;
106
+ if (snapshot.log === null) findings.L09.push(`\`${snapshot.logPath}\` 없음`);
107
+ else for (const [index,line] of snapshot.log.split(/\r\n|\n|\r/).entries()) {
108
+ if (!line.trim() || line.startsWith("#")) continue;
109
+ data++;
110
+ const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\s+(\S+)\s+(.*)$/.exec(line);
111
+ if (!m) findings.L09.push(`\`log:${index+1}\`: 구조 위반 (\`YYYY-MM-DD HH:MM TYPE ...\` 아님): \`${[...line].slice(0,60).join("")}\``);
112
+ else if (LEGACY.has(m[3]!)) { legacy++; if (m[1]! > last) last = m[1]!; }
113
+ else if (!CANONICAL.has(m[3]!)) findings.L09.push(`\`log:${index+1}\`: TYPE \`${m[3]}\` 미등록 (정규 12종·정규화표 모두 없음)`);
114
+ }
115
+ if (data > 20000) findings.L09.push(`log ${data} 라인 > 20000 — 수동 아카이브 권장`);
116
+ const graph = graphChecks(snapshot); findings.L11 = graph.l11; findings.L12 = graph.l12;
117
+ if (only) for (const code of ["L01","L02","L03","L10","L13","L14"] as const) findings[code] = findings[code].filter(s => s.startsWith(`\`${only}\``));
118
+ const errors = findings.L01.length + findings.L02.length + findings.L13.length;
119
+ const warnings = findings.L03.length + findings.L09.length + findings.L10.length + findings.L11.length + findings.L12.length + findings.L14.length;
120
+ const summary = `SUMMARY errors=${errors} warnings=${warnings}`;
121
+ const extra: Partial<Record<LintCode,string>> = {
122
+ L02: `정보: \`status: superseded\` ${superseded}건${superseded === 0 ? " — supersede 프로토콜 미가동" : ""}`,
123
+ L03: "hub MOC `notes/index.md`는 설계상 예외",
124
+ L09: snapshot.log === null ? "정보: log 없음" : `정보: ${legacy ? `legacy TYPE ${legacy}건 (정규화표 등재), 최종 사용 ${last}, 신규 발생 없음` : "legacy TYPE 0건"}`,
125
+ L10: snapshot.registryIds === null ? "registry 미지정 — plain id 검사 생략" : "", L12: graph.info,
126
+ };
127
+ const lines = [`<!-- lint.py ${snapshot.today} notes=${notes.length} errors=${errors} warnings=${warnings} -->`,""];
128
+ for (const code of Object.keys(findings) as LintCode[]) {
129
+ const sev = ["L01","L02","L13"].includes(code) ? "error" : "warn";
130
+ lines.push(`## ${CODE_TITLES[code]} (${sev}, ${findings[code].length}건)`,"",...(findings[code].length ? findings[code].map(s => `- ${s}`) : ["위반 없음"]));
131
+ if (extra[code]) lines.push("",extra[code]!);
132
+ lines.push("");
133
+ }
134
+ return { findings, errors, warnings, markdown: lines.join("\n"), summary };
135
+ }