@staix/vault-mem 0.2.2 → 0.2.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 +6 -0
- package/README.md +23 -1
- package/bin/vmem.js +1 -1
- package/package.json +1 -1
- package/src/amem/seam.ts +56 -0
- package/src/analysis-commands.ts +4 -4
- package/src/analysis-reports.ts +12 -3
- package/src/cli.ts +5 -1
- package/src/commands-table.ts +4 -3
- package/src/mcp-vault.ts +11 -3
- package/src/skills-mine.ts +485 -0
- package/src/vault-graph.ts +234 -29
- package/src/workspace-graph.ts +139 -0
- package/src/workspace-input.ts +150 -0
package/src/vault-graph.ts
CHANGED
|
@@ -6,23 +6,24 @@ export interface GraphNode {
|
|
|
6
6
|
label: string;
|
|
7
7
|
type: string;
|
|
8
8
|
domain: string;
|
|
9
|
-
description
|
|
10
|
-
confidence
|
|
9
|
+
description?: string;
|
|
10
|
+
confidence?: string;
|
|
11
11
|
source_file: string;
|
|
12
|
-
topics
|
|
12
|
+
topics?: string;
|
|
13
13
|
community: number | null;
|
|
14
14
|
degree: number;
|
|
15
15
|
}
|
|
16
|
-
export interface GraphEdge { source: string; target: string; relation: "wiki_link"; confidence_tag: "EXTRACTED"; confidence: 1 }
|
|
16
|
+
export interface GraphEdge { source: string; target: string; relation: "wiki_link" | "fm_ref" | "bu_member" | "source_ref" | "related" | "supersedes"; field?: string; rel?: string; confidence_tag: "EXTRACTED"; confidence: 1 }
|
|
17
17
|
export interface VaultGraph {
|
|
18
18
|
directed: false;
|
|
19
19
|
multigraph: false;
|
|
20
|
-
graph: { partitioner: "
|
|
20
|
+
graph: { partitioner: "leiden"; resolution: number; modularity: number; iterations: number; max_iterations: number };
|
|
21
21
|
nodes: GraphNode[];
|
|
22
22
|
edges: GraphEdge[];
|
|
23
23
|
}
|
|
24
24
|
export interface GraphNeighborhood { start: string; hops: 1 | 2; nodes: GraphNode[]; edges: GraphEdge[]; truncated: boolean }
|
|
25
|
-
|
|
25
|
+
/** Bound on Leiden levels summed over refinement rounds (`MAX_LEVELS` × `MAX_ROUNDS`). */
|
|
26
|
+
const MAX_ITERATIONS = 32 * 8;
|
|
26
27
|
const MAX_NODES = 10000;
|
|
27
28
|
const MAX_EDGES = 100000;
|
|
28
29
|
const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---\s*\n/;
|
|
@@ -74,7 +75,7 @@ function mapping(value: unknown): value is Record<string, unknown> { return valu
|
|
|
74
75
|
function truthy(value: unknown): boolean {
|
|
75
76
|
return yamlTruthy(value);
|
|
76
77
|
}
|
|
77
|
-
function pyString(value: unknown): string {
|
|
78
|
+
export function pyString(value: unknown): string {
|
|
78
79
|
const scalar = scalarText(value);
|
|
79
80
|
if (scalar !== undefined) return scalar;
|
|
80
81
|
if (value === null || value === undefined) return "None";
|
|
@@ -116,28 +117,224 @@ function adjacency(graph: Pick<VaultGraph, "nodes" | "edges">): Map<string, Set<
|
|
|
116
117
|
for (const edge of graph.edges) { neighbors.get(edge.source)?.add(edge.target); neighbors.get(edge.target)?.add(edge.source); }
|
|
117
118
|
return neighbors;
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
120
|
+
/** Leiden over the graph's own nodes and edges, with MOC/bu hubs excluded; writes `community` and the method metadata. */
|
|
121
|
+
function communities(graph: VaultGraph): void {
|
|
122
|
+
const exclude = new Set(graph.nodes.filter(node => HUB_TYPES.has(node.type)).map(node => node.id));
|
|
123
|
+
const result = leidenCommunities(graph.nodes.map(node => node.id), graph.edges.map(edge => [edge.source, edge.target] as const), { exclude });
|
|
124
|
+
for (const node of graph.nodes) node.community = result.membership.get(node.id) ?? null;
|
|
125
|
+
graph.graph = { partitioner: "leiden", resolution: result.resolution, modularity: result.modularity, iterations: result.levels, max_iterations: MAX_ITERATIONS };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Leiden (Traag, Waltman, van Eck 2019), modularity, deterministic ─────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** Resolution γ of the modularity objective. */
|
|
131
|
+
export const LEIDEN_RESOLUTION = 1;
|
|
132
|
+
const MAX_LEVELS = 32, MAX_ROUNDS = 8;
|
|
133
|
+
interface WeightedGraph { n: number; start: Int32Array; to: Int32Array; weight: Float64Array; self: Float64Array; strength: Float64Array; m2: number }
|
|
134
|
+
function weightedGraph(n: number, pairs: Map<number, number>, self: Float64Array): WeightedGraph {
|
|
135
|
+
const degree = new Int32Array(n);
|
|
136
|
+
for (const key of pairs.keys()) { degree[Math.floor(key / n)]!++; degree[key % n]!++; }
|
|
137
|
+
const start = new Int32Array(n + 1);
|
|
138
|
+
for (let i = 0; i < n; i++) start[i + 1] = start[i]! + degree[i]!;
|
|
139
|
+
const to = new Int32Array(start[n]!), weight = new Float64Array(start[n]!), fill = start.slice(0, n), strength = new Float64Array(n);
|
|
140
|
+
// Keys are visited in ascending order so every adjacency list is sorted by neighbor index.
|
|
141
|
+
for (const key of [...pairs.keys()].sort((a, b) => a - b)) {
|
|
142
|
+
const a = Math.floor(key / n), b = key % n, w = pairs.get(key)!;
|
|
143
|
+
to[fill[a]!] = b; weight[fill[a]!++] = w; to[fill[b]!] = a; weight[fill[b]!++] = w;
|
|
144
|
+
strength[a]! += w; strength[b]! += w;
|
|
145
|
+
}
|
|
146
|
+
let m2 = 0;
|
|
147
|
+
for (let i = 0; i < n; i++) { strength[i]! += 2 * self[i]!; m2 += strength[i]!; }
|
|
148
|
+
return { n, start, to, weight, self, strength, m2 };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Fast local moving (queue). Gains are compared as `k_in·2m − γ·k_v·K_C`, exact in floating point for integer weights.
|
|
152
|
+
* Ties keep the current community, then take the lowest community id. Returns whether any node moved.
|
|
153
|
+
*/
|
|
154
|
+
function moveNodes(g: WeightedGraph, comm: Int32Array, gamma: number): boolean {
|
|
155
|
+
const { n, start, to, weight, strength, m2 } = g;
|
|
156
|
+
const total = new Float64Array(n), size = new Int32Array(n);
|
|
157
|
+
for (let i = 0; i < n; i++) { total[comm[i]!]! += strength[i]!; size[comm[i]!]!++; }
|
|
158
|
+
const empty: number[] = [];
|
|
159
|
+
for (let c = n - 1; c >= 0; c--) if (!size[c]) empty.push(c);
|
|
160
|
+
const queue = new Int32Array(n), queued = new Uint8Array(n), neighbor = new Float64Array(n), seen = new Uint8Array(n), touched: number[] = [];
|
|
161
|
+
for (let i = 0; i < n; i++) { queue[i] = i; queued[i] = 1; }
|
|
162
|
+
let head = 0, count = n, moved = false;
|
|
163
|
+
while (count > 0) {
|
|
164
|
+
const v = queue[head]!; head = (head + 1) % n; count--; queued[v] = 0;
|
|
165
|
+
const own = comm[v]!, kv = strength[v]!;
|
|
166
|
+
for (let e = start[v]!; e < start[v + 1]!; e++) {
|
|
167
|
+
const c = comm[to[e]!]!;
|
|
168
|
+
if (!seen[c]) { seen[c] = 1; touched.push(c); }
|
|
169
|
+
neighbor[c]! += weight[e]!;
|
|
170
|
+
}
|
|
171
|
+
total[own]! -= kv; size[own]!--;
|
|
172
|
+
let best = own, bestScore = neighbor[own]! * m2 - gamma * kv * total[own]!;
|
|
173
|
+
for (const c of touched) {
|
|
174
|
+
if (c === own) continue;
|
|
175
|
+
const score = neighbor[c]! * m2 - gamma * kv * total[c]!;
|
|
176
|
+
if (score > bestScore || (score === bestScore && best !== own && c < best)) { best = c; bestScore = score; }
|
|
177
|
+
}
|
|
178
|
+
if (bestScore < 0 && size[own]! > 0) { best = empty.pop()!; bestScore = 0; }
|
|
179
|
+
comm[v] = best; total[best]! += kv; size[best]!++;
|
|
180
|
+
if (!size[own]) empty.push(own);
|
|
181
|
+
if (best !== own) {
|
|
182
|
+
moved = true;
|
|
183
|
+
for (let e = start[v]!; e < start[v + 1]!; e++) {
|
|
184
|
+
const u = to[e]!;
|
|
185
|
+
if (comm[u] !== best && !queued[u]) { queue[(head + count) % n] = u; queued[u] = 1; count++; }
|
|
129
186
|
}
|
|
130
|
-
const next = [...counts].sort(([a, ac], [b, bc]) => bc - ac || compare(a, b))[0]?.[0] ?? current;
|
|
131
|
-
if (next !== current) { labels.set(id, next); changed = true; }
|
|
132
187
|
}
|
|
133
|
-
|
|
188
|
+
for (const c of touched) { neighbor[c] = 0; seen[c] = 0; }
|
|
189
|
+
touched.length = 0;
|
|
190
|
+
}
|
|
191
|
+
return moved;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Refinement inside each community of `comm`, merging only well-connected singletons into well-connected subsets along
|
|
195
|
+
* edges (the greedy θ → 0 variant: the largest non-negative gain wins, ties to the lowest id). Returns the refined partition.
|
|
196
|
+
*/
|
|
197
|
+
function refine(g: WeightedGraph, comm: Int32Array, gamma: number): Int32Array {
|
|
198
|
+
const { n, start, to, weight, strength, m2 } = g;
|
|
199
|
+
const refined = new Int32Array(n), totalR = new Float64Array(n), external = new Float64Array(n), sizeR = new Int32Array(n), totalS = new Float64Array(n);
|
|
200
|
+
for (let i = 0; i < n; i++) { refined[i] = i; totalR[i] = strength[i]!; sizeR[i] = 1; totalS[comm[i]!]! += strength[i]!; }
|
|
201
|
+
for (let v = 0; v < n; v++) for (let e = start[v]!; e < start[v + 1]!; e++) if (comm[to[e]!] === comm[v]) external[v]! += weight[e]!;
|
|
202
|
+
const neighbor = new Float64Array(n), seen = new Uint8Array(n), touched: number[] = [];
|
|
203
|
+
for (let v = 0; v < n; v++) {
|
|
204
|
+
if (sizeR[refined[v]!] !== 1) continue;
|
|
205
|
+
const s = comm[v]!, kv = strength[v]!, ks = totalS[s]!, ext = external[refined[v]!]!;
|
|
206
|
+
if (ext * m2 < gamma * kv * (ks - kv)) continue;
|
|
207
|
+
for (let e = start[v]!; e < start[v + 1]!; e++) {
|
|
208
|
+
const u = to[e]!;
|
|
209
|
+
if (comm[u] !== s) continue;
|
|
210
|
+
const c = refined[u]!;
|
|
211
|
+
if (!seen[c]) { seen[c] = 1; touched.push(c); }
|
|
212
|
+
neighbor[c]! += weight[e]!;
|
|
213
|
+
}
|
|
214
|
+
let best = -1, bestScore = 0;
|
|
215
|
+
for (const c of touched) {
|
|
216
|
+
if (c === refined[v]) continue;
|
|
217
|
+
if (external[c]! * m2 < gamma * totalR[c]! * (ks - totalR[c]!)) continue;
|
|
218
|
+
const score = neighbor[c]! * m2 - gamma * kv * totalR[c]!;
|
|
219
|
+
if (score > bestScore || (score === bestScore && best !== -1 && c < best)) { best = c; bestScore = score; }
|
|
220
|
+
}
|
|
221
|
+
if (best >= 0 && bestScore > 0) {
|
|
222
|
+
const from = refined[v]!;
|
|
223
|
+
external[best] = external[best]! + ext - 2 * neighbor[best]!;
|
|
224
|
+
totalR[best]! += kv; sizeR[best]!++; refined[v] = best;
|
|
225
|
+
totalR[from] = 0; sizeR[from] = 0; external[from] = 0;
|
|
226
|
+
}
|
|
227
|
+
for (const c of touched) { neighbor[c] = 0; seen[c] = 0; }
|
|
228
|
+
touched.length = 0;
|
|
134
229
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
230
|
+
return refined;
|
|
231
|
+
}
|
|
232
|
+
/** Aggregate on `refined` (nodes numbered by first member), with `comm` as the initial partition of the aggregate graph. */
|
|
233
|
+
function aggregate(g: WeightedGraph, refined: Int32Array, comm: Int32Array): { graph: WeightedGraph; nodeOf: Int32Array; comm: Int32Array } {
|
|
234
|
+
const index = new Int32Array(g.n).fill(-1), nodeOf = new Int32Array(g.n);
|
|
235
|
+
let count = 0;
|
|
236
|
+
for (let i = 0; i < g.n; i++) { const r = refined[i]!; if (index[r] === -1) index[r] = count++; nodeOf[i] = index[r]!; }
|
|
237
|
+
const self = new Float64Array(count), pairs = new Map<number, number>(), partition = new Int32Array(count);
|
|
238
|
+
const dense = new Int32Array(g.n).fill(-1);
|
|
239
|
+
let communities = 0;
|
|
240
|
+
for (let i = 0; i < g.n; i++) {
|
|
241
|
+
const a = nodeOf[i]!;
|
|
242
|
+
if (dense[comm[i]!] === -1) dense[comm[i]!] = communities++;
|
|
243
|
+
partition[a] = dense[comm[i]!]!;
|
|
244
|
+
self[a]! += g.self[i]!;
|
|
245
|
+
for (let e = g.start[i]!; e < g.start[i + 1]!; e++) {
|
|
246
|
+
const j = g.to[e]!;
|
|
247
|
+
if (j < i) continue;
|
|
248
|
+
const b = nodeOf[j]!, w = g.weight[e]!;
|
|
249
|
+
if (a === b) self[a]! += w;
|
|
250
|
+
else { const key = Math.min(a, b) * count + Math.max(a, b); pairs.set(key, (pairs.get(key) ?? 0) + w); }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { graph: weightedGraph(count, pairs, self), nodeOf, comm: partition };
|
|
254
|
+
}
|
|
255
|
+
function modularity(g: WeightedGraph, comm: Int32Array, gamma: number): number {
|
|
256
|
+
if (!g.m2) return 0;
|
|
257
|
+
const inside = new Float64Array(g.n), total = new Float64Array(g.n);
|
|
258
|
+
for (let i = 0; i < g.n; i++) {
|
|
259
|
+
total[comm[i]!]! += g.strength[i]!; inside[comm[i]!]! += 2 * g.self[i]!;
|
|
260
|
+
for (let e = g.start[i]!; e < g.start[i + 1]!; e++) if (comm[g.to[e]!] === comm[i]) inside[comm[i]!]! += g.weight[e]!;
|
|
261
|
+
}
|
|
262
|
+
let q = 0;
|
|
263
|
+
for (let c = 0; c < g.n; c++) if (total[c]) q += inside[c]! / g.m2 - gamma * (total[c]! / g.m2) ** 2;
|
|
264
|
+
return q;
|
|
265
|
+
}
|
|
266
|
+
/** Split every community into its connected components (never lowers modularity), then renumber densely by first node. */
|
|
267
|
+
function connectedSplit(g: WeightedGraph, comm: Int32Array): Int32Array {
|
|
268
|
+
const out = new Int32Array(g.n).fill(-1);
|
|
269
|
+
let next = 0;
|
|
270
|
+
const stack: number[] = [];
|
|
271
|
+
for (let i = 0; i < g.n; i++) {
|
|
272
|
+
if (out[i] !== -1) continue;
|
|
273
|
+
out[i] = next; stack.push(i);
|
|
274
|
+
while (stack.length) {
|
|
275
|
+
const v = stack.pop()!;
|
|
276
|
+
for (let e = g.start[v]!; e < g.start[v + 1]!; e++) { const u = g.to[e]!; if (out[u] === -1 && comm[u] === comm[i]) { out[u] = next; stack.push(u); } }
|
|
277
|
+
}
|
|
278
|
+
next++;
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
function leidenPass(base: WeightedGraph, initial: Int32Array, gamma: number): { comm: Int32Array; levels: number } {
|
|
283
|
+
let g = base, comm: Int32Array = initial.slice(), map: Int32Array = new Int32Array(base.n).map((_, i) => i), levels = 0;
|
|
284
|
+
for (; levels < MAX_LEVELS; levels++) {
|
|
285
|
+
moveNodes(g, comm, gamma);
|
|
286
|
+
let communities = 0;
|
|
287
|
+
const used = new Uint8Array(g.n);
|
|
288
|
+
for (let i = 0; i < g.n; i++) if (!used[comm[i]!]) { used[comm[i]!] = 1; communities++; }
|
|
289
|
+
if (communities === g.n) { levels++; break; }
|
|
290
|
+
const refined = refine(g, comm, gamma);
|
|
291
|
+
const next = aggregate(g, refined, comm);
|
|
292
|
+
if (next.graph.n === g.n) { levels++; break; } // no refinement merge: further levels cannot progress
|
|
293
|
+
map = map.map(x => next.nodeOf[x]!);
|
|
294
|
+
g = next.graph; comm = next.comm;
|
|
295
|
+
}
|
|
296
|
+
return { comm: connectedSplit(base, map.map(x => comm[x]!)), levels };
|
|
297
|
+
}
|
|
298
|
+
export interface LeidenResult { membership: Map<string, number>; modularity: number; levels: number; resolution: number }
|
|
299
|
+
/**
|
|
300
|
+
* Deterministic Leiden for modularity over an undirected simple graph. Excluded ids get no community; every other id gets
|
|
301
|
+
* one (an isolate is a singleton). Duplicate pairs count once and a self-loop has weight 1, as in a networkx Graph. Nodes are
|
|
302
|
+
* indexed in code-point id order, so the result does not depend on input order. Communities are numbered by size
|
|
303
|
+
* (descending), then by their smallest member id, and every community induces a connected subgraph.
|
|
304
|
+
*/
|
|
305
|
+
export function leidenCommunities(ids: readonly string[], edges: readonly (readonly [string, string])[], options: { exclude?: ReadonlySet<string>; resolution?: number } = {}): LeidenResult {
|
|
306
|
+
const gamma = options.resolution ?? LEIDEN_RESOLUTION;
|
|
307
|
+
const order = [...new Set(ids)].filter(id => !options.exclude?.has(id)).sort(compare), index = new Map(order.map((id, i) => [id, i]));
|
|
308
|
+
const n = order.length, pairs = new Map<number, number>(), self = new Float64Array(n), selfSeen = new Uint8Array(n);
|
|
309
|
+
for (const [source, target] of edges) {
|
|
310
|
+
const a = index.get(source), b = index.get(target);
|
|
311
|
+
if (a === undefined || b === undefined) continue;
|
|
312
|
+
if (a === b) { if (!selfSeen[a]) { selfSeen[a] = 1; self[a] = 1; } continue; }
|
|
313
|
+
pairs.set(Math.min(a, b) * n + Math.max(a, b), 1);
|
|
314
|
+
}
|
|
315
|
+
const g = weightedGraph(n, pairs, self);
|
|
316
|
+
let comm: Int32Array = new Int32Array(n).map((_, i) => i), quality = modularity(g, comm, gamma), levels = 0;
|
|
317
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
318
|
+
const pass = leidenPass(g, comm, gamma), q = modularity(g, pass.comm, gamma);
|
|
319
|
+
levels += pass.levels;
|
|
320
|
+
if (round > 0 && !(q > quality)) break;
|
|
321
|
+
comm = pass.comm; quality = q;
|
|
322
|
+
}
|
|
323
|
+
const groups = new Map<number, number[]>();
|
|
324
|
+
for (let i = 0; i < n; i++) { const members = groups.get(comm[i]!) ?? []; members.push(i); groups.set(comm[i]!, members); }
|
|
325
|
+
const ranked = [...groups.values()].sort((a, b) => b.length - a.length || a[0]! - b[0]!);
|
|
326
|
+
const membership = new Map<string, number>();
|
|
327
|
+
ranked.forEach((members, id) => { for (const i of members) membership.set(order[i]!, id); });
|
|
328
|
+
return { membership, modularity: Math.round(quality * 1e6) / 1e6, levels, resolution: gamma };
|
|
329
|
+
}
|
|
330
|
+
/** Code-point string order, the tie-break every deterministic graph output uses. */
|
|
331
|
+
export const codePointCompare = compare;
|
|
332
|
+
/**
|
|
333
|
+
* The same deterministic Leiden as `buildGraph`, over an arbitrary undirected graph whose nodes are all clustered (no hub
|
|
334
|
+
* exclusion). Returns node id → community id (by size descending, then smallest member id).
|
|
335
|
+
*/
|
|
336
|
+
export function labelCommunities(ids: readonly string[], edges: readonly (readonly [string, string])[]): Map<string, number> {
|
|
337
|
+
return leidenCommunities(ids, edges).membership;
|
|
141
338
|
}
|
|
142
339
|
/** Pure transformation of the derived-index snapshot, preserving the reference wiki graph semantics. */
|
|
143
340
|
export function buildGraph(snapshot: AnalysisSnapshot): VaultGraph {
|
|
@@ -161,11 +358,11 @@ export function buildGraph(snapshot: AnalysisSnapshot): VaultGraph {
|
|
|
161
358
|
if (edges.size > MAX_EDGES) throw new Error(`graph: exceeds ${MAX_EDGES} edges`);
|
|
162
359
|
}
|
|
163
360
|
}
|
|
164
|
-
const graph: VaultGraph = { directed: false, multigraph: false, graph: { partitioner: "
|
|
361
|
+
const graph: VaultGraph = { directed: false, multigraph: false, graph: { partitioner: "leiden", resolution: LEIDEN_RESOLUTION, modularity: 0, iterations: 0, max_iterations: MAX_ITERATIONS }, nodes,
|
|
165
362
|
edges: [...edges.values()].sort((a, b) => compare(a.source, b.source) || compare(a.target, b.target)) };
|
|
166
363
|
const neighbors = adjacency(graph);
|
|
167
364
|
for (const node of nodes) node.degree = neighbors.get(node.id)!.size + Number(neighbors.get(node.id)!.has(node.id));
|
|
168
|
-
|
|
365
|
+
communities(graph);
|
|
169
366
|
return graph;
|
|
170
367
|
}
|
|
171
368
|
export function graphNeighbors(graph: VaultGraph, start: string, hops: 1 | 2, limit: number): GraphNeighborhood {
|
|
@@ -199,7 +396,7 @@ export function graphReport(graph: VaultGraph, today: string): string {
|
|
|
199
396
|
for (let i = 0; i < queue.length; i++) for (const neighbor of neighbors.get(queue[i]!)!) if (!seen.has(neighbor)) { seen.add(neighbor); queue.push(neighbor); }
|
|
200
397
|
}
|
|
201
398
|
const count = graph.nodes.length;
|
|
202
|
-
const out = [`# Graph Report (${today})`, "", "## Overview", "", "- **Mode**: wiki", "- **Partitioner**: deterministic
|
|
399
|
+
const out = [`# Graph Report (${today})`, "", "## Overview", "", "- **Mode**: wiki", "- **Partitioner**: deterministic Leiden (modularity)", `- **Resolution**: ${graph.graph.resolution}`, `- **Modularity**: ${graph.graph.modularity.toFixed(4)}`,
|
|
203
400
|
`- **Nodes**: ${count} (${graph.nodes.filter(node => HUB_TYPES.has(node.type)).length} hubs excluded from clustering)`, `- **Edges**: ${graph.edges.length}`,
|
|
204
401
|
`- **Communities**: ${groups.size}`, `- **Isolates**: ${graph.nodes.filter(node => node.degree === 0).length}`, `- **Connected components**: ${components}`,
|
|
205
402
|
`- **Density**: ${(count > 1 ? 2 * graph.edges.length / (count * (count - 1)) : 0).toFixed(4)}`, "", "## God Nodes (Top Connected)", "", "| Rank | Node | Degree |", "|------|------|--------|"];
|
|
@@ -231,3 +428,11 @@ export function graphReport(graph: VaultGraph, today: string): string {
|
|
|
231
428
|
else out.push("No cross-community edges found.");
|
|
232
429
|
return out.join("\n") + "\n";
|
|
233
430
|
}
|
|
431
|
+
|
|
432
|
+
/** Recompute derived degrees and communities after adding workspace nodes and edges. */
|
|
433
|
+
export function finalizeGraph(graph: VaultGraph): VaultGraph {
|
|
434
|
+
const neighbors = adjacency(graph);
|
|
435
|
+
for (const node of graph.nodes) node.degree = neighbors.get(node.id)!.size + Number(neighbors.get(node.id)!.has(node.id));
|
|
436
|
+
communities(graph);
|
|
437
|
+
return graph;
|
|
438
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { AnalysisYamlScalar, parseAnalysisYaml, yamlTruthy } from "./analysis-yaml.ts";
|
|
2
|
+
import type { AnalysisSnapshot } from "./analysis-types.ts";
|
|
3
|
+
import { buildGraph, finalizeGraph, pythonTitle, pyString, type GraphNode, type GraphEdge, type VaultGraph } from "./vault-graph.ts";
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface WorkspaceSnapshot {
|
|
7
|
+
files: { path: string; text: string }[];
|
|
8
|
+
buSlugs: string[];
|
|
9
|
+
skipRoots: string[];
|
|
10
|
+
assertUnchanged?: () => void;
|
|
11
|
+
sourceTargets: Record<string, "file" | "directory" | "missing" | "denied">;
|
|
12
|
+
}
|
|
13
|
+
export const WORK_SKIP_DIRS = new Set([".git", "node_modules", ".venv", ".sync-conflicts", "temp", ".maru", ".pnpm-store", ".conductor", ".dotfiles", ".omx", ".omc", ".gstack", ".frontend-slides", ".claude", ".archive", "archive", "vault", "inbox", "sites", "dev", "shared", "_templates", "templates"]);
|
|
14
|
+
const FIELDS = ["projects", "project", "vault_note", "topics", "attendees", "relatedMeetings", "relatedTasks"];
|
|
15
|
+
const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---\s*\n/;
|
|
16
|
+
const WIKI = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
|
|
17
|
+
const PRIORITY = { wiki_link: 0, fm_ref: 1, bu_member: 2, source_ref: 3, related: 4, supersedes: 5 };
|
|
18
|
+
export type WorkCounters = Record<"work_docs" | "yaml_fail" | "template_skip" | "related_unresolved" | "related_ambiguous" | "related_dir_skip" | "related_root_escape" | "related_home_escape" | "source_stub" | "source_dead" | "source_nonmd" | "source_skiproot" | "collisions" | "dup_edges", number>;
|
|
19
|
+
export function workSkipped(path: string, roots: string[]): boolean {
|
|
20
|
+
const normalized = path.normalize("NFC").replace(/^\/+|\/+$/g, "");
|
|
21
|
+
return roots.some(root => normalized === root || normalized.startsWith(root + "/"));
|
|
22
|
+
}
|
|
23
|
+
function mapping(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof AnalysisYamlScalar); }
|
|
24
|
+
export function workspaceFrontmatter(text: string): Record<string, unknown> {
|
|
25
|
+
const match = FRONTMATTER.exec(text);
|
|
26
|
+
if (!match) return {};
|
|
27
|
+
try { const value = parseAnalysisYaml(match[1]!); return mapping(value) ? value : {}; }
|
|
28
|
+
catch { return {}; } // The reference skips rejected YAML and counts work-doc failures.
|
|
29
|
+
}
|
|
30
|
+
function links(value: unknown): string[] { return [...pyString(value).matchAll(WIKI)].map(match => match[1]!.trim().normalize("NFC").toLowerCase().replaceAll(" ", "-")); }
|
|
31
|
+
function coerce(value: unknown): string {
|
|
32
|
+
return value instanceof AnalysisYamlScalar && value.kind === "timestamp" ? value.text.replace(" ", "T") : pyString(value);
|
|
33
|
+
}
|
|
34
|
+
function label(path: string): string {
|
|
35
|
+
let stem = posix.basename(path, ".md").normalize("NFC");
|
|
36
|
+
const parent = posix.basename(posix.dirname(path));
|
|
37
|
+
if (["readme", "index"].includes(stem.toLowerCase()) && parent !== ".") stem = parent + "-" + stem;
|
|
38
|
+
return pythonTitle(stem.replaceAll("-", " "));
|
|
39
|
+
}
|
|
40
|
+
/** Reference source spelling: keep its ID spelling, including path segments, rather than silently remap it. */
|
|
41
|
+
export function workspaceSource(fm: Record<string, unknown>): string | undefined {
|
|
42
|
+
if (typeof fm.source !== "string" || !fm.source.trim()) return undefined;
|
|
43
|
+
let raw = fm.source.trim().replace(/^"+|"+$/g, "").replace(/^'+|'+$/g, "");
|
|
44
|
+
if (!raw || raw.startsWith("http://") || raw.startsWith("https://")) return undefined;
|
|
45
|
+
if (raw.startsWith("work/")) raw = raw.slice(5);
|
|
46
|
+
return raw.normalize("NFC");
|
|
47
|
+
}
|
|
48
|
+
export function buildWorkspaceGraph(snapshot: AnalysisSnapshot, work: WorkspaceSnapshot): { graph: VaultGraph; counters: WorkCounters; relations: Record<string, number> } {
|
|
49
|
+
const graph = buildGraph(snapshot);
|
|
50
|
+
const counters: WorkCounters = { work_docs: 0, yaml_fail: 0, template_skip: 0, related_unresolved: 0, related_ambiguous: 0, related_dir_skip: 0, related_root_escape: 0, related_home_escape: 0, source_stub: 0, source_dead: 0, source_nonmd: 0, source_skiproot: 0, collisions: 0, dup_edges: 0 };
|
|
51
|
+
const vaultIds = new Set(graph.nodes.map(node => node.id));
|
|
52
|
+
const docs = new Map<string, { path: string; fm: Record<string, unknown>; body: string }>(), stems = new Map<string, string[]>();
|
|
53
|
+
for (const file of work.files) {
|
|
54
|
+
const match = FRONTMATTER.exec(file.text);
|
|
55
|
+
if (!match) continue;
|
|
56
|
+
if (match[1]!.includes("{{")) { counters.template_skip++; continue; }
|
|
57
|
+
const fm = workspaceFrontmatter(file.text);
|
|
58
|
+
if (!Object.keys(fm).length) { if (match[1]!.trim()) counters.yaml_fail++; continue; }
|
|
59
|
+
if (!yamlTruthy(fm.document_type) && !FIELDS.some(field => yamlTruthy(fm[field])) && !yamlTruthy(fm.related)) continue;
|
|
60
|
+
const path = file.path.normalize("NFC"), id = "work:" + path.slice(0, -3);
|
|
61
|
+
docs.set(id, { path, fm, body: file.text.slice(match[0].length) });
|
|
62
|
+
const key = posix.basename(path, ".md").toLowerCase().replaceAll(" ", "-");
|
|
63
|
+
stems.set(key, [...stems.get(key) ?? [], id]); counters.work_docs++;
|
|
64
|
+
}
|
|
65
|
+
const validBu = new Set(work.buSlugs), usedBu = new Set<string>();
|
|
66
|
+
const businessUnit = (fm: Record<string, unknown>) => pyString(fm.business_unit ?? "").replaceAll("[[", "").replaceAll("]]", "").trim();
|
|
67
|
+
for (const doc of docs.values()) { const bu = businessUnit(doc.fm); if (validBu.has(bu)) usedBu.add(bu); }
|
|
68
|
+
const node = (value: Omit<GraphNode, "degree" | "community">): GraphNode => ({ ...value, degree: 0, community: null });
|
|
69
|
+
for (const slug of [...usedBu].sort()) graph.nodes.push(node({ id: "bu:" + slug, type: "bu", domain: "bu", label: slug, source_file: "" }));
|
|
70
|
+
for (const [id, doc] of docs) graph.nodes.push(node({ id, label: label(doc.path), type: coerce(yamlTruthy(doc.fm.document_type) ? doc.fm.document_type : "work-doc"), domain: doc.path.split("/")[0]!, description: Array.from(coerce(yamlTruthy(doc.fm.title) ? doc.fm.title : yamlTruthy(doc.fm.description) ? doc.fm.description : "")).slice(0, 200).join(""), source_file: doc.path }));
|
|
71
|
+
if (graph.nodes.length > 20000) throw new Error("workspace graph: exceeds 20000 nodes");
|
|
72
|
+
const candidates: GraphEdge[] = [];
|
|
73
|
+
const add = (relation: GraphEdge["relation"], source: string, target: string, attrs: { rel?: string; field?: string } = {}) => {
|
|
74
|
+
if (candidates.length >= 200000) throw new Error("workspace graph: exceeds 200000 edge candidates");
|
|
75
|
+
candidates.push({ source, target, relation, confidence_tag: "EXTRACTED", confidence: 1, ...attrs });
|
|
76
|
+
};
|
|
77
|
+
for (const [id, doc] of docs) {
|
|
78
|
+
const related = doc.fm.related;
|
|
79
|
+
if (related !== null && related !== undefined) for (const entry of Array.isArray(related) ? related : [related]) {
|
|
80
|
+
const rtype = mapping(entry) ? entry.rel : undefined;
|
|
81
|
+
let value = pyString(mapping(entry) ? entry.doc ?? "" : entry).trim();
|
|
82
|
+
if (!value) continue;
|
|
83
|
+
value = value.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
84
|
+
const wiki = links(value);
|
|
85
|
+
let target: string | undefined;
|
|
86
|
+
if (wiki.length) {
|
|
87
|
+
const targets = stems.get(wiki[0]!);
|
|
88
|
+
if (!targets?.length) counters.related_unresolved++;
|
|
89
|
+
else if (targets.length > 1) counters.related_ambiguous++;
|
|
90
|
+
else target = targets[0];
|
|
91
|
+
} else if (value.startsWith("~")) counters.related_home_escape++;
|
|
92
|
+
else {
|
|
93
|
+
const joined = posix.isAbsolute(value) ? posix.normalize(value) : posix.normalize(posix.join(posix.dirname(doc.path), value));
|
|
94
|
+
if (joined.startsWith("..")) counters.related_root_escape++;
|
|
95
|
+
else if (value.endsWith("/") || !joined.endsWith(".md")) counters.related_dir_skip++;
|
|
96
|
+
else if (docs.has("work:" + joined.slice(0, -3))) target = "work:" + joined.slice(0, -3);
|
|
97
|
+
else counters.related_unresolved++;
|
|
98
|
+
}
|
|
99
|
+
if (target) add("related", id, target, yamlTruthy(rtype) ? { rel: coerce(rtype) } : {});
|
|
100
|
+
}
|
|
101
|
+
for (const field of FIELDS) if (field in doc.fm) for (const key of links(doc.fm[field])) if (vaultIds.has(key)) add("fm_ref", id, key, { field });
|
|
102
|
+
for (const key of links(doc.body)) if (vaultIds.has(key)) add("wiki_link", id, key);
|
|
103
|
+
const bu = businessUnit(doc.fm); if (usedBu.has(bu)) add("bu_member", id, "bu:" + bu);
|
|
104
|
+
}
|
|
105
|
+
const stubs = new Set<string>();
|
|
106
|
+
for (const note of [...snapshot.notes].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
|
|
107
|
+
const fm = workspaceFrontmatter(note.source), id = posix.basename(note.path, ".md"), raw = workspaceSource(fm);
|
|
108
|
+
if (raw !== undefined) {
|
|
109
|
+
const ext = posix.extname(raw), norm = raw.endsWith(".md") ? raw.slice(0, -3) : raw, target = "work:" + norm;
|
|
110
|
+
if (raw.endsWith("/") || (ext && ext !== ".md")) counters.source_nonmd++;
|
|
111
|
+
else if (docs.has(target)) add("source_ref", id, target);
|
|
112
|
+
else if (norm.split("/").some(part => WORK_SKIP_DIRS.has(part)) || workSkipped(norm, work.skipRoots) || work.sourceTargets[norm] === "denied") counters.source_skiproot++;
|
|
113
|
+
else if (work.sourceTargets[norm] === "file") {
|
|
114
|
+
if (!stubs.has(target)) { stubs.add(target); counters.source_stub++; graph.nodes.push(node({ id: target, type: "file", label: label(norm + ".md"), domain: norm.split("/")[0]!, source_file: norm + ".md" })); }
|
|
115
|
+
add("source_ref", id, target);
|
|
116
|
+
} else if (work.sourceTargets[norm] === "directory") counters.source_nonmd++;
|
|
117
|
+
else counters.source_dead++;
|
|
118
|
+
}
|
|
119
|
+
for (const field of ["supersedes", "superseded_by"]) if (yamlTruthy(fm[field])) for (const key of links(fm[field])) if (vaultIds.has(key)) add("supersedes", id, key, { field });
|
|
120
|
+
}
|
|
121
|
+
const known = new Set(graph.nodes.map(node => node.id)), best = new Map<string, GraphEdge>();
|
|
122
|
+
const pair = (edge: GraphEdge) => JSON.stringify([edge.source, edge.target].sort());
|
|
123
|
+
for (const edge of candidates) {
|
|
124
|
+
if (edge.source === edge.target || !known.has(edge.source) || !known.has(edge.target)) continue;
|
|
125
|
+
const key = pair(edge), current = best.get(key);
|
|
126
|
+
if (current) {
|
|
127
|
+
if (current.relation !== edge.relation) counters.collisions++; else counters.dup_edges++;
|
|
128
|
+
}
|
|
129
|
+
if (!current || PRIORITY[edge.relation] > PRIORITY[current.relation]) best.set(key, edge);
|
|
130
|
+
}
|
|
131
|
+
const edges = new Map(graph.edges.map(edge => [pair(edge), edge]));
|
|
132
|
+
for (const [key, edge] of best) edges.set(key, { ...edges.get(key), ...edge });
|
|
133
|
+
graph.edges = [...edges.values()];
|
|
134
|
+
if (graph.nodes.length > 20000 || graph.edges.length > 100000) throw new Error("workspace graph: node or edge bound exceeded");
|
|
135
|
+
finalizeGraph(graph);
|
|
136
|
+
const relations: Record<string, number> = {};
|
|
137
|
+
for (const edge of graph.edges) relations[edge.relation] = (relations[edge.relation] ?? 0) + 1;
|
|
138
|
+
return { graph, counters, relations };
|
|
139
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, opendirSync, openSync, readSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { VaultSpec, VmemConfig } from "./config.ts";
|
|
5
|
+
import type { AnalysisSnapshot } from "./analysis-types.ts";
|
|
6
|
+
import { analysisRead } from "./analysis-input.ts";
|
|
7
|
+
import { parseAnalysisYaml } from "./analysis-yaml.ts";
|
|
8
|
+
import { isDenied } from "./platform.ts";
|
|
9
|
+
import { sourceWorkspaceRoot } from "./source.ts";
|
|
10
|
+
import { WORK_SKIP_DIRS, workspaceFrontmatter, workspaceSource, workSkipped, type WorkspaceSnapshot } from "./workspace-graph.ts";
|
|
11
|
+
|
|
12
|
+
const CONFIG_SKIP = new Set([".git", "node_modules", ".venv", ".sync-conflicts", "archive", ".archive", "vault", "temp"]);
|
|
13
|
+
export const WORK_MAX_FILES = 50000;
|
|
14
|
+
/** Per-file bound for work docs that open with frontmatter (the only ones that can become nodes). */
|
|
15
|
+
export const WORK_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
export const WORK_MAX_TOTAL_BYTES = 128 * 1024 * 1024;
|
|
17
|
+
/** Bytes probed from every `.md` file to decide whether it opens with frontmatter; plain files are never read further. */
|
|
18
|
+
const PROBE_BYTES = 4;
|
|
19
|
+
/** Directory entries visited per walk (symlinks included), so a runaway tree fails explicitly instead of stalling. */
|
|
20
|
+
export const WORK_MAX_ENTRIES = 2_000_000;
|
|
21
|
+
const DEFAULT_ROOTS = ["scratchpad/memos", "scratchpad/temp"];
|
|
22
|
+
function object(value: unknown): Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
|
23
|
+
/** Filesystem-only snapshot. No directory or file symlinks are followed, including source stubs. */
|
|
24
|
+
/** Test-only overrides of the reader bounds; production callers pass nothing and get the exported constants. */
|
|
25
|
+
export interface WorkspaceLimits { files?: number; fileBytes?: number; bytes?: number; entries?: number }
|
|
26
|
+
export function workspaceInput(snapshot: AnalysisSnapshot, vault: VaultSpec, config: VmemConfig, limits: WorkspaceLimits = {}): WorkspaceSnapshot {
|
|
27
|
+
const maxFiles = limits.files ?? WORK_MAX_FILES, maxFileBytes = limits.fileBytes ?? WORK_MAX_FILE_BYTES;
|
|
28
|
+
const maxBytes = limits.bytes ?? WORK_MAX_TOTAL_BYTES, maxEntries = limits.entries ?? WORK_MAX_ENTRIES;
|
|
29
|
+
const found = sourceWorkspaceRoot(vault);
|
|
30
|
+
if (!found) throw new Error("workspace graph: vault requires a registry");
|
|
31
|
+
const root = realpathSync(found);
|
|
32
|
+
if (root !== found || lstatSync(found).isSymbolicLink() || !lstatSync(found).isDirectory()) throw new Error("workspace graph: work root must be a plain directory");
|
|
33
|
+
const denied = (file: string) => isDenied(file, config.denyPaths ?? []);
|
|
34
|
+
if (denied(root)) throw new Error("workspace graph: work root is denied");
|
|
35
|
+
let filesRead = 0, bytesRead = 0, entriesSeen = 0;
|
|
36
|
+
const stamps = new Map<string, string>();
|
|
37
|
+
const stamp = (file: string) => { const stat = lstatSync(file, { bigint: true }); return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join(":"); };
|
|
38
|
+
const remember = (file: string) => {
|
|
39
|
+
const current = stamp(file), prior = stamps.get(file);
|
|
40
|
+
if (prior !== undefined && prior !== current) throw new Error("workspace graph: input changed during snapshot");
|
|
41
|
+
stamps.set(file, current);
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* FRONTMATTER_RE needs `---` then whitespace at offset 0; a file that cannot match is skipped unread, as in the reference.
|
|
45
|
+
* Every probed file counts once toward WORK_MAX_FILES and is stamped, so one that later gains frontmatter fails assertUnchanged.
|
|
46
|
+
*/
|
|
47
|
+
const probed = new Set<string>();
|
|
48
|
+
const opensWithFrontmatter = (file: string): boolean => {
|
|
49
|
+
if (++filesRead > maxFiles) throw new Error("workspace graph: file count exceeds bound");
|
|
50
|
+
probed.add(file);
|
|
51
|
+
remember(file);
|
|
52
|
+
let fd: number;
|
|
53
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
54
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
55
|
+
try {
|
|
56
|
+
if (!fstatSync(fd).isFile()) return false;
|
|
57
|
+
const head = Buffer.alloc(PROBE_BYTES), n = readSync(fd, head, 0, PROBE_BYTES, 0);
|
|
58
|
+
return n === PROBE_BYTES && head.subarray(0, 3).toString("latin1") === "---" && /\s/.test(String.fromCharCode(head[3]!));
|
|
59
|
+
} finally { closeSync(fd); }
|
|
60
|
+
};
|
|
61
|
+
const read = (file: string, maximum = maxFileBytes): string => {
|
|
62
|
+
// A probed work doc was already counted; other reads (configs) count here.
|
|
63
|
+
if (!probed.has(file) && ++filesRead > maxFiles) throw new Error("workspace graph: file count exceeds bound");
|
|
64
|
+
remember(file);
|
|
65
|
+
const text = analysisRead(file, maximum);
|
|
66
|
+
if (text === null) throw new Error("workspace graph: input disappeared");
|
|
67
|
+
remember(file);
|
|
68
|
+
bytesRead += Buffer.byteLength(text);
|
|
69
|
+
if (bytesRead > maxBytes) throw new Error("workspace graph: total bytes exceed bound");
|
|
70
|
+
return text;
|
|
71
|
+
};
|
|
72
|
+
let skipRoots = DEFAULT_ROOTS;
|
|
73
|
+
const workspaceConfig = join(root, "workspace.config.yaml");
|
|
74
|
+
try {
|
|
75
|
+
const stat = lstatSync(workspaceConfig);
|
|
76
|
+
if (stat.isFile() && !stat.isSymbolicLink() && !denied(workspaceConfig)) {
|
|
77
|
+
const text = read(workspaceConfig, 1024 * 1024);
|
|
78
|
+
let data: Record<string, unknown> = {};
|
|
79
|
+
try { data = object(parseAnalysisYaml(text)); } catch { /* Reference falls back on malformed config. */ }
|
|
80
|
+
const paths = object(data.paths), scratchpad = object(data.scratchpad), configured = String(paths.scratchpad ?? "").trim();
|
|
81
|
+
let scratchRoot = "scratchpad";
|
|
82
|
+
if (configured) {
|
|
83
|
+
const expanded = configured === "~" ? homedir() : configured.startsWith("~/") ? join(homedir(), configured.slice(2)) : configured;
|
|
84
|
+
const absolute = resolve(expanded), rel = relative(root, absolute);
|
|
85
|
+
if (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel)) scratchRoot = rel.normalize("NFC").replace(/^\/+|\/+$/g, "");
|
|
86
|
+
else scratchRoot = "";
|
|
87
|
+
}
|
|
88
|
+
if (scratchRoot) skipRoots = [String(scratchpad.memos_subdir || "memos"), String(scratchpad.temp_subdir || "temp")].map(dir => scratchRoot + "/" + dir.normalize("NFC").replace(/^\/+|\/+$/g, ""));
|
|
89
|
+
}
|
|
90
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
91
|
+
const walk = (dir: string, skip: Set<string>, roots: string[], visit: (file: string, rel: string) => void): void => {
|
|
92
|
+
if (realpathSync(dir) !== dir || denied(dir)) return;
|
|
93
|
+
remember(dir);
|
|
94
|
+
const handle = opendirSync(dir), children: { name: string; directory: boolean; file: boolean }[] = [];
|
|
95
|
+
try {
|
|
96
|
+
for (;;) {
|
|
97
|
+
const entry = handle.readSync(); if (!entry) break;
|
|
98
|
+
if (++entriesSeen > maxEntries) throw new Error("workspace graph: directory entry count exceeds bound");
|
|
99
|
+
if (entry.isSymbolicLink()) continue;
|
|
100
|
+
children.push({ name: entry.name, directory: entry.isDirectory(), file: entry.isFile() });
|
|
101
|
+
}
|
|
102
|
+
} finally { handle.closeSync(); }
|
|
103
|
+
for (const entry of children) {
|
|
104
|
+
const file = join(dir, entry.name);
|
|
105
|
+
if (entry.file && !denied(file)) visit(file, relative(root, file).normalize("NFC"));
|
|
106
|
+
}
|
|
107
|
+
for (const entry of children) {
|
|
108
|
+
const file = join(dir, entry.name), rel = relative(root, file).normalize("NFC");
|
|
109
|
+
if (entry.directory && !skip.has(entry.name) && !workSkipped(rel, roots) && !denied(file)) walk(file, skip, roots, visit);
|
|
110
|
+
}
|
|
111
|
+
remember(dir);
|
|
112
|
+
};
|
|
113
|
+
const files: WorkspaceSnapshot["files"] = [], buSlugs = new Set<string>();
|
|
114
|
+
walk(root, WORK_SKIP_DIRS, skipRoots, (file, path) => { if (path.endsWith(".md") && opensWithFrontmatter(file)) files.push({ path, text: read(file) }); });
|
|
115
|
+
// The bu-config walk prunes fewer trees (it must see `.maru/`), like the reference's rglob, so it gets its own entry budget.
|
|
116
|
+
entriesSeen = 0;
|
|
117
|
+
walk(root, CONFIG_SKIP, [], (file, path) => {
|
|
118
|
+
if (!path.endsWith("/bu-config.yaml") && path !== "bu-config.yaml") return;
|
|
119
|
+
const text = read(file, 1024 * 1024);
|
|
120
|
+
try {
|
|
121
|
+
const data = object(parseAnalysisYaml(text)), slug = String(data.bu_id ?? "").replaceAll("[[", "").replaceAll("]]", "").trim();
|
|
122
|
+
if (/^[a-z0-9][a-z0-9-]*$/.test(slug)) buSlugs.add(slug);
|
|
123
|
+
} catch { /* Invalid bu config does not declare a hub. */ }
|
|
124
|
+
});
|
|
125
|
+
const plain = (candidate: string): "file" | "directory" | "missing" | "denied" => {
|
|
126
|
+
const rel = relative(root, candidate);
|
|
127
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) return "missing";
|
|
128
|
+
let current = root;
|
|
129
|
+
for (const part of rel.split(sep)) {
|
|
130
|
+
current = join(current, part);
|
|
131
|
+
if (denied(current)) return "denied";
|
|
132
|
+
try { const stat = lstatSync(current); if (stat.isSymbolicLink()) return "missing"; remember(current); }
|
|
133
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return "missing"; throw error; }
|
|
134
|
+
}
|
|
135
|
+
const stat = lstatSync(candidate);
|
|
136
|
+
return stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "missing";
|
|
137
|
+
};
|
|
138
|
+
const sourceTargets: WorkspaceSnapshot["sourceTargets"] = {};
|
|
139
|
+
for (const note of snapshot.notes) {
|
|
140
|
+
const raw = workspaceSource(workspaceFrontmatter(note.source));
|
|
141
|
+
if (raw === undefined) continue;
|
|
142
|
+
const norm = raw.endsWith(".md") ? raw.slice(0, -3) : raw;
|
|
143
|
+
if (norm.split("/").some(part => WORK_SKIP_DIRS.has(part)) || workSkipped(norm, skipRoots)) continue;
|
|
144
|
+
const file = plain(resolve(root, norm + ".md"));
|
|
145
|
+
sourceTargets[norm] = file === "file" || file === "denied" ? file : plain(resolve(root, norm)) === "directory" ? "directory" : "missing";
|
|
146
|
+
}
|
|
147
|
+
const assertUnchanged = () => { for (const [file, expected] of stamps) if (stamp(file) !== expected) throw new Error("workspace graph: input changed during build"); };
|
|
148
|
+
assertUnchanged();
|
|
149
|
+
return { files, buSlugs: [...buSlugs].sort(), skipRoots, sourceTargets, assertUnchanged };
|
|
150
|
+
}
|