@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,163 @@
1
+ import { isAbsolute, posix, win32 } from "node:path";
2
+ import { specGit } from "./spec-git.ts";
3
+ import type { SpecCandidate, SpecDocument, SpecSnapshot } from "./spec-source.ts";
4
+ import type { SpecValidationIssue } from "./spec-validate.ts";
5
+
6
+ export interface SpecFreshnessEntry { path: string; id?: string; state: "fresh" | "stale"; commits: number; dirty: boolean }
7
+ export interface SpecFreshnessReport { items: SpecFreshnessEntry[]; issues: SpecValidationIssue[] }
8
+ export interface SpecFreshnessOptions { env?: NodeJS.ProcessEnv }
9
+ const PATH_OUTPUT_BYTES = 8 * 1024 * 1024;
10
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
11
+ const ordered = <T extends { path: string }>(items: readonly T[]): T[] => [...items].sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path));
12
+ const idOf = (candidate: SpecCandidate): { id?: string } => typeof candidate.frontmatter.id === "string" && candidate.frontmatter.id.trim().length > 0 ? { id: candidate.frontmatter.id } : {};
13
+ function issue(candidate: SpecCandidate, code: string, field: string, message: string): SpecValidationIssue {
14
+ return { code, severity: "error", path: candidate.path, ...idOf(candidate), field, message };
15
+ }
16
+ function pathsShape(value: unknown): value is string[] {
17
+ return Array.isArray(value) && value.length > 0 && value.every(item => typeof item === "string" && item.length > 0);
18
+ }
19
+ /** Strip only Git's magic header for the relative/no-parent check; Git interprets the original pathspec. */
20
+ function pathspecBody(value: string): string | undefined {
21
+ if (value.startsWith(":(")) {
22
+ const end = value.indexOf(")");
23
+ return end < 0 ? undefined : value.slice(end + 1);
24
+ }
25
+ return value.startsWith(":") ? value.replace(/^:[!^/]*:?/, "") : value;
26
+ }
27
+ function relativePathspec(value: string): boolean {
28
+ const body = pathspecBody(value);
29
+ return body !== undefined && !value.includes("\0") && !isAbsolute(body) && !win32.isAbsolute(body) && !body.split(/[\\/]/).includes("..");
30
+ }
31
+ function hasPaths(bytes: Buffer): boolean {
32
+ if (bytes.length && bytes[bytes.length - 1] !== 0) throw new Error("spec freshness: Git returned incomplete NUL-delimited paths; retry on a stable repository");
33
+ return bytes.length > 0;
34
+ }
35
+ function pathRecords(bytes: Buffer): Buffer[] {
36
+ hasPaths(bytes);
37
+ const paths: Buffer[] = [];
38
+ for (let start = 0; start < bytes.length;) {
39
+ const end = bytes.indexOf(0, start);
40
+ if (end > start) paths.push(bytes.subarray(start, end));
41
+ start = end + 1;
42
+ }
43
+ return paths;
44
+ }
45
+ type ScopeMatch = { files: Buffer } | { invalid: "syntax" | "unmatched"; diagnostic?: string };
46
+ function trackedScope(root: string, paths: string[], options: SpecFreshnessOptions): ScopeMatch {
47
+ // This conflict is known before Git quotes a possibly long pattern into a truncated diagnostic.
48
+ if (paths.some(path => {
49
+ if (!path.startsWith(":(")) return false;
50
+ const magic = path.slice(2, path.indexOf(")")).split(",");
51
+ return magic.includes("literal") && magic.includes("glob");
52
+ })) return { invalid: "syntax" };
53
+ const result = specGit(root, ["ls-files", "--cached", "-z", "--error-unmatch", "--", ...paths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES, allowedExitCodes: [0, 1, 128] });
54
+ if (result.status === 128) {
55
+ // A library caller may have a stale snapshot: distinguish a broken index from bad scope syntax.
56
+ specGit(root, ["ls-files", "--cached", "-z"], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES });
57
+ const diagnostic = result.stderr.toString("utf8").split(/\r?\n/, 1)[0]?.split(root).join("<path>").slice(0, 240) || "no diagnostic";
58
+ return { invalid: "syntax", diagnostic };
59
+ }
60
+ if (result.status !== 0 || !hasPaths(result.stdout)) return { invalid: "unmatched" };
61
+ return { files: result.stdout };
62
+ }
63
+ function fullHash(bytes: Buffer): string {
64
+ const value = bytes.toString("utf8").replace(/\n$/, "");
65
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw new Error("spec freshness: Git returned an invalid full commit identity; inspect git rev-parse HEAD and repair the repository before retrying");
66
+ return value;
67
+ }
68
+ function candidatePaths(candidate: SpecCandidate, issues: SpecValidationIssue[]): string[] | undefined {
69
+ const value = candidate.frontmatter.paths;
70
+ if (value === undefined) return undefined;
71
+ if (!pathsShape(value)) {
72
+ issues.push(issue(candidate, "wrong_shape", "paths", "paths must be a nonempty list of nonempty strings; write repository-relative Git pathspecs as a YAML list"));
73
+ return undefined;
74
+ }
75
+ if (!value.every(relativePathspec)) {
76
+ issues.push(issue(candidate, "invalid_paths", "paths", "paths must use well-formed relative Git pathspecs without parent traversal or NUL; correct magic syntax or replace absolute/parent-relative entries with in-repository scopes"));
77
+ return undefined;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ /** Pure metadata checks remain available when callers intentionally disable native Git inspection. */
83
+ export function specMetadataIssues(candidate: SpecCandidate): SpecValidationIssue[] {
84
+ const issues: SpecValidationIssue[] = [];
85
+ candidatePaths(candidate, issues);
86
+ const stamp = candidate.frontmatter["verified-against"];
87
+ if (stamp !== undefined && (typeof stamp !== "string" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(stamp))) issues.push(issue(candidate, "invalid_verified_against", "verified-against", "verified-against must be a full commit SHA; set it to the full git rev-parse HEAD result after reviewing the spec"));
88
+ return issues;
89
+ }
90
+
91
+ /** Computed freshness only: malformed metadata is reported, while Git operational failures propagate. */
92
+ export function specFreshness(snapshot: SpecSnapshot, options: SpecFreshnessOptions = {}): SpecFreshnessReport {
93
+ const items: SpecFreshnessEntry[] = [], issues: SpecValidationIssue[] = [];
94
+ let loadedHead = false, head: string | undefined;
95
+ const stampChecks = new Map<string, string | undefined>();
96
+ const headCommit = () => {
97
+ if (!loadedHead) {
98
+ const result = specGit(snapshot.root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { env: options.env, allowedExitCodes: [0, 1] });
99
+ head = result.status === 0 ? fullHash(result.stdout) : undefined;
100
+ loadedHead = true;
101
+ }
102
+ return head;
103
+ };
104
+ const checkStamp = (stamp: string): string | undefined => {
105
+ if (stampChecks.has(stamp)) return stampChecks.get(stamp);
106
+ const current = headCommit();
107
+ let failure: string | undefined;
108
+ const commit = specGit(snapshot.root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${stamp}^{commit}`], { env: options.env, allowedExitCodes: [0, 1] });
109
+ if (commit.status !== 0 || fullHash(commit.stdout) !== stamp.toLowerCase()) failure = "verified-against does not name a commit in this repository; choose a full commit SHA from git log after reviewing the spec";
110
+ else if (current === undefined) failure = "verified-against cannot be checked without a HEAD commit; commit the tracked source and review it before recording a verification stamp";
111
+ else if (specGit(snapshot.root, ["merge-base", "--is-ancestor", stamp, current], { env: options.env, allowedExitCodes: [0, 1] }).status !== 0) failure = "verified-against is not an ancestor of HEAD; review the spec against a reachable commit and update the full verification SHA";
112
+ stampChecks.set(stamp, failure);
113
+ return failure;
114
+ };
115
+ for (const candidate of ordered(snapshot.candidates ?? snapshot.specs)) {
116
+ const paths = candidatePaths(candidate, issues);
117
+ let validPaths: string[] | undefined;
118
+ if (paths !== undefined) {
119
+ const scope = trackedScope(snapshot.root, paths, options);
120
+ if ("invalid" in scope) issues.push(issue(candidate, "invalid_paths", "paths", scope.invalid === "syntax"
121
+ ? `paths contain invalid Git pathspec syntax${scope.diagnostic ? ` (${scope.diagnostic})` : ""}; correct the pathspec magic or use a repository-relative tracked path from git ls-files`
122
+ : "one or more paths match no tracked files, or exclusions leave the scope empty; choose matching pathspecs from git ls-files and correct the paths list"));
123
+ else validPaths = paths;
124
+ }
125
+ const stamp = candidate.frontmatter["verified-against"];
126
+ if (stamp === undefined) continue;
127
+ if (typeof stamp !== "string" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(stamp)) {
128
+ issues.push(issue(candidate, "invalid_verified_against", "verified-against", "verified-against must be a full commit SHA; set it to the full git rev-parse HEAD result after reviewing the spec"));
129
+ continue;
130
+ }
131
+ const failure = checkStamp(stamp);
132
+ if (failure) { issues.push(issue(candidate, "invalid_verified_against", "verified-against", failure)); continue; }
133
+ if (validPaths === undefined) continue;
134
+ const count = specGit(snapshot.root, ["rev-list", "--count", "--full-history", `${stamp}..${head!}`, "--", ...validPaths], { env: options.env }).stdout.toString("utf8").trim();
135
+ if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count))) throw new Error("spec freshness: Git returned an invalid source commit count; inspect the scoped history before retrying");
136
+ const trackedDirty = specGit(snapshot.root, ["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", head!, "--", ...validPaths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES }).stdout;
137
+ const stagedDirty = specGit(snapshot.root, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", head!, "--", ...validPaths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES }).stdout;
138
+ const untrackedDirty = specGit(snapshot.root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...validPaths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES }).stdout;
139
+ const changedTracked = hasPaths(trackedDirty), changedStaged = hasPaths(stagedDirty), changedUntracked = hasPaths(untrackedDirty);
140
+ const dirty = changedTracked || changedStaged || changedUntracked, commits = Number(count);
141
+ items.push({ path: candidate.path, ...idOf(candidate), state: commits > 0 || dirty ? "stale" : "fresh", commits, dirty });
142
+ }
143
+ return { items, issues };
144
+ }
145
+
146
+ /** Match tracked files through Git's own pathspec engine; invalid metadata has no invented ownership. */
147
+ export function specsCoveringPath(snapshot: SpecSnapshot, relativeFile: string, options: SpecFreshnessOptions = {}): SpecDocument[] {
148
+ if (typeof relativeFile !== "string" || !relativeFile || relativeFile.includes("\0") || isAbsolute(relativeFile) || win32.isAbsolute(relativeFile) || relativeFile.split(/[\\/]/).includes("..")) throw new Error("spec coversPath: expected a tracked repository-relative filename without parent traversal or NUL; pass a file listed by git ls-files");
149
+ const file = posix.normalize(relativeFile);
150
+ if (file === ".") return [];
151
+ const requested = specGit(snapshot.root, ["ls-files", "--cached", "-z", "--error-unmatch", "--", `:(literal)${file}`], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES, allowedExitCodes: [0, 1] });
152
+ if (requested.status !== 0) return [];
153
+ // A literal directory pathspec also lists descendants; coversPath accepts a file, not a directory.
154
+ const spelling = file.normalize("NFC").toLowerCase();
155
+ const tracked = pathRecords(requested.stdout).filter(path => path.toString("utf8").normalize("NFC").toLowerCase() === spelling);
156
+ if (!tracked.length) return [];
157
+ return ordered(snapshot.specs).filter(document => {
158
+ const paths = document.frontmatter.paths;
159
+ if (!pathsShape(paths) || !paths.every(relativePathspec)) return false;
160
+ const scope = trackedScope(snapshot.root, paths, options);
161
+ return "files" in scope && pathRecords(scope.files).some(path => tracked.some(file => file.equals(path)));
162
+ });
163
+ }
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { memoryGitArgv, memoryGitEnv } from "./platform.ts";
3
+
4
+ export interface SpecGitOptions {
5
+ env?: NodeJS.ProcessEnv;
6
+ maxBuffer?: number;
7
+ allowedExitCodes?: readonly number[];
8
+ discovery?: boolean;
9
+ }
10
+ export interface SpecGitResult { status: number; stdout: Buffer; stderr: Buffer }
11
+ /** Internal bounded Git inspection, shared by discovery and validation; callers build readonly argv. */
12
+ export function specGit(root: string, args: string[], options: SpecGitOptions = {}): SpecGitResult {
13
+ const argv = memoryGitArgv(["-C", root, ...args]), maxBuffer = options.maxBuffer ?? 65536;
14
+ const env = memoryGitEnv({ ...(options.env ?? process.env), GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, { repositoryIsolation: true });
15
+ for (const key of ["GIT_LITERAL_PATHSPECS", "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"]) delete env[key];
16
+ const result = spawnSync(argv[0]!, argv.slice(1), { env, timeout: 10000, maxBuffer, stdio: ["ignore", "pipe", "pipe"] });
17
+ if (result.error) {
18
+ const code = (result.error as NodeJS.ErrnoException).code;
19
+ if (code === "ETIMEDOUT") throw new Error("spec source: git inspection timed out after 10000 ms; retry and check for a locked repository or slow mount", { cause: result.error });
20
+ if (code === "ENOBUFS") {
21
+ const advice = options.discovery ? "ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before retrying" : "make SPEC.md paths more specific, or inspect the failed Git command if its output is unexpected, before retrying";
22
+ throw new Error(`spec source: git output exceeded ${maxBuffer} bytes; ${advice}`, { cause: result.error });
23
+ }
24
+ if (code === "E2BIG") throw new Error("spec source: git arguments exceed the platform limit; shorten the paths list before retrying", { cause: result.error });
25
+ throw new Error(`spec source: git could not run (${code ?? "spawn failure"}); install git or fix PATH before retrying`, { cause: result.error });
26
+ }
27
+ const status = result.status;
28
+ if (status === null || !(options.allowedExitCodes ?? [0]).includes(status)) {
29
+ const first = result.stderr.toString("utf8").split(/\r?\n/, 1)[0]?.split(root).join("<path>").slice(0, 240) || "no diagnostic";
30
+ throw new Error(`spec source: git refused inspection (exit ${status ?? "signal"}): ${first}; inspect git -C <path> rev-parse --show-toplevel and repair the repository or ownership configuration`);
31
+ }
32
+ return { status, stdout: result.stdout, stderr: result.stderr };
33
+ }
package/src/spec-graph.ts CHANGED
@@ -24,10 +24,15 @@ export interface SpecSlice { nodes: SpecNode[]; edges: SpecEdge[]; truncated: bo
24
24
  const compare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
25
25
  const pathOrder = (a: SpecDocument, b: SpecDocument): number => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path) || compare(a.id, b.id);
26
26
  const edgeOrder = (a: SpecEdge, b: SpecEdge): number => compare(a.source, b.source) || SPEC_EDGE_KINDS.indexOf(a.kind) - SPEC_EDGE_KINDS.indexOf(b.kind) || compare(a.target, b.target);
27
- function reference(value: unknown): string | undefined {
27
+ /** Metadata wiki addresses use the same id/label/heading split as body links. */
28
+ export function specReference(value: unknown): string | undefined {
28
29
  if (typeof value !== "string" || !value.trim()) return undefined;
29
- const text = value.trim(), wiki = /^\[\[([^\]|#]+)\]\]$/.exec(text);
30
- return wiki ? wiki[1]!.trim() : text;
30
+ const text = value.trim();
31
+ if (!text.startsWith("[[") && !text.endsWith("]]")) return text;
32
+ const wiki = /^\[\[([^\]\n]+)\]\]$/.exec(text);
33
+ if (!wiki) return undefined;
34
+ const address = wiki[1]!.split("|")[0]!, hash = address.indexOf("#");
35
+ return (hash < 0 ? address : address.slice(0, hash)).trim() || undefined;
31
36
  }
32
37
 
33
38
  /** Build only the read graph: unknown types/statuses remain visible for the later validation pass. */
@@ -54,23 +59,29 @@ export function buildSpecGraph(snapshot: SpecSnapshot): SpecGraph {
54
59
  const edges: SpecEdge[] = [], unresolved: SpecEdge[] = [];
55
60
  const seenEdges = new Map<string, Map<SpecEdgeKind, Set<string>>>();
56
61
  const add = (source: string, value: unknown, kind: SpecEdgeKind) => {
57
- const target = reference(value);
58
- if (!target) return;
62
+ const target = specReference(value);
63
+ if (!target) return false;
59
64
  let kinds = seenEdges.get(source);
60
65
  if (!kinds) seenEdges.set(source, kinds = new Map());
61
66
  let targets = kinds.get(kind);
62
67
  if (!targets) kinds.set(kind, targets = new Set());
63
- if (targets.has(target)) return;
68
+ if (targets.has(target)) return true;
64
69
  if (edges.length + unresolved.length >= MAX_SPEC_EDGES) throw new Error(`spec graph: more than ${MAX_SPEC_EDGES} unique edges; reduce link lists or pass a smaller SpecSnapshot before rebuilding`);
65
70
  targets.add(target);
66
71
  const collection = documents.has(target) ? edges : unresolved;
67
72
  collection.push({ source, target, kind });
73
+ return true;
68
74
  };
69
75
  for (const document of documents.values()) {
70
- for (const kind of ["parent", "superseded-by"] as const) add(document.id, document.frontmatter[kind], kind);
76
+ const wrongShape = (field: string) => diagnostics.push({ code: "wrong_shape", path: document.path, id: document.id, field, message: "frontmatter link has the wrong shape; use one link for parent/superseded-by and a list for dependency/reference/implementation links" });
77
+ for (const kind of ["parent", "superseded-by"] as const) if (document.frontmatter[kind] !== undefined && !add(document.id, document.frontmatter[kind], kind)) wrongShape(kind);
71
78
  for (const kind of ["depends-on", "references", "implements"] as const) {
72
79
  const values = document.frontmatter[kind];
73
- if (Array.isArray(values)) for (const value of values) add(document.id, value, kind);
80
+ if (values === undefined) continue;
81
+ if (!Array.isArray(values)) { wrongShape(kind); continue; }
82
+ let invalid = false;
83
+ for (const value of values) if (!add(document.id, value, kind)) invalid = true;
84
+ if (invalid) wrongShape(kind);
74
85
  }
75
86
  for (const link of extractLinks(document.body)) add(document.id, link.target, "body_link");
76
87
  }
@@ -1,31 +1,34 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { lstatSync } from "node:fs";
3
2
  import { tmpdir } from "node:os";
4
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
4
  import { absolute, type SpecSourceSpec } from "./config.ts";
6
- import { realPath, memoryGitArgv, memoryGitEnv } from "./platform.ts";
5
+ import { realPath } from "./platform.ts";
6
+ import { specGit } from "./spec-git.ts";
7
+ import { parseFrontmatter } from "./frontmatter.ts";
7
8
  import { VaultIndex, IndexCacheError, type IndexSourceAdapter, type SearchOptions, type SearchResult } from "./vault-index.ts";
8
9
 
9
10
  /** Private spec input snapshot; raw sources are for library transforms, never graph results. */
10
- export interface SpecDocument {
11
- id: string;
11
+ export interface SpecCandidate {
12
12
  path: string;
13
13
  hash: string;
14
14
  source: string;
15
15
  frontmatter: Record<string, unknown>;
16
16
  body: string;
17
17
  }
18
+ export interface SpecDocument extends SpecCandidate { id: string }
18
19
  export interface SpecDiagnostic {
19
- code: "duplicate_id" | "invalid_frontmatter" | "invalid_identity" | "skipped_path";
20
+ code: "duplicate_id" | "invalid_frontmatter" | "invalid_identity" | "skipped_path" | "wrong_shape";
20
21
  path: string;
21
22
  id?: string;
22
23
  firstPath?: string;
24
+ field?: string;
23
25
  message: string;
24
26
  }
25
27
  export interface SpecSnapshot {
26
28
  root: string;
27
29
  commonDir: string;
28
30
  specs: SpecDocument[];
31
+ candidates?: SpecCandidate[];
29
32
  diagnostics: SpecDiagnostic[];
30
33
  }
31
34
 
@@ -36,22 +39,7 @@ interface Binding { root: string; registeredRoot: string; commonDir: string; roo
36
39
  const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
37
40
  const pathOrder = (a: string, b: string) => compare(a.normalize("NFC"), b.normalize("NFC")) || compare(a, b);
38
41
  function git(root: string, args: string[], env: NodeJS.ProcessEnv, maxBuffer = 65536): Buffer {
39
- const argv = memoryGitArgv(["-C", root, ...args]);
40
- const result = spawnSync(argv[0]!, argv.slice(1), { env: memoryGitEnv({ ...env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, { repositoryIsolation: true }), timeout: 10000, maxBuffer, stdio: ["ignore", "pipe", "pipe"] });
41
- if (result.error) {
42
- const code = (result.error as NodeJS.ErrnoException).code;
43
- if (code === "ETIMEDOUT") throw new Error("spec source: git inspection timed out after 10000 ms; retry and check for a locked repository or slow mount", { cause: result.error });
44
- if (code === "ENOBUFS") {
45
- const advice = args[0] === "ls-files" ? "ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before retrying" : "inspect git -C <path> rev-parse --show-toplevel and repair unexpected output before retrying";
46
- throw new Error(`spec source: git output exceeded ${maxBuffer} bytes; ${advice}`, { cause: result.error });
47
- }
48
- throw new Error(`spec source: git could not run (${code ?? "spawn failure"}); install git or fix PATH before retrying`, { cause: result.error });
49
- }
50
- if (result.status !== 0) {
51
- const first = result.stderr.toString("utf8").split(/\r?\n/, 1)[0]?.split(root).join("<path>").slice(0, 240) || "no diagnostic";
52
- throw new Error(`spec source: git refused inspection (exit ${result.status ?? "signal"}): ${first}; inspect git -C <path> rev-parse --show-toplevel and repair the repository or ownership configuration`);
53
- }
54
- return result.stdout;
42
+ return specGit(root, args, { env, maxBuffer, discovery: args[0] === "ls-files" }).stdout;
55
43
  }
56
44
  function rootInfo(path: string, env: NodeJS.ProcessEnv): { root: string; commonDir: string } {
57
45
  try { absolute(path, "spec worktree"); }
@@ -73,7 +61,7 @@ function directoryIdentity(root: string): string | undefined {
73
61
  }
74
62
  function bind(source: SpecSourceSpec, options: SpecSourceOptions): Binding {
75
63
  const env = options.env ?? process.env, registered = rootInfo(source.path, env);
76
- if (registered.root !== source.path) throw new Error("spec source: registered root is no longer canonical; re-register it with vmem specs add");
64
+ if (registered.root !== source.path) throw new Error("spec source: registered root is no longer canonical; restore the canonical root or preview unregistering it with vmem specs remove <id>, then confirm removal before adding the new root");
77
65
  const selected = options.worktree === undefined ? registered : rootInfo(options.worktree, env);
78
66
  if (selected.commonDir !== registered.commonDir) throw new Error("spec source: worktree belongs to another repository; select a worktree sharing the registered repository's git common directory");
79
67
  const rootIdentity = directoryIdentity(selected.root);
@@ -116,13 +104,28 @@ function pathProblem(binding: Binding, path: string): string | undefined {
116
104
  if (physicalRelative === ".." || physicalRelative.startsWith(`..${sep}`) || isAbsolute(physicalRelative)) return `spec source ${path}: physical path leaves the registered root; restore the original root and plain in-repository file before retrying`;
117
105
  return undefined;
118
106
  }
107
+ function namedSpec(path: string): boolean { return basename(path) === "SPEC.md"; }
108
+ function candidateData(path: string, data: Record<string, unknown>): boolean { return namedSpec(path) || Object.hasOwn(data, "id") || Object.hasOwn(data, "type"); }
109
+ function candidateProbe(path: string, reason: "oversized" | "invalid_utf8", bytes: Buffer): boolean {
110
+ if (namedSpec(path)) return true;
111
+ try {
112
+ const document = parseFrontmatter(bytes.toString("utf8"));
113
+ if (!document.frontmatter) return false;
114
+ // This is a conservative signal only: visible reserved keys keep the blocking encoding error.
115
+ // Accepted documents are still decoded strictly by the scanner.
116
+ return candidateData(path, document.data);
117
+ } catch (error) {
118
+ if (reason === "oversized" && error instanceof Error && error.message === "frontmatter: missing closing delimiter") throw new Error(`spec source ${path}: frontmatter exceeds the bounded candidate probe; shorten the header before validating`, { cause: error });
119
+ return false;
120
+ }
121
+ }
119
122
  function adapter(binding: Binding) {
120
- let specs: SpecDocument[] = [], diagnostics: SpecDiagnostic[] = [];
123
+ let specs: SpecDocument[] = [], candidates: SpecCandidate[] = [], diagnostics: SpecDiagnostic[] = [];
121
124
  const seen = new Map<string, string>();
122
125
  const source: IndexSourceAdapter = {
123
126
  validate: () => validate(binding),
124
127
  paths: () => {
125
- specs = []; diagnostics = []; seen.clear();
128
+ specs = []; candidates = []; diagnostics = []; seen.clear();
126
129
  const bytes = git(binding.root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "*.md"], binding.env, 8 * 1024 * 1024);
127
130
  let output: string;
128
131
  try { output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
@@ -141,9 +144,19 @@ function adapter(binding: Binding) {
141
144
  const problem = pathProblem(binding, path);
142
145
  if (problem !== undefined) throw new Error(problem);
143
146
  },
144
- invalid: path => diagnostics.push({ code: "invalid_frontmatter", path, message: "spec source: invalid frontmatter; repair the Markdown YAML before rebuilding" }),
147
+ invalid: path => {
148
+ if (!namedSpec(path)) return false;
149
+ diagnostics.push({ code: "invalid_frontmatter", path, message: "spec source: invalid frontmatter; repair the SPEC.md YAML before validating" });
150
+ return true;
151
+ },
152
+ skipFile: (path, reason, bytes) => {
153
+ if (candidateProbe(path, reason, bytes)) return false;
154
+ diagnostics.push({ code: "skipped_path", path, message: `non-candidate Markdown ${path}: ${reason === "oversized" ? "file exceeds the bounded read size" : "file is not valid UTF-8"}; split/re-encode it or exclude it via .gitignore` });
155
+ return true;
156
+ },
145
157
  accept: (path, document, hash) => {
146
158
  const data = document.data;
159
+ if (candidateData(path, data)) candidates.push({ path, hash, source: document.source, frontmatter: data, body: document.body });
147
160
  if (!Object.hasOwn(data, "id") || !Object.hasOwn(data, "type")) return false;
148
161
  if (typeof data.id !== "string" || !data.id.trim() || typeof data.type !== "string" || !data.type.trim()) {
149
162
  diagnostics.push({ code: "invalid_identity", path, message: "spec source: id and type must be nonempty strings; repair the frontmatter before rebuilding" });
@@ -159,7 +172,7 @@ function adapter(binding: Binding) {
159
172
  return true;
160
173
  },
161
174
  };
162
- return { source, snapshot: (): SpecSnapshot => ({ root: binding.root, commonDir: binding.commonDir, specs: [...specs], diagnostics: [...diagnostics] }) };
175
+ return { source, snapshot: (): SpecSnapshot => ({ root: binding.root, commonDir: binding.commonDir, specs: [...specs], candidates: [...candidates], diagnostics: [...diagnostics] }) };
163
176
  }
164
177
  /** Call scan() to refresh. Search and snapshots use the last completed scan, like VaultIndex. */
165
178
  export class SpecIndex {
@@ -183,15 +196,23 @@ export class SpecIndex {
183
196
  get file(): string { return this.index.file; }
184
197
  close(): void { this.index.close(); }
185
198
  scan(rebuild = false): ReturnType<VaultIndex["scan"]> {
186
- const result = this.index.scan(rebuild);
187
- this.current = this.adapter.snapshot();
188
- return result;
199
+ try {
200
+ const result = this.index.scan(rebuild);
201
+ this.current = this.adapter.snapshot();
202
+ return result;
203
+ } catch (error) { this.current = undefined; throw error; }
189
204
  }
190
205
  snapshot(): SpecSnapshot { if (!this.current) this.scan(); return this.current!; }
191
- search(query: string, options: SearchOptions = {}): SpecSearchResult[] {
206
+ search(query: string, options: SearchOptions & { offset?: number } = {}): SpecSearchResult[] {
207
+ if (options.offset !== undefined && (!Number.isSafeInteger(options.offset) || options.offset < 0 || options.offset > 10000)) throw new Error("spec search offset: expected integer 0..10000; choose a bounded continuation offset");
208
+ return this.searchRows(query, options, false);
209
+ }
210
+ /** One bounded query materializes the matching snapshot before callers filter or page it. */
211
+ searchAll(query: string): SpecSearchResult[] { return this.searchRows(query, {}, true); }
212
+ private searchRows(query: string, options: SearchOptions & { offset?: number }, all: boolean): SpecSearchResult[] {
192
213
  const snapshot = this.snapshot(), ids = new Map(snapshot.specs.map(spec => [spec.path, spec.id]));
193
214
  const hashes = new Map(snapshot.specs.map(spec => [spec.path, spec.hash]));
194
- return this.index.search(query, options, hashes).flatMap(hit => {
215
+ return this.index.search(query, options, hashes, options.offset ?? 0, all).flatMap(hit => {
195
216
  const id = ids.get(hit.path);
196
217
  return id === undefined ? [] : [{ ...hit, id }];
197
218
  });