@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.
- package/CHANGELOG.md +8 -0
- package/README.md +35 -2
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/commands-table.ts +5 -3
- package/src/index.ts +6 -1
- package/src/mcp-spec.ts +65 -0
- package/src/mcp-vault.ts +6 -2
- package/src/spec-command-table.ts +15 -0
- package/src/spec-commands.ts +174 -0
- package/src/spec-freshness.ts +163 -0
- package/src/spec-git.ts +33 -0
- package/src/spec-graph.ts +19 -8
- package/src/spec-source.ts +53 -32
- package/src/spec-validate.ts +241 -0
- package/src/vault-index.ts +42 -20
|
@@ -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
|
+
}
|
package/src/spec-git.ts
ADDED
|
@@ -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
|
-
|
|
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()
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
package/src/spec-source.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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;
|
|
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 =>
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
});
|