@staix/vault-mem 0.3.0 → 0.3.2
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 +17 -1
- package/README.md +23 -5
- package/package.json +1 -1
- package/src/cli.ts +52 -11
- package/src/commands-table.ts +5 -2
- package/src/config.ts +28 -8
- package/src/index.ts +8 -3
- package/src/log-commands.ts +1 -1
- package/src/mcp-vault.ts +6 -2
- package/src/mcp.ts +44 -19
- package/src/org-promote.ts +1 -14
- package/src/registry.ts +63 -2
- package/src/spec-graph.ts +117 -0
- package/src/spec-source.ts +204 -0
- package/src/sync.ts +17 -5
- package/src/tidy.ts +7 -1
- package/src/vault-index.ts +159 -97
- package/src/write-commands.ts +8 -3
- package/src/write-operations.ts +46 -1
- package/src/write-validation.ts +20 -8
package/src/registry.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realPath, memoryGitArgv, memoryGitEnv } from "./platform.ts";
|
|
2
4
|
import { hostname } from "node:os";
|
|
3
5
|
import { join } from "node:path";
|
|
4
6
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, piiExtensions, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
|
|
7
|
+
import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, parseSpec, piiExtensions, readConfig, type VaultSpec, type SpecSourceSpec, type VmemConfig } from "./config.ts";
|
|
6
8
|
import { loosenedCells, piiPolicy, piiShorthand } from "./pii.ts";
|
|
7
9
|
export const LOCK_FILE = "config.lock";
|
|
8
10
|
const TEMP = /^\.config-[0-9a-f-]{36}\.tmp$/;
|
|
@@ -41,6 +43,7 @@ function mutate<T>(home: string, update: (config: VmemConfig) => T): T {
|
|
|
41
43
|
const owner: LockOwner = { pid: process.pid, host: hostname(), created: new Date().toISOString() };
|
|
42
44
|
writeFileSync(lockFd, JSON.stringify(owner) + "\n");
|
|
43
45
|
const config = loadRegistry(home), result = update(config);
|
|
46
|
+
if (config.specs !== undefined) config.version = Math.max(config.version ?? 1, 2);
|
|
44
47
|
parseConfig(config);
|
|
45
48
|
const text = JSON.stringify(config, null, 2) + "\n";
|
|
46
49
|
if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) throw new Error(`${join(home, CONFIG_FILE)}: serialized config exceeds 64 KiB`);
|
|
@@ -148,7 +151,7 @@ export function addVault(home: string, value: unknown): VaultSpec {
|
|
|
148
151
|
if (!isDir) throw new Error(`vault ${vault.id}: ${vault.path} must contain notes directory`);
|
|
149
152
|
if (realpathSync(notes) !== notes) throw new Error(`vault ${vault.id}: ${notes} must not be a symlink`);
|
|
150
153
|
return mutate(home, config => {
|
|
151
|
-
if (config.vaults.some(v => v.id === vault.id)) throw new Error(`vault ${vault.id}: id already registered`);
|
|
154
|
+
if ([...config.vaults, ...(config.specs ?? [])].some(v => v.id === vault.id)) throw new Error(`vault ${vault.id}: id already registered; use vmem vaults list and vmem specs list, then choose a unique id`);
|
|
152
155
|
if (config.vaults.some(v => {
|
|
153
156
|
if (v.path === vault.path) return true;
|
|
154
157
|
try { return realpathSync(v.path) === vault.path; }
|
|
@@ -199,3 +202,61 @@ export function registryStatus(home: string) {
|
|
|
199
202
|
} catch { return { ...base, healthy: false, issue: "vault path or notes directory unavailable or changed" }; }
|
|
200
203
|
});
|
|
201
204
|
}
|
|
205
|
+
|
|
206
|
+
/** Inspect only local git metadata, with inherited repository overrides removed and bounded output/time. */
|
|
207
|
+
function requireSpecRoot(path: string, env: NodeJS.ProcessEnv): void {
|
|
208
|
+
const argv = memoryGitArgv(["-C", path, "rev-parse", "--show-toplevel"]);
|
|
209
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { env: memoryGitEnv({ ...env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, { repositoryIsolation: true }), encoding: "utf8", timeout: 5000, maxBuffer: 65536, stdio: ["ignore", "pipe", "pipe"] });
|
|
210
|
+
if (result.error) {
|
|
211
|
+
const code = (result.error as NodeJS.ErrnoException).code;
|
|
212
|
+
if (code === "ETIMEDOUT") throw new Error("spec source: git root check timed out after 5000 ms; retry and check for a locked repository or slow mount");
|
|
213
|
+
if (code === "ENOBUFS") throw new Error("spec source: git root check output exceeded 64 KiB; inspect git -C <path> rev-parse --show-toplevel and repair unexpected output before retrying");
|
|
214
|
+
throw new Error(`spec source: git could not run (${code ?? "spawn failure"}); install git or fix PATH, then retry the root check`);
|
|
215
|
+
}
|
|
216
|
+
if (result.status !== 0) {
|
|
217
|
+
const firstLine = result.stderr.split(/\r?\n/, 1)[0]?.split(path).join("<path>").slice(0, 240) || "no diagnostic";
|
|
218
|
+
throw new Error(`spec source: git refused the root check (exit ${result.status ?? "signal"}): ${firstLine}; inspect git -C <path> rev-parse --show-toplevel and repair the repository or ownership configuration`);
|
|
219
|
+
}
|
|
220
|
+
const root = result.stdout.replace(/\r?\n$/, "");
|
|
221
|
+
if (root !== path) throw new Error(`spec source: selected path is inside a work tree but not its root; register the reported root ${root} with vmem specs add <id> <absolute-path>`);
|
|
222
|
+
}
|
|
223
|
+
export function addSpec(home: string, value: unknown, env: NodeJS.ProcessEnv = process.env): SpecSourceSpec {
|
|
224
|
+
const spec = parseSpec(value);
|
|
225
|
+
try { spec.path = realPath(spec.path); }
|
|
226
|
+
catch { throw new Error(`spec source ${spec.id}: path is unavailable; restore the repository or pass its existing absolute root to vmem specs add`); }
|
|
227
|
+
requireSpecRoot(spec.path, env);
|
|
228
|
+
return mutate(home, config => {
|
|
229
|
+
if ([...config.vaults, ...(config.specs ?? [])].some(source => source.id === spec.id)) throw new Error(`spec source ${spec.id}: id already registered; use vmem specs list and vmem vaults list, then choose a unique id`);
|
|
230
|
+
if ((config.specs ?? []).some(source => {
|
|
231
|
+
if (source.path === spec.path) return true;
|
|
232
|
+
try { return realPath(source.path) === spec.path; }
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (["ENOENT", "ENOTDIR", "EACCES", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
})) throw new Error(`spec source ${spec.id}: path already registered; use vmem specs list to select its existing id`);
|
|
238
|
+
(config.specs ??= []).push(spec);
|
|
239
|
+
return spec;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
export function removeSpec(home: string, id: string, confirm = false): { applied: boolean; spec: SpecSourceSpec } {
|
|
243
|
+
const remove = (config: VmemConfig) => {
|
|
244
|
+
const spec = config.specs?.find(source => source.id === id);
|
|
245
|
+
if (!spec) throw new Error(`unknown spec source id ${id}; use vmem specs list to select a registered id`);
|
|
246
|
+
if (confirm) config.specs = config.specs!.filter(source => source.id !== id);
|
|
247
|
+
return { applied: confirm, spec };
|
|
248
|
+
};
|
|
249
|
+
return confirm ? mutate(home, remove) : remove(loadRegistry(home));
|
|
250
|
+
}
|
|
251
|
+
export function specStatus(home: string, env: NodeJS.ProcessEnv = process.env) {
|
|
252
|
+
return (loadRegistry(home).specs ?? []).map(spec => {
|
|
253
|
+
try {
|
|
254
|
+
if (realPath(spec.path) !== spec.path) throw new Error("spec source path changed; restore the canonical repository root or unregister it with vmem specs remove <id>");
|
|
255
|
+
requireSpecRoot(spec.path, env);
|
|
256
|
+
return { ...spec, healthy: true };
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const cause = error instanceof Error ? error.message : "spec source path inspection failed";
|
|
259
|
+
return { ...spec, healthy: false, issue: `${cause}; restore a readable canonical root, or unregister it with vmem specs remove <id>` };
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { extractLinks } from "./links.ts";
|
|
2
|
+
import type { SpecDiagnostic, SpecDocument, SpecSnapshot } from "./spec-source.ts";
|
|
3
|
+
|
|
4
|
+
export const SPEC_EDGE_KINDS = ["parent", "depends-on", "references", "implements", "superseded-by", "body_link"] as const;
|
|
5
|
+
/** Pure transforms keep the discovery ceiling and bound resolved plus unresolved edges together. */
|
|
6
|
+
export const MAX_SPEC_INPUTS = 10000, MAX_SPEC_EDGES = 100000;
|
|
7
|
+
export type SpecEdgeKind = typeof SPEC_EDGE_KINDS[number];
|
|
8
|
+
export interface SpecNode { id: string; path: string; title: string; type: string; status?: string }
|
|
9
|
+
export interface SpecEdge { source: string; target: string; kind: SpecEdgeKind }
|
|
10
|
+
export interface SpecGraph {
|
|
11
|
+
nodes: SpecNode[];
|
|
12
|
+
edges: SpecEdge[];
|
|
13
|
+
unresolved: SpecEdge[];
|
|
14
|
+
diagnostics: SpecDiagnostic[];
|
|
15
|
+
}
|
|
16
|
+
export interface SpecSliceOptions {
|
|
17
|
+
direction?: "subtree" | "ancestors" | "neighbors";
|
|
18
|
+
depth?: number;
|
|
19
|
+
limit?: number;
|
|
20
|
+
edge?: SpecEdgeKind;
|
|
21
|
+
}
|
|
22
|
+
export interface SpecSlice { nodes: SpecNode[]; edges: SpecEdge[]; truncated: boolean }
|
|
23
|
+
|
|
24
|
+
const compare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
|
|
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
|
+
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 {
|
|
28
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
29
|
+
const text = value.trim(), wiki = /^\[\[([^\]|#]+)\]\]$/.exec(text);
|
|
30
|
+
return wiki ? wiki[1]!.trim() : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build only the read graph: unknown types/statuses remain visible for the later validation pass. */
|
|
34
|
+
export function buildSpecGraph(snapshot: SpecSnapshot): SpecGraph {
|
|
35
|
+
if (snapshot.specs.length > MAX_SPEC_INPUTS) throw new Error(`spec graph: more than ${MAX_SPEC_INPUTS} input specs; pass a smaller SpecSnapshot before building the graph`);
|
|
36
|
+
const documents = new Map<string, SpecDocument>(), diagnostics = [...snapshot.diagnostics];
|
|
37
|
+
for (const document of [...snapshot.specs].sort(pathOrder)) {
|
|
38
|
+
const first = documents.get(document.id);
|
|
39
|
+
if (first) {
|
|
40
|
+
if (!diagnostics.some(item => item.code === "duplicate_id" && item.path === document.path && item.id === document.id)) diagnostics.push({
|
|
41
|
+
code: "duplicate_id", path: document.path, id: document.id, firstPath: first.path,
|
|
42
|
+
message: "duplicate spec id; give each spec a unique frontmatter id and rebuild the graph",
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
documents.set(document.id, document);
|
|
47
|
+
}
|
|
48
|
+
const nodes = [...documents.values()].map(document => ({
|
|
49
|
+
id: document.id, path: document.path,
|
|
50
|
+
title: typeof document.frontmatter.title === "string" ? document.frontmatter.title : document.id,
|
|
51
|
+
type: typeof document.frontmatter.type === "string" ? document.frontmatter.type : "",
|
|
52
|
+
...(typeof document.frontmatter.status === "string" ? { status: document.frontmatter.status } : {}),
|
|
53
|
+
})).sort((a, b) => compare(a.id, b.id));
|
|
54
|
+
const edges: SpecEdge[] = [], unresolved: SpecEdge[] = [];
|
|
55
|
+
const seenEdges = new Map<string, Map<SpecEdgeKind, Set<string>>>();
|
|
56
|
+
const add = (source: string, value: unknown, kind: SpecEdgeKind) => {
|
|
57
|
+
const target = reference(value);
|
|
58
|
+
if (!target) return;
|
|
59
|
+
let kinds = seenEdges.get(source);
|
|
60
|
+
if (!kinds) seenEdges.set(source, kinds = new Map());
|
|
61
|
+
let targets = kinds.get(kind);
|
|
62
|
+
if (!targets) kinds.set(kind, targets = new Set());
|
|
63
|
+
if (targets.has(target)) return;
|
|
64
|
+
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
|
+
targets.add(target);
|
|
66
|
+
const collection = documents.has(target) ? edges : unresolved;
|
|
67
|
+
collection.push({ source, target, kind });
|
|
68
|
+
};
|
|
69
|
+
for (const document of documents.values()) {
|
|
70
|
+
for (const kind of ["parent", "superseded-by"] as const) add(document.id, document.frontmatter[kind], kind);
|
|
71
|
+
for (const kind of ["depends-on", "references", "implements"] as const) {
|
|
72
|
+
const values = document.frontmatter[kind];
|
|
73
|
+
if (Array.isArray(values)) for (const value of values) add(document.id, value, kind);
|
|
74
|
+
}
|
|
75
|
+
for (const link of extractLinks(document.body)) add(document.id, link.target, "body_link");
|
|
76
|
+
}
|
|
77
|
+
diagnostics.sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path) || compare(a.code, b.code));
|
|
78
|
+
return { nodes, edges: edges.sort(edgeOrder), unresolved: unresolved.sort(edgeOrder), diagnostics };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A deterministic BFS; parent edges point child -> parent, while subtree traversal reverses them. */
|
|
82
|
+
export function specSlice(graph: SpecGraph, id: string, options: SpecSliceOptions = {}): SpecSlice {
|
|
83
|
+
const direction = options.direction ?? "subtree", depth = options.depth ?? 1, limit = options.limit ?? 50;
|
|
84
|
+
if (!["subtree", "ancestors", "neighbors"].includes(direction)) throw new Error("spec slice: invalid direction; choose subtree, ancestors or neighbors");
|
|
85
|
+
if (!Number.isInteger(depth) || depth < 0 || depth > 3) throw new Error("spec slice: depth must be an integer from 0 to 3; pass a smaller depth");
|
|
86
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("spec slice: limit must be an integer from 1 to 500; pass a valid node limit");
|
|
87
|
+
if (options.edge !== undefined && !SPEC_EDGE_KINDS.includes(options.edge)) throw new Error("spec slice: unknown edge kind; choose a kind from SPEC_EDGE_KINDS");
|
|
88
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
|
|
89
|
+
if (typeof id !== "string" || !nodes.has(id)) throw new Error("spec slice: unknown spec id; select an id from buildSpecGraph(...).nodes");
|
|
90
|
+
const edge = options.edge ?? (direction === "neighbors" ? undefined : "parent");
|
|
91
|
+
const edges = graph.edges.filter(item => (edge === undefined || item.kind === edge) && nodes.has(item.source) && nodes.has(item.target));
|
|
92
|
+
const adjacency = new Map<string, Set<string>>();
|
|
93
|
+
const add = (from: string, to: string) => {
|
|
94
|
+
let neighbors = adjacency.get(from);
|
|
95
|
+
if (!neighbors) adjacency.set(from, neighbors = new Set());
|
|
96
|
+
neighbors.add(to);
|
|
97
|
+
};
|
|
98
|
+
for (const item of edges) {
|
|
99
|
+
if (direction === "ancestors" || direction === "neighbors") add(item.source, item.target);
|
|
100
|
+
if (direction === "subtree" || direction === "neighbors") add(item.target, item.source);
|
|
101
|
+
}
|
|
102
|
+
const selected = new Set([id]), queue: { id: string; depth: number }[] = [{ id, depth: 0 }];
|
|
103
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
104
|
+
const current = queue[cursor]!;
|
|
105
|
+
for (const neighbor of [...(adjacency.get(current.id) ?? [])].sort(compare)) {
|
|
106
|
+
if (selected.has(neighbor)) continue;
|
|
107
|
+
if (current.depth >= depth || selected.size >= limit) continue;
|
|
108
|
+
selected.add(neighbor); queue.push({ id: neighbor, depth: current.depth + 1 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const truncated = [...selected].some(node => [...(adjacency.get(node) ?? [])].some(neighbor => !selected.has(neighbor)));
|
|
112
|
+
return {
|
|
113
|
+
nodes: graph.nodes.filter(node => selected.has(node.id)).sort((a, b) => compare(a.id, b.id)),
|
|
114
|
+
edges: edges.filter(item => selected.has(item.source) && selected.has(item.target)).sort(edgeOrder),
|
|
115
|
+
truncated,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { absolute, type SpecSourceSpec } from "./config.ts";
|
|
6
|
+
import { realPath, memoryGitArgv, memoryGitEnv } from "./platform.ts";
|
|
7
|
+
import { VaultIndex, IndexCacheError, type IndexSourceAdapter, type SearchOptions, type SearchResult } from "./vault-index.ts";
|
|
8
|
+
|
|
9
|
+
/** Private spec input snapshot; raw sources are for library transforms, never graph results. */
|
|
10
|
+
export interface SpecDocument {
|
|
11
|
+
id: string;
|
|
12
|
+
path: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
source: string;
|
|
15
|
+
frontmatter: Record<string, unknown>;
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SpecDiagnostic {
|
|
19
|
+
code: "duplicate_id" | "invalid_frontmatter" | "invalid_identity" | "skipped_path";
|
|
20
|
+
path: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
firstPath?: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SpecSnapshot {
|
|
26
|
+
root: string;
|
|
27
|
+
commonDir: string;
|
|
28
|
+
specs: SpecDocument[];
|
|
29
|
+
diagnostics: SpecDiagnostic[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SpecSourceOptions { worktree?: string; env?: NodeJS.ProcessEnv }
|
|
33
|
+
export interface SpecIndexOptions extends SpecSourceOptions { memory?: boolean; reuseCache?: boolean; protectedRoots?: string[] }
|
|
34
|
+
export interface SpecSearchResult extends SearchResult { id: string }
|
|
35
|
+
interface Binding { root: string; registeredRoot: string; commonDir: string; rootIdentity: string; env: NodeJS.ProcessEnv }
|
|
36
|
+
const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
|
37
|
+
const pathOrder = (a: string, b: string) => compare(a.normalize("NFC"), b.normalize("NFC")) || compare(a, b);
|
|
38
|
+
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;
|
|
55
|
+
}
|
|
56
|
+
function rootInfo(path: string, env: NodeJS.ProcessEnv): { root: string; commonDir: string } {
|
|
57
|
+
try { absolute(path, "spec worktree"); }
|
|
58
|
+
catch { throw new Error("spec source: worktree must be an absolute path without control characters; pass the repository root"); }
|
|
59
|
+
let root: string;
|
|
60
|
+
try { root = realPath(path); } catch { throw new Error("spec source: worktree root is unavailable; restore the repository or select an existing worktree"); }
|
|
61
|
+
// rev-parse has no NUL-output mode: root paths reject control characters, and exactly two lines are required.
|
|
62
|
+
const lines = git(root, ["rev-parse", "--show-toplevel", "--git-common-dir"], env).toString("utf8").replace(/\n$/, "").split("\n");
|
|
63
|
+
if (lines.length !== 2 || lines[0]?.normalize("NFC") !== root.normalize("NFC") || !lines[1]) throw new Error("spec source: selected path is not a git working-tree root; select the root from git rev-parse --show-toplevel");
|
|
64
|
+
let commonDir: string;
|
|
65
|
+
try { commonDir = realPath(resolve(root, lines[1])); } catch { throw new Error("spec source: git common directory is unavailable; repair the repository before retrying"); }
|
|
66
|
+
return { root, commonDir };
|
|
67
|
+
}
|
|
68
|
+
function directoryIdentity(root: string): string | undefined {
|
|
69
|
+
try {
|
|
70
|
+
const stat = lstatSync(root, { bigint: true });
|
|
71
|
+
return stat.isDirectory() && !stat.isSymbolicLink() ? `${stat.dev}:${stat.ino}` : undefined;
|
|
72
|
+
} catch (error) { throw new Error("spec source: canonical root cannot be inspected; restore the worktree directory and access, then reopen the source index", { cause: error }); }
|
|
73
|
+
}
|
|
74
|
+
function bind(source: SpecSourceSpec, options: SpecSourceOptions): Binding {
|
|
75
|
+
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");
|
|
77
|
+
const selected = options.worktree === undefined ? registered : rootInfo(options.worktree, env);
|
|
78
|
+
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
|
+
const rootIdentity = directoryIdentity(selected.root);
|
|
80
|
+
if (rootIdentity === undefined) throw new Error("spec source: selected root is not a plain directory; restore the canonical worktree root and retry");
|
|
81
|
+
return { root: selected.root, registeredRoot: registered.root, commonDir: selected.commonDir, rootIdentity, env };
|
|
82
|
+
}
|
|
83
|
+
export function resolveSpecWorktree(source: SpecSourceSpec, worktree?: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
84
|
+
return bind(source, { worktree, env }).root;
|
|
85
|
+
}
|
|
86
|
+
function validate(binding: Binding): void {
|
|
87
|
+
const registered = rootInfo(binding.registeredRoot, binding.env), selected = rootInfo(binding.root, binding.env);
|
|
88
|
+
if (registered.root !== binding.registeredRoot || selected.root !== binding.root || registered.commonDir !== binding.commonDir || selected.commonDir !== binding.commonDir || directoryIdentity(binding.root) !== binding.rootIdentity) throw new Error("spec source: repository/worktree binding changed; resolve the registered source again before retrying");
|
|
89
|
+
}
|
|
90
|
+
/** A canonical root plus component checks permits filesystem spelling aliases without following links. */
|
|
91
|
+
function pathProblem(binding: Binding, path: string): string | undefined {
|
|
92
|
+
const root = binding.root;
|
|
93
|
+
const parts = path.split("/"), contained = relative(root, resolve(root, path));
|
|
94
|
+
if (!path.endsWith(".md") || isAbsolute(path) || parts.some(part => !part || part === "." || part === "..") || contained === ".." || contained.startsWith(`..${sep}`) || isAbsolute(contained)) throw new Error(`spec source ${path}: git returned an unsafe Markdown path; repair the index and retry discovery`);
|
|
95
|
+
let rootIdentity: string | undefined;
|
|
96
|
+
try { rootIdentity = directoryIdentity(root); }
|
|
97
|
+
catch (error) { throw new Error(`spec source ${path}: canonical root cannot be inspected; restore root access and retry`, { cause: error }); }
|
|
98
|
+
if (rootIdentity !== binding.rootIdentity) return `spec source ${path}: root directory identity changed; restore the original root before retrying`;
|
|
99
|
+
let current = root;
|
|
100
|
+
for (let i = 0; i < parts.length; i++) {
|
|
101
|
+
current = join(current, parts[i]!);
|
|
102
|
+
let stat;
|
|
103
|
+
try { stat = lstatSync(current); }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return `spec source ${path}: path is missing; restore the tracked file or stage its deletion before retrying`;
|
|
106
|
+
throw new Error(`spec source ${path}: Markdown path cannot be inspected; restore read access and retry discovery`, { cause: error });
|
|
107
|
+
}
|
|
108
|
+
if (stat.isSymbolicLink()) return `spec source ${path}: symlink component excluded; replace it with a plain file or directory before retrying`;
|
|
109
|
+
if (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) return `spec source ${path}: path is not a regular Markdown file with directory parents; restore the plain file and retry`;
|
|
110
|
+
}
|
|
111
|
+
let physical: string;
|
|
112
|
+
try { physical = realPath(current); }
|
|
113
|
+
catch (error) { throw new Error(`spec source ${path}: physical Markdown path cannot be resolved; restore read access and a plain path, then retry`, { cause: error }); }
|
|
114
|
+
// Git's full spelling may differ, but the file must stay below the captured physical root.
|
|
115
|
+
const physicalRelative = relative(root, physical);
|
|
116
|
+
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
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
function adapter(binding: Binding) {
|
|
120
|
+
let specs: SpecDocument[] = [], diagnostics: SpecDiagnostic[] = [];
|
|
121
|
+
const seen = new Map<string, string>();
|
|
122
|
+
const source: IndexSourceAdapter = {
|
|
123
|
+
validate: () => validate(binding),
|
|
124
|
+
paths: () => {
|
|
125
|
+
specs = []; diagnostics = []; seen.clear();
|
|
126
|
+
const bytes = git(binding.root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "*.md"], binding.env, 8 * 1024 * 1024);
|
|
127
|
+
let output: string;
|
|
128
|
+
try { output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
129
|
+
catch { throw new Error("spec source: Git paths are not valid UTF-8; repair the filenames before retrying discovery"); }
|
|
130
|
+
if (output && !output.endsWith("\0")) throw new Error("spec source: git returned incomplete NUL-delimited paths; retry discovery on a stable repository");
|
|
131
|
+
const paths = [...new Set(output.split("\0").filter(Boolean))].sort(pathOrder);
|
|
132
|
+
if (paths.length > 10000) throw new Error("spec source: more than 10000 Markdown paths; ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before rebuilding");
|
|
133
|
+
return paths.filter(path => {
|
|
134
|
+
const problem = pathProblem(binding, path);
|
|
135
|
+
if (problem === undefined) return true;
|
|
136
|
+
diagnostics.push({ code: "skipped_path", path, message: problem });
|
|
137
|
+
return false;
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
validatePath: path => {
|
|
141
|
+
const problem = pathProblem(binding, path);
|
|
142
|
+
if (problem !== undefined) throw new Error(problem);
|
|
143
|
+
},
|
|
144
|
+
invalid: path => diagnostics.push({ code: "invalid_frontmatter", path, message: "spec source: invalid frontmatter; repair the Markdown YAML before rebuilding" }),
|
|
145
|
+
accept: (path, document, hash) => {
|
|
146
|
+
const data = document.data;
|
|
147
|
+
if (!Object.hasOwn(data, "id") || !Object.hasOwn(data, "type")) return false;
|
|
148
|
+
if (typeof data.id !== "string" || !data.id.trim() || typeof data.type !== "string" || !data.type.trim()) {
|
|
149
|
+
diagnostics.push({ code: "invalid_identity", path, message: "spec source: id and type must be nonempty strings; repair the frontmatter before rebuilding" });
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const firstPath = seen.get(data.id);
|
|
153
|
+
if (firstPath !== undefined) {
|
|
154
|
+
diagnostics.push({ code: "duplicate_id", path, id: data.id, firstPath, message: "duplicate spec id; give each spec a unique frontmatter id before rebuilding" });
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
seen.set(data.id, path);
|
|
158
|
+
specs.push({ id: data.id, path, hash, source: document.source, frontmatter: data, body: document.body });
|
|
159
|
+
return true;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
return { source, snapshot: (): SpecSnapshot => ({ root: binding.root, commonDir: binding.commonDir, specs: [...specs], diagnostics: [...diagnostics] }) };
|
|
163
|
+
}
|
|
164
|
+
/** Call scan() to refresh. Search and snapshots use the last completed scan, like VaultIndex. */
|
|
165
|
+
export class SpecIndex {
|
|
166
|
+
private readonly index: VaultIndex<SpecSourceSpec>;
|
|
167
|
+
private readonly adapter: ReturnType<typeof adapter>;
|
|
168
|
+
private current?: SpecSnapshot;
|
|
169
|
+
constructor(home: string, source: SpecSourceSpec, options: SpecIndexOptions = {}) {
|
|
170
|
+
const binding = bind(source, options);
|
|
171
|
+
this.adapter = adapter(binding);
|
|
172
|
+
try {
|
|
173
|
+
this.index = new VaultIndex(home, { ...source, path: binding.root }, [...new Set([source.path, binding.root, ...(options.protectedRoots ?? [])])], { memory: options.memory, reuseCache: options.reuseCache, adapter: this.adapter.source });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof IndexCacheError) {
|
|
176
|
+
if (error.message.startsWith("spec index:")) throw error;
|
|
177
|
+
const message = error.message.replace(/^(?:index|analysis): /, "");
|
|
178
|
+
throw new Error(`spec index: ${message}; choose a readable plain cache directory outside protected sources and retry`, { cause: error });
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
get file(): string { return this.index.file; }
|
|
184
|
+
close(): void { this.index.close(); }
|
|
185
|
+
scan(rebuild = false): ReturnType<VaultIndex["scan"]> {
|
|
186
|
+
const result = this.index.scan(rebuild);
|
|
187
|
+
this.current = this.adapter.snapshot();
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
snapshot(): SpecSnapshot { if (!this.current) this.scan(); return this.current!; }
|
|
191
|
+
search(query: string, options: SearchOptions = {}): SpecSearchResult[] {
|
|
192
|
+
const snapshot = this.snapshot(), ids = new Map(snapshot.specs.map(spec => [spec.path, spec.id]));
|
|
193
|
+
const hashes = new Map(snapshot.specs.map(spec => [spec.path, spec.hash]));
|
|
194
|
+
return this.index.search(query, options, hashes).flatMap(hit => {
|
|
195
|
+
const id = ids.get(hit.path);
|
|
196
|
+
return id === undefined ? [] : [{ ...hit, id }];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** One-shot discovery is in memory and never opens or creates a cache on disk. */
|
|
201
|
+
export function discoverSpecs(source: SpecSourceSpec, options: SpecSourceOptions = {}): SpecSnapshot {
|
|
202
|
+
const index = new SpecIndex(tmpdir(), source, { ...options, memory: true, reuseCache: false, protectedRoots: [] });
|
|
203
|
+
try { index.scan(); return index.snapshot(); } finally { index.close(); }
|
|
204
|
+
}
|
package/src/sync.ts
CHANGED
|
@@ -52,19 +52,30 @@ function gitIn(cwd: string, env: NodeJS.ProcessEnv): Git {
|
|
|
52
52
|
return out;
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* insteadOf/pushInsteadOf rules that could rewrite any of `urls` or origin's configured URLs. Git applies a rule
|
|
57
|
+
* when its value is a literal prefix of the configured URL, so the raw config values are checked too; this covers
|
|
58
|
+
* an explicit pushurl, whose rewrite git does not report. A rule without a value is treated as matching.
|
|
59
|
+
*/
|
|
60
|
+
function applicableRewrites(lines: (args: string[]) => string[], urls: string[]): string[] {
|
|
61
|
+
const all = [...urls, ...lines(["config", "--get-all", "remote.origin.url"]), ...lines(["config", "--get-all", "remote.origin.pushurl"])];
|
|
62
|
+
return lines(["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"]).filter(line => {
|
|
63
|
+
const at = line.indexOf(" "), prefix = at < 0 ? "" : line.slice(at + 1);
|
|
64
|
+
return !prefix || all.some(url => url.startsWith(prefix));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
/** Local-only origin validation shared by doctor and sync; never fetches or pushes. */
|
|
56
68
|
export function originCheck(root: string, remote: string, env: NodeJS.ProcessEnv = process.env): { ok: boolean; origin: string | null; fetchUrls: string[]; pushUrls: string[]; issue?: string } {
|
|
57
69
|
const git = gitIn(root, env);
|
|
58
70
|
if (realpathSync(git(["rev-parse", "--show-toplevel"]).stdout.trim()) !== root) throw new Error("vault path is not the root of its git repository");
|
|
59
71
|
const origin = git(["remote", "get-url", "origin"], { allowFail: true });
|
|
60
72
|
const originUrl = origin.status === 0 ? origin.stdout.trim() : null;
|
|
61
|
-
// Every effective fetch and push URL must be the configured remote
|
|
62
|
-
// because git does not report pushInsteadOf rewrites of an explicit pushurl.
|
|
73
|
+
// Every effective fetch and push URL must be the configured remote, and no URL rewrite may apply to it.
|
|
63
74
|
const lines = (args: string[]) => { const out = git(args, { allowFail: true }); return out.status === 0 ? out.stdout.split("\n").map(line => line.trim()).filter(Boolean) : []; };
|
|
64
75
|
const fetchUrls = lines(["remote", "get-url", "--all", "origin"]), pushUrls = lines(["remote", "get-url", "--push", "--all", "origin"]);
|
|
65
|
-
const rewrites = lines
|
|
76
|
+
const rewrites = applicableRewrites(lines, [remote, ...fetchUrls, ...pushUrls]);
|
|
66
77
|
const ok = originUrl !== null && rewrites.length === 0 && fetchUrls.length > 0 && pushUrls.length > 0 && [...fetchUrls, ...pushUrls].every(url => sameRemote(url, remote));
|
|
67
|
-
return { ok, origin: originUrl, fetchUrls, pushUrls, ...(!ok ? { issue: originUrl === null ? "repository has no origin remote" : rewrites.length ?
|
|
78
|
+
return { ok, origin: originUrl, fetchUrls, pushUrls, ...(!ok ? { issue: originUrl === null ? "repository has no origin remote" : rewrites.length ? `git url rewrite ${rewrites[0]} applies to the vault remote; refusing because the push destination cannot be verified` : "an origin fetch or push URL does not match the configured remote; nothing was fetched or pushed" } : {}) };
|
|
68
79
|
}
|
|
69
80
|
function planned(path: string): boolean { return STAGE_FILES.includes(path) || STAGE_PREFIXES.some(prefix => path.startsWith(prefix)); }
|
|
70
81
|
/** Parse `status --porcelain=v1 -z` into changed paths (renames contribute both sides). */
|
|
@@ -141,7 +152,8 @@ export function syncVault(home: string, request: { vault: string; confirm?: bool
|
|
|
141
152
|
}
|
|
142
153
|
}
|
|
143
154
|
if (!result.commit && (!hasUpstream ? false : git(["rev-list", "--count", `${upstream}..HEAD`]).stdout.trim() === "0")) return { ...result, ok: true, applied: false, pushed: false };
|
|
144
|
-
|
|
155
|
+
const pushNow = lines(["remote", "get-url", "--push", "--all", "origin"]);
|
|
156
|
+
if (pushNow.some(url => !sameRemote(url, spec.remote!)) || applicableRewrites(lines, [spec.remote, ...fetchUrls, ...pushNow]).length) return { ...result, applied: Boolean(result.commit), pushed: false, issues: [issue("REMOTE", "push destination changed before push; nothing was pushed")] };
|
|
145
157
|
const push = git(["push", "--quiet", "--porcelain", "origin", `HEAD:refs/heads/${result.branch}`], { allowFail: true });
|
|
146
158
|
if (push.status !== 0) return { ...result, applied: Boolean(result.commit), pushed: false, issues: [issue("PUSH", `push failed: ${push.stderr.trim().split("\n").at(-1)?.slice(0, 300) ?? "error"}; the local commit was kept`)] };
|
|
147
159
|
// Verify the remote really has our head before reporting success.
|
package/src/tidy.ts
CHANGED
|
@@ -329,7 +329,7 @@ function rewriteLog(root: string, expected: string, next: string, state: { commi
|
|
|
329
329
|
* is then written through applyWrite (atomic per note, under the vault lock), the log is rewritten only for
|
|
330
330
|
* `--log`, and one LINT event summarises the run. A rerun after an interruption completes the rest.
|
|
331
331
|
*/
|
|
332
|
-
export function tidyVault(home: string, request: TidyRequest, options: { env?: NodeJS.ProcessEnv } = {}): TidyResult {
|
|
332
|
+
export function tidyVault(home: string, request: TidyRequest, options: { env?: NodeJS.ProcessEnv; validate?: boolean } = {}): TidyResult {
|
|
333
333
|
const rules = [...new Set(request.rules)].filter((rule): rule is TidyRule => (TIDY_RULES as readonly string[]).includes(rule)).sort((a, b) => TIDY_RULES.indexOf(a) - TIDY_RULES.indexOf(b));
|
|
334
334
|
const result: TidyResult = { ok: false, applied: false, rules, counts: {}, notes: [], reported: [], written: [], issues: [] };
|
|
335
335
|
if (!rules.length) return { ...result, issues: [issue("V01", "select at least one rule or --all")] };
|
|
@@ -374,6 +374,12 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
|
|
|
374
374
|
if (sanitized.text !== before) { logPlan = { before, ...sanitized }; result.counts.log = sanitized.redactions + sanitized.formats; }
|
|
375
375
|
}
|
|
376
376
|
}
|
|
377
|
+
// Doctor needs planning counts only: do not resnapshot the vault once per planned note.
|
|
378
|
+
// A confirmation always keeps writer validation, even if this internal option is supplied.
|
|
379
|
+
if (options.validate === false && request.confirm !== true) {
|
|
380
|
+
for (const change of planned) for (const [rule, n] of Object.entries(change.counts)) if (n > 0) ruleCount(result.counts, rule, n);
|
|
381
|
+
return { ...result, ok: true, preview: true };
|
|
382
|
+
}
|
|
377
383
|
// Validate every planned note with the writer (dry-run). A note the writer would refuse is reported and skipped
|
|
378
384
|
// (left fully unchanged for manual repair) instead of blocking the rest of the migration.
|
|
379
385
|
for (const change of [...planned]) {
|