@staix/vault-mem 0.3.2 → 0.3.3

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,241 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { realPath } from "./platform.ts";
4
+ import { extractLinks } from "./links.ts";
5
+ import { specReference, MAX_SPEC_INPUTS } from "./spec-graph.ts";
6
+ import { discoverSpecs, type SpecCandidate, type SpecDiagnostic, type SpecSnapshot } from "./spec-source.ts";
7
+ import { specFreshness, specMetadataIssues, type SpecFreshnessEntry } from "./spec-freshness.ts";
8
+
9
+ export interface SpecValidationIssue {
10
+ code: string;
11
+ severity: "error" | "warning";
12
+ path: string;
13
+ id?: string;
14
+ field?: string;
15
+ target?: string;
16
+ message: string;
17
+ }
18
+ export interface SpecValidationOptions { env?: NodeJS.ProcessEnv; requireFresh?: boolean; limit?: number; git?: boolean }
19
+ export interface SpecValidationReport {
20
+ ok: boolean;
21
+ errors: number;
22
+ warnings: number;
23
+ items: SpecValidationIssue[];
24
+ diagnostics: SpecDiagnostic[];
25
+ freshness: SpecFreshnessEntry[];
26
+ truncated: boolean;
27
+ }
28
+ export interface SpecRepoValidationOptions { env?: NodeJS.ProcessEnv; requireFresh?: boolean; limit?: number }
29
+ export interface SpecRepoValidation { code: 0 | 1; report: SpecValidationReport }
30
+ const RANK: Readonly<Record<string, number>> = { goal: 0, architecture: 1, module: 2, submodule: 3 };
31
+ const STATUSES = new Set(["draft", "active", "deprecated"]);
32
+ const MAX_OPERATIONS = 200000, MAX_ITEMS = 500, MAX_REPORT_BYTES = 128 * 1024;
33
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
34
+ const nonempty = (value: unknown): value is string => typeof value === "string" && !!value.trim();
35
+ const candidateId = (candidate: SpecCandidate) => nonempty(candidate.frontmatter.id) ? candidate.frontmatter.id : undefined;
36
+ interface ErrorOwner { kind: "old" | "new"; path: string }
37
+ interface Collected { records: { issue: SpecValidationIssue; key: string }[]; freshness: SpecFreshnessEntry[] }
38
+
39
+ /** Complete collection is separate from display limits, so the introduced-error gate never compares samples. */
40
+ function collect(snapshot: SpecSnapshot, options: SpecValidationOptions, owners?: ReadonlyMap<string, ErrorOwner>): Collected {
41
+ const candidates = [...(snapshot.candidates ?? snapshot.specs)].sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path));
42
+ if (new Set(candidates.map(candidate => candidate.path)).size !== candidates.length) throw new Error("spec validation: duplicate candidate paths; provide one candidate record per file");
43
+ if (candidates.length > MAX_SPEC_INPUTS) throw new Error("spec validation: more than 10000 candidates; reduce the candidate tree before validating");
44
+ let operations = 0;
45
+ const tick = () => { if (++operations > MAX_OPERATIONS) throw new Error("spec validation: more than 200000 checks; reduce repeated links or metadata lists before validating"); };
46
+ const hashes = new Map<string, string>();
47
+ const digest = (value: string): string => {
48
+ let hash = hashes.get(value);
49
+ if (hash === undefined) { hash = createHash("sha256").update(value).digest("hex"); hashes.set(value, hash); }
50
+ return hash;
51
+ };
52
+ const records: Collected["records"] = [], keys = new Set<string>();
53
+ const addIssue = (issue: SpecValidationIssue) => {
54
+ tick();
55
+ const correspondence = owners?.get(issue.path) ?? { kind: owners === undefined ? "old" : "new", path: issue.path };
56
+ const owner = `${correspondence.kind}:${digest(correspondence.path)}`;
57
+ // Hash values independently: a long owner id is never copied once per relation/error key.
58
+ const key = JSON.stringify([digest(snapshot.commonDir), issue.severity, issue.code, owner, issue.field ?? "", issue.target === undefined ? "" : digest(issue.target)]);
59
+ if (!keys.has(key)) { keys.add(key); records.push({ issue, key }); }
60
+ };
61
+ const add = (candidate: SpecCandidate, code: string, severity: SpecValidationIssue["severity"], field: string, message: string, target?: string) => {
62
+ const id = candidateId(candidate);
63
+ addIssue({ code, severity, path: candidate.path, ...(id === undefined ? {} : { id }), field, message, ...(target === undefined ? {} : { target }) });
64
+ };
65
+ const nodes = new Map<string, SpecCandidate>(), first = new Map<string, string>();
66
+ for (const candidate of candidates) {
67
+ const id = candidateId(candidate);
68
+ if (id !== undefined) {
69
+ const previous = first.get(id);
70
+ if (previous !== undefined) add(candidate, "duplicate_id", "error", "id", "duplicate id; give each candidate a unique id", previous);
71
+ else first.set(id, candidate.path);
72
+ if (nonempty(candidate.frontmatter.type) && !nodes.has(id)) nodes.set(id, candidate);
73
+ }
74
+ }
75
+ const covers = new Map<string, Map<string, SpecCandidate>>();
76
+ for (const candidate of candidates) {
77
+ tick();
78
+ const data = candidate.frontmatter, id = candidateId(candidate);
79
+ if (options.git === false) for (const issue of specMetadataIssues(candidate)) addIssue(issue);
80
+ for (const field of ["id", "type", "title"] as const) {
81
+ if (data[field] === undefined) add(candidate, `missing_${field}`, "error", field, `required ${field} is missing; add a nonempty string field`);
82
+ else if (!nonempty(data[field])) add(candidate, "wrong_shape", "error", field, `${field} must be a nonempty string; correct the frontmatter field`);
83
+ }
84
+ if (nonempty(data.type) && !Object.hasOwn(RANK, data.type)) add(candidate, "unknown_type", "error", "type", "unknown type; use goal, architecture, module or submodule");
85
+ if (data.status !== undefined && (typeof data.status !== "string" || !STATUSES.has(data.status))) add(candidate, "unknown_status", "error", "status", "unknown status; use draft, active or deprecated");
86
+ if (data.parent === undefined && data.type !== "goal") add(candidate, "orphan", "warning", "parent", "non-goal spec has no parent; link it to its owning higher-level spec");
87
+ if (data.status === "active" && data.paths === undefined) add(candidate, "active_without_paths", "warning", "paths", "active spec has no paths; add relative Git pathspecs for its source scope");
88
+ const checkLink = (field: string, value: unknown, shapeField = field): string | undefined => {
89
+ tick();
90
+ const target = specReference(value);
91
+ if (target === undefined) { add(candidate, "wrong_shape", "error", shapeField, "link must be one id or wiki link; correct the frontmatter value"); return undefined; }
92
+ if (!nodes.has(target)) add(candidate, "dangling_link", "error", field, "frontmatter link names no graph node; link to an existing spec id or remove it", target);
93
+ return target;
94
+ };
95
+ let parent: string | undefined, replacement: string | undefined;
96
+ if (data.parent !== undefined) parent = checkLink("parent", data.parent);
97
+ if (data["superseded-by"] !== undefined) {
98
+ replacement = checkLink("superseded-by", data["superseded-by"]);
99
+ if (data.status !== "deprecated") add(candidate, "superseded_status", "error", "superseded-by", "replacement link requires deprecated status; deprecate the spec or remove the link");
100
+ if (replacement !== undefined && replacement === id) add(candidate, "superseded_self", "error", "superseded-by", "replacement points to this spec; link to a different replacement", replacement);
101
+ } else if (data.status === "deprecated") add(candidate, "deprecated_without_replacement", "warning", "superseded-by", "deprecated spec has no replacement; add a superseded-by id when one exists");
102
+ if (data.status === "deprecated" && replacement !== undefined && replacement !== id && nodes.get(replacement)?.frontmatter.status === "deprecated") add(candidate, "deprecated_replacement", "warning", "superseded-by", "replacement is also deprecated; link to an active replacement", replacement);
103
+ for (const field of ["depends-on", "references", "implements"] as const) {
104
+ if (data[field] === undefined) continue;
105
+ const values = data[field];
106
+ if (!Array.isArray(values)) { add(candidate, "wrong_shape", "error", field, `${field} must be a list of links; wrap the value in a list`); continue; }
107
+ for (let i = 0; i < values.length; i++) checkLink(field, values[i], `${field}[${i}]`);
108
+ }
109
+ if (parent !== undefined) {
110
+ const parentType = nodes.get(parent)?.frontmatter.type;
111
+ if (typeof data.type === "string" && typeof parentType === "string" && Object.hasOwn(RANK, data.type) && Object.hasOwn(RANK, parentType) && RANK[data.type]! <= RANK[parentType]!) add(candidate, "type_rank", "error", "parent", "child rank must exceed parent rank; choose a higher-level parent (levels may be skipped)", parent);
112
+ }
113
+ for (const field of ["covers", "tags"] as const) {
114
+ const values = data[field];
115
+ if (values === undefined) continue;
116
+ let valid = Array.isArray(values);
117
+ if (Array.isArray(values)) for (const value of values) { tick(); if (!nonempty(value)) valid = false; }
118
+ if (!valid) {
119
+ add(candidate, "wrong_shape", "error", field, `${field} must be a list of nonempty strings; correct the frontmatter field`);
120
+ continue;
121
+ }
122
+ if (field === "covers") for (const value of values as string[]) {
123
+ const token = value.trim();
124
+ let owners = covers.get(token); if (!owners) covers.set(token, owners = new Map());
125
+ owners.set(candidate.path, candidate);
126
+ }
127
+ }
128
+ for (const link of extractLinks(candidate.body)) {
129
+ tick();
130
+ if (link.target && !nodes.has(link.target)) add(candidate, "unknown_body_link", "warning", "body", "body wiki link names no graph node; correct the target id or remove the link", link.target);
131
+ }
132
+ }
133
+ for (const [token, owners] of covers) if (owners.size > 1) for (const candidate of owners.values()) add(candidate, "duplicate_covers", "warning", "covers", "covers token is claimed by multiple specs; choose a single owning spec", token);
134
+ const goals = [...nodes.values()].filter(candidate => candidate.frontmatter.type === "goal");
135
+ if (goals.length > 1) for (const candidate of goals) add(candidate, "multiple_goals", "warning", "type", "multiple goal roots exist; consolidate or document separate goal trees");
136
+ // Iterative traversal handles a 10000-node chain without overflowing the JS stack.
137
+ const finished = new Set<string>();
138
+ for (const id of nodes.keys()) {
139
+ if (finished.has(id)) continue;
140
+ const chain: string[] = [], positions = new Map<string, number>();
141
+ let next: string | undefined = id;
142
+ while (next !== undefined && nodes.has(next) && !finished.has(next) && !positions.has(next)) {
143
+ tick(); positions.set(next, chain.length); chain.push(next);
144
+ next = specReference(nodes.get(next)!.frontmatter.parent);
145
+ }
146
+ if (next !== undefined && positions.has(next)) for (const member of chain.slice(positions.get(next)!)) add(nodes.get(member)!, "parent_cycle", "error", "parent", "parent cycle detected; remove a cycle link and point to a higher-level parent", specReference(nodes.get(member)!.frontmatter.parent));
147
+ for (const member of chain) finished.add(member);
148
+ }
149
+ for (const diagnostic of snapshot.diagnostics) {
150
+ if (diagnostic.code === "invalid_frontmatter") addIssue({ code: "invalid_frontmatter", severity: "error", path: diagnostic.path, field: "frontmatter", message: diagnostic.message });
151
+ else if (snapshot.candidates === undefined && diagnostic.code === "duplicate_id") addIssue({ code: "duplicate_id", severity: "error", path: diagnostic.path, id: diagnostic.id, field: "id", target: diagnostic.firstPath, message: diagnostic.message });
152
+ else if (snapshot.candidates === undefined && diagnostic.code === "invalid_identity") addIssue({ code: "wrong_shape", severity: "error", path: diagnostic.path, field: "id/type", message: diagnostic.message });
153
+ }
154
+ if (options.requireFresh && options.git === false) throw new Error("spec validation: requireFresh needs Git inspection; enable Git checks before requiring freshness");
155
+ const freshness = options.git === false ? { items: [], issues: [] } : specFreshness(snapshot, { env: options.env });
156
+ for (const issue of freshness.issues) addIssue(issue);
157
+ if (options.requireFresh) for (const item of freshness.items) if (item.state === "stale") addIssue({ code: "stale", severity: "error", path: item.path, id: item.id, field: "verified-against", message: "spec source scope is stale; review its paths and refresh verified-against after verification" });
158
+ records.sort((a, b) => (a.issue.severity === b.issue.severity ? 0 : a.issue.severity === "error" ? -1 : 1) || compare(a.issue.path.normalize("NFC"), b.issue.path.normalize("NFC")) || compare(a.issue.path, b.issue.path) || compare(a.issue.code, b.issue.code) || compare(a.issue.field ?? "", b.issue.field ?? "") || compare(a.key, b.key));
159
+ return { records, freshness: freshness.items };
160
+ }
161
+
162
+ /** Display is count/byte bounded; oversized optional identifiers are omitted, never silently renamed. */
163
+ function display<T extends object>(values: readonly T[], limit: number, budget: { bytes: number; truncated: boolean }): T[] {
164
+ const out: T[] = [];
165
+ for (const original of values) {
166
+ if (out.length >= limit) { budget.truncated = true; break; }
167
+ const value = { ...original } as T & { id?: string; target?: string };
168
+ for (const field of ["id", "target"] as const) if (typeof value[field] === "string" && Buffer.byteLength(value[field]!) > 1024) { delete value[field]; budget.truncated = true; }
169
+ const bytes = Buffer.byteLength(JSON.stringify(value)) + 1;
170
+ if (budget.bytes + bytes > MAX_REPORT_BYTES) { budget.truncated = true; break; }
171
+ budget.bytes += bytes; out.push(value);
172
+ }
173
+ return out;
174
+ }
175
+ function reportLimit(value = MAX_ITEMS): number {
176
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_ITEMS) throw new Error("spec validation: report limit must be 1..500; pass a smaller positive item limit");
177
+ return value;
178
+ }
179
+ export function validateSpecs(snapshot: SpecSnapshot, options: SpecValidationOptions = {}): SpecValidationReport {
180
+ const limit = reportLimit(options.limit), result = collect(snapshot, options), budget = { bytes: 1024, truncated: false };
181
+ const errors = result.records.filter(record => record.issue.severity === "error").length;
182
+ return { ok: errors === 0, errors, warnings: result.records.length - errors,
183
+ items: display(result.records.map(record => record.issue), limit, budget),
184
+ diagnostics: display(snapshot.diagnostics, limit, budget), freshness: display(result.freshness, limit, budget),
185
+ get truncated() { return budget.truncated; } };
186
+ }
187
+ /** Existing error identities are retained even when their display sample was truncated. */
188
+ export function introducedSpecErrors(current: SpecSnapshot, prospective: SpecSnapshot, options: SpecValidationOptions = {}): SpecValidationIssue[] {
189
+ const limit = reportLimit(options.limit);
190
+ const pathsById = (input: SpecSnapshot) => {
191
+ const paths = new Map<string, Set<string>>();
192
+ const add = (id: string, path: string) => { let found = paths.get(id); if (!found) paths.set(id, found = new Set()); found.add(path); };
193
+ for (const candidate of input.candidates ?? input.specs) { const id = candidateId(candidate); if (id !== undefined) add(id, candidate.path); }
194
+ if (input.candidates === undefined) for (const diagnostic of input.diagnostics) if (diagnostic.code === "duplicate_id" && diagnostic.id !== undefined) add(diagnostic.id, diagnostic.path);
195
+ return paths;
196
+ };
197
+ const oldIds = pathsById(current), nextIds = pathsById(prospective), owners = new Map<string, ErrorOwner>(), reserved = new Set<string>();
198
+ for (const candidate of prospective.candidates ?? prospective.specs) {
199
+ const id = candidateId(candidate), previous = id === undefined ? undefined : oldIds.get(id);
200
+ if (previous?.size === 1 && nextIds.get(id!)?.size === 1) {
201
+ const path = previous.values().next().value!;
202
+ owners.set(candidate.path, { kind: "old", path }); reserved.add(path);
203
+ }
204
+ }
205
+ const previousPaths = new Set([...(current.candidates ?? current.specs).map(candidate => candidate.path), ...current.diagnostics.map(diagnostic => diagnostic.path)]);
206
+ const prospectivePaths = new Set([...(prospective.candidates ?? prospective.specs).map(candidate => candidate.path), ...prospective.diagnostics.map(diagnostic => diagnostic.path)]);
207
+ for (const path of prospectivePaths) if (!owners.has(path)) {
208
+ const retained = previousPaths.has(path) && !reserved.has(path);
209
+ owners.set(path, { kind: retained ? "old" : "new", path });
210
+ if (retained) reserved.add(path);
211
+ }
212
+ // Duplicate identity belongs to the id, even when a move changes the first file in path order.
213
+ // Keep collection keys unchanged so validation still reports each duplicate file.
214
+ const comparisonKey = (input: SpecSnapshot, record: Collected["records"][number]): string =>
215
+ record.issue.code === "duplicate_id" && record.issue.id !== undefined
216
+ ? JSON.stringify([createHash("sha256").update(input.commonDir).digest("hex"), "duplicate_id", createHash("sha256").update(record.issue.id).digest("hex")])
217
+ : record.key;
218
+ const old = new Map<string, number>();
219
+ for (const record of collect(current, options).records) if (record.issue.severity === "error") {
220
+ const key = comparisonKey(current, record); old.set(key, (old.get(key) ?? 0) + 1);
221
+ }
222
+ const prospectiveErrors = collect(prospective, options, owners).records.filter(record => record.issue.severity === "error");
223
+ // Retained owners consume existing debt before new or moved files do.
224
+ prospectiveErrors.sort((a, b) => Number(owners.get(b.issue.path)?.kind === "old") - Number(owners.get(a.issue.path)?.kind === "old"));
225
+ const next = prospectiveErrors.filter(record => {
226
+ const key = comparisonKey(prospective, record), count = old.get(key) ?? 0;
227
+ if (count === 0) return true;
228
+ old.set(key, count - 1); return false;
229
+ });
230
+ const result = display(next.map(record => record.issue), limit, { bytes: 1024, truncated: false });
231
+ if (next.length && !result.length) throw new Error("spec validation: introduced error metadata exceeds the report budget; reduce metadata before writing");
232
+ return result;
233
+ }
234
+ /** Registry-free CI logic; no home/config/cache creation and no authored freshness fields. */
235
+ export function validateSpecRepo(repo: string, options: SpecRepoValidationOptions = {}): SpecRepoValidation {
236
+ let root: string;
237
+ try { root = realPath(resolve(repo)); } catch (error) { throw new Error("spec validation: repository root is unavailable; pass an existing checkout root", { cause: error }); }
238
+ const snapshot = discoverSpecs({ id: "repo", path: root, write: "none", pii: "deny" }, { env: options.env });
239
+ const report = validateSpecs(snapshot, options);
240
+ return { code: report.ok ? 0 : 1, report };
241
+ }
@@ -87,7 +87,7 @@ function readPage(prefix: Buffer, maxBytes: number, offset: number, totalBytes:
87
87
  const truncated = offset + end < totalBytes;
88
88
  return { content: prefix.subarray(0, end).toString("utf8"), truncated, ...(truncated ? { nextOffset: offset + end } : {}) };
89
89
  }
90
- function h2Sections(body: string): { name: string; start: number; end: number }[] {
90
+ export function h2Sections(body: string): { name: string; start: number; end: number }[] {
91
91
  const visible = proseLines(body), lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
92
92
  const sections: { name: string; start: number; end: number }[] = [];
93
93
  let offset = 0, active: number | undefined;
@@ -104,7 +104,7 @@ function h2Sections(body: string): { name: string; start: number; end: number }[
104
104
  }
105
105
  return sections;
106
106
  }
107
- function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "headings" | "headingsTruncated"> {
107
+ export function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "headings" | "headingsTruncated"> {
108
108
  const headings: string[] = [];
109
109
  let bytes = 2, truncated = false;
110
110
  for (const { name } of sections) {
@@ -120,7 +120,8 @@ export interface IndexSourceAdapter {
120
120
  paths(): string[];
121
121
  validatePath(path: string): void;
122
122
  accept(path: string, document: FrontmatterDocument, hash: string): boolean;
123
- invalid(path: string): void;
123
+ invalid(path: string): boolean | void;
124
+ skipFile?(path: string, reason: "oversized" | "invalid_utf8", bytes: Buffer): boolean;
124
125
  }
125
126
  export class IndexCacheError extends Error {}
126
127
  export interface IndexOptions { memory?: boolean; adapter?: IndexSourceAdapter; reuseCache?: boolean }
@@ -206,10 +207,12 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
206
207
  }
207
208
  /** Scan metadata each call; hash only new or metadata-changed files. Atomic cache update. */
208
209
  scan(rebuild = false, options: { skipInvalidFrontmatter?: boolean; captureInvalidFrontmatter?: boolean } = {}): { notes: number; changed: number; removed: number; skipped?: string[] } {
209
- return this.db.transaction(() => {
210
+ // Discovery/binding Git calls must never hold a SQLite writer lock.
211
+ let preparedPaths: string[] | undefined;
212
+ if (this.adapter) { this.adapter.validate(); preparedPaths = this.adapter.paths(); }
213
+ const result = this.db.transaction(() => {
210
214
  const notesDir = join(this.root, "notes");
211
- if (this.adapter) this.adapter.validate();
212
- else if (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory()) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
215
+ if (!this.adapter && (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory())) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
213
216
  const indexed = this.adapter ? new Set(this.db.query<{path:string}, []>("SELECT path FROM notes").all().map(row => row.path)) : undefined;
214
217
  const previous = new Map(this.db.query<{ path: string; hash: string; metadata: string }, []>(this.adapter ? "SELECT path,hash,metadata FROM analysis_notes" : 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]));
215
218
  if (rebuild) this.db.run("DELETE FROM fts");
@@ -218,21 +221,25 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
218
221
  const skipped: string[] = [], touched = new Set<string>(), keys = new Set<string>();
219
222
  // Links resolve by path only, so other notes re-resolve only when they target a touched path.
220
223
  const touch = (path: string) => { touched.add(path); keys.add(noteKey(path)); };
221
- const paths = this.adapter ? this.adapter.paths() : readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).filter(entry => entry.name.endsWith(".md") && entry.isFile()).map(entry => entry.name);
224
+ const paths = preparedPaths ?? readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).filter(entry => entry.name.endsWith(".md") && entry.isFile()).map(entry => entry.name);
222
225
  for (const path of paths) {
223
226
  this.adapter?.validatePath(path);
224
227
  const file = join(this.adapter ? this.root : notesDir, path);
225
228
  const before = this.adapter ? specFileIO(path, "inspect", () => lstatSync(file)) : lstatSync(file);
226
229
  if (!before.isFile() || before.isSymbolicLink()) throw new Error(this.adapter ? `spec discovery ${path}: selected path changed; restore a plain file and retry` : `vault ${this.vault.id}: note path changed`);
227
- if (before.size > MAX_NOTE_BYTES) throw new Error(this.adapter ? `spec discovery ${path}: Markdown exceeds ${MAX_NOTE_BYTES} bytes; split the file before rebuilding` : `vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
230
+ if (!this.adapter && before.size > MAX_NOTE_BYTES) throw new Error(`vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
228
231
  live.add(path); present.add(path);
229
- if (options.captureInvalidFrontmatter || this.adapter) {
232
+ if (options.captureInvalidFrontmatter && !this.adapter) {
230
233
  analysisBytes += before.size;
231
234
  if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error(this.adapter ? `spec discovery ${path}: more than 10000 Markdown files or 64 MiB of input; ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before rebuilding` : "analysis: snapshot exceeds bound");
232
235
  }
236
+ if (this.adapter) {
237
+ analysisBytes += Math.min(before.size, MAX_NOTE_BYTES + 1);
238
+ if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error(`spec discovery ${path}: more than 10000 Markdown inputs or 64 MiB of bounded reads; ignore vendored Markdown via .gitignore and untrack excluded files with git rm --cached`);
239
+ }
233
240
  const old = previous.get(path), stamp = metadata(before);
234
241
  if (!this.adapter && !rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
235
- const cached = this.adapter && !rebuild && old?.metadata === stamp ? this.db.query<{source:string}, [string]>("SELECT source FROM analysis_notes WHERE path=?").get(path) : null;
242
+ const cached = this.adapter && before.size <= MAX_NOTE_BYTES && !rebuild && old?.metadata === stamp ? this.db.query<{source:string}, [string]>("SELECT source FROM analysis_notes WHERE path=?").get(path) : null;
236
243
  let source: string, hash: string;
237
244
  if (cached && old) { source = cached.source; hash = old.hash; }
238
245
  else {
@@ -240,7 +247,7 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
240
247
  let buffer: Buffer;
241
248
  try {
242
249
  const stat = this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd);
243
- if (!stat.isFile() || stat.size > MAX_NOTE_BYTES || metadata(stat) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
250
+ if (!stat.isFile() || (!this.adapter && stat.size > MAX_NOTE_BYTES) || metadata(stat) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
244
251
  // Fixed bound even if a concurrent writer grows the file.
245
252
  buffer = Buffer.alloc(MAX_NOTE_BYTES + 1);
246
253
  let size = 0;
@@ -250,15 +257,28 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
250
257
  if (!count || size === buffer.length) break;
251
258
  }
252
259
  buffer = buffer.subarray(0, size);
253
- if (size > MAX_NOTE_BYTES || metadata(this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd)) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
260
+ if ((!this.adapter && size > MAX_NOTE_BYTES) || metadata(this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd)) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
254
261
  } finally { if (this.adapter) specFileIO(path, "close", () => closeSync(fd)); else closeSync(fd); }
262
+ this.adapter?.validatePath(path);
263
+ if (this.adapter && metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during read; retry on a stable worktree`);
255
264
  if (this.adapter) {
265
+ const skip = (reason: "oversized" | "invalid_utf8") => {
266
+ if (!this.adapter?.skipFile?.(path, reason, buffer.subarray(0, MAX_NOTE_BYTES))) return false;
267
+ live.delete(path); skipped.push(path);
268
+ this.db.query("DELETE FROM analysis_notes WHERE path=?").run(path);
269
+ return true;
270
+ };
271
+ if (before.size > MAX_NOTE_BYTES || buffer.length > MAX_NOTE_BYTES) {
272
+ if (skip("oversized")) continue;
273
+ throw new Error(`spec discovery ${path}: Markdown exceeds ${MAX_NOTE_BYTES} bytes; split the candidate before validating`);
274
+ }
256
275
  try { source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer); }
257
- catch { throw new Error(`spec discovery ${path}: Markdown is not valid UTF-8; re-encode the file as UTF-8 before rebuilding`); }
276
+ catch {
277
+ if (skip("invalid_utf8")) continue;
278
+ throw new Error(`spec discovery ${path}: Markdown is not valid UTF-8; re-encode the candidate as UTF-8 before validating`);
279
+ }
258
280
  } else source = buffer.toString("utf8");
259
281
  hash = digest(buffer);
260
- this.adapter?.validatePath(path);
261
- if (this.adapter && metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during read; retry on a stable worktree`);
262
282
  this.db.query("INSERT OR REPLACE INTO analysis_notes VALUES (?,?,?,?)").run(path, source, hash, stamp);
263
283
  }
264
284
  if (!this.adapter && !rebuild && old?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
@@ -266,8 +286,8 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
266
286
  try { document = parseFrontmatter(source); }
267
287
  catch {
268
288
  if (!this.adapter && !options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
269
- this.adapter?.invalid(path);
270
- live.delete(path); skipped.push(path); continue;
289
+ const report = this.adapter?.invalid(path);
290
+ live.delete(path); if (report !== false) skipped.push(path); continue;
271
291
  }
272
292
  if (this.adapter) {
273
293
  this.adapter.validatePath(path);
@@ -290,7 +310,6 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
290
310
  if (this.adapter) {
291
311
  // Spec links are resolved by frontmatter id in buildSpecGraph, never by vault paths.
292
312
  this.db.run("DELETE FROM links");
293
- this.adapter.validate();
294
313
  return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
295
314
  }
296
315
  const stale = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version !== LINK_RESOLUTION;
@@ -313,6 +332,8 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
313
332
  if (stale) this.db.run(`PRAGMA user_version = ${LINK_RESOLUTION}`);
314
333
  return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
315
334
  }).immediate();
335
+ this.adapter?.validate();
336
+ return result;
316
337
  }
317
338
  /** Consume cached note sources only. Auxiliary inputs are captured separately by analysisInput. */
318
339
  analysisSnapshot(inputs: Omit<AnalysisSnapshot, "notes">): AnalysisSnapshot {
@@ -321,7 +342,7 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
321
342
  const notes = this.db.query<{path: string; source: string}, []>("SELECT path,source FROM analysis_notes ORDER BY path").all();
322
343
  return { ...inputs, notes };
323
344
  }
324
- search(query: string, options: SearchOptions = {}, snapshot?: ReadonlyMap<string, string>): SearchResult[] {
345
+ search(query: string, options: SearchOptions = {}, snapshot?: ReadonlyMap<string, string>, snapshotOffset = 0, allSnapshotRows = false): SearchResult[] {
325
346
  const limit = resultLimit(options.limit);
326
347
  if (!query.trim() || Buffer.byteLength(query) > 1024) throw new Error("search: query must contain 1..1024 bytes");
327
348
  // Literal AND terms: users cannot inject FTS operators, columns or SQL.
@@ -329,8 +350,9 @@ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
329
350
  const hashColumn = snapshot ? ",n.hash AS sourceHash" : "";
330
351
  return this.db.query<SearchResult & { sourceHash?: string }, [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,
331
352
  bm25(fts,0,5,2,1) AS score, snippet(fts,3,'','',' ... ',32) AS snippet${hashColumn} FROM fts JOIN notes n ON n.path=fts.path
332
- 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)
353
+ 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, snapshot ? 10000 : limit)
333
354
  .filter(row => !snapshot || snapshot.get(row.path) === row.sourceHash)
355
+ .slice(snapshot ? snapshotOffset : 0, (snapshot ? snapshotOffset : 0) + (snapshot && allSnapshotRows ? 10000 : limit))
334
356
  .map(({ sourceHash: _, ...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 }));
335
357
  }
336
358
  private find(target: string): string {