polycodegraph 0.1.0__py3-none-any.whl

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.
Files changed (67) hide show
  1. codegraph/__init__.py +10 -0
  2. codegraph/analysis/__init__.py +30 -0
  3. codegraph/analysis/_common.py +125 -0
  4. codegraph/analysis/blast_radius.py +63 -0
  5. codegraph/analysis/cycles.py +79 -0
  6. codegraph/analysis/dataflow.py +861 -0
  7. codegraph/analysis/dead_code.py +165 -0
  8. codegraph/analysis/hotspots.py +68 -0
  9. codegraph/analysis/infrastructure.py +439 -0
  10. codegraph/analysis/metrics.py +52 -0
  11. codegraph/analysis/report.py +222 -0
  12. codegraph/analysis/roles.py +323 -0
  13. codegraph/analysis/untested.py +79 -0
  14. codegraph/cli.py +1506 -0
  15. codegraph/config.py +64 -0
  16. codegraph/embed/__init__.py +35 -0
  17. codegraph/embed/chunker.py +120 -0
  18. codegraph/embed/embedder.py +113 -0
  19. codegraph/embed/query.py +181 -0
  20. codegraph/embed/store.py +360 -0
  21. codegraph/graph/__init__.py +0 -0
  22. codegraph/graph/builder.py +212 -0
  23. codegraph/graph/schema.py +69 -0
  24. codegraph/graph/store_networkx.py +55 -0
  25. codegraph/graph/store_sqlite.py +249 -0
  26. codegraph/mcp_server/__init__.py +6 -0
  27. codegraph/mcp_server/server.py +933 -0
  28. codegraph/parsers/__init__.py +0 -0
  29. codegraph/parsers/base.py +70 -0
  30. codegraph/parsers/go.py +570 -0
  31. codegraph/parsers/python.py +1707 -0
  32. codegraph/parsers/typescript.py +1397 -0
  33. codegraph/py.typed +0 -0
  34. codegraph/resolve/__init__.py +4 -0
  35. codegraph/resolve/calls.py +480 -0
  36. codegraph/review/__init__.py +31 -0
  37. codegraph/review/baseline.py +32 -0
  38. codegraph/review/differ.py +211 -0
  39. codegraph/review/hook.py +70 -0
  40. codegraph/review/risk.py +219 -0
  41. codegraph/review/rules.py +342 -0
  42. codegraph/viz/__init__.py +17 -0
  43. codegraph/viz/_style.py +45 -0
  44. codegraph/viz/dashboard.py +740 -0
  45. codegraph/viz/diagrams.py +370 -0
  46. codegraph/viz/explore.py +453 -0
  47. codegraph/viz/hld.py +683 -0
  48. codegraph/viz/html.py +115 -0
  49. codegraph/viz/mermaid.py +111 -0
  50. codegraph/viz/svg.py +77 -0
  51. codegraph/web/__init__.py +4 -0
  52. codegraph/web/server.py +165 -0
  53. codegraph/web/static/app.css +664 -0
  54. codegraph/web/static/app.js +919 -0
  55. codegraph/web/static/index.html +112 -0
  56. codegraph/web/static/views/architecture.js +1671 -0
  57. codegraph/web/static/views/graph3d.css +564 -0
  58. codegraph/web/static/views/graph3d.js +999 -0
  59. codegraph/web/static/views/graph3d_transform.js +984 -0
  60. codegraph/workspace/__init__.py +34 -0
  61. codegraph/workspace/config.py +110 -0
  62. codegraph/workspace/operations.py +294 -0
  63. polycodegraph-0.1.0.dist-info/METADATA +687 -0
  64. polycodegraph-0.1.0.dist-info/RECORD +67 -0
  65. polycodegraph-0.1.0.dist-info/WHEEL +4 -0
  66. polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
  67. polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,984 @@
1
+ /* graph3d_transform.js — pure data transforms for the 3D Graph view.
2
+ *
3
+ * Loaded as a classic browser <script> AND as a CommonJS module under Node
4
+ * for `node --test`. No DOM, no globals other than the optional
5
+ * `module.exports` guard at the bottom.
6
+ *
7
+ * Two transforms live here:
8
+ *
9
+ * buildGraph3dData(hld, filters)
10
+ * Legacy "show everything matching filters" view. Kept for tests and
11
+ * future side-panel use; the focus-mode controller does NOT call it.
12
+ *
13
+ * buildFocusGraph(hld, rootQn, depth, direction)
14
+ * BFS from rootQn over the per-symbol callers/callees adjacency in the
15
+ * HLD payload. Returns the same {nodes, links} shape, with a `role`
16
+ * field on each node ('root' | 'ancestor' | 'descendant') and edge
17
+ * colors keyed to the descendant role.
18
+ *
19
+ * Node shape: { id, name, qualname, kind, file, language, layer,
20
+ * fan_in, fan_out, role, depth, val, color }
21
+ * Link shape: { source, target, kind, color }
22
+ */
23
+ 'use strict';
24
+
25
+ // ---- Color tokens ----------------------------------------------------------
26
+
27
+ var KIND_COLORS = {
28
+ FUNCTION: '#34d399', // emerald
29
+ CLASS: '#a78bfa', // violet
30
+ METHOD: '#22d3ee', // cyan
31
+ MODULE: '#818cf8', // brand
32
+ };
33
+ var KIND_FALLBACK = '#8b9ab8';
34
+
35
+ var EDGE_COLORS = {
36
+ CALLS: 'rgba(129,140,248,0.5)',
37
+ IMPORTS: 'rgba(34,211,238,0.4)',
38
+ INHERITS: 'rgba(167,139,250,0.5)',
39
+ IMPLEMENTS: 'rgba(167,139,250,0.5)',
40
+ };
41
+ var EDGE_FALLBACK = 'rgba(139,154,184,0.35)';
42
+
43
+ // Focus-mode role colors. The root pops violet, ancestors flow in amber
44
+ // and descendants flow out cyan. Edge color follows the descendant role
45
+ // so caller→root edges read amber and root→callee edges read cyan.
46
+ // External (stdlib / third-party) nodes render as gray-outline terminal
47
+ // leaves — visible at the boundary but never traversed.
48
+ var ROLE_COLORS = {
49
+ root: '#a78bfa', // violet
50
+ ancestor: '#fbbf24', // amber
51
+ descendant: '#22d3ee', // cyan
52
+ external: '#8b9ab8', // gray (terminal leaf)
53
+ };
54
+ var ROLE_EDGE_COLORS = {
55
+ ancestor: 'rgba(251,191,36,0.55)',
56
+ descendant: 'rgba(34,211,238,0.55)',
57
+ external: 'rgba(139,154,184,0.35)',
58
+ };
59
+
60
+ function kindColor(kind) {
61
+ return KIND_COLORS[kind] || KIND_FALLBACK;
62
+ }
63
+ function edgeColor(kind) {
64
+ return EDGE_COLORS[kind] || EDGE_FALLBACK;
65
+ }
66
+ function roleColor(role) {
67
+ return ROLE_COLORS[role] || KIND_FALLBACK;
68
+ }
69
+
70
+ // ---- Call-arg label (Change 2 / DF0) ---------------------------------------
71
+ //
72
+ // Format a single edge's args+kwargs into a short label.
73
+ // args=[1], kwargs={x:2} -> "1, x=2"
74
+ // args=[], kwargs={} -> ""
75
+ // missing -> ""
76
+ function formatCallArgs(callArg) {
77
+ if (!callArg || typeof callArg !== 'object') return '';
78
+ var parts = [];
79
+ var args = Array.isArray(callArg.args) ? callArg.args : [];
80
+ args.forEach(function (a) {
81
+ if (a === null || a === undefined) return;
82
+ var s = String(a);
83
+ if (s.length) parts.push(s);
84
+ });
85
+ var kwargs = (callArg.kwargs && typeof callArg.kwargs === 'object') ? callArg.kwargs : {};
86
+ Object.keys(kwargs).forEach(function (k) {
87
+ var v = kwargs[k];
88
+ parts.push(String(k) + '=' + String(v == null ? '' : v));
89
+ });
90
+ return parts.join(', ');
91
+ }
92
+
93
+ // Build a map: callee qualname -> argLabel from a symbol's parallel
94
+ // callees / callee_args arrays. Older payloads without callee_args
95
+ // yield an empty map.
96
+ function callArgsFromSym(sym) {
97
+ var out = {};
98
+ if (!sym) return out;
99
+ var callees = sym.callees || [];
100
+ var callArgs = sym.callee_args || [];
101
+ for (var i = 0; i < callees.length; i++) {
102
+ var label = formatCallArgs(callArgs[i]);
103
+ if (label) out[callees[i]] = label;
104
+ }
105
+ return out;
106
+ }
107
+
108
+ // ---- Signature formatting (Change 4 / DF0) ---------------------------------
109
+ //
110
+ // Render a function's signature as a single line:
111
+ // f(a: int, b: str = "x") -> bool
112
+ // Skip ": type" if type is None/missing; skip "= default" if missing;
113
+ // skip "-> returns" if returns is None/missing. Returns '' when params is
114
+ // empty AND returns is missing — the caller suppresses the entire block.
115
+ function formatSignature(node) {
116
+ if (!node) return '';
117
+ var name = node.name || '';
118
+ var params = Array.isArray(node.params) ? node.params : [];
119
+ var returns = node.returns;
120
+ var hasParams = params.length > 0;
121
+ var hasReturns = returns != null && String(returns).length > 0;
122
+ if (!hasParams && !hasReturns) return '';
123
+ var parts = params.map(function (p) {
124
+ if (!p || !p.name) return '';
125
+ var s = String(p.name);
126
+ if (p.type != null && String(p.type).length) s += ': ' + String(p.type);
127
+ if (p.default != null && String(p.default).length) s += ' = ' + String(p.default);
128
+ return s;
129
+ }).filter(function (s) { return s; });
130
+ var sig = String(name) + '(' + parts.join(', ') + ')';
131
+ if (hasReturns) sig += ' -> ' + String(returns);
132
+ return sig;
133
+ }
134
+
135
+ function clampVal(fanIn) {
136
+ var v = 2 + (Number(fanIn) || 0);
137
+ if (v < 2) v = 2;
138
+ if (v > 12) v = 12;
139
+ return v;
140
+ }
141
+
142
+ // ---- Symbol index ----------------------------------------------------------
143
+
144
+ // Walk hld.modules once and return a map qualname -> { sym, modQn, mod }.
145
+ function indexSymbols(hld) {
146
+ var modules = (hld && hld.modules) || {};
147
+ var index = new Map();
148
+ Object.keys(modules).forEach(function (modQn) {
149
+ var mod = modules[modQn] || {};
150
+ var symbols = mod.symbols || [];
151
+ symbols.forEach(function (sym) {
152
+ if (!sym || !sym.qualname) return;
153
+ index.set(sym.qualname, { sym: sym, modQn: modQn, mod: mod });
154
+ });
155
+ });
156
+ return index;
157
+ }
158
+
159
+ // ---- Legacy "show all" transform ------------------------------------------
160
+
161
+ function buildGraph3dData(hld, filters) {
162
+ var modules = (hld && hld.modules) || {};
163
+ var kinds = (filters && filters.kinds) || new Set();
164
+ var edgeKinds = (filters && filters.edgeKinds) || new Set();
165
+
166
+ var nodeMap = new Map();
167
+
168
+ Object.keys(modules).forEach(function (modQn) {
169
+ var mod = modules[modQn] || {};
170
+ var symbols = mod.symbols || [];
171
+ symbols.forEach(function (sym) {
172
+ if (!sym || !sym.qualname) return;
173
+ if (!kinds.has(sym.kind)) return;
174
+ nodeMap.set(sym.qualname, {
175
+ id: sym.qualname,
176
+ name: sym.name || sym.qualname,
177
+ qualname: sym.qualname,
178
+ kind: sym.kind,
179
+ file: mod.file || '',
180
+ language: mod.language || '',
181
+ layer: mod.layer || '',
182
+ fan_in: Number(sym.fan_in) || 0,
183
+ fan_out: Number(sym.fan_out) || 0,
184
+ val: clampVal(sym.fan_in),
185
+ color: kindColor(sym.kind),
186
+ });
187
+ });
188
+ });
189
+
190
+ var links = [];
191
+ if (edgeKinds.has('CALLS')) {
192
+ var seen = new Set();
193
+ Object.keys(modules).forEach(function (modQn) {
194
+ var symbols = (modules[modQn] || {}).symbols || [];
195
+ symbols.forEach(function (sym) {
196
+ var srcId = sym.qualname;
197
+ if (!nodeMap.has(srcId)) return;
198
+ (sym.callees || []).forEach(function (dstId) {
199
+ if (!nodeMap.has(dstId)) return;
200
+ var key = srcId + '' + dstId;
201
+ if (seen.has(key)) return;
202
+ seen.add(key);
203
+ links.push({
204
+ source: srcId,
205
+ target: dstId,
206
+ kind: 'CALLS',
207
+ color: edgeColor('CALLS'),
208
+ });
209
+ });
210
+ (sym.callers || []).forEach(function (callerId) {
211
+ if (!nodeMap.has(callerId)) return;
212
+ var key = callerId + '' + srcId;
213
+ if (seen.has(key)) return;
214
+ seen.add(key);
215
+ links.push({
216
+ source: callerId,
217
+ target: srcId,
218
+ kind: 'CALLS',
219
+ color: edgeColor('CALLS'),
220
+ });
221
+ });
222
+ });
223
+ });
224
+ }
225
+
226
+ return {
227
+ nodes: Array.from(nodeMap.values()),
228
+ links: links,
229
+ };
230
+ }
231
+
232
+ // ---- Focus mode (BFS from a root) -----------------------------------------
233
+
234
+ function makeNode(entry, role, depth) {
235
+ var sym = entry.sym;
236
+ var mod = entry.mod;
237
+ // Root pops larger; descendants/ancestors get a moderate boost.
238
+ var baseVal = clampVal(sym.fan_in);
239
+ if (role === 'root') baseVal = 8;
240
+ return {
241
+ id: sym.qualname,
242
+ name: sym.name || sym.qualname,
243
+ qualname: sym.qualname,
244
+ kind: sym.kind,
245
+ file: mod.file || '',
246
+ language: mod.language || '',
247
+ layer: mod.layer || '',
248
+ fan_in: Number(sym.fan_in) || 0,
249
+ fan_out: Number(sym.fan_out) || 0,
250
+ role: role,
251
+ depth: depth,
252
+ val: baseVal,
253
+ color: roleColor(role),
254
+ external: false,
255
+ // DF0 enrichment — fall back gracefully on older payloads.
256
+ params: Array.isArray(sym.params) ? sym.params : [],
257
+ returns: sym.returns != null ? sym.returns : null,
258
+ symbolRole: sym.role || null,
259
+ };
260
+ }
261
+
262
+ // Build a synthetic node for an external (stdlib / third-party) symbol
263
+ // that the BFS doesn't expand. Pretty-print short name from the qualname.
264
+ function makeExternalNode(qn, depth) {
265
+ var raw = String(qn || '');
266
+ var clean = raw.indexOf('unresolved::') === 0 ? raw.slice('unresolved::'.length) : raw;
267
+ var parts = clean.split('.');
268
+ var short = parts[parts.length - 1] || clean;
269
+ return {
270
+ id: raw,
271
+ name: short,
272
+ qualname: raw,
273
+ kind: 'EXTERNAL',
274
+ file: '',
275
+ language: '',
276
+ layer: '',
277
+ fan_in: 0,
278
+ fan_out: 0,
279
+ role: 'external',
280
+ depth: depth,
281
+ val: 3,
282
+ color: ROLE_COLORS.external,
283
+ external: true,
284
+ };
285
+ }
286
+
287
+ // A qualname is "external" if it starts with `unresolved::` or it is not
288
+ // present in the symbol index built from hld.modules.
289
+ function isExternalQn(qn, index) {
290
+ if (!qn) return true;
291
+ if (String(qn).indexOf('unresolved::') === 0) return true;
292
+ return !index.has(qn);
293
+ }
294
+
295
+ // A node belongs to test code if its qualname or file matches a common
296
+ // test convention. Drives the "Hide tests" toggle in the 3D view, since
297
+ // most users want to see real-code call chains by default and only opt
298
+ // into test fan-in when debugging coverage.
299
+ //
300
+ // Heuristics (intentionally conservative — false negatives over noise):
301
+ // - qualname starts with `tests.` or `test.`
302
+ // - qualname segment-bounded `tests` (e.g. `pkg.tests.foo`)
303
+ // - file path under a tests/ directory
304
+ // - filename matches Python (test_*.py, *_test.py), JS/TS (*.test.[jt]sx?,
305
+ // *.spec.[jt]sx?), or Go (*_test.go) test conventions
306
+ var TEST_QN_RE = /(^|\.)(tests?)(\.|$)/i;
307
+ var TEST_FILE_RE = new RegExp(
308
+ // tests/ directory anywhere
309
+ '(^|/)tests?/' +
310
+ // OR Python test_* / _test files
311
+ '|(^|/)test_[^/]+\\.py$|_test\\.py$' +
312
+ // OR JS/TS *.test.* / *.spec.*
313
+ '|\\.(test|spec)\\.[jt]sx?$' +
314
+ // OR Go *_test.go
315
+ '|_test\\.go$',
316
+ 'i'
317
+ );
318
+
319
+ function isTestNode(qualname, file) {
320
+ if (qualname && TEST_QN_RE.test(String(qualname))) return true;
321
+ if (file && TEST_FILE_RE.test(String(file))) return true;
322
+ return false;
323
+ }
324
+
325
+ function buildFocusGraph(hld, rootQn, depth, direction, opts) {
326
+ if (!rootQn) return { nodes: [], links: [] };
327
+
328
+ var maxDepth = Number(depth);
329
+ if (!isFinite(maxDepth) || maxDepth < 1) maxDepth = 1;
330
+ if (maxDepth > 8) maxDepth = 8;
331
+
332
+ var dir = direction || 'both';
333
+ var hideTests = !!(opts && opts.hideTests);
334
+
335
+ var index = indexSymbols(hld);
336
+ if (!index.has(rootQn)) return { nodes: [], links: [] };
337
+
338
+ // The root itself is never filtered: if the user explicitly searched
339
+ // for a test function, they want to see it.
340
+ function shouldSkipNeighbor(qn) {
341
+ if (!hideTests || qn === rootQn) return false;
342
+ var entry = index.get(qn);
343
+ var file = entry ? (entry.sym && entry.sym.file) || (entry.mod && entry.mod.file) || '' : '';
344
+ return isTestNode(qn, file);
345
+ }
346
+
347
+ var nodes = new Map(); // qn -> node
348
+ var links = [];
349
+ var linkKeys = new Set();
350
+
351
+ function addLink(source, target, role, external, argLabel) {
352
+ var key = source + '' + target + '' + role;
353
+ if (linkKeys.has(key)) return;
354
+ linkKeys.add(key);
355
+ var edgeRole = external ? 'external' : role;
356
+ links.push({
357
+ source: source,
358
+ target: target,
359
+ kind: 'CALLS',
360
+ color: ROLE_EDGE_COLORS[edgeRole] || EDGE_FALLBACK,
361
+ external: !!external,
362
+ argLabel: argLabel || '',
363
+ });
364
+ }
365
+
366
+ // Seed with root.
367
+ nodes.set(rootQn, makeNode(index.get(rootQn), 'root', 0));
368
+
369
+ // Generic BFS that walks one direction. `step(qn) -> [neighbor qn]`
370
+ // maps a node to its outgoing/incoming neighbors per direction.
371
+ function bfs(role, step, edgeFromTo) {
372
+ var frontier = [rootQn];
373
+ var visited = new Set([rootQn]);
374
+ for (var d = 1; d <= maxDepth; d++) {
375
+ var next = [];
376
+ for (var i = 0; i < frontier.length; i++) {
377
+ var here = frontier[i];
378
+ var neighbors = step(here);
379
+ // For descendant edges, args live on `here.callees`. For ancestor
380
+ // edges, args live on the neighbor's callees pointing at `here`.
381
+ var hereEntry = index.get(here);
382
+ var hereCallArgs = (role === 'descendant' && hereEntry)
383
+ ? callArgsFromSym(hereEntry.sym) : {};
384
+ for (var j = 0; j < neighbors.length; j++) {
385
+ var nb = neighbors[j];
386
+ if (!nb) continue;
387
+ if (shouldSkipNeighbor(nb)) continue;
388
+ var external = isExternalQn(nb, index);
389
+ var argLabel = '';
390
+ if (role === 'descendant') {
391
+ argLabel = hereCallArgs[nb] || '';
392
+ } else if (role === 'ancestor') {
393
+ var nbEntry = index.get(nb);
394
+ if (nbEntry) {
395
+ argLabel = callArgsFromSym(nbEntry.sym)[here] || '';
396
+ }
397
+ }
398
+ if (external) {
399
+ // Terminal leaf: render once, never traverse.
400
+ var fromToExt = edgeFromTo(here, nb);
401
+ addLink(fromToExt[0], fromToExt[1], role, true, argLabel);
402
+ if (!nodes.has(nb)) {
403
+ nodes.set(nb, makeExternalNode(nb, d));
404
+ }
405
+ continue;
406
+ }
407
+ // Emit the edge (even if neighbor was already visited via
408
+ // another path — but dedup via linkKeys).
409
+ var fromTo = edgeFromTo(here, nb);
410
+ addLink(fromTo[0], fromTo[1], role, false, argLabel);
411
+ if (visited.has(nb)) continue;
412
+ visited.add(nb);
413
+ // Don't downgrade root if it shows up in a cycle.
414
+ if (!nodes.has(nb)) {
415
+ nodes.set(nb, makeNode(index.get(nb), role, d));
416
+ }
417
+ next.push(nb);
418
+ }
419
+ }
420
+ if (next.length === 0) break;
421
+ frontier = next;
422
+ }
423
+ }
424
+
425
+ if (dir === 'ancestors' || dir === 'both') {
426
+ bfs(
427
+ 'ancestor',
428
+ function (qn) { return (index.get(qn).sym.callers || []); },
429
+ function (here, nb) { return [nb, here]; } // caller -> here
430
+ );
431
+ }
432
+ if (dir === 'descendants' || dir === 'both') {
433
+ bfs(
434
+ 'descendant',
435
+ function (qn) { return (index.get(qn).sym.callees || []); },
436
+ function (here, nb) { return [here, nb]; } // here -> callee
437
+ );
438
+ }
439
+
440
+ return {
441
+ nodes: Array.from(nodes.values()),
442
+ links: links,
443
+ };
444
+ }
445
+
446
+ // ---- Inline expand / collapse ---------------------------------------------
447
+ //
448
+ // expandNode(state, hld, qn) and collapseNode(state, qn) mutate a graph
449
+ // state object in place:
450
+ //
451
+ // state = {
452
+ // nodes: Map<qn, node>,
453
+ // links: Array<{source, target, kind, color, external}>,
454
+ // linkKeys: Set<string>, // dedup key
455
+ // refcount: Map<qn, number>, // node refcount (>= 1 while present)
456
+ // expansions: Map<qn, {addedNodes: Set<qn>, addedLinkKeys: Set<string>}>,
457
+ // }
458
+ //
459
+ // The graph state lives in the controller (graph3d.js); these helpers are
460
+ // pure transforms over it so we can unit-test the expand/collapse logic
461
+ // without touching the DOM.
462
+
463
+ function makeFocusState(hld, rootQn, depth, direction, opts) {
464
+ var graph = buildFocusGraph(hld, rootQn, depth, direction, opts);
465
+ var nodes = new Map();
466
+ var refcount = new Map();
467
+ graph.nodes.forEach(function (n) {
468
+ nodes.set(n.id, n);
469
+ refcount.set(n.id, 1);
470
+ });
471
+ var linkKeys = new Set();
472
+ var links = [];
473
+ graph.links.forEach(function (l) {
474
+ var key = linkKey(l.source, l.target);
475
+ if (linkKeys.has(key)) return;
476
+ linkKeys.add(key);
477
+ links.push(l);
478
+ });
479
+ return {
480
+ rootQn: rootQn,
481
+ nodes: nodes,
482
+ links: links,
483
+ linkKeys: linkKeys,
484
+ refcount: refcount,
485
+ expansions: new Map(),
486
+ };
487
+ }
488
+
489
+ function linkKey(source, target) {
490
+ var s = (source && source.id) || source;
491
+ var t = (target && target.id) || target;
492
+ return String(s) + '->' + String(t);
493
+ }
494
+
495
+ function snapshotState(state) {
496
+ return { nodes: Array.from(state.nodes.values()), links: state.links.slice() };
497
+ }
498
+
499
+ // Expand: bring 1-hop neighbors of qn into the graph.
500
+ // External neighbors render as terminal leaves; internals get a role
501
+ // matching the relationship to qn (callers => ancestor, callees => descendant).
502
+ function expandNode(state, hld, qn, opts) {
503
+ if (!state || !qn) return state;
504
+ if (state.expansions.has(qn)) return state; // already expanded
505
+ if (qn === state.rootQn) return state;
506
+
507
+ var index = indexSymbols(hld);
508
+ if (!index.has(qn)) return state;
509
+
510
+ var hideTests = !!(opts && opts.hideTests);
511
+ var entry = index.get(qn);
512
+ var sym = entry.sym;
513
+ var addedNodes = new Set();
514
+ var addedLinkKeys = new Set();
515
+
516
+ function shouldSkipNeighbor(neighborQn) {
517
+ if (!hideTests || neighborQn === state.rootQn) return false;
518
+ var nbEntry = index.get(neighborQn);
519
+ var file = nbEntry ? (nbEntry.sym && nbEntry.sym.file) || (nbEntry.mod && nbEntry.mod.file) || '' : '';
520
+ return isTestNode(neighborQn, file);
521
+ }
522
+
523
+ function addLeaf(neighborQn, role, edgePair, argLabel) {
524
+ if (!neighborQn) return;
525
+ var external = isExternalQn(neighborQn, index);
526
+ var key = linkKey(edgePair[0], edgePair[1]);
527
+ if (!state.linkKeys.has(key)) {
528
+ state.linkKeys.add(key);
529
+ addedLinkKeys.add(key);
530
+ var edgeRole = external ? 'external' : role;
531
+ state.links.push({
532
+ source: edgePair[0],
533
+ target: edgePair[1],
534
+ kind: 'CALLS',
535
+ color: ROLE_EDGE_COLORS[edgeRole] || EDGE_FALLBACK,
536
+ external: external,
537
+ argLabel: argLabel || '',
538
+ });
539
+ }
540
+ if (state.nodes.has(neighborQn)) {
541
+ // Bump refcount; another expansion already brought it in.
542
+ state.refcount.set(neighborQn, (state.refcount.get(neighborQn) || 0) + 1);
543
+ addedNodes.add(neighborQn);
544
+ return;
545
+ }
546
+ var node;
547
+ if (external) {
548
+ node = makeExternalNode(neighborQn, 1);
549
+ } else {
550
+ node = makeNode(index.get(neighborQn), role, 1);
551
+ }
552
+ state.nodes.set(neighborQn, node);
553
+ state.refcount.set(neighborQn, 1);
554
+ addedNodes.add(neighborQn);
555
+ }
556
+
557
+ // Descendant edges carry args from this symbol's callee_args.
558
+ // Ancestor edges (caller -> qn) carry args from the caller's
559
+ // own callee_args list pointing at qn.
560
+ var ownCallArgs = callArgsFromSym(sym);
561
+ (sym.callers || []).forEach(function (c) {
562
+ if (shouldSkipNeighbor(c)) return;
563
+ var cEntry = index.get(c);
564
+ var argLabel = '';
565
+ if (cEntry) argLabel = callArgsFromSym(cEntry.sym)[qn] || '';
566
+ addLeaf(c, 'ancestor', [c, qn], argLabel);
567
+ });
568
+ (sym.callees || []).forEach(function (c) {
569
+ if (shouldSkipNeighbor(c)) return;
570
+ addLeaf(c, 'descendant', [qn, c], ownCallArgs[c] || '');
571
+ });
572
+
573
+ state.expansions.set(qn, { addedNodes: addedNodes, addedLinkKeys: addedLinkKeys });
574
+ return state;
575
+ }
576
+
577
+ // Collapse: undo a previous expand. Decrement refcounts of nodes added
578
+ // by this expansion; nodes whose refcount drops to 0 are removed. Edges
579
+ // added by this expansion are always removed.
580
+ function collapseNode(state, qn) {
581
+ if (!state || !qn) return state;
582
+ var rec = state.expansions.get(qn);
583
+ if (!rec) return state;
584
+ // Drop edges this expansion added.
585
+ state.links = state.links.filter(function (l) {
586
+ var key = linkKey(l.source, l.target);
587
+ if (rec.addedLinkKeys.has(key)) {
588
+ state.linkKeys.delete(key);
589
+ return false;
590
+ }
591
+ return true;
592
+ });
593
+ // Decrement refcounts; remove orphaned nodes.
594
+ rec.addedNodes.forEach(function (id) {
595
+ var rc = (state.refcount.get(id) || 0) - 1;
596
+ if (rc <= 0) {
597
+ state.refcount.delete(id);
598
+ state.nodes.delete(id);
599
+ } else {
600
+ state.refcount.set(id, rc);
601
+ }
602
+ });
603
+ state.expansions.delete(qn);
604
+ return state;
605
+ }
606
+
607
+ function isExpanded(state, qn) {
608
+ return !!(state && state.expansions && state.expansions.has(qn));
609
+ }
610
+
611
+ // ---- Grouped picker (Item 4) ----------------------------------------------
612
+ //
613
+ // groupSymbols(hld) returns a list of modules. Each module contains:
614
+ // { qualname, file, language, classes: [...], functions: [...] }
615
+ // Each class:
616
+ // { qualname, name, methods: [{qualname, name, kind, fan_in, fan_out}] }
617
+ // Each function (top-level):
618
+ // { qualname, name, kind, fan_in, fan_out }
619
+ //
620
+ // The shape is intentionally flat-but-grouped so the picker can render it
621
+ // as a tree: module -> class -> method, plus module-level functions.
622
+
623
+ function groupSymbols(hld) {
624
+ var modules = (hld && hld.modules) || {};
625
+ var out = [];
626
+ Object.keys(modules).sort().forEach(function (modQn) {
627
+ var mod = modules[modQn] || {};
628
+ var symbols = mod.symbols || [];
629
+ var classes = {}; // qn -> class entry
630
+ var functions = [];
631
+ var methodOwners = {}; // method qn -> class qn
632
+
633
+ // First pass: seed classes (CLASS kind).
634
+ symbols.forEach(function (s) {
635
+ if (!s || !s.qualname) return;
636
+ if (s.kind === 'CLASS') {
637
+ classes[s.qualname] = {
638
+ qualname: s.qualname,
639
+ name: s.name || s.qualname,
640
+ methods: [],
641
+ };
642
+ }
643
+ });
644
+
645
+ // Second pass: assign methods to their class by qualname prefix.
646
+ symbols.forEach(function (s) {
647
+ if (!s || !s.qualname || s.kind === 'CLASS') return;
648
+ var meta = {
649
+ qualname: s.qualname,
650
+ name: s.name || s.qualname,
651
+ kind: s.kind,
652
+ fan_in: Number(s.fan_in) || 0,
653
+ fan_out: Number(s.fan_out) || 0,
654
+ };
655
+ // Find owning class: longest prefix s.qualname.startsWith(classQn + '.')
656
+ var owner = null;
657
+ Object.keys(classes).forEach(function (cqn) {
658
+ if (s.qualname.indexOf(cqn + '.') === 0) {
659
+ if (!owner || cqn.length > owner.length) owner = cqn;
660
+ }
661
+ });
662
+ if (owner) {
663
+ classes[owner].methods.push(meta);
664
+ methodOwners[s.qualname] = owner;
665
+ } else {
666
+ functions.push(meta);
667
+ }
668
+ });
669
+
670
+ // Sort: classes alpha by name, methods alpha, functions alpha.
671
+ var classList = Object.keys(classes).sort().map(function (k) {
672
+ var c = classes[k];
673
+ c.methods.sort(function (a, b) { return a.name.localeCompare(b.name); });
674
+ return c;
675
+ });
676
+ functions.sort(function (a, b) { return a.name.localeCompare(b.name); });
677
+
678
+ out.push({
679
+ qualname: modQn,
680
+ file: mod.file || '',
681
+ language: mod.language || '',
682
+ classes: classList,
683
+ functions: functions,
684
+ });
685
+ });
686
+ return out;
687
+ }
688
+
689
+ // Filter a grouped tree by query — keeps modules/classes that contain a
690
+ // match, plus the matching leaves themselves.
691
+ function filterGrouped(groups, query) {
692
+ var q = String(query || '').trim().toLowerCase();
693
+ if (!q) return groups;
694
+
695
+ function matches(text) {
696
+ return String(text || '').toLowerCase().indexOf(q) !== -1;
697
+ }
698
+
699
+ var out = [];
700
+ groups.forEach(function (g) {
701
+ var keptClasses = [];
702
+ g.classes.forEach(function (c) {
703
+ var keptMethods = c.methods.filter(function (m) {
704
+ return matches(m.name) || matches(m.qualname);
705
+ });
706
+ var classMatches = matches(c.name) || matches(c.qualname);
707
+ if (keptMethods.length || classMatches) {
708
+ keptClasses.push({
709
+ qualname: c.qualname,
710
+ name: c.name,
711
+ methods: classMatches ? c.methods : keptMethods,
712
+ });
713
+ }
714
+ });
715
+ var keptFns = g.functions.filter(function (f) {
716
+ return matches(f.name) || matches(f.qualname);
717
+ });
718
+ var moduleMatches = matches(g.qualname);
719
+ if (keptClasses.length || keptFns.length || moduleMatches) {
720
+ out.push({
721
+ qualname: g.qualname,
722
+ file: g.file,
723
+ language: g.language,
724
+ classes: moduleMatches ? g.classes : keptClasses,
725
+ functions: moduleMatches ? g.functions : keptFns,
726
+ });
727
+ }
728
+ });
729
+ return out;
730
+ }
731
+
732
+ // ---- Role-grouped picker (Change 3 / DF1.5) -------------------------------
733
+ //
734
+ // groupSymbolsByRole(hld) -> [
735
+ // { role: 'HANDLER', color: '#fbbf24', modules: [<group>] },
736
+ // { role: 'SERVICE', color: '#3b82f6', modules: [...] },
737
+ // { role: 'COMPONENT',color: '#34d399', modules: [...] },
738
+ // { role: 'REPO', color: '#c084fc', modules: [...] },
739
+ // { role: '(no role)',color: '#8b9ab8', modules: [...] },
740
+ // ]
741
+ // Each <group> has the same shape as groupSymbols() emits, but only the
742
+ // symbols matching the bucket role are retained. Methods inherit their
743
+ // class's role; free functions use their own role; symbols without a
744
+ // recognized role land in the "(no role)" bucket. Buckets render in fixed
745
+ // order so the picker UI has stable headers even when a bucket is empty.
746
+
747
+ var ROLE_ORDER = ['HANDLER', 'SERVICE', 'COMPONENT', 'REPO', '(no role)'];
748
+ var ROLE_PICKER_COLORS = {
749
+ 'HANDLER': '#fbbf24', // amber
750
+ 'SERVICE': '#3b82f6', // blue
751
+ 'COMPONENT': '#34d399', // green
752
+ 'REPO': '#c084fc', // purple-pink
753
+ '(no role)': '#8b9ab8', // gray
754
+ };
755
+
756
+ function normalizeRole(role) {
757
+ if (!role) return '(no role)';
758
+ var r = String(role).toUpperCase();
759
+ if (ROLE_PICKER_COLORS[r]) return r;
760
+ return '(no role)';
761
+ }
762
+
763
+ function groupSymbolsByRole(hld) {
764
+ var modules = (hld && hld.modules) || {};
765
+ var buckets = {};
766
+ ROLE_ORDER.forEach(function (r) { buckets[r] = {}; });
767
+
768
+ function getModule(role, modQn, mod) {
769
+ if (!buckets[role][modQn]) {
770
+ buckets[role][modQn] = {
771
+ qualname: modQn,
772
+ file: mod.file || '',
773
+ language: mod.language || '',
774
+ classes: {},
775
+ functions: [],
776
+ };
777
+ }
778
+ return buckets[role][modQn];
779
+ }
780
+
781
+ Object.keys(modules).forEach(function (modQn) {
782
+ var mod = modules[modQn] || {};
783
+ var symbols = mod.symbols || [];
784
+ var classByQn = {};
785
+ symbols.forEach(function (s) {
786
+ if (s && s.kind === 'CLASS' && s.qualname) classByQn[s.qualname] = s;
787
+ });
788
+
789
+ symbols.forEach(function (s) {
790
+ if (!s || !s.qualname) return;
791
+ // Methods inherit their class's role.
792
+ var ownerClass = null;
793
+ if (s.kind !== 'CLASS') {
794
+ Object.keys(classByQn).forEach(function (cqn) {
795
+ if (s.qualname.indexOf(cqn + '.') === 0) {
796
+ if (!ownerClass || cqn.length > ownerClass.qualname.length) {
797
+ ownerClass = classByQn[cqn];
798
+ }
799
+ }
800
+ });
801
+ }
802
+ var roleSrc = ownerClass ? (ownerClass.role || s.role) : s.role;
803
+ var role = normalizeRole(roleSrc);
804
+ var modEntry = getModule(role, modQn, mod);
805
+ var meta = {
806
+ qualname: s.qualname,
807
+ name: s.name || s.qualname,
808
+ kind: s.kind,
809
+ fan_in: Number(s.fan_in) || 0,
810
+ fan_out: Number(s.fan_out) || 0,
811
+ };
812
+ if (s.kind === 'CLASS') {
813
+ if (!modEntry.classes[s.qualname]) {
814
+ modEntry.classes[s.qualname] = {
815
+ qualname: s.qualname,
816
+ name: s.name || s.qualname,
817
+ methods: [],
818
+ };
819
+ }
820
+ } else if (ownerClass) {
821
+ if (!modEntry.classes[ownerClass.qualname]) {
822
+ modEntry.classes[ownerClass.qualname] = {
823
+ qualname: ownerClass.qualname,
824
+ name: ownerClass.name || ownerClass.qualname,
825
+ methods: [],
826
+ };
827
+ }
828
+ modEntry.classes[ownerClass.qualname].methods.push(meta);
829
+ } else {
830
+ modEntry.functions.push(meta);
831
+ }
832
+ });
833
+ });
834
+
835
+ return ROLE_ORDER.map(function (role) {
836
+ var modMap = buckets[role];
837
+ var modList = Object.keys(modMap).sort().map(function (mqn) {
838
+ var m = modMap[mqn];
839
+ var classList = Object.keys(m.classes).sort().map(function (k) {
840
+ var c = m.classes[k];
841
+ c.methods.sort(function (a, b) { return a.name.localeCompare(b.name); });
842
+ return c;
843
+ });
844
+ m.functions.sort(function (a, b) { return a.name.localeCompare(b.name); });
845
+ return {
846
+ qualname: m.qualname,
847
+ file: m.file,
848
+ language: m.language,
849
+ classes: classList,
850
+ functions: m.functions,
851
+ };
852
+ });
853
+ return {
854
+ role: role,
855
+ color: ROLE_PICKER_COLORS[role],
856
+ modules: modList,
857
+ };
858
+ });
859
+ }
860
+
861
+ // Filter a role-grouped tree by query, keeping role buckets that contain
862
+ // at least one match. Reuses filterGrouped() per bucket.
863
+ function filterGroupedByRole(roleGroups, query) {
864
+ var q = String(query || '').trim();
865
+ if (!q) return roleGroups;
866
+ return roleGroups.map(function (rg) {
867
+ return {
868
+ role: rg.role,
869
+ color: rg.color,
870
+ modules: filterGrouped(rg.modules, q),
871
+ };
872
+ });
873
+ }
874
+
875
+ // ---- Symbol search (top-N matches) ----------------------------------------
876
+
877
+ function searchSymbols(hld, query, limit) {
878
+ var max = Number(limit) || 20;
879
+ var q = String(query || '').trim().toLowerCase();
880
+ var modules = (hld && hld.modules) || {};
881
+ var results = [];
882
+
883
+ Object.keys(modules).forEach(function (modQn) {
884
+ var mod = modules[modQn] || {};
885
+ var symbols = mod.symbols || [];
886
+ symbols.forEach(function (sym) {
887
+ if (!sym || !sym.qualname) return;
888
+ var qn = String(sym.qualname).toLowerCase();
889
+ var nm = String(sym.name || '').toLowerCase();
890
+ var score = -1;
891
+ if (!q) {
892
+ // No query: surface high-fan-in symbols first.
893
+ score = 1000 - (Number(sym.fan_in) || 0);
894
+ } else if (qn === q || nm === q) {
895
+ score = 0;
896
+ } else if (nm.startsWith(q)) {
897
+ score = 1;
898
+ } else if (qn.indexOf(q) !== -1) {
899
+ score = 2 + qn.indexOf(q);
900
+ } else if (nm.indexOf(q) !== -1) {
901
+ score = 50 + nm.indexOf(q);
902
+ } else {
903
+ return;
904
+ }
905
+ results.push({
906
+ qualname: sym.qualname,
907
+ name: sym.name || sym.qualname,
908
+ kind: sym.kind,
909
+ file: mod.file || '',
910
+ fan_in: Number(sym.fan_in) || 0,
911
+ fan_out: Number(sym.fan_out) || 0,
912
+ _score: score,
913
+ });
914
+ });
915
+ });
916
+
917
+ results.sort(function (a, b) {
918
+ if (a._score !== b._score) return a._score - b._score;
919
+ // Tie-break: higher fan_in first, then qualname asc.
920
+ if (a.fan_in !== b.fan_in) return b.fan_in - a.fan_in;
921
+ return a.qualname < b.qualname ? -1 : 1;
922
+ });
923
+
924
+ return results.slice(0, max).map(function (r) {
925
+ delete r._score;
926
+ return r;
927
+ });
928
+ }
929
+
930
+ // ---- Exports --------------------------------------------------------------
931
+
932
+ if (typeof window !== 'undefined') {
933
+ window.CG_Graph3DTransform = {
934
+ buildGraph3dData: buildGraph3dData,
935
+ buildFocusGraph: buildFocusGraph,
936
+ makeFocusState: makeFocusState,
937
+ expandNode: expandNode,
938
+ collapseNode: collapseNode,
939
+ isExpanded: isExpanded,
940
+ snapshotState: snapshotState,
941
+ searchSymbols: searchSymbols,
942
+ groupSymbols: groupSymbols,
943
+ groupSymbolsByRole: groupSymbolsByRole,
944
+ filterGrouped: filterGrouped,
945
+ filterGroupedByRole: filterGroupedByRole,
946
+ formatCallArgs: formatCallArgs,
947
+ formatSignature: formatSignature,
948
+ ROLE_ORDER: ROLE_ORDER,
949
+ ROLE_PICKER_COLORS: ROLE_PICKER_COLORS,
950
+ isExternalQn: isExternalQn,
951
+ isTestNode: isTestNode,
952
+ indexSymbols: indexSymbols,
953
+ kindColor: kindColor,
954
+ edgeColor: edgeColor,
955
+ roleColor: roleColor,
956
+ };
957
+ }
958
+
959
+ if (typeof module !== 'undefined' && module.exports) {
960
+ module.exports = {
961
+ buildGraph3dData: buildGraph3dData,
962
+ buildFocusGraph: buildFocusGraph,
963
+ makeFocusState: makeFocusState,
964
+ expandNode: expandNode,
965
+ collapseNode: collapseNode,
966
+ isExpanded: isExpanded,
967
+ snapshotState: snapshotState,
968
+ searchSymbols: searchSymbols,
969
+ groupSymbols: groupSymbols,
970
+ groupSymbolsByRole: groupSymbolsByRole,
971
+ filterGrouped: filterGrouped,
972
+ filterGroupedByRole: filterGroupedByRole,
973
+ formatCallArgs: formatCallArgs,
974
+ formatSignature: formatSignature,
975
+ ROLE_ORDER: ROLE_ORDER,
976
+ ROLE_PICKER_COLORS: ROLE_PICKER_COLORS,
977
+ isExternalQn: isExternalQn,
978
+ isTestNode: isTestNode,
979
+ indexSymbols: indexSymbols,
980
+ kindColor: kindColor,
981
+ edgeColor: edgeColor,
982
+ roleColor: roleColor,
983
+ };
984
+ }