@staix/vault-mem 0.3.1 → 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 +21 -1
- package/README.md +57 -6
- package/package.json +1 -1
- package/src/cli.ts +55 -11
- package/src/commands-table.ts +8 -3
- package/src/config.ts +28 -8
- package/src/index.ts +13 -3
- package/src/log-commands.ts +1 -1
- package/src/mcp-spec.ts +65 -0
- package/src/mcp-vault.ts +12 -4
- package/src/mcp.ts +44 -19
- package/src/org-promote.ts +1 -14
- package/src/registry.ts +63 -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 +128 -0
- package/src/spec-source.ts +225 -0
- package/src/spec-validate.ts +241 -0
- package/src/tidy.ts +7 -1
- package/src/vault-index.ts +186 -102
- package/src/write-commands.ts +8 -3
- package/src/write-operations.ts +46 -1
- package/src/write-validation.ts +20 -8
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
/** Metadata wiki addresses use the same id/label/heading split as body links. */
|
|
28
|
+
export function specReference(value: unknown): string | undefined {
|
|
29
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
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;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build only the read graph: unknown types/statuses remain visible for the later validation pass. */
|
|
39
|
+
export function buildSpecGraph(snapshot: SpecSnapshot): SpecGraph {
|
|
40
|
+
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`);
|
|
41
|
+
const documents = new Map<string, SpecDocument>(), diagnostics = [...snapshot.diagnostics];
|
|
42
|
+
for (const document of [...snapshot.specs].sort(pathOrder)) {
|
|
43
|
+
const first = documents.get(document.id);
|
|
44
|
+
if (first) {
|
|
45
|
+
if (!diagnostics.some(item => item.code === "duplicate_id" && item.path === document.path && item.id === document.id)) diagnostics.push({
|
|
46
|
+
code: "duplicate_id", path: document.path, id: document.id, firstPath: first.path,
|
|
47
|
+
message: "duplicate spec id; give each spec a unique frontmatter id and rebuild the graph",
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
documents.set(document.id, document);
|
|
52
|
+
}
|
|
53
|
+
const nodes = [...documents.values()].map(document => ({
|
|
54
|
+
id: document.id, path: document.path,
|
|
55
|
+
title: typeof document.frontmatter.title === "string" ? document.frontmatter.title : document.id,
|
|
56
|
+
type: typeof document.frontmatter.type === "string" ? document.frontmatter.type : "",
|
|
57
|
+
...(typeof document.frontmatter.status === "string" ? { status: document.frontmatter.status } : {}),
|
|
58
|
+
})).sort((a, b) => compare(a.id, b.id));
|
|
59
|
+
const edges: SpecEdge[] = [], unresolved: SpecEdge[] = [];
|
|
60
|
+
const seenEdges = new Map<string, Map<SpecEdgeKind, Set<string>>>();
|
|
61
|
+
const add = (source: string, value: unknown, kind: SpecEdgeKind) => {
|
|
62
|
+
const target = specReference(value);
|
|
63
|
+
if (!target) return false;
|
|
64
|
+
let kinds = seenEdges.get(source);
|
|
65
|
+
if (!kinds) seenEdges.set(source, kinds = new Map());
|
|
66
|
+
let targets = kinds.get(kind);
|
|
67
|
+
if (!targets) kinds.set(kind, targets = new Set());
|
|
68
|
+
if (targets.has(target)) return true;
|
|
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`);
|
|
70
|
+
targets.add(target);
|
|
71
|
+
const collection = documents.has(target) ? edges : unresolved;
|
|
72
|
+
collection.push({ source, target, kind });
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
for (const document of documents.values()) {
|
|
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);
|
|
78
|
+
for (const kind of ["depends-on", "references", "implements"] as const) {
|
|
79
|
+
const values = document.frontmatter[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);
|
|
85
|
+
}
|
|
86
|
+
for (const link of extractLinks(document.body)) add(document.id, link.target, "body_link");
|
|
87
|
+
}
|
|
88
|
+
diagnostics.sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path) || compare(a.code, b.code));
|
|
89
|
+
return { nodes, edges: edges.sort(edgeOrder), unresolved: unresolved.sort(edgeOrder), diagnostics };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A deterministic BFS; parent edges point child -> parent, while subtree traversal reverses them. */
|
|
93
|
+
export function specSlice(graph: SpecGraph, id: string, options: SpecSliceOptions = {}): SpecSlice {
|
|
94
|
+
const direction = options.direction ?? "subtree", depth = options.depth ?? 1, limit = options.limit ?? 50;
|
|
95
|
+
if (!["subtree", "ancestors", "neighbors"].includes(direction)) throw new Error("spec slice: invalid direction; choose subtree, ancestors or neighbors");
|
|
96
|
+
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");
|
|
97
|
+
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");
|
|
98
|
+
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");
|
|
99
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
|
|
100
|
+
if (typeof id !== "string" || !nodes.has(id)) throw new Error("spec slice: unknown spec id; select an id from buildSpecGraph(...).nodes");
|
|
101
|
+
const edge = options.edge ?? (direction === "neighbors" ? undefined : "parent");
|
|
102
|
+
const edges = graph.edges.filter(item => (edge === undefined || item.kind === edge) && nodes.has(item.source) && nodes.has(item.target));
|
|
103
|
+
const adjacency = new Map<string, Set<string>>();
|
|
104
|
+
const add = (from: string, to: string) => {
|
|
105
|
+
let neighbors = adjacency.get(from);
|
|
106
|
+
if (!neighbors) adjacency.set(from, neighbors = new Set());
|
|
107
|
+
neighbors.add(to);
|
|
108
|
+
};
|
|
109
|
+
for (const item of edges) {
|
|
110
|
+
if (direction === "ancestors" || direction === "neighbors") add(item.source, item.target);
|
|
111
|
+
if (direction === "subtree" || direction === "neighbors") add(item.target, item.source);
|
|
112
|
+
}
|
|
113
|
+
const selected = new Set([id]), queue: { id: string; depth: number }[] = [{ id, depth: 0 }];
|
|
114
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
115
|
+
const current = queue[cursor]!;
|
|
116
|
+
for (const neighbor of [...(adjacency.get(current.id) ?? [])].sort(compare)) {
|
|
117
|
+
if (selected.has(neighbor)) continue;
|
|
118
|
+
if (current.depth >= depth || selected.size >= limit) continue;
|
|
119
|
+
selected.add(neighbor); queue.push({ id: neighbor, depth: current.depth + 1 });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const truncated = [...selected].some(node => [...(adjacency.get(node) ?? [])].some(neighbor => !selected.has(neighbor)));
|
|
123
|
+
return {
|
|
124
|
+
nodes: graph.nodes.filter(node => selected.has(node.id)).sort((a, b) => compare(a.id, b.id)),
|
|
125
|
+
edges: edges.filter(item => selected.has(item.source) && selected.has(item.target)).sort(edgeOrder),
|
|
126
|
+
truncated,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { absolute, type SpecSourceSpec } from "./config.ts";
|
|
5
|
+
import { realPath } from "./platform.ts";
|
|
6
|
+
import { specGit } from "./spec-git.ts";
|
|
7
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
8
|
+
import { VaultIndex, IndexCacheError, type IndexSourceAdapter, type SearchOptions, type SearchResult } from "./vault-index.ts";
|
|
9
|
+
|
|
10
|
+
/** Private spec input snapshot; raw sources are for library transforms, never graph results. */
|
|
11
|
+
export interface SpecCandidate {
|
|
12
|
+
path: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
source: string;
|
|
15
|
+
frontmatter: Record<string, unknown>;
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SpecDocument extends SpecCandidate { id: string }
|
|
19
|
+
export interface SpecDiagnostic {
|
|
20
|
+
code: "duplicate_id" | "invalid_frontmatter" | "invalid_identity" | "skipped_path" | "wrong_shape";
|
|
21
|
+
path: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
firstPath?: string;
|
|
24
|
+
field?: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SpecSnapshot {
|
|
28
|
+
root: string;
|
|
29
|
+
commonDir: string;
|
|
30
|
+
specs: SpecDocument[];
|
|
31
|
+
candidates?: SpecCandidate[];
|
|
32
|
+
diagnostics: SpecDiagnostic[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SpecSourceOptions { worktree?: string; env?: NodeJS.ProcessEnv }
|
|
36
|
+
export interface SpecIndexOptions extends SpecSourceOptions { memory?: boolean; reuseCache?: boolean; protectedRoots?: string[] }
|
|
37
|
+
export interface SpecSearchResult extends SearchResult { id: string }
|
|
38
|
+
interface Binding { root: string; registeredRoot: string; commonDir: string; rootIdentity: string; env: NodeJS.ProcessEnv }
|
|
39
|
+
const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
|
40
|
+
const pathOrder = (a: string, b: string) => compare(a.normalize("NFC"), b.normalize("NFC")) || compare(a, b);
|
|
41
|
+
function git(root: string, args: string[], env: NodeJS.ProcessEnv, maxBuffer = 65536): Buffer {
|
|
42
|
+
return specGit(root, args, { env, maxBuffer, discovery: args[0] === "ls-files" }).stdout;
|
|
43
|
+
}
|
|
44
|
+
function rootInfo(path: string, env: NodeJS.ProcessEnv): { root: string; commonDir: string } {
|
|
45
|
+
try { absolute(path, "spec worktree"); }
|
|
46
|
+
catch { throw new Error("spec source: worktree must be an absolute path without control characters; pass the repository root"); }
|
|
47
|
+
let root: string;
|
|
48
|
+
try { root = realPath(path); } catch { throw new Error("spec source: worktree root is unavailable; restore the repository or select an existing worktree"); }
|
|
49
|
+
// rev-parse has no NUL-output mode: root paths reject control characters, and exactly two lines are required.
|
|
50
|
+
const lines = git(root, ["rev-parse", "--show-toplevel", "--git-common-dir"], env).toString("utf8").replace(/\n$/, "").split("\n");
|
|
51
|
+
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");
|
|
52
|
+
let commonDir: string;
|
|
53
|
+
try { commonDir = realPath(resolve(root, lines[1])); } catch { throw new Error("spec source: git common directory is unavailable; repair the repository before retrying"); }
|
|
54
|
+
return { root, commonDir };
|
|
55
|
+
}
|
|
56
|
+
function directoryIdentity(root: string): string | undefined {
|
|
57
|
+
try {
|
|
58
|
+
const stat = lstatSync(root, { bigint: true });
|
|
59
|
+
return stat.isDirectory() && !stat.isSymbolicLink() ? `${stat.dev}:${stat.ino}` : undefined;
|
|
60
|
+
} 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 }); }
|
|
61
|
+
}
|
|
62
|
+
function bind(source: SpecSourceSpec, options: SpecSourceOptions): Binding {
|
|
63
|
+
const env = options.env ?? process.env, registered = rootInfo(source.path, env);
|
|
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");
|
|
65
|
+
const selected = options.worktree === undefined ? registered : rootInfo(options.worktree, env);
|
|
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");
|
|
67
|
+
const rootIdentity = directoryIdentity(selected.root);
|
|
68
|
+
if (rootIdentity === undefined) throw new Error("spec source: selected root is not a plain directory; restore the canonical worktree root and retry");
|
|
69
|
+
return { root: selected.root, registeredRoot: registered.root, commonDir: selected.commonDir, rootIdentity, env };
|
|
70
|
+
}
|
|
71
|
+
export function resolveSpecWorktree(source: SpecSourceSpec, worktree?: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
72
|
+
return bind(source, { worktree, env }).root;
|
|
73
|
+
}
|
|
74
|
+
function validate(binding: Binding): void {
|
|
75
|
+
const registered = rootInfo(binding.registeredRoot, binding.env), selected = rootInfo(binding.root, binding.env);
|
|
76
|
+
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");
|
|
77
|
+
}
|
|
78
|
+
/** A canonical root plus component checks permits filesystem spelling aliases without following links. */
|
|
79
|
+
function pathProblem(binding: Binding, path: string): string | undefined {
|
|
80
|
+
const root = binding.root;
|
|
81
|
+
const parts = path.split("/"), contained = relative(root, resolve(root, path));
|
|
82
|
+
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`);
|
|
83
|
+
let rootIdentity: string | undefined;
|
|
84
|
+
try { rootIdentity = directoryIdentity(root); }
|
|
85
|
+
catch (error) { throw new Error(`spec source ${path}: canonical root cannot be inspected; restore root access and retry`, { cause: error }); }
|
|
86
|
+
if (rootIdentity !== binding.rootIdentity) return `spec source ${path}: root directory identity changed; restore the original root before retrying`;
|
|
87
|
+
let current = root;
|
|
88
|
+
for (let i = 0; i < parts.length; i++) {
|
|
89
|
+
current = join(current, parts[i]!);
|
|
90
|
+
let stat;
|
|
91
|
+
try { stat = lstatSync(current); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
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`;
|
|
94
|
+
throw new Error(`spec source ${path}: Markdown path cannot be inspected; restore read access and retry discovery`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
if (stat.isSymbolicLink()) return `spec source ${path}: symlink component excluded; replace it with a plain file or directory before retrying`;
|
|
97
|
+
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`;
|
|
98
|
+
}
|
|
99
|
+
let physical: string;
|
|
100
|
+
try { physical = realPath(current); }
|
|
101
|
+
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 }); }
|
|
102
|
+
// Git's full spelling may differ, but the file must stay below the captured physical root.
|
|
103
|
+
const physicalRelative = relative(root, physical);
|
|
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`;
|
|
105
|
+
return undefined;
|
|
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
|
+
}
|
|
122
|
+
function adapter(binding: Binding) {
|
|
123
|
+
let specs: SpecDocument[] = [], candidates: SpecCandidate[] = [], diagnostics: SpecDiagnostic[] = [];
|
|
124
|
+
const seen = new Map<string, string>();
|
|
125
|
+
const source: IndexSourceAdapter = {
|
|
126
|
+
validate: () => validate(binding),
|
|
127
|
+
paths: () => {
|
|
128
|
+
specs = []; candidates = []; diagnostics = []; seen.clear();
|
|
129
|
+
const bytes = git(binding.root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "*.md"], binding.env, 8 * 1024 * 1024);
|
|
130
|
+
let output: string;
|
|
131
|
+
try { output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
132
|
+
catch { throw new Error("spec source: Git paths are not valid UTF-8; repair the filenames before retrying discovery"); }
|
|
133
|
+
if (output && !output.endsWith("\0")) throw new Error("spec source: git returned incomplete NUL-delimited paths; retry discovery on a stable repository");
|
|
134
|
+
const paths = [...new Set(output.split("\0").filter(Boolean))].sort(pathOrder);
|
|
135
|
+
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");
|
|
136
|
+
return paths.filter(path => {
|
|
137
|
+
const problem = pathProblem(binding, path);
|
|
138
|
+
if (problem === undefined) return true;
|
|
139
|
+
diagnostics.push({ code: "skipped_path", path, message: problem });
|
|
140
|
+
return false;
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
validatePath: path => {
|
|
144
|
+
const problem = pathProblem(binding, path);
|
|
145
|
+
if (problem !== undefined) throw new Error(problem);
|
|
146
|
+
},
|
|
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
|
+
},
|
|
157
|
+
accept: (path, document, hash) => {
|
|
158
|
+
const data = document.data;
|
|
159
|
+
if (candidateData(path, data)) candidates.push({ path, hash, source: document.source, frontmatter: data, body: document.body });
|
|
160
|
+
if (!Object.hasOwn(data, "id") || !Object.hasOwn(data, "type")) return false;
|
|
161
|
+
if (typeof data.id !== "string" || !data.id.trim() || typeof data.type !== "string" || !data.type.trim()) {
|
|
162
|
+
diagnostics.push({ code: "invalid_identity", path, message: "spec source: id and type must be nonempty strings; repair the frontmatter before rebuilding" });
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const firstPath = seen.get(data.id);
|
|
166
|
+
if (firstPath !== undefined) {
|
|
167
|
+
diagnostics.push({ code: "duplicate_id", path, id: data.id, firstPath, message: "duplicate spec id; give each spec a unique frontmatter id before rebuilding" });
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
seen.set(data.id, path);
|
|
171
|
+
specs.push({ id: data.id, path, hash, source: document.source, frontmatter: data, body: document.body });
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
return { source, snapshot: (): SpecSnapshot => ({ root: binding.root, commonDir: binding.commonDir, specs: [...specs], candidates: [...candidates], diagnostics: [...diagnostics] }) };
|
|
176
|
+
}
|
|
177
|
+
/** Call scan() to refresh. Search and snapshots use the last completed scan, like VaultIndex. */
|
|
178
|
+
export class SpecIndex {
|
|
179
|
+
private readonly index: VaultIndex<SpecSourceSpec>;
|
|
180
|
+
private readonly adapter: ReturnType<typeof adapter>;
|
|
181
|
+
private current?: SpecSnapshot;
|
|
182
|
+
constructor(home: string, source: SpecSourceSpec, options: SpecIndexOptions = {}) {
|
|
183
|
+
const binding = bind(source, options);
|
|
184
|
+
this.adapter = adapter(binding);
|
|
185
|
+
try {
|
|
186
|
+
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 });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof IndexCacheError) {
|
|
189
|
+
if (error.message.startsWith("spec index:")) throw error;
|
|
190
|
+
const message = error.message.replace(/^(?:index|analysis): /, "");
|
|
191
|
+
throw new Error(`spec index: ${message}; choose a readable plain cache directory outside protected sources and retry`, { cause: error });
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
get file(): string { return this.index.file; }
|
|
197
|
+
close(): void { this.index.close(); }
|
|
198
|
+
scan(rebuild = false): ReturnType<VaultIndex["scan"]> {
|
|
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; }
|
|
204
|
+
}
|
|
205
|
+
snapshot(): SpecSnapshot { if (!this.current) this.scan(); return this.current!; }
|
|
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[] {
|
|
213
|
+
const snapshot = this.snapshot(), ids = new Map(snapshot.specs.map(spec => [spec.path, spec.id]));
|
|
214
|
+
const hashes = new Map(snapshot.specs.map(spec => [spec.path, spec.hash]));
|
|
215
|
+
return this.index.search(query, options, hashes, options.offset ?? 0, all).flatMap(hit => {
|
|
216
|
+
const id = ids.get(hit.path);
|
|
217
|
+
return id === undefined ? [] : [{ ...hit, id }];
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** One-shot discovery is in memory and never opens or creates a cache on disk. */
|
|
222
|
+
export function discoverSpecs(source: SpecSourceSpec, options: SpecSourceOptions = {}): SpecSnapshot {
|
|
223
|
+
const index = new SpecIndex(tmpdir(), source, { ...options, memory: true, reuseCache: false, protectedRoots: [] });
|
|
224
|
+
try { index.scan(); return index.snapshot(); } finally { index.close(); }
|
|
225
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { realPath } from "./platform.ts";
|
|
4
|
+
import { extractLinks } from "./links.ts";
|
|
5
|
+
import { specReference, MAX_SPEC_INPUTS } from "./spec-graph.ts";
|
|
6
|
+
import { discoverSpecs, type SpecCandidate, type SpecDiagnostic, type SpecSnapshot } from "./spec-source.ts";
|
|
7
|
+
import { specFreshness, specMetadataIssues, type SpecFreshnessEntry } from "./spec-freshness.ts";
|
|
8
|
+
|
|
9
|
+
export interface SpecValidationIssue {
|
|
10
|
+
code: string;
|
|
11
|
+
severity: "error" | "warning";
|
|
12
|
+
path: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
field?: string;
|
|
15
|
+
target?: string;
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SpecValidationOptions { env?: NodeJS.ProcessEnv; requireFresh?: boolean; limit?: number; git?: boolean }
|
|
19
|
+
export interface SpecValidationReport {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
errors: number;
|
|
22
|
+
warnings: number;
|
|
23
|
+
items: SpecValidationIssue[];
|
|
24
|
+
diagnostics: SpecDiagnostic[];
|
|
25
|
+
freshness: SpecFreshnessEntry[];
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface SpecRepoValidationOptions { env?: NodeJS.ProcessEnv; requireFresh?: boolean; limit?: number }
|
|
29
|
+
export interface SpecRepoValidation { code: 0 | 1; report: SpecValidationReport }
|
|
30
|
+
const RANK: Readonly<Record<string, number>> = { goal: 0, architecture: 1, module: 2, submodule: 3 };
|
|
31
|
+
const STATUSES = new Set(["draft", "active", "deprecated"]);
|
|
32
|
+
const MAX_OPERATIONS = 200000, MAX_ITEMS = 500, MAX_REPORT_BYTES = 128 * 1024;
|
|
33
|
+
const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
|
34
|
+
const nonempty = (value: unknown): value is string => typeof value === "string" && !!value.trim();
|
|
35
|
+
const candidateId = (candidate: SpecCandidate) => nonempty(candidate.frontmatter.id) ? candidate.frontmatter.id : undefined;
|
|
36
|
+
interface ErrorOwner { kind: "old" | "new"; path: string }
|
|
37
|
+
interface Collected { records: { issue: SpecValidationIssue; key: string }[]; freshness: SpecFreshnessEntry[] }
|
|
38
|
+
|
|
39
|
+
/** Complete collection is separate from display limits, so the introduced-error gate never compares samples. */
|
|
40
|
+
function collect(snapshot: SpecSnapshot, options: SpecValidationOptions, owners?: ReadonlyMap<string, ErrorOwner>): Collected {
|
|
41
|
+
const candidates = [...(snapshot.candidates ?? snapshot.specs)].sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path));
|
|
42
|
+
if (new Set(candidates.map(candidate => candidate.path)).size !== candidates.length) throw new Error("spec validation: duplicate candidate paths; provide one candidate record per file");
|
|
43
|
+
if (candidates.length > MAX_SPEC_INPUTS) throw new Error("spec validation: more than 10000 candidates; reduce the candidate tree before validating");
|
|
44
|
+
let operations = 0;
|
|
45
|
+
const tick = () => { if (++operations > MAX_OPERATIONS) throw new Error("spec validation: more than 200000 checks; reduce repeated links or metadata lists before validating"); };
|
|
46
|
+
const hashes = new Map<string, string>();
|
|
47
|
+
const digest = (value: string): string => {
|
|
48
|
+
let hash = hashes.get(value);
|
|
49
|
+
if (hash === undefined) { hash = createHash("sha256").update(value).digest("hex"); hashes.set(value, hash); }
|
|
50
|
+
return hash;
|
|
51
|
+
};
|
|
52
|
+
const records: Collected["records"] = [], keys = new Set<string>();
|
|
53
|
+
const addIssue = (issue: SpecValidationIssue) => {
|
|
54
|
+
tick();
|
|
55
|
+
const correspondence = owners?.get(issue.path) ?? { kind: owners === undefined ? "old" : "new", path: issue.path };
|
|
56
|
+
const owner = `${correspondence.kind}:${digest(correspondence.path)}`;
|
|
57
|
+
// Hash values independently: a long owner id is never copied once per relation/error key.
|
|
58
|
+
const key = JSON.stringify([digest(snapshot.commonDir), issue.severity, issue.code, owner, issue.field ?? "", issue.target === undefined ? "" : digest(issue.target)]);
|
|
59
|
+
if (!keys.has(key)) { keys.add(key); records.push({ issue, key }); }
|
|
60
|
+
};
|
|
61
|
+
const add = (candidate: SpecCandidate, code: string, severity: SpecValidationIssue["severity"], field: string, message: string, target?: string) => {
|
|
62
|
+
const id = candidateId(candidate);
|
|
63
|
+
addIssue({ code, severity, path: candidate.path, ...(id === undefined ? {} : { id }), field, message, ...(target === undefined ? {} : { target }) });
|
|
64
|
+
};
|
|
65
|
+
const nodes = new Map<string, SpecCandidate>(), first = new Map<string, string>();
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const id = candidateId(candidate);
|
|
68
|
+
if (id !== undefined) {
|
|
69
|
+
const previous = first.get(id);
|
|
70
|
+
if (previous !== undefined) add(candidate, "duplicate_id", "error", "id", "duplicate id; give each candidate a unique id", previous);
|
|
71
|
+
else first.set(id, candidate.path);
|
|
72
|
+
if (nonempty(candidate.frontmatter.type) && !nodes.has(id)) nodes.set(id, candidate);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const covers = new Map<string, Map<string, SpecCandidate>>();
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
tick();
|
|
78
|
+
const data = candidate.frontmatter, id = candidateId(candidate);
|
|
79
|
+
if (options.git === false) for (const issue of specMetadataIssues(candidate)) addIssue(issue);
|
|
80
|
+
for (const field of ["id", "type", "title"] as const) {
|
|
81
|
+
if (data[field] === undefined) add(candidate, `missing_${field}`, "error", field, `required ${field} is missing; add a nonempty string field`);
|
|
82
|
+
else if (!nonempty(data[field])) add(candidate, "wrong_shape", "error", field, `${field} must be a nonempty string; correct the frontmatter field`);
|
|
83
|
+
}
|
|
84
|
+
if (nonempty(data.type) && !Object.hasOwn(RANK, data.type)) add(candidate, "unknown_type", "error", "type", "unknown type; use goal, architecture, module or submodule");
|
|
85
|
+
if (data.status !== undefined && (typeof data.status !== "string" || !STATUSES.has(data.status))) add(candidate, "unknown_status", "error", "status", "unknown status; use draft, active or deprecated");
|
|
86
|
+
if (data.parent === undefined && data.type !== "goal") add(candidate, "orphan", "warning", "parent", "non-goal spec has no parent; link it to its owning higher-level spec");
|
|
87
|
+
if (data.status === "active" && data.paths === undefined) add(candidate, "active_without_paths", "warning", "paths", "active spec has no paths; add relative Git pathspecs for its source scope");
|
|
88
|
+
const checkLink = (field: string, value: unknown, shapeField = field): string | undefined => {
|
|
89
|
+
tick();
|
|
90
|
+
const target = specReference(value);
|
|
91
|
+
if (target === undefined) { add(candidate, "wrong_shape", "error", shapeField, "link must be one id or wiki link; correct the frontmatter value"); return undefined; }
|
|
92
|
+
if (!nodes.has(target)) add(candidate, "dangling_link", "error", field, "frontmatter link names no graph node; link to an existing spec id or remove it", target);
|
|
93
|
+
return target;
|
|
94
|
+
};
|
|
95
|
+
let parent: string | undefined, replacement: string | undefined;
|
|
96
|
+
if (data.parent !== undefined) parent = checkLink("parent", data.parent);
|
|
97
|
+
if (data["superseded-by"] !== undefined) {
|
|
98
|
+
replacement = checkLink("superseded-by", data["superseded-by"]);
|
|
99
|
+
if (data.status !== "deprecated") add(candidate, "superseded_status", "error", "superseded-by", "replacement link requires deprecated status; deprecate the spec or remove the link");
|
|
100
|
+
if (replacement !== undefined && replacement === id) add(candidate, "superseded_self", "error", "superseded-by", "replacement points to this spec; link to a different replacement", replacement);
|
|
101
|
+
} else if (data.status === "deprecated") add(candidate, "deprecated_without_replacement", "warning", "superseded-by", "deprecated spec has no replacement; add a superseded-by id when one exists");
|
|
102
|
+
if (data.status === "deprecated" && replacement !== undefined && replacement !== id && nodes.get(replacement)?.frontmatter.status === "deprecated") add(candidate, "deprecated_replacement", "warning", "superseded-by", "replacement is also deprecated; link to an active replacement", replacement);
|
|
103
|
+
for (const field of ["depends-on", "references", "implements"] as const) {
|
|
104
|
+
if (data[field] === undefined) continue;
|
|
105
|
+
const values = data[field];
|
|
106
|
+
if (!Array.isArray(values)) { add(candidate, "wrong_shape", "error", field, `${field} must be a list of links; wrap the value in a list`); continue; }
|
|
107
|
+
for (let i = 0; i < values.length; i++) checkLink(field, values[i], `${field}[${i}]`);
|
|
108
|
+
}
|
|
109
|
+
if (parent !== undefined) {
|
|
110
|
+
const parentType = nodes.get(parent)?.frontmatter.type;
|
|
111
|
+
if (typeof data.type === "string" && typeof parentType === "string" && Object.hasOwn(RANK, data.type) && Object.hasOwn(RANK, parentType) && RANK[data.type]! <= RANK[parentType]!) add(candidate, "type_rank", "error", "parent", "child rank must exceed parent rank; choose a higher-level parent (levels may be skipped)", parent);
|
|
112
|
+
}
|
|
113
|
+
for (const field of ["covers", "tags"] as const) {
|
|
114
|
+
const values = data[field];
|
|
115
|
+
if (values === undefined) continue;
|
|
116
|
+
let valid = Array.isArray(values);
|
|
117
|
+
if (Array.isArray(values)) for (const value of values) { tick(); if (!nonempty(value)) valid = false; }
|
|
118
|
+
if (!valid) {
|
|
119
|
+
add(candidate, "wrong_shape", "error", field, `${field} must be a list of nonempty strings; correct the frontmatter field`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (field === "covers") for (const value of values as string[]) {
|
|
123
|
+
const token = value.trim();
|
|
124
|
+
let owners = covers.get(token); if (!owners) covers.set(token, owners = new Map());
|
|
125
|
+
owners.set(candidate.path, candidate);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const link of extractLinks(candidate.body)) {
|
|
129
|
+
tick();
|
|
130
|
+
if (link.target && !nodes.has(link.target)) add(candidate, "unknown_body_link", "warning", "body", "body wiki link names no graph node; correct the target id or remove the link", link.target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [token, owners] of covers) if (owners.size > 1) for (const candidate of owners.values()) add(candidate, "duplicate_covers", "warning", "covers", "covers token is claimed by multiple specs; choose a single owning spec", token);
|
|
134
|
+
const goals = [...nodes.values()].filter(candidate => candidate.frontmatter.type === "goal");
|
|
135
|
+
if (goals.length > 1) for (const candidate of goals) add(candidate, "multiple_goals", "warning", "type", "multiple goal roots exist; consolidate or document separate goal trees");
|
|
136
|
+
// Iterative traversal handles a 10000-node chain without overflowing the JS stack.
|
|
137
|
+
const finished = new Set<string>();
|
|
138
|
+
for (const id of nodes.keys()) {
|
|
139
|
+
if (finished.has(id)) continue;
|
|
140
|
+
const chain: string[] = [], positions = new Map<string, number>();
|
|
141
|
+
let next: string | undefined = id;
|
|
142
|
+
while (next !== undefined && nodes.has(next) && !finished.has(next) && !positions.has(next)) {
|
|
143
|
+
tick(); positions.set(next, chain.length); chain.push(next);
|
|
144
|
+
next = specReference(nodes.get(next)!.frontmatter.parent);
|
|
145
|
+
}
|
|
146
|
+
if (next !== undefined && positions.has(next)) for (const member of chain.slice(positions.get(next)!)) add(nodes.get(member)!, "parent_cycle", "error", "parent", "parent cycle detected; remove a cycle link and point to a higher-level parent", specReference(nodes.get(member)!.frontmatter.parent));
|
|
147
|
+
for (const member of chain) finished.add(member);
|
|
148
|
+
}
|
|
149
|
+
for (const diagnostic of snapshot.diagnostics) {
|
|
150
|
+
if (diagnostic.code === "invalid_frontmatter") addIssue({ code: "invalid_frontmatter", severity: "error", path: diagnostic.path, field: "frontmatter", message: diagnostic.message });
|
|
151
|
+
else if (snapshot.candidates === undefined && diagnostic.code === "duplicate_id") addIssue({ code: "duplicate_id", severity: "error", path: diagnostic.path, id: diagnostic.id, field: "id", target: diagnostic.firstPath, message: diagnostic.message });
|
|
152
|
+
else if (snapshot.candidates === undefined && diagnostic.code === "invalid_identity") addIssue({ code: "wrong_shape", severity: "error", path: diagnostic.path, field: "id/type", message: diagnostic.message });
|
|
153
|
+
}
|
|
154
|
+
if (options.requireFresh && options.git === false) throw new Error("spec validation: requireFresh needs Git inspection; enable Git checks before requiring freshness");
|
|
155
|
+
const freshness = options.git === false ? { items: [], issues: [] } : specFreshness(snapshot, { env: options.env });
|
|
156
|
+
for (const issue of freshness.issues) addIssue(issue);
|
|
157
|
+
if (options.requireFresh) for (const item of freshness.items) if (item.state === "stale") addIssue({ code: "stale", severity: "error", path: item.path, id: item.id, field: "verified-against", message: "spec source scope is stale; review its paths and refresh verified-against after verification" });
|
|
158
|
+
records.sort((a, b) => (a.issue.severity === b.issue.severity ? 0 : a.issue.severity === "error" ? -1 : 1) || compare(a.issue.path.normalize("NFC"), b.issue.path.normalize("NFC")) || compare(a.issue.path, b.issue.path) || compare(a.issue.code, b.issue.code) || compare(a.issue.field ?? "", b.issue.field ?? "") || compare(a.key, b.key));
|
|
159
|
+
return { records, freshness: freshness.items };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Display is count/byte bounded; oversized optional identifiers are omitted, never silently renamed. */
|
|
163
|
+
function display<T extends object>(values: readonly T[], limit: number, budget: { bytes: number; truncated: boolean }): T[] {
|
|
164
|
+
const out: T[] = [];
|
|
165
|
+
for (const original of values) {
|
|
166
|
+
if (out.length >= limit) { budget.truncated = true; break; }
|
|
167
|
+
const value = { ...original } as T & { id?: string; target?: string };
|
|
168
|
+
for (const field of ["id", "target"] as const) if (typeof value[field] === "string" && Buffer.byteLength(value[field]!) > 1024) { delete value[field]; budget.truncated = true; }
|
|
169
|
+
const bytes = Buffer.byteLength(JSON.stringify(value)) + 1;
|
|
170
|
+
if (budget.bytes + bytes > MAX_REPORT_BYTES) { budget.truncated = true; break; }
|
|
171
|
+
budget.bytes += bytes; out.push(value);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function reportLimit(value = MAX_ITEMS): number {
|
|
176
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_ITEMS) throw new Error("spec validation: report limit must be 1..500; pass a smaller positive item limit");
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
export function validateSpecs(snapshot: SpecSnapshot, options: SpecValidationOptions = {}): SpecValidationReport {
|
|
180
|
+
const limit = reportLimit(options.limit), result = collect(snapshot, options), budget = { bytes: 1024, truncated: false };
|
|
181
|
+
const errors = result.records.filter(record => record.issue.severity === "error").length;
|
|
182
|
+
return { ok: errors === 0, errors, warnings: result.records.length - errors,
|
|
183
|
+
items: display(result.records.map(record => record.issue), limit, budget),
|
|
184
|
+
diagnostics: display(snapshot.diagnostics, limit, budget), freshness: display(result.freshness, limit, budget),
|
|
185
|
+
get truncated() { return budget.truncated; } };
|
|
186
|
+
}
|
|
187
|
+
/** Existing error identities are retained even when their display sample was truncated. */
|
|
188
|
+
export function introducedSpecErrors(current: SpecSnapshot, prospective: SpecSnapshot, options: SpecValidationOptions = {}): SpecValidationIssue[] {
|
|
189
|
+
const limit = reportLimit(options.limit);
|
|
190
|
+
const pathsById = (input: SpecSnapshot) => {
|
|
191
|
+
const paths = new Map<string, Set<string>>();
|
|
192
|
+
const add = (id: string, path: string) => { let found = paths.get(id); if (!found) paths.set(id, found = new Set()); found.add(path); };
|
|
193
|
+
for (const candidate of input.candidates ?? input.specs) { const id = candidateId(candidate); if (id !== undefined) add(id, candidate.path); }
|
|
194
|
+
if (input.candidates === undefined) for (const diagnostic of input.diagnostics) if (diagnostic.code === "duplicate_id" && diagnostic.id !== undefined) add(diagnostic.id, diagnostic.path);
|
|
195
|
+
return paths;
|
|
196
|
+
};
|
|
197
|
+
const oldIds = pathsById(current), nextIds = pathsById(prospective), owners = new Map<string, ErrorOwner>(), reserved = new Set<string>();
|
|
198
|
+
for (const candidate of prospective.candidates ?? prospective.specs) {
|
|
199
|
+
const id = candidateId(candidate), previous = id === undefined ? undefined : oldIds.get(id);
|
|
200
|
+
if (previous?.size === 1 && nextIds.get(id!)?.size === 1) {
|
|
201
|
+
const path = previous.values().next().value!;
|
|
202
|
+
owners.set(candidate.path, { kind: "old", path }); reserved.add(path);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const previousPaths = new Set([...(current.candidates ?? current.specs).map(candidate => candidate.path), ...current.diagnostics.map(diagnostic => diagnostic.path)]);
|
|
206
|
+
const prospectivePaths = new Set([...(prospective.candidates ?? prospective.specs).map(candidate => candidate.path), ...prospective.diagnostics.map(diagnostic => diagnostic.path)]);
|
|
207
|
+
for (const path of prospectivePaths) if (!owners.has(path)) {
|
|
208
|
+
const retained = previousPaths.has(path) && !reserved.has(path);
|
|
209
|
+
owners.set(path, { kind: retained ? "old" : "new", path });
|
|
210
|
+
if (retained) reserved.add(path);
|
|
211
|
+
}
|
|
212
|
+
// Duplicate identity belongs to the id, even when a move changes the first file in path order.
|
|
213
|
+
// Keep collection keys unchanged so validation still reports each duplicate file.
|
|
214
|
+
const comparisonKey = (input: SpecSnapshot, record: Collected["records"][number]): string =>
|
|
215
|
+
record.issue.code === "duplicate_id" && record.issue.id !== undefined
|
|
216
|
+
? JSON.stringify([createHash("sha256").update(input.commonDir).digest("hex"), "duplicate_id", createHash("sha256").update(record.issue.id).digest("hex")])
|
|
217
|
+
: record.key;
|
|
218
|
+
const old = new Map<string, number>();
|
|
219
|
+
for (const record of collect(current, options).records) if (record.issue.severity === "error") {
|
|
220
|
+
const key = comparisonKey(current, record); old.set(key, (old.get(key) ?? 0) + 1);
|
|
221
|
+
}
|
|
222
|
+
const prospectiveErrors = collect(prospective, options, owners).records.filter(record => record.issue.severity === "error");
|
|
223
|
+
// Retained owners consume existing debt before new or moved files do.
|
|
224
|
+
prospectiveErrors.sort((a, b) => Number(owners.get(b.issue.path)?.kind === "old") - Number(owners.get(a.issue.path)?.kind === "old"));
|
|
225
|
+
const next = prospectiveErrors.filter(record => {
|
|
226
|
+
const key = comparisonKey(prospective, record), count = old.get(key) ?? 0;
|
|
227
|
+
if (count === 0) return true;
|
|
228
|
+
old.set(key, count - 1); return false;
|
|
229
|
+
});
|
|
230
|
+
const result = display(next.map(record => record.issue), limit, { bytes: 1024, truncated: false });
|
|
231
|
+
if (next.length && !result.length) throw new Error("spec validation: introduced error metadata exceeds the report budget; reduce metadata before writing");
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
/** Registry-free CI logic; no home/config/cache creation and no authored freshness fields. */
|
|
235
|
+
export function validateSpecRepo(repo: string, options: SpecRepoValidationOptions = {}): SpecRepoValidation {
|
|
236
|
+
let root: string;
|
|
237
|
+
try { root = realPath(resolve(repo)); } catch (error) { throw new Error("spec validation: repository root is unavailable; pass an existing checkout root", { cause: error }); }
|
|
238
|
+
const snapshot = discoverSpecs({ id: "repo", path: root, write: "none", pii: "deny" }, { env: options.env });
|
|
239
|
+
const report = validateSpecs(snapshot, options);
|
|
240
|
+
return { code: report.ok ? 0 : 1, report };
|
|
241
|
+
}
|
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]) {
|