@optave/codegraph 3.1.2 → 3.1.4
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/README.md +19 -21
- package/package.json +10 -7
- package/src/analysis/context.js +408 -0
- package/src/analysis/dependencies.js +341 -0
- package/src/analysis/exports.js +130 -0
- package/src/analysis/impact.js +463 -0
- package/src/analysis/module-map.js +322 -0
- package/src/analysis/roles.js +45 -0
- package/src/analysis/symbol-lookup.js +232 -0
- package/src/ast-analysis/shared.js +5 -4
- package/src/batch.js +2 -1
- package/src/builder/context.js +85 -0
- package/src/builder/helpers.js +218 -0
- package/src/builder/incremental.js +178 -0
- package/src/builder/pipeline.js +130 -0
- package/src/builder/stages/build-edges.js +297 -0
- package/src/builder/stages/build-structure.js +113 -0
- package/src/builder/stages/collect-files.js +44 -0
- package/src/builder/stages/detect-changes.js +413 -0
- package/src/builder/stages/finalize.js +139 -0
- package/src/builder/stages/insert-nodes.js +195 -0
- package/src/builder/stages/parse-files.js +28 -0
- package/src/builder/stages/resolve-imports.js +143 -0
- package/src/builder/stages/run-analyses.js +44 -0
- package/src/builder.js +10 -1472
- package/src/cfg.js +1 -2
- package/src/cli/commands/ast.js +26 -0
- package/src/cli/commands/audit.js +46 -0
- package/src/cli/commands/batch.js +68 -0
- package/src/cli/commands/branch-compare.js +21 -0
- package/src/cli/commands/build.js +26 -0
- package/src/cli/commands/cfg.js +30 -0
- package/src/cli/commands/check.js +79 -0
- package/src/cli/commands/children.js +31 -0
- package/src/cli/commands/co-change.js +65 -0
- package/src/cli/commands/communities.js +23 -0
- package/src/cli/commands/complexity.js +45 -0
- package/src/cli/commands/context.js +34 -0
- package/src/cli/commands/cycles.js +28 -0
- package/src/cli/commands/dataflow.js +32 -0
- package/src/cli/commands/deps.js +16 -0
- package/src/cli/commands/diff-impact.js +30 -0
- package/src/cli/commands/embed.js +30 -0
- package/src/cli/commands/export.js +75 -0
- package/src/cli/commands/exports.js +18 -0
- package/src/cli/commands/flow.js +36 -0
- package/src/cli/commands/fn-impact.js +30 -0
- package/src/cli/commands/impact.js +16 -0
- package/src/cli/commands/info.js +76 -0
- package/src/cli/commands/map.js +19 -0
- package/src/cli/commands/mcp.js +18 -0
- package/src/cli/commands/models.js +19 -0
- package/src/cli/commands/owners.js +25 -0
- package/src/cli/commands/path.js +36 -0
- package/src/cli/commands/plot.js +80 -0
- package/src/cli/commands/query.js +49 -0
- package/src/cli/commands/registry.js +100 -0
- package/src/cli/commands/roles.js +34 -0
- package/src/cli/commands/search.js +42 -0
- package/src/cli/commands/sequence.js +32 -0
- package/src/cli/commands/snapshot.js +61 -0
- package/src/cli/commands/stats.js +15 -0
- package/src/cli/commands/structure.js +32 -0
- package/src/cli/commands/triage.js +78 -0
- package/src/cli/commands/watch.js +12 -0
- package/src/cli/commands/where.js +24 -0
- package/src/cli/index.js +118 -0
- package/src/cli/shared/options.js +39 -0
- package/src/cli/shared/output.js +1 -0
- package/src/cli.js +11 -1514
- package/src/commands/check.js +5 -5
- package/src/commands/manifesto.js +3 -3
- package/src/commands/structure.js +1 -1
- package/src/communities.js +15 -87
- package/src/complexity.js +1 -1
- package/src/cycles.js +30 -85
- package/src/dataflow.js +1 -2
- package/src/db/connection.js +4 -4
- package/src/db/migrations.js +41 -0
- package/src/db/query-builder.js +6 -5
- package/src/db/repository/base.js +201 -0
- package/src/db/repository/cached-stmt.js +19 -0
- package/src/db/repository/cfg.js +27 -38
- package/src/db/repository/cochange.js +16 -3
- package/src/db/repository/complexity.js +11 -6
- package/src/db/repository/dataflow.js +6 -1
- package/src/db/repository/edges.js +120 -98
- package/src/db/repository/embeddings.js +14 -3
- package/src/db/repository/graph-read.js +32 -9
- package/src/db/repository/in-memory-repository.js +584 -0
- package/src/db/repository/index.js +6 -1
- package/src/db/repository/nodes.js +110 -40
- package/src/db/repository/sqlite-repository.js +219 -0
- package/src/db.js +5 -0
- package/src/embeddings/generator.js +163 -0
- package/src/embeddings/index.js +13 -0
- package/src/embeddings/models.js +218 -0
- package/src/embeddings/search/cli-formatter.js +151 -0
- package/src/embeddings/search/filters.js +46 -0
- package/src/embeddings/search/hybrid.js +121 -0
- package/src/embeddings/search/keyword.js +68 -0
- package/src/embeddings/search/prepare.js +66 -0
- package/src/embeddings/search/semantic.js +145 -0
- package/src/embeddings/stores/fts5.js +27 -0
- package/src/embeddings/stores/sqlite-blob.js +24 -0
- package/src/embeddings/strategies/source.js +14 -0
- package/src/embeddings/strategies/structured.js +43 -0
- package/src/embeddings/strategies/text-utils.js +43 -0
- package/src/errors.js +78 -0
- package/src/export.js +217 -520
- package/src/extractors/csharp.js +10 -2
- package/src/extractors/go.js +3 -1
- package/src/extractors/helpers.js +71 -0
- package/src/extractors/java.js +9 -2
- package/src/extractors/javascript.js +38 -1
- package/src/extractors/php.js +3 -1
- package/src/extractors/python.js +14 -3
- package/src/extractors/rust.js +3 -1
- package/src/graph/algorithms/bfs.js +49 -0
- package/src/graph/algorithms/centrality.js +16 -0
- package/src/graph/algorithms/index.js +5 -0
- package/src/graph/algorithms/louvain.js +26 -0
- package/src/graph/algorithms/shortest-path.js +41 -0
- package/src/graph/algorithms/tarjan.js +49 -0
- package/src/graph/builders/dependency.js +91 -0
- package/src/graph/builders/index.js +3 -0
- package/src/graph/builders/structure.js +40 -0
- package/src/graph/builders/temporal.js +33 -0
- package/src/graph/classifiers/index.js +2 -0
- package/src/graph/classifiers/risk.js +85 -0
- package/src/graph/classifiers/roles.js +64 -0
- package/src/graph/index.js +13 -0
- package/src/graph/model.js +230 -0
- package/src/index.js +33 -204
- package/src/infrastructure/result-formatter.js +2 -21
- package/src/mcp/index.js +2 -0
- package/src/mcp/middleware.js +26 -0
- package/src/mcp/server.js +128 -0
- package/src/mcp/tool-registry.js +801 -0
- package/src/mcp/tools/ast-query.js +14 -0
- package/src/mcp/tools/audit.js +21 -0
- package/src/mcp/tools/batch-query.js +11 -0
- package/src/mcp/tools/branch-compare.js +10 -0
- package/src/mcp/tools/cfg.js +21 -0
- package/src/mcp/tools/check.js +43 -0
- package/src/mcp/tools/co-changes.js +20 -0
- package/src/mcp/tools/code-owners.js +12 -0
- package/src/mcp/tools/communities.js +15 -0
- package/src/mcp/tools/complexity.js +18 -0
- package/src/mcp/tools/context.js +17 -0
- package/src/mcp/tools/dataflow.js +26 -0
- package/src/mcp/tools/diff-impact.js +24 -0
- package/src/mcp/tools/execution-flow.js +26 -0
- package/src/mcp/tools/export-graph.js +57 -0
- package/src/mcp/tools/file-deps.js +12 -0
- package/src/mcp/tools/file-exports.js +13 -0
- package/src/mcp/tools/find-cycles.js +15 -0
- package/src/mcp/tools/fn-impact.js +15 -0
- package/src/mcp/tools/impact-analysis.js +12 -0
- package/src/mcp/tools/index.js +71 -0
- package/src/mcp/tools/list-functions.js +14 -0
- package/src/mcp/tools/list-repos.js +11 -0
- package/src/mcp/tools/module-map.js +6 -0
- package/src/mcp/tools/node-roles.js +14 -0
- package/src/mcp/tools/path.js +12 -0
- package/src/mcp/tools/query.js +30 -0
- package/src/mcp/tools/semantic-search.js +65 -0
- package/src/mcp/tools/sequence.js +17 -0
- package/src/mcp/tools/structure.js +15 -0
- package/src/mcp/tools/symbol-children.js +14 -0
- package/src/mcp/tools/triage.js +35 -0
- package/src/mcp/tools/where.js +13 -0
- package/src/mcp.js +2 -1470
- package/src/native.js +34 -10
- package/src/parser.js +53 -2
- package/src/presentation/colors.js +44 -0
- package/src/presentation/export.js +444 -0
- package/src/presentation/result-formatter.js +21 -0
- package/src/presentation/sequence-renderer.js +43 -0
- package/src/presentation/table.js +47 -0
- package/src/presentation/viewer.js +634 -0
- package/src/queries.js +35 -2276
- package/src/resolve.js +1 -1
- package/src/sequence.js +2 -38
- package/src/shared/file-utils.js +153 -0
- package/src/shared/generators.js +125 -0
- package/src/shared/hierarchy.js +27 -0
- package/src/shared/normalize.js +59 -0
- package/src/snapshot.js +6 -5
- package/src/structure.js +15 -40
- package/src/triage.js +20 -72
- package/src/viewer.js +35 -656
- package/src/watcher.js +8 -148
- package/src/embedder.js +0 -1097
package/src/native.js
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
10
|
import os from 'node:os';
|
|
11
|
+
import { EngineError } from './errors.js';
|
|
11
12
|
|
|
12
13
|
let _cached; // undefined = not yet tried, null = failed, object = module
|
|
13
14
|
let _loadError = null;
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Detect whether the current Linux environment uses glibc or musl.
|
|
@@ -18,7 +20,7 @@ let _loadError = null;
|
|
|
18
20
|
*/
|
|
19
21
|
function detectLibc() {
|
|
20
22
|
try {
|
|
21
|
-
const { readdirSync } =
|
|
23
|
+
const { readdirSync } = _require('node:fs');
|
|
22
24
|
const files = readdirSync('/lib');
|
|
23
25
|
if (files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) {
|
|
24
26
|
return 'musl';
|
|
@@ -38,6 +40,17 @@ const PLATFORM_PACKAGES = {
|
|
|
38
40
|
'win32-x64': '@optave/codegraph-win32-x64-msvc',
|
|
39
41
|
};
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the platform-specific npm package name for the native addon.
|
|
45
|
+
* Returns null if the current platform is not supported.
|
|
46
|
+
*/
|
|
47
|
+
function resolvePlatformPackage() {
|
|
48
|
+
const platform = os.platform();
|
|
49
|
+
const arch = os.arch();
|
|
50
|
+
const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
|
|
51
|
+
return PLATFORM_PACKAGES[key] || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/**
|
|
42
55
|
* Try to load the native napi addon.
|
|
43
56
|
* Returns the module on success, null on failure.
|
|
@@ -45,21 +58,16 @@ const PLATFORM_PACKAGES = {
|
|
|
45
58
|
export function loadNative() {
|
|
46
59
|
if (_cached !== undefined) return _cached;
|
|
47
60
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const platform = os.platform();
|
|
51
|
-
const arch = os.arch();
|
|
52
|
-
const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
|
|
53
|
-
const pkg = PLATFORM_PACKAGES[key];
|
|
61
|
+
const pkg = resolvePlatformPackage();
|
|
54
62
|
if (pkg) {
|
|
55
63
|
try {
|
|
56
|
-
_cached =
|
|
64
|
+
_cached = _require(pkg);
|
|
57
65
|
return _cached;
|
|
58
66
|
} catch (err) {
|
|
59
67
|
_loadError = err;
|
|
60
68
|
}
|
|
61
69
|
} else {
|
|
62
|
-
_loadError = new Error(`Unsupported platform: ${
|
|
70
|
+
_loadError = new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`);
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
_cached = null;
|
|
@@ -73,15 +81,31 @@ export function isNativeAvailable() {
|
|
|
73
81
|
return loadNative() !== null;
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Read the version from the platform-specific npm package.json.
|
|
86
|
+
* Returns null if the package is not installed or has no version.
|
|
87
|
+
*/
|
|
88
|
+
export function getNativePackageVersion() {
|
|
89
|
+
const pkg = resolvePlatformPackage();
|
|
90
|
+
if (!pkg) return null;
|
|
91
|
+
try {
|
|
92
|
+
const pkgJson = _require(`${pkg}/package.json`);
|
|
93
|
+
return pkgJson.version || null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/**
|
|
77
100
|
* Return the native module or throw if not available.
|
|
78
101
|
*/
|
|
79
102
|
export function getNative() {
|
|
80
103
|
const mod = loadNative();
|
|
81
104
|
if (!mod) {
|
|
82
|
-
throw new
|
|
105
|
+
throw new EngineError(
|
|
83
106
|
`Native codegraph-core not available: ${_loadError?.message || 'unknown error'}. ` +
|
|
84
107
|
'Install the platform package or use --engine wasm.',
|
|
108
|
+
{ cause: _loadError },
|
|
85
109
|
);
|
|
86
110
|
}
|
|
87
111
|
return mod;
|
package/src/parser.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { Language, Parser, Query } from 'web-tree-sitter';
|
|
5
5
|
import { warn } from './logger.js';
|
|
6
|
-
import { getNative, loadNative } from './native.js';
|
|
6
|
+
import { getNative, getNativePackageVersion, loadNative } from './native.js';
|
|
7
7
|
|
|
8
8
|
// Re-export all extractors for backward compatibility
|
|
9
9
|
export {
|
|
@@ -41,6 +41,9 @@ let _initialized = false;
|
|
|
41
41
|
// Memoized parsers — avoids reloading WASM grammars on every createParsers() call
|
|
42
42
|
let _cachedParsers = null;
|
|
43
43
|
|
|
44
|
+
// Cached Language objects — WASM-backed, must be .delete()'d explicitly
|
|
45
|
+
let _cachedLanguages = null;
|
|
46
|
+
|
|
44
47
|
// Query cache for JS/TS/TSX extractors (populated during createParsers)
|
|
45
48
|
const _queryCache = new Map();
|
|
46
49
|
|
|
@@ -77,12 +80,14 @@ export async function createParsers() {
|
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
const parsers = new Map();
|
|
83
|
+
const languages = new Map();
|
|
80
84
|
for (const entry of LANGUAGE_REGISTRY) {
|
|
81
85
|
try {
|
|
82
86
|
const lang = await Language.load(grammarPath(entry.grammarFile));
|
|
83
87
|
const parser = new Parser();
|
|
84
88
|
parser.setLanguage(lang);
|
|
85
89
|
parsers.set(entry.id, parser);
|
|
90
|
+
languages.set(entry.id, lang);
|
|
86
91
|
// Compile and cache tree-sitter Query for JS/TS/TSX extractors
|
|
87
92
|
if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) {
|
|
88
93
|
const isTS = entry.id === 'typescript' || entry.id === 'tsx';
|
|
@@ -100,9 +105,47 @@ export async function createParsers() {
|
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
107
|
_cachedParsers = parsers;
|
|
108
|
+
_cachedLanguages = languages;
|
|
103
109
|
return parsers;
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Dispose all cached WASM parsers and queries to free WASM linear memory.
|
|
114
|
+
* Call this between repeated builds in the same process (e.g. benchmarks)
|
|
115
|
+
* to prevent memory accumulation that can cause segfaults.
|
|
116
|
+
*/
|
|
117
|
+
export function disposeParsers() {
|
|
118
|
+
if (_cachedParsers) {
|
|
119
|
+
for (const [, parser] of _cachedParsers) {
|
|
120
|
+
if (parser && typeof parser.delete === 'function') {
|
|
121
|
+
try {
|
|
122
|
+
parser.delete();
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
_cachedParsers = null;
|
|
127
|
+
}
|
|
128
|
+
for (const [, query] of _queryCache) {
|
|
129
|
+
if (query && typeof query.delete === 'function') {
|
|
130
|
+
try {
|
|
131
|
+
query.delete();
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
_queryCache.clear();
|
|
136
|
+
if (_cachedLanguages) {
|
|
137
|
+
for (const [, lang] of _cachedLanguages) {
|
|
138
|
+
if (lang && typeof lang.delete === 'function') {
|
|
139
|
+
try {
|
|
140
|
+
lang.delete();
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
_cachedLanguages = null;
|
|
145
|
+
}
|
|
146
|
+
_initialized = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
106
149
|
export function getParser(parsers, filePath) {
|
|
107
150
|
const ext = path.extname(filePath);
|
|
108
151
|
const entry = _extToLang.get(ext);
|
|
@@ -214,6 +257,7 @@ function patchNativeResult(r) {
|
|
|
214
257
|
if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using;
|
|
215
258
|
if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require;
|
|
216
259
|
if (i.phpUse === undefined) i.phpUse = i.php_use;
|
|
260
|
+
if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import;
|
|
217
261
|
}
|
|
218
262
|
}
|
|
219
263
|
|
|
@@ -429,11 +473,18 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
|
|
|
429
473
|
*/
|
|
430
474
|
export function getActiveEngine(opts = {}) {
|
|
431
475
|
const { name, native } = resolveEngine(opts);
|
|
432
|
-
|
|
476
|
+
let version = native
|
|
433
477
|
? typeof native.engineVersion === 'function'
|
|
434
478
|
? native.engineVersion()
|
|
435
479
|
: null
|
|
436
480
|
: null;
|
|
481
|
+
// Prefer platform package.json version over binary-embedded version
|
|
482
|
+
// to handle stale binaries that weren't recompiled during a release
|
|
483
|
+
if (native) {
|
|
484
|
+
try {
|
|
485
|
+
version = getNativePackageVersion() ?? version;
|
|
486
|
+
} catch {}
|
|
487
|
+
}
|
|
437
488
|
return { name, version };
|
|
438
489
|
}
|
|
439
490
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared color constants for the graph viewer.
|
|
3
|
+
*
|
|
4
|
+
* These live in a standalone module so both the domain layer (src/viewer.js)
|
|
5
|
+
* and the presentation layer (src/presentation/viewer.js) can import them
|
|
6
|
+
* without creating a cross-layer dependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_NODE_COLORS = {
|
|
10
|
+
function: '#4CAF50',
|
|
11
|
+
method: '#66BB6A',
|
|
12
|
+
class: '#2196F3',
|
|
13
|
+
interface: '#42A5F5',
|
|
14
|
+
type: '#7E57C2',
|
|
15
|
+
struct: '#FF7043',
|
|
16
|
+
enum: '#FFA726',
|
|
17
|
+
trait: '#26A69A',
|
|
18
|
+
record: '#EC407A',
|
|
19
|
+
module: '#78909C',
|
|
20
|
+
file: '#90A4AE',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_ROLE_COLORS = {
|
|
24
|
+
entry: '#e8f5e9',
|
|
25
|
+
core: '#e3f2fd',
|
|
26
|
+
utility: '#f5f5f5',
|
|
27
|
+
dead: '#ffebee',
|
|
28
|
+
leaf: '#fffde7',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const COMMUNITY_COLORS = [
|
|
32
|
+
'#4CAF50',
|
|
33
|
+
'#2196F3',
|
|
34
|
+
'#FF9800',
|
|
35
|
+
'#9C27B0',
|
|
36
|
+
'#F44336',
|
|
37
|
+
'#00BCD4',
|
|
38
|
+
'#CDDC39',
|
|
39
|
+
'#E91E63',
|
|
40
|
+
'#3F51B5',
|
|
41
|
+
'#FF5722',
|
|
42
|
+
'#009688',
|
|
43
|
+
'#795548',
|
|
44
|
+
];
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph export serializers — pure data → formatted string transforms.
|
|
3
|
+
*
|
|
4
|
+
* Each function receives pre-loaded graph data and returns a formatted string
|
|
5
|
+
* (or structured object for CSV). No DB access — all data must be pre-loaded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
// ─── Escape Helpers ──────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Escape special XML characters. */
|
|
13
|
+
export function escapeXml(s) {
|
|
14
|
+
return String(s)
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** RFC 4180 CSV field escaping — quote fields containing commas, quotes, or newlines. */
|
|
23
|
+
export function escapeCsv(s) {
|
|
24
|
+
const str = String(s);
|
|
25
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
26
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
27
|
+
}
|
|
28
|
+
return str;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Escape double quotes for Mermaid labels. */
|
|
32
|
+
export function escapeLabel(label) {
|
|
33
|
+
return label.replace(/"/g, '#quot;');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map node kind to Mermaid shape wrapper. */
|
|
37
|
+
export function mermaidShape(kind, label) {
|
|
38
|
+
const escaped = escapeLabel(label);
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case 'function':
|
|
41
|
+
case 'method':
|
|
42
|
+
return `(["${escaped}"])`;
|
|
43
|
+
case 'class':
|
|
44
|
+
case 'interface':
|
|
45
|
+
case 'type':
|
|
46
|
+
case 'struct':
|
|
47
|
+
case 'enum':
|
|
48
|
+
case 'trait':
|
|
49
|
+
case 'record':
|
|
50
|
+
return `{{"${escaped}"}}`;
|
|
51
|
+
case 'module':
|
|
52
|
+
return `[["${escaped}"]]`;
|
|
53
|
+
default:
|
|
54
|
+
return `["${escaped}"]`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Map node role to Mermaid style colors. */
|
|
59
|
+
export const ROLE_STYLES = {
|
|
60
|
+
entry: 'fill:#e8f5e9,stroke:#4caf50',
|
|
61
|
+
core: 'fill:#e3f2fd,stroke:#2196f3',
|
|
62
|
+
utility: 'fill:#f5f5f5,stroke:#9e9e9e',
|
|
63
|
+
dead: 'fill:#ffebee,stroke:#f44336',
|
|
64
|
+
leaf: 'fill:#fffde7,stroke:#fdd835',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ─── DOT Serializer ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render file-level graph data as DOT (Graphviz) format.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ dirs: Array<{ name: string, files: Array<{ path: string, basename: string }>, cohesion: number|null }>, edges: Array<{ source: string, target: string }>, totalEdges: number, limit?: number }} data
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function renderFileLevelDOT(data) {
|
|
76
|
+
const lines = [
|
|
77
|
+
'digraph codegraph {',
|
|
78
|
+
' rankdir=LR;',
|
|
79
|
+
' node [shape=box, fontname="monospace", fontsize=10];',
|
|
80
|
+
' edge [color="#666666"];',
|
|
81
|
+
'',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
let clusterIdx = 0;
|
|
85
|
+
for (const dir of data.dirs) {
|
|
86
|
+
lines.push(` subgraph cluster_${clusterIdx++} {`);
|
|
87
|
+
const cohLabel = dir.cohesion !== null ? ` (cohesion: ${dir.cohesion.toFixed(2)})` : '';
|
|
88
|
+
lines.push(` label="${dir.name}${cohLabel}";`);
|
|
89
|
+
lines.push(` style=dashed;`);
|
|
90
|
+
lines.push(` color="#999999";`);
|
|
91
|
+
for (const f of dir.files) {
|
|
92
|
+
lines.push(` "${f.path}" [label="${f.basename}"];`);
|
|
93
|
+
}
|
|
94
|
+
lines.push(` }`);
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const { source, target } of data.edges) {
|
|
99
|
+
lines.push(` "${source}" -> "${target}";`);
|
|
100
|
+
}
|
|
101
|
+
if (data.limit && data.totalEdges > data.limit) {
|
|
102
|
+
lines.push(` // Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push('}');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render function-level graph data as DOT (Graphviz) format.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ edges: Array<{ source_name: string, source_file: string, target_name: string, target_file: string }>, totalEdges: number, limit?: number }} data
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function renderFunctionLevelDOT(data) {
|
|
116
|
+
const lines = [
|
|
117
|
+
'digraph codegraph {',
|
|
118
|
+
' rankdir=LR;',
|
|
119
|
+
' node [shape=box, fontname="monospace", fontsize=10];',
|
|
120
|
+
' edge [color="#666666"];',
|
|
121
|
+
'',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const emittedNodes = new Set();
|
|
125
|
+
for (const e of data.edges) {
|
|
126
|
+
const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
127
|
+
const tId = `${e.target_file}:${e.target_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
128
|
+
if (!emittedNodes.has(sId)) {
|
|
129
|
+
lines.push(` ${sId} [label="${e.source_name}\\n${path.basename(e.source_file)}"];`);
|
|
130
|
+
emittedNodes.add(sId);
|
|
131
|
+
}
|
|
132
|
+
if (!emittedNodes.has(tId)) {
|
|
133
|
+
lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
|
|
134
|
+
emittedNodes.add(tId);
|
|
135
|
+
}
|
|
136
|
+
lines.push(` ${sId} -> ${tId};`);
|
|
137
|
+
}
|
|
138
|
+
if (data.limit && data.totalEdges > data.limit) {
|
|
139
|
+
lines.push(` // Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
lines.push('}');
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Mermaid Serializer ──────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render file-level graph data as Mermaid flowchart format.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ direction: string, dirs: Array<{ name: string, files: string[] }>, edges: Array<{ source: string, target: string, edge_kind: string }>, totalEdges: number, limit?: number }} data
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function renderFileLevelMermaid(data) {
|
|
155
|
+
const lines = [`flowchart ${data.direction || 'LR'}`];
|
|
156
|
+
|
|
157
|
+
let nodeCounter = 0;
|
|
158
|
+
const nodeIdMap = new Map();
|
|
159
|
+
function nodeId(key) {
|
|
160
|
+
if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
|
|
161
|
+
return nodeIdMap.get(key);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Emit subgraphs
|
|
165
|
+
for (const dir of data.dirs) {
|
|
166
|
+
const sgId = dir.name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
167
|
+
lines.push(` subgraph ${sgId}["${escapeLabel(dir.name)}"]`);
|
|
168
|
+
for (const f of dir.files) {
|
|
169
|
+
const nId = nodeId(f);
|
|
170
|
+
lines.push(` ${nId}["${escapeLabel(path.basename(f))}"]`);
|
|
171
|
+
}
|
|
172
|
+
lines.push(' end');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Deduplicate edges per source-target pair, collecting all distinct kinds
|
|
176
|
+
const edgeMap = new Map();
|
|
177
|
+
for (const { source, target, edge_kind } of data.edges) {
|
|
178
|
+
const key = `${source}|${target}`;
|
|
179
|
+
const label = edge_kind === 'imports-type' ? 'imports' : edge_kind;
|
|
180
|
+
if (!edgeMap.has(key)) edgeMap.set(key, { source, target, labels: new Set() });
|
|
181
|
+
edgeMap.get(key).labels.add(label);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const { source, target, labels } of edgeMap.values()) {
|
|
185
|
+
lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`);
|
|
186
|
+
}
|
|
187
|
+
if (data.limit && data.totalEdges > data.limit) {
|
|
188
|
+
lines.push(` %% Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return lines.join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render function-level graph data as Mermaid flowchart format.
|
|
196
|
+
*
|
|
197
|
+
* @param {{ direction: string, edges: Array, roles: Map<string, string>, totalEdges: number, limit?: number }} data
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
export function renderFunctionLevelMermaid(data) {
|
|
201
|
+
const lines = [`flowchart ${data.direction || 'LR'}`];
|
|
202
|
+
|
|
203
|
+
let nodeCounter = 0;
|
|
204
|
+
const nodeIdMap = new Map();
|
|
205
|
+
function nodeId(key) {
|
|
206
|
+
if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
|
|
207
|
+
return nodeIdMap.get(key);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Group nodes by file for subgraphs
|
|
211
|
+
const fileNodes = new Map();
|
|
212
|
+
const nodeKinds = new Map();
|
|
213
|
+
for (const e of data.edges) {
|
|
214
|
+
const sKey = `${e.source_file}::${e.source_name}`;
|
|
215
|
+
const tKey = `${e.target_file}::${e.target_name}`;
|
|
216
|
+
nodeId(sKey);
|
|
217
|
+
nodeId(tKey);
|
|
218
|
+
nodeKinds.set(sKey, e.source_kind);
|
|
219
|
+
nodeKinds.set(tKey, e.target_kind);
|
|
220
|
+
|
|
221
|
+
if (!fileNodes.has(e.source_file)) fileNodes.set(e.source_file, new Map());
|
|
222
|
+
fileNodes.get(e.source_file).set(sKey, e.source_name);
|
|
223
|
+
|
|
224
|
+
if (!fileNodes.has(e.target_file)) fileNodes.set(e.target_file, new Map());
|
|
225
|
+
fileNodes.get(e.target_file).set(tKey, e.target_name);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Emit subgraphs grouped by file
|
|
229
|
+
for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
230
|
+
const sgId = file.replace(/[^a-zA-Z0-9]/g, '_');
|
|
231
|
+
lines.push(` subgraph ${sgId}["${escapeLabel(file)}"]`);
|
|
232
|
+
for (const [key, name] of nodes) {
|
|
233
|
+
const kind = nodeKinds.get(key);
|
|
234
|
+
lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`);
|
|
235
|
+
}
|
|
236
|
+
lines.push(' end');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Emit edges with labels
|
|
240
|
+
for (const e of data.edges) {
|
|
241
|
+
const sId = nodeId(`${e.source_file}::${e.source_name}`);
|
|
242
|
+
const tId = nodeId(`${e.target_file}::${e.target_name}`);
|
|
243
|
+
lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
|
|
244
|
+
}
|
|
245
|
+
if (data.limit && data.totalEdges > data.limit) {
|
|
246
|
+
lines.push(` %% Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Role styling
|
|
250
|
+
const roleStyles = [];
|
|
251
|
+
for (const [key, nid] of nodeIdMap) {
|
|
252
|
+
const role = data.roles?.get(key);
|
|
253
|
+
if (role && ROLE_STYLES[role]) {
|
|
254
|
+
roleStyles.push(` style ${nid} ${ROLE_STYLES[role]}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
lines.push(...roleStyles);
|
|
258
|
+
|
|
259
|
+
return lines.join('\n');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── GraphML Serializer ──────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Render file-level graph data as GraphML (XML) format.
|
|
266
|
+
*
|
|
267
|
+
* @param {{ edges: Array<{ source: string, target: string }> }} data
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
export function renderFileLevelGraphML(data) {
|
|
271
|
+
const lines = [
|
|
272
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
273
|
+
'<graphml xmlns="http://graphml.graphstruct.net/graphml">',
|
|
274
|
+
' <key id="d0" for="node" attr.name="name" attr.type="string"/>',
|
|
275
|
+
' <key id="d1" for="node" attr.name="file" attr.type="string"/>',
|
|
276
|
+
' <key id="d2" for="edge" attr.name="kind" attr.type="string"/>',
|
|
277
|
+
' <graph id="codegraph" edgedefault="directed">',
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
const files = new Set();
|
|
281
|
+
for (const { source, target } of data.edges) {
|
|
282
|
+
files.add(source);
|
|
283
|
+
files.add(target);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const fileIds = new Map();
|
|
287
|
+
let nIdx = 0;
|
|
288
|
+
for (const f of files) {
|
|
289
|
+
const id = `n${nIdx++}`;
|
|
290
|
+
fileIds.set(f, id);
|
|
291
|
+
lines.push(` <node id="${id}">`);
|
|
292
|
+
lines.push(` <data key="d0">${escapeXml(path.basename(f))}</data>`);
|
|
293
|
+
lines.push(` <data key="d1">${escapeXml(f)}</data>`);
|
|
294
|
+
lines.push(' </node>');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let eIdx = 0;
|
|
298
|
+
for (const { source, target } of data.edges) {
|
|
299
|
+
lines.push(
|
|
300
|
+
` <edge id="e${eIdx++}" source="${fileIds.get(source)}" target="${fileIds.get(target)}">`,
|
|
301
|
+
);
|
|
302
|
+
lines.push(' <data key="d2">imports</data>');
|
|
303
|
+
lines.push(' </edge>');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
lines.push(' </graph>');
|
|
307
|
+
lines.push('</graphml>');
|
|
308
|
+
return lines.join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Render function-level graph data as GraphML (XML) format.
|
|
313
|
+
*
|
|
314
|
+
* @param {{ edges: Array }} data
|
|
315
|
+
* @returns {string}
|
|
316
|
+
*/
|
|
317
|
+
export function renderFunctionLevelGraphML(data) {
|
|
318
|
+
const lines = [
|
|
319
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
320
|
+
'<graphml xmlns="http://graphml.graphstruct.net/graphml">',
|
|
321
|
+
' <key id="d0" for="node" attr.name="name" attr.type="string"/>',
|
|
322
|
+
' <key id="d1" for="node" attr.name="kind" attr.type="string"/>',
|
|
323
|
+
' <key id="d2" for="node" attr.name="file" attr.type="string"/>',
|
|
324
|
+
' <key id="d3" for="node" attr.name="line" attr.type="int"/>',
|
|
325
|
+
' <key id="d4" for="node" attr.name="role" attr.type="string"/>',
|
|
326
|
+
' <key id="d5" for="edge" attr.name="kind" attr.type="string"/>',
|
|
327
|
+
' <key id="d6" for="edge" attr.name="confidence" attr.type="double"/>',
|
|
328
|
+
' <graph id="codegraph" edgedefault="directed">',
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
const emittedNodes = new Set();
|
|
332
|
+
function emitNode(id, name, kind, file, line, role) {
|
|
333
|
+
if (emittedNodes.has(id)) return;
|
|
334
|
+
emittedNodes.add(id);
|
|
335
|
+
lines.push(` <node id="n${id}">`);
|
|
336
|
+
lines.push(` <data key="d0">${escapeXml(name)}</data>`);
|
|
337
|
+
lines.push(` <data key="d1">${escapeXml(kind)}</data>`);
|
|
338
|
+
lines.push(` <data key="d2">${escapeXml(file)}</data>`);
|
|
339
|
+
lines.push(` <data key="d3">${line}</data>`);
|
|
340
|
+
if (role) lines.push(` <data key="d4">${escapeXml(role)}</data>`);
|
|
341
|
+
lines.push(' </node>');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let eIdx = 0;
|
|
345
|
+
for (const e of data.edges) {
|
|
346
|
+
emitNode(
|
|
347
|
+
e.source_id,
|
|
348
|
+
e.source_name,
|
|
349
|
+
e.source_kind,
|
|
350
|
+
e.source_file,
|
|
351
|
+
e.source_line,
|
|
352
|
+
e.source_role,
|
|
353
|
+
);
|
|
354
|
+
emitNode(
|
|
355
|
+
e.target_id,
|
|
356
|
+
e.target_name,
|
|
357
|
+
e.target_kind,
|
|
358
|
+
e.target_file,
|
|
359
|
+
e.target_line,
|
|
360
|
+
e.target_role,
|
|
361
|
+
);
|
|
362
|
+
lines.push(` <edge id="e${eIdx++}" source="n${e.source_id}" target="n${e.target_id}">`);
|
|
363
|
+
lines.push(` <data key="d5">${escapeXml(e.edge_kind)}</data>`);
|
|
364
|
+
lines.push(` <data key="d6">${e.confidence}</data>`);
|
|
365
|
+
lines.push(' </edge>');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
lines.push(' </graph>');
|
|
369
|
+
lines.push('</graphml>');
|
|
370
|
+
return lines.join('\n');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Neo4j CSV Serializer ────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Render file-level graph data as Neo4j bulk-import CSV.
|
|
377
|
+
*
|
|
378
|
+
* @param {{ edges: Array<{ source: string, target: string, edge_kind: string, confidence: number }> }} data
|
|
379
|
+
* @returns {{ nodes: string, relationships: string }}
|
|
380
|
+
*/
|
|
381
|
+
export function renderFileLevelNeo4jCSV(data) {
|
|
382
|
+
const files = new Map();
|
|
383
|
+
let idx = 0;
|
|
384
|
+
for (const { source, target } of data.edges) {
|
|
385
|
+
if (!files.has(source)) files.set(source, idx++);
|
|
386
|
+
if (!files.has(target)) files.set(target, idx++);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
|
|
390
|
+
for (const [file, id] of files) {
|
|
391
|
+
nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
|
|
395
|
+
for (const e of data.edges) {
|
|
396
|
+
const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
|
|
397
|
+
relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Render function-level graph data as Neo4j bulk-import CSV.
|
|
405
|
+
*
|
|
406
|
+
* @param {{ edges: Array }} data
|
|
407
|
+
* @returns {{ nodes: string, relationships: string }}
|
|
408
|
+
*/
|
|
409
|
+
export function renderFunctionLevelNeo4jCSV(data) {
|
|
410
|
+
const emitted = new Set();
|
|
411
|
+
const nodeLines = ['nodeId:ID,name,kind,file:string,line:int,role,:LABEL'];
|
|
412
|
+
function emitNode(id, name, kind, file, line, role) {
|
|
413
|
+
if (emitted.has(id)) return;
|
|
414
|
+
emitted.add(id);
|
|
415
|
+
const label = kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
416
|
+
nodeLines.push(
|
|
417
|
+
`${id},${escapeCsv(name)},${escapeCsv(kind)},${escapeCsv(file)},${line},${escapeCsv(role || '')},${label}`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
|
|
422
|
+
for (const e of data.edges) {
|
|
423
|
+
emitNode(
|
|
424
|
+
e.source_id,
|
|
425
|
+
e.source_name,
|
|
426
|
+
e.source_kind,
|
|
427
|
+
e.source_file,
|
|
428
|
+
e.source_line,
|
|
429
|
+
e.source_role,
|
|
430
|
+
);
|
|
431
|
+
emitNode(
|
|
432
|
+
e.target_id,
|
|
433
|
+
e.target_name,
|
|
434
|
+
e.target_kind,
|
|
435
|
+
e.target_file,
|
|
436
|
+
e.target_line,
|
|
437
|
+
e.target_role,
|
|
438
|
+
);
|
|
439
|
+
const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
|
|
440
|
+
relLines.push(`${e.source_id},${e.target_id},${edgeType},${e.confidence}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
|
|
444
|
+
}
|