@grafema/util 0.3.17 → 0.3.20
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/dist/core/FileOverview.d.ts +12 -0
- package/dist/core/FileOverview.d.ts.map +1 -1
- package/dist/core/FileOverview.js +98 -2
- package/dist/core/FileOverview.js.map +1 -1
- package/dist/federation/FederatedRouter.d.ts +124 -0
- package/dist/federation/FederatedRouter.d.ts.map +1 -0
- package/dist/federation/FederatedRouter.js +297 -0
- package/dist/federation/FederatedRouter.js.map +1 -0
- package/dist/federation/ShardDiscovery.d.ts +56 -0
- package/dist/federation/ShardDiscovery.d.ts.map +1 -0
- package/dist/federation/ShardDiscovery.js +100 -0
- package/dist/federation/ShardDiscovery.js.map +1 -0
- package/dist/federation/index.d.ts +28 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +26 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +26 -4
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/index.d.ts +2 -0
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +1 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/registry.d.ts +116 -0
- package/dist/manifest/registry.d.ts.map +1 -0
- package/dist/manifest/registry.js +638 -0
- package/dist/manifest/registry.js.map +1 -0
- package/dist/manifest/resolver.d.ts +9 -0
- package/dist/manifest/resolver.d.ts.map +1 -1
- package/dist/manifest/resolver.js +31 -0
- package/dist/manifest/resolver.js.map +1 -1
- package/dist/notation/traceRenderer.d.ts +2 -0
- package/dist/notation/traceRenderer.d.ts.map +1 -1
- package/dist/notation/traceRenderer.js +6 -5
- package/dist/notation/traceRenderer.js.map +1 -1
- package/package.json +3 -3
- package/src/core/FileOverview.ts +104 -2
- package/src/federation/FederatedRouter.ts +440 -0
- package/src/federation/ShardDiscovery.ts +130 -0
- package/src/federation/index.ts +35 -0
- package/src/index.ts +16 -1
- package/src/manifest/generator.ts +25 -4
- package/src/manifest/index.ts +2 -0
- package/src/manifest/registry.ts +769 -0
- package/src/manifest/resolver.ts +33 -0
- package/src/notation/traceRenderer.ts +8 -5
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FederatedRouter — orchestrates cross-shard graph queries.
|
|
3
|
+
*
|
|
4
|
+
* Two query patterns:
|
|
5
|
+
* 1. Traversal with frontier: trace_dataflow, trace_alias
|
|
6
|
+
* - Start in local shard, follow edges until boundary
|
|
7
|
+
* - Frontier edges grouped by target shard, batch-resolved via SUBGRAPH
|
|
8
|
+
* - Repeat until no more frontier or depth exhausted
|
|
9
|
+
*
|
|
10
|
+
* 2. Scatter-gather: find_calls, find_nodes
|
|
11
|
+
* - Broadcast query to all shards, merge results
|
|
12
|
+
*
|
|
13
|
+
* Key properties:
|
|
14
|
+
* - Global visited set across all hops (cycle prevention / INV-3)
|
|
15
|
+
* - Cost budget: max connections, max total nodes (fan-out protection / INV-6)
|
|
16
|
+
* - Frontier grouping: N edges to same shard → 1 SUBGRAPH call (not N)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { WireNode, WireEdge } from '@grafema/types';
|
|
20
|
+
import type { ShardDiscovery, ShardRegistration } from './ShardDiscovery.js';
|
|
21
|
+
import type { ManifestResolver } from '../manifest/index.js';
|
|
22
|
+
import type { ResolveResult } from '../manifest/resolver.js';
|
|
23
|
+
|
|
24
|
+
// ── Types ──────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface FrontierEdge {
|
|
27
|
+
/** Source node semantic ID (in the source shard) */
|
|
28
|
+
src: string;
|
|
29
|
+
/** Target node ID (hash — target doesn't exist in source shard) */
|
|
30
|
+
dst: string;
|
|
31
|
+
/** Edge type */
|
|
32
|
+
edgeType: string;
|
|
33
|
+
/** Edge metadata (JSON string, may contain "source" for IMPORTS_FROM) */
|
|
34
|
+
metadata?: string;
|
|
35
|
+
/** Absolute file path of the target (extracted from semantic ID) */
|
|
36
|
+
targetFile?: string;
|
|
37
|
+
/** How this edge was resolved: "graph" | "manifest" | "unresolved" */
|
|
38
|
+
resolvedVia?: 'graph' | 'manifest' | 'unresolved';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SubgraphResponse {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
nodes: WireNode[];
|
|
44
|
+
edges: WireEdge[];
|
|
45
|
+
frontier: FrontierEdge[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FederatedTraceHop {
|
|
49
|
+
/** Which shard this hop came from */
|
|
50
|
+
shard: ShardRegistration | null;
|
|
51
|
+
/** Nodes discovered in this hop */
|
|
52
|
+
nodes: WireNode[];
|
|
53
|
+
/** Edges discovered in this hop */
|
|
54
|
+
edges: WireEdge[];
|
|
55
|
+
/** Unresolved frontier (shard not found or unavailable) */
|
|
56
|
+
unresolvedFrontier: FrontierEdge[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Node resolved from manifest (no full graph, just export surface) */
|
|
60
|
+
export interface ManifestResolvedNode {
|
|
61
|
+
/** Package name (e.g., "@grafema/util") */
|
|
62
|
+
packageName: string;
|
|
63
|
+
/** Symbol name */
|
|
64
|
+
symbolName: string;
|
|
65
|
+
/** Export kind: FUNCTION, CLASS, etc. */
|
|
66
|
+
kind: string;
|
|
67
|
+
/** Known effects */
|
|
68
|
+
effects: string[];
|
|
69
|
+
/** Semantic ID from manifest (if available) */
|
|
70
|
+
semanticId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FederatedTraceResult {
|
|
74
|
+
/** All nodes from all hops, merged */
|
|
75
|
+
nodes: WireNode[];
|
|
76
|
+
/** All edges from all hops, merged */
|
|
77
|
+
edges: WireEdge[];
|
|
78
|
+
/** Individual hops for debugging/visualization */
|
|
79
|
+
hops: FederatedTraceHop[];
|
|
80
|
+
/** Nodes resolved via manifest (partial info, no subgraph) */
|
|
81
|
+
manifestNodes: ManifestResolvedNode[];
|
|
82
|
+
/** Unresolved frontier across all hops */
|
|
83
|
+
unresolvedFrontier: FrontierEdge[];
|
|
84
|
+
/** Whether the traversal was cut short by cost budget */
|
|
85
|
+
truncated: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FederatedRouterOptions {
|
|
89
|
+
/** Max total nodes across all hops (default: 10000) */
|
|
90
|
+
maxNodes?: number;
|
|
91
|
+
/** Max shard connections per round (default: 10) */
|
|
92
|
+
maxShardsPerRound?: number;
|
|
93
|
+
/** Max traversal rounds (default: 5) */
|
|
94
|
+
maxRounds?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Function that sends a SUBGRAPH command to an rfdb-server via its socket */
|
|
98
|
+
type SubgraphSender = (
|
|
99
|
+
socket: string,
|
|
100
|
+
entries: string[],
|
|
101
|
+
direction: string,
|
|
102
|
+
edgeTypes: string[],
|
|
103
|
+
maxDepth: number,
|
|
104
|
+
) => Promise<SubgraphResponse>;
|
|
105
|
+
|
|
106
|
+
// ── Router ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export class FederatedRouter {
|
|
109
|
+
private discovery: ShardDiscovery;
|
|
110
|
+
private sendSubgraph: SubgraphSender;
|
|
111
|
+
private manifestResolver: ManifestResolver | null;
|
|
112
|
+
private options: Required<FederatedRouterOptions>;
|
|
113
|
+
|
|
114
|
+
constructor(
|
|
115
|
+
discovery: ShardDiscovery,
|
|
116
|
+
sendSubgraph: SubgraphSender,
|
|
117
|
+
options: FederatedRouterOptions = {},
|
|
118
|
+
manifestResolver?: ManifestResolver,
|
|
119
|
+
) {
|
|
120
|
+
this.discovery = discovery;
|
|
121
|
+
this.sendSubgraph = sendSubgraph;
|
|
122
|
+
this.manifestResolver = manifestResolver ?? null;
|
|
123
|
+
this.options = {
|
|
124
|
+
maxNodes: options.maxNodes ?? 10_000,
|
|
125
|
+
maxShardsPerRound: options.maxShardsPerRound ?? 10,
|
|
126
|
+
maxRounds: options.maxRounds ?? 5,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Federated traversal: start from entry points, hop across shards via frontier.
|
|
132
|
+
*
|
|
133
|
+
* 1. Send SUBGRAPH to the shard owning the first entry point
|
|
134
|
+
* 2. Collect frontier, group by target shard
|
|
135
|
+
* 3. Send SUBGRAPH to each target shard (parallel)
|
|
136
|
+
* 4. Repeat until no more frontier or budget exhausted
|
|
137
|
+
*/
|
|
138
|
+
async trace(
|
|
139
|
+
entries: string[],
|
|
140
|
+
direction: 'forward' | 'backward' | 'both',
|
|
141
|
+
edgeTypes: string[],
|
|
142
|
+
maxDepth: number,
|
|
143
|
+
): Promise<FederatedTraceResult> {
|
|
144
|
+
const allNodes: WireNode[] = [];
|
|
145
|
+
const allEdges: WireEdge[] = [];
|
|
146
|
+
const allHops: FederatedTraceHop[] = [];
|
|
147
|
+
const manifestNodes: ManifestResolvedNode[] = [];
|
|
148
|
+
const globalVisited = new Set<string>(); // INV-3: global across all hops
|
|
149
|
+
let unresolvedFrontier: FrontierEdge[] = [];
|
|
150
|
+
let truncated = false;
|
|
151
|
+
|
|
152
|
+
// Initial frontier: the entry points themselves, grouped by shard
|
|
153
|
+
let currentFrontier: Array<{ shard: ShardRegistration; entries: string[] }> = [];
|
|
154
|
+
|
|
155
|
+
// Group entry points by shard
|
|
156
|
+
const entryGroups = this.groupByShard(
|
|
157
|
+
entries.map(e => ({ src: '', dst: e, edgeType: '' })),
|
|
158
|
+
);
|
|
159
|
+
for (const [, group] of entryGroups) {
|
|
160
|
+
currentFrontier.push({
|
|
161
|
+
shard: group.shard,
|
|
162
|
+
entries: group.edges.map(e => e.dst),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let remainingDepth = maxDepth;
|
|
167
|
+
|
|
168
|
+
for (let round = 0; round < this.options.maxRounds; round++) {
|
|
169
|
+
if (currentFrontier.length === 0 || remainingDepth <= 0) break;
|
|
170
|
+
|
|
171
|
+
// Cost check: limit shards per round
|
|
172
|
+
const roundShards = currentFrontier.slice(0, this.options.maxShardsPerRound);
|
|
173
|
+
if (roundShards.length < currentFrontier.length) {
|
|
174
|
+
truncated = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Query all shards in parallel
|
|
178
|
+
const hopResults = await Promise.all(
|
|
179
|
+
roundShards.map(async ({ shard, entries: entryIds }) => {
|
|
180
|
+
// Filter out already-visited entries
|
|
181
|
+
const newEntries = entryIds.filter(e => !globalVisited.has(e));
|
|
182
|
+
if (newEntries.length === 0) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const result = await this.sendSubgraph(
|
|
188
|
+
shard.socket,
|
|
189
|
+
newEntries,
|
|
190
|
+
direction,
|
|
191
|
+
edgeTypes,
|
|
192
|
+
remainingDepth,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Mark visited
|
|
196
|
+
for (const node of result.nodes) {
|
|
197
|
+
const nodeId = node.semanticId || node.id;
|
|
198
|
+
globalVisited.add(nodeId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { shard, result };
|
|
202
|
+
} catch {
|
|
203
|
+
// Shard unavailable — all its entries become unresolved
|
|
204
|
+
return {
|
|
205
|
+
shard,
|
|
206
|
+
result: {
|
|
207
|
+
ok: false,
|
|
208
|
+
nodes: [],
|
|
209
|
+
edges: [],
|
|
210
|
+
frontier: newEntries.map(e => ({
|
|
211
|
+
src: '',
|
|
212
|
+
dst: e,
|
|
213
|
+
edgeType: 'UNRESOLVED',
|
|
214
|
+
})),
|
|
215
|
+
} as SubgraphResponse,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Collect results
|
|
222
|
+
const nextFrontierEdges: FrontierEdge[] = [];
|
|
223
|
+
|
|
224
|
+
for (const hopResult of hopResults) {
|
|
225
|
+
if (!hopResult) continue;
|
|
226
|
+
|
|
227
|
+
const { shard, result } = hopResult;
|
|
228
|
+
const hop: FederatedTraceHop = {
|
|
229
|
+
shard,
|
|
230
|
+
nodes: result.nodes,
|
|
231
|
+
edges: result.edges,
|
|
232
|
+
unresolvedFrontier: [],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
allNodes.push(...result.nodes);
|
|
236
|
+
allEdges.push(...result.edges);
|
|
237
|
+
|
|
238
|
+
// Check cost budget
|
|
239
|
+
if (allNodes.length >= this.options.maxNodes) {
|
|
240
|
+
truncated = true;
|
|
241
|
+
allHops.push(hop);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Process frontier
|
|
246
|
+
for (const edge of result.frontier) {
|
|
247
|
+
if (globalVisited.has(edge.dst)) continue; // Already visited
|
|
248
|
+
nextFrontierEdges.push(edge);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
allHops.push(hop);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (truncated) break;
|
|
255
|
+
|
|
256
|
+
// Group next frontier by target shard
|
|
257
|
+
const nextGroups = this.groupByShard(nextFrontierEdges);
|
|
258
|
+
currentFrontier = [];
|
|
259
|
+
const newUnresolved: FrontierEdge[] = [];
|
|
260
|
+
|
|
261
|
+
for (const [, group] of nextGroups) {
|
|
262
|
+
if (group.shard) {
|
|
263
|
+
currentFrontier.push({
|
|
264
|
+
shard: group.shard,
|
|
265
|
+
entries: group.edges.map(e => e.dst),
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
// No shard found — try manifest fallback
|
|
269
|
+
for (const edge of group.edges) {
|
|
270
|
+
const resolved = this.tryManifestFallback(edge);
|
|
271
|
+
if (resolved) {
|
|
272
|
+
manifestNodes.push(resolved);
|
|
273
|
+
edge.resolvedVia = 'manifest';
|
|
274
|
+
} else {
|
|
275
|
+
edge.resolvedVia = 'unresolved';
|
|
276
|
+
newUnresolved.push(edge);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
unresolvedFrontier = newUnresolved;
|
|
283
|
+
remainingDepth = Math.max(0, remainingDepth - 1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
nodes: allNodes,
|
|
288
|
+
edges: allEdges,
|
|
289
|
+
hops: allHops,
|
|
290
|
+
manifestNodes,
|
|
291
|
+
unresolvedFrontier,
|
|
292
|
+
truncated,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Scatter-gather: broadcast a query to all known shards, merge results.
|
|
298
|
+
* Used for find_nodes, find_calls when no file filter is specified.
|
|
299
|
+
*/
|
|
300
|
+
async scatterGather<T>(
|
|
301
|
+
queryFn: (socket: string) => Promise<T[]>,
|
|
302
|
+
): Promise<{ results: T[]; queriedShards: number }> {
|
|
303
|
+
const shards = this.discovery.all();
|
|
304
|
+
const allResults: T[] = [];
|
|
305
|
+
|
|
306
|
+
const responses = await Promise.all(
|
|
307
|
+
shards.map(async (shard) => {
|
|
308
|
+
try {
|
|
309
|
+
return await queryFn(shard.socket);
|
|
310
|
+
} catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
for (const results of responses) {
|
|
317
|
+
allResults.push(...results);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { results: allResults, queriedShards: shards.length };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Private ────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Try to resolve a frontier edge via ManifestResolver.
|
|
327
|
+
* Works for IMPORTS_FROM edges that carry "source" in metadata.
|
|
328
|
+
* Returns ManifestResolvedNode if found, null otherwise.
|
|
329
|
+
*/
|
|
330
|
+
private tryManifestFallback(edge: FrontierEdge): ManifestResolvedNode | null {
|
|
331
|
+
if (!this.manifestResolver) return null;
|
|
332
|
+
if (edge.edgeType !== 'IMPORTS_FROM') return null;
|
|
333
|
+
|
|
334
|
+
// Extract package source and symbol from edge metadata
|
|
335
|
+
const meta = parseEdgeMetadata(edge.metadata);
|
|
336
|
+
if (!meta.source) return null;
|
|
337
|
+
|
|
338
|
+
// Try to find the symbol in the manifest
|
|
339
|
+
// Symbol name might be in metadata or extractable from src semantic ID
|
|
340
|
+
const symbolName = meta.specifier || meta.localName || extractSymbolFromId(edge.src);
|
|
341
|
+
if (!symbolName) {
|
|
342
|
+
// No specific symbol — check if package exists in manifests at all
|
|
343
|
+
if (this.manifestResolver.has(meta.source)) {
|
|
344
|
+
return {
|
|
345
|
+
packageName: meta.source,
|
|
346
|
+
symbolName: '*',
|
|
347
|
+
kind: 'VARIABLE',
|
|
348
|
+
effects: ['UNKNOWN'],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const result: ResolveResult | null = this.manifestResolver.resolve(meta.source, symbolName);
|
|
355
|
+
if (!result) return null;
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
packageName: meta.source,
|
|
359
|
+
symbolName,
|
|
360
|
+
kind: result.export.kind,
|
|
361
|
+
effects: result.export.effects,
|
|
362
|
+
semanticId: result.export.semanticId,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Group frontier edges by target shard using ShardDiscovery.
|
|
368
|
+
* Edges whose target shard can't be found go into a null group.
|
|
369
|
+
*/
|
|
370
|
+
private groupByShard(
|
|
371
|
+
edges: FrontierEdge[],
|
|
372
|
+
): Map<string, { shard: ShardRegistration; edges: FrontierEdge[] }> {
|
|
373
|
+
const groups = new Map<string, { shard: ShardRegistration; edges: FrontierEdge[] }>();
|
|
374
|
+
|
|
375
|
+
for (const edge of edges) {
|
|
376
|
+
// Try to extract file path from the target ID (dst)
|
|
377
|
+
// Semantic IDs contain file path as first segment: "path/to/file.ts->FUNCTION->name"
|
|
378
|
+
const filePath = extractFilePath(edge.dst);
|
|
379
|
+
|
|
380
|
+
const shard = filePath ? this.discovery.resolve(filePath) : null;
|
|
381
|
+
const key = shard ? shard.root : '__unresolved__';
|
|
382
|
+
|
|
383
|
+
if (!groups.has(key)) {
|
|
384
|
+
groups.set(key, { shard: shard!, edges: [] });
|
|
385
|
+
}
|
|
386
|
+
groups.get(key)!.edges.push(edge);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return groups;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Parse JSON edge metadata, returning an object with known fields. */
|
|
394
|
+
function parseEdgeMetadata(metadata?: string): Record<string, string> {
|
|
395
|
+
if (!metadata) return {};
|
|
396
|
+
try {
|
|
397
|
+
return JSON.parse(metadata);
|
|
398
|
+
} catch {
|
|
399
|
+
return {};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Extract symbol name from the last segment of a semantic ID. */
|
|
404
|
+
function extractSymbolFromId(semanticId: string): string | null {
|
|
405
|
+
if (!semanticId) return null;
|
|
406
|
+
// "file.ts->FUNCTION->myFunc" → "myFunc"
|
|
407
|
+
const parts = semanticId.split('->');
|
|
408
|
+
return parts.length >= 3 ? parts[parts.length - 1] : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Extract absolute file path from a semantic ID or grafema URI.
|
|
413
|
+
* Handles both formats:
|
|
414
|
+
* - Arrow format: "path/to/file.ts->FUNCTION->name"
|
|
415
|
+
* - URI format: "grafema://path/to/file.ts#FUNCTION%3Aname"
|
|
416
|
+
*/
|
|
417
|
+
function extractFilePath(semanticId: string): string | null {
|
|
418
|
+
if (!semanticId) return null;
|
|
419
|
+
|
|
420
|
+
// URI format: grafema://path/to/file.ts#fragment
|
|
421
|
+
if (semanticId.startsWith('grafema://')) {
|
|
422
|
+
const hashIdx = semanticId.indexOf('#');
|
|
423
|
+
return hashIdx > 0
|
|
424
|
+
? semanticId.slice('grafema://'.length, hashIdx)
|
|
425
|
+
: semanticId.slice('grafema://'.length);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Arrow format: path/to/file.ts->FUNCTION->name
|
|
429
|
+
const arrowIdx = semanticId.indexOf('->');
|
|
430
|
+
if (arrowIdx > 0) {
|
|
431
|
+
return semanticId.slice(0, arrowIdx);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Might be just a file path or hash — return as-is if it looks like a path
|
|
435
|
+
if (semanticId.includes('/') && !semanticId.includes(' ')) {
|
|
436
|
+
return semanticId;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShardDiscovery — discovers registered RFDB shards via /tmp/rfdb-shards/.
|
|
3
|
+
*
|
|
4
|
+
* Each rfdb-server running with --federate writes a JSON registration file
|
|
5
|
+
* containing its root path, socket, port, and PID. ShardDiscovery scans
|
|
6
|
+
* these files and resolves file paths to the appropriate shard.
|
|
7
|
+
*
|
|
8
|
+
* Discovery is by file path prefix: if a target file's absolute path
|
|
9
|
+
* starts with a shard's root, that shard owns the file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
export interface ShardRegistration {
|
|
16
|
+
/** Absolute path of the project root this shard covers */
|
|
17
|
+
root: string;
|
|
18
|
+
/** Unix socket path for this shard */
|
|
19
|
+
socket: string;
|
|
20
|
+
/** WebSocket port (if available) */
|
|
21
|
+
wsPort?: number;
|
|
22
|
+
/** Process ID of the rfdb-server */
|
|
23
|
+
pid: number;
|
|
24
|
+
/** Epoch timestamp when the server started */
|
|
25
|
+
started: number;
|
|
26
|
+
/** Server version */
|
|
27
|
+
serverVersion: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SHARDS_DIR = '/tmp/rfdb-shards';
|
|
31
|
+
|
|
32
|
+
export class ShardDiscovery {
|
|
33
|
+
/** root path → registration (sorted by longest root first for prefix matching) */
|
|
34
|
+
private shards = new Map<string, ShardRegistration>();
|
|
35
|
+
|
|
36
|
+
/** Number of discovered shards */
|
|
37
|
+
get size(): number {
|
|
38
|
+
return this.shards.size;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** All registered shard roots */
|
|
42
|
+
get roots(): string[] {
|
|
43
|
+
return [...this.shards.keys()];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scan /tmp/rfdb-shards/ for registered shards.
|
|
48
|
+
* Validates each registration: checks PID is alive, parses JSON.
|
|
49
|
+
* Call this periodically or before federated queries.
|
|
50
|
+
*/
|
|
51
|
+
scan(): number {
|
|
52
|
+
this.shards.clear();
|
|
53
|
+
|
|
54
|
+
if (!existsSync(SHARDS_DIR)) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const files = readdirSync(SHARDS_DIR).filter(f => f.endsWith('.json'));
|
|
59
|
+
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = readFileSync(join(SHARDS_DIR, file), 'utf-8');
|
|
63
|
+
const reg = JSON.parse(raw) as ShardRegistration;
|
|
64
|
+
|
|
65
|
+
if (!reg.root || !reg.socket || !reg.pid) continue;
|
|
66
|
+
|
|
67
|
+
// Check if process is still alive
|
|
68
|
+
if (!isProcessAlive(reg.pid)) continue;
|
|
69
|
+
|
|
70
|
+
// Normalize root path (ensure no trailing slash)
|
|
71
|
+
const root = reg.root.replace(/\/$/, '');
|
|
72
|
+
this.shards.set(root, { ...reg, root });
|
|
73
|
+
} catch {
|
|
74
|
+
// Skip malformed registration files
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return this.shards.size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Find the shard that owns a given absolute file path.
|
|
83
|
+
* Uses longest-prefix matching: /repo/packages/api/ wins over /repo/.
|
|
84
|
+
*/
|
|
85
|
+
resolve(absoluteFilePath: string): ShardRegistration | null {
|
|
86
|
+
let bestMatch: ShardRegistration | null = null;
|
|
87
|
+
let bestLength = 0;
|
|
88
|
+
|
|
89
|
+
for (const [root, reg] of this.shards) {
|
|
90
|
+
if (absoluteFilePath.startsWith(root) && root.length > bestLength) {
|
|
91
|
+
bestMatch = reg;
|
|
92
|
+
bestLength = root.length;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return bestMatch;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get all known shards (for scatter-gather queries).
|
|
101
|
+
*/
|
|
102
|
+
all(): ShardRegistration[] {
|
|
103
|
+
return [...this.shards.values()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register a shard programmatically (for testing or remote shards).
|
|
108
|
+
*/
|
|
109
|
+
register(reg: ShardRegistration): void {
|
|
110
|
+
const root = reg.root.replace(/\/$/, '');
|
|
111
|
+
this.shards.set(root, { ...reg, root });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove a shard by root path.
|
|
116
|
+
*/
|
|
117
|
+
unregister(root: string): void {
|
|
118
|
+
this.shards.delete(root.replace(/\/$/, ''));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Check if a process with given PID is alive */
|
|
123
|
+
function isProcessAlive(pid: number): boolean {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation module — cross-shard query routing for RFDB.
|
|
3
|
+
*
|
|
4
|
+
* Enables multiple RFDB shards to discover each other and resolve
|
|
5
|
+
* cross-shard references at query time. Within a shard, the graph
|
|
6
|
+
* is fully materialized (analysis-time). Between shards, references
|
|
7
|
+
* are resolved lazily (execution-time) via the SUBGRAPH primitive.
|
|
8
|
+
*
|
|
9
|
+
* ## Architecture
|
|
10
|
+
*
|
|
11
|
+
* - ShardDiscovery: scans /tmp/rfdb-shards/ for registered shards
|
|
12
|
+
* - FederatedRouter: orchestrates cross-shard traversal and scatter-gather
|
|
13
|
+
* - Each shard is a separate rfdb-server process with its own graph
|
|
14
|
+
* - Discovery is by file path prefix matching (zero-config)
|
|
15
|
+
*
|
|
16
|
+
* ## Usage
|
|
17
|
+
*
|
|
18
|
+
* const discovery = new ShardDiscovery();
|
|
19
|
+
* await discovery.scan();
|
|
20
|
+
*
|
|
21
|
+
* const router = new FederatedRouter(discovery, localClient);
|
|
22
|
+
* const result = await router.traceDataflow('src/app.ts->userInput', 'forward', 10);
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export { ShardDiscovery } from './ShardDiscovery.js';
|
|
26
|
+
export type { ShardRegistration } from './ShardDiscovery.js';
|
|
27
|
+
|
|
28
|
+
export { FederatedRouter } from './FederatedRouter.js';
|
|
29
|
+
export type {
|
|
30
|
+
FederatedTraceResult,
|
|
31
|
+
FederatedTraceHop,
|
|
32
|
+
FrontierEdge,
|
|
33
|
+
SubgraphResponse,
|
|
34
|
+
ManifestResolvedNode,
|
|
35
|
+
} from './FederatedRouter.js';
|
package/src/index.ts
CHANGED
|
@@ -257,8 +257,19 @@ export type {
|
|
|
257
257
|
TraceNarrativeOptions,
|
|
258
258
|
} from './notation/index.js';
|
|
259
259
|
|
|
260
|
+
// Federation — cross-shard query routing
|
|
261
|
+
export { ShardDiscovery, FederatedRouter } from './federation/index.js';
|
|
262
|
+
export type {
|
|
263
|
+
ShardRegistration,
|
|
264
|
+
FederatedTraceResult,
|
|
265
|
+
FederatedTraceHop,
|
|
266
|
+
FrontierEdge,
|
|
267
|
+
SubgraphResponse,
|
|
268
|
+
ManifestResolvedNode,
|
|
269
|
+
} from './federation/index.js';
|
|
270
|
+
|
|
260
271
|
// Manifest generation & resolution (federation)
|
|
261
|
-
export { ManifestGenerator, ManifestResolver } from './manifest/index.js';
|
|
272
|
+
export { ManifestGenerator, ManifestResolver, RegistryBuilder, resolvePackageDir, detectSourceType, resolveEntryPoint } from './manifest/index.js';
|
|
262
273
|
export type {
|
|
263
274
|
Manifest,
|
|
264
275
|
ManifestExport,
|
|
@@ -269,6 +280,10 @@ export type {
|
|
|
269
280
|
ExportKind,
|
|
270
281
|
ResolveResult,
|
|
271
282
|
ManifestSummary,
|
|
283
|
+
RegistryEntry,
|
|
284
|
+
RegistryIndex,
|
|
285
|
+
RegistryBuilderOptions,
|
|
286
|
+
BuildResult,
|
|
272
287
|
} from './manifest/index.js';
|
|
273
288
|
|
|
274
289
|
// Re-export types for convenience
|
|
@@ -174,9 +174,13 @@ export class ManifestGenerator {
|
|
|
174
174
|
// Graph-based approach:
|
|
175
175
|
// EXPORT(named) --EXPORTS--> EXPORT_BINDING(name, source) --> definition in source file
|
|
176
176
|
await this.collectExportsViaBindings(entryFile, exports, seen, new Set());
|
|
177
|
-
}
|
|
178
|
-
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Fallback: if entry-file mode found 0 exports (e.g., CJS barrel,
|
|
180
|
+
// broken re-export chain in compiled_js), scan all exported definitions
|
|
181
|
+
if (exports.length === 0) {
|
|
179
182
|
const prefix = this.options.packagePrefix ?? '';
|
|
183
|
+
// Check standard definition types (FUNCTION, CLASS, CONSTANT, INTERFACE)
|
|
180
184
|
for (const type of ManifestGenerator.DEF_TYPES) {
|
|
181
185
|
for await (const node of this.backend.queryNodes({ type: type as never })) {
|
|
182
186
|
if (prefix && !node.file?.startsWith(prefix)) continue;
|
|
@@ -187,6 +191,21 @@ export class ManifestGenerator {
|
|
|
187
191
|
await this.addExportFromDefinition(node, exports);
|
|
188
192
|
}
|
|
189
193
|
}
|
|
194
|
+
// Also check EXPORT_BINDING nodes (from CJS exports.foo = ...)
|
|
195
|
+
if (exports.length === 0) {
|
|
196
|
+
for await (const node of this.backend.queryNodes({ type: 'EXPORT_BINDING' as never })) {
|
|
197
|
+
if (prefix && !node.file?.startsWith(prefix)) continue;
|
|
198
|
+
if (!node.name || node.name === 'named' || node.name === 'default') continue;
|
|
199
|
+
if (seen.has(node.name)) continue;
|
|
200
|
+
seen.add(node.name);
|
|
201
|
+
exports.push({
|
|
202
|
+
name: node.name,
|
|
203
|
+
kind: 'VARIABLE',
|
|
204
|
+
semanticId: node.id,
|
|
205
|
+
effects: ['UNKNOWN'],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
exports.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -276,7 +295,8 @@ export class ManifestGenerator {
|
|
|
276
295
|
const dir = fromFile.substring(0, fromFile.lastIndexOf('/'));
|
|
277
296
|
let resolved = source.startsWith('.') ? `${dir}/${source}` : source;
|
|
278
297
|
resolved = resolved.replace(/\/\.\//g, '/');
|
|
279
|
-
|
|
298
|
+
// Only replace .js → .ts for source TypeScript code, not compiled_js
|
|
299
|
+
if (this.options.sourceType !== 'compiled_js' && resolved.endsWith('.js')) {
|
|
280
300
|
resolved = resolved.replace(/\.js$/, '.ts');
|
|
281
301
|
}
|
|
282
302
|
return resolved;
|
|
@@ -330,7 +350,8 @@ export class ManifestGenerator {
|
|
|
330
350
|
/** Enrich all exports with computed effects (transitive call graph analysis) */
|
|
331
351
|
private async enrichEffects(exports: ManifestExport[]): Promise<void> {
|
|
332
352
|
for (const entry of exports) {
|
|
333
|
-
|
|
353
|
+
// Enrich FUNCTION, CLASS, and VARIABLE (CJS exports may be VARIABLE kind)
|
|
354
|
+
if (entry.kind !== 'FUNCTION' && entry.kind !== 'CLASS' && entry.kind !== 'VARIABLE') continue;
|
|
334
355
|
|
|
335
356
|
const effects = new Set<EffectType>();
|
|
336
357
|
const visited = new Set<string>();
|
package/src/manifest/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { ManifestGenerator } from './generator.js';
|
|
2
2
|
export { ManifestResolver } from './resolver.js';
|
|
3
3
|
export type { ResolveResult, ManifestSummary } from './resolver.js';
|
|
4
|
+
export { RegistryBuilder, resolvePackageDir, detectSourceType, resolveEntryPoint } from './registry.js';
|
|
5
|
+
export type { RegistryEntry, RegistryIndex, RegistryBuilderOptions, BuildResult } from './registry.js';
|
|
4
6
|
export type {
|
|
5
7
|
Manifest,
|
|
6
8
|
ManifestExport,
|