@geml/geml 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +2 -5
- package/dist/geml.js +191 -98
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +528 -0
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -62
package/codemap/adapters/crg.mjs
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
// geml-code-graph adapter: code-review-graph SQLite (`graph.db`, tree-sitter based) →
|
|
2
|
-
// the exchange format of docs/DESIGN-geml-code-graph.md §3 (symbols + edges).
|
|
3
|
-
//
|
|
4
|
-
// Everything this adapter emits is syntax-level extraction, so per §3.3 every
|
|
5
|
-
// symbol and edge carries resolution:"heuristic"; resolved targets get
|
|
6
|
-
// confidence:"medium", unresolved ones become `to_text` rows (confidence:"low").
|
|
7
|
-
// CONTAINS is not an edge — containment is document structure (GEP-0002).
|
|
8
|
-
//
|
|
9
|
-
// Beyond the core schema, symbols may carry optional navigation flags the
|
|
10
|
-
// engine knows about: `entry` (a `main` function) and `flow_crit` (max
|
|
11
|
-
// criticality of execution flows entered at this symbol, when >= 0.6).
|
|
12
|
-
import { DatabaseSync } from "node:sqlite";
|
|
13
|
-
|
|
14
|
-
const EDGE_KIND = {
|
|
15
|
-
CALLS: "calls",
|
|
16
|
-
IMPORTS_FROM: "imports",
|
|
17
|
-
INHERITS: "inherits",
|
|
18
|
-
TESTED_BY: "tested-by",
|
|
19
|
-
REFERENCES: "references",
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function extract({ db: dbPath, root }) {
|
|
23
|
-
const db = new DatabaseSync(dbPath);
|
|
24
|
-
try {
|
|
25
|
-
return extractFrom(db, root);
|
|
26
|
-
} finally {
|
|
27
|
-
// Always release the handle: an open DatabaseSync keeps graph.db locked for
|
|
28
|
-
// the process lifetime, so on Windows the caller cannot delete or replace
|
|
29
|
-
// it (EPERM). try/finally closes it even if extraction throws.
|
|
30
|
-
db.close();
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function extractFrom(db, root) {
|
|
35
|
-
const rootFs = root.replace(/\\/g, "/").replace(/\/?$/, "/");
|
|
36
|
-
const rel = (p) => {
|
|
37
|
-
p = String(p).replace(/\\/g, "/");
|
|
38
|
-
return p.startsWith(rootFs) ? p.slice(rootFs.length) : p;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const rows = db.prepare(
|
|
42
|
-
"SELECT id, kind, name, qualified_name, file_path, line_start, line_end, language, is_test FROM nodes",
|
|
43
|
-
).all();
|
|
44
|
-
|
|
45
|
-
// anchor = "<lang>:<relfile>#<name>", File symbols just "<lang>:<relfile>".
|
|
46
|
-
// Same-file same-name collisions get ~2, ~3 … ordered by line_start so the
|
|
47
|
-
// numbering is stable across rebuilds (§4.2 / risk 3).
|
|
48
|
-
const byKey = new Map();
|
|
49
|
-
for (const r of rows) {
|
|
50
|
-
const key = `${r.language ?? "unknown"}:${rel(r.file_path)}#${r.name}`;
|
|
51
|
-
if (!byKey.has(key)) byKey.set(key, []);
|
|
52
|
-
byKey.get(key).push(r);
|
|
53
|
-
}
|
|
54
|
-
const anchorOf = new Map(); // node rowid -> anchor
|
|
55
|
-
for (const [key, list] of byKey) {
|
|
56
|
-
list.sort((a, b) => (a.line_start ?? 0) - (b.line_start ?? 0) || a.id - b.id);
|
|
57
|
-
list.forEach((r, i) => {
|
|
58
|
-
const base = r.kind === "File" ? key.slice(0, key.lastIndexOf("#")) : key;
|
|
59
|
-
anchorOf.set(r.id, i === 0 ? base : `${base}~${i + 1}`);
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Navigation flags from engine-specific tables.
|
|
64
|
-
const mains = new Set(
|
|
65
|
-
db.prepare("SELECT id FROM nodes WHERE kind='Function' AND name='main'").all().map((r) => r.id),
|
|
66
|
-
);
|
|
67
|
-
const flowCrit = new Map();
|
|
68
|
-
try {
|
|
69
|
-
for (const r of db.prepare(
|
|
70
|
-
"SELECT entry_point_id id, max(criticality) c FROM flows GROUP BY 1 HAVING c >= 0.6",
|
|
71
|
-
).all()) flowCrit.set(r.id, r.c);
|
|
72
|
-
} catch { /* no flows table */ }
|
|
73
|
-
|
|
74
|
-
const symbols = rows.map((r) => {
|
|
75
|
-
const s = {
|
|
76
|
-
anchor: anchorOf.get(r.id),
|
|
77
|
-
lang: r.language ?? "unknown",
|
|
78
|
-
kind: r.kind,
|
|
79
|
-
name: r.name,
|
|
80
|
-
file: rel(r.file_path),
|
|
81
|
-
line_start: r.line_start ?? undefined,
|
|
82
|
-
line_end: r.line_end ?? undefined,
|
|
83
|
-
is_test: r.is_test ? true : undefined,
|
|
84
|
-
entry: mains.has(r.id) ? true : undefined,
|
|
85
|
-
flow_crit: flowCrit.get(r.id),
|
|
86
|
-
resolution: "heuristic",
|
|
87
|
-
};
|
|
88
|
-
return s;
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const idByQual = new Map(rows.map((r) => [r.qualified_name, r.id]));
|
|
92
|
-
const edges = [];
|
|
93
|
-
for (const e of db.prepare(
|
|
94
|
-
"SELECT kind, source_qualified, target_qualified, file_path, line FROM edges",
|
|
95
|
-
).all()) {
|
|
96
|
-
const kind = EDGE_KIND[e.kind];
|
|
97
|
-
if (!kind) continue; // CONTAINS and anything unknown
|
|
98
|
-
const fromId = idByQual.get(e.source_qualified);
|
|
99
|
-
if (fromId === undefined) continue; // dangling source: nothing to attach to
|
|
100
|
-
const toId = idByQual.get(e.target_qualified);
|
|
101
|
-
const edge = {
|
|
102
|
-
kind,
|
|
103
|
-
from: anchorOf.get(fromId),
|
|
104
|
-
resolution: "heuristic",
|
|
105
|
-
site: { file: rel(e.file_path), line: e.line ?? 0 },
|
|
106
|
-
};
|
|
107
|
-
if (toId !== undefined) {
|
|
108
|
-
edge.to = anchorOf.get(toId);
|
|
109
|
-
edge.confidence = "medium";
|
|
110
|
-
} else {
|
|
111
|
-
// Keep only a readable short name for the unresolved target (§5.2's
|
|
112
|
-
// calls-unresolved line) — qualified names here are often paths.
|
|
113
|
-
edge.to_text = String(e.target_qualified).replace(/\\/g, "/").split("/").pop();
|
|
114
|
-
edge.confidence = "low";
|
|
115
|
-
}
|
|
116
|
-
edges.push(edge);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return { symbols, edges };
|
|
120
|
-
}
|
|
1
|
+
// geml-code-graph adapter: code-review-graph SQLite (`graph.db`, tree-sitter based) →
|
|
2
|
+
// the exchange format of docs/DESIGN-geml-code-graph.md §3 (symbols + edges).
|
|
3
|
+
//
|
|
4
|
+
// Everything this adapter emits is syntax-level extraction, so per §3.3 every
|
|
5
|
+
// symbol and edge carries resolution:"heuristic"; resolved targets get
|
|
6
|
+
// confidence:"medium", unresolved ones become `to_text` rows (confidence:"low").
|
|
7
|
+
// CONTAINS is not an edge — containment is document structure (GEP-0002).
|
|
8
|
+
//
|
|
9
|
+
// Beyond the core schema, symbols may carry optional navigation flags the
|
|
10
|
+
// engine knows about: `entry` (a `main` function) and `flow_crit` (max
|
|
11
|
+
// criticality of execution flows entered at this symbol, when >= 0.6).
|
|
12
|
+
import { DatabaseSync } from "node:sqlite";
|
|
13
|
+
|
|
14
|
+
const EDGE_KIND = {
|
|
15
|
+
CALLS: "calls",
|
|
16
|
+
IMPORTS_FROM: "imports",
|
|
17
|
+
INHERITS: "inherits",
|
|
18
|
+
TESTED_BY: "tested-by",
|
|
19
|
+
REFERENCES: "references",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function extract({ db: dbPath, root }) {
|
|
23
|
+
const db = new DatabaseSync(dbPath);
|
|
24
|
+
try {
|
|
25
|
+
return extractFrom(db, root);
|
|
26
|
+
} finally {
|
|
27
|
+
// Always release the handle: an open DatabaseSync keeps graph.db locked for
|
|
28
|
+
// the process lifetime, so on Windows the caller cannot delete or replace
|
|
29
|
+
// it (EPERM). try/finally closes it even if extraction throws.
|
|
30
|
+
db.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractFrom(db, root) {
|
|
35
|
+
const rootFs = root.replace(/\\/g, "/").replace(/\/?$/, "/");
|
|
36
|
+
const rel = (p) => {
|
|
37
|
+
p = String(p).replace(/\\/g, "/");
|
|
38
|
+
return p.startsWith(rootFs) ? p.slice(rootFs.length) : p;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const rows = db.prepare(
|
|
42
|
+
"SELECT id, kind, name, qualified_name, file_path, line_start, line_end, language, is_test FROM nodes",
|
|
43
|
+
).all();
|
|
44
|
+
|
|
45
|
+
// anchor = "<lang>:<relfile>#<name>", File symbols just "<lang>:<relfile>".
|
|
46
|
+
// Same-file same-name collisions get ~2, ~3 … ordered by line_start so the
|
|
47
|
+
// numbering is stable across rebuilds (§4.2 / risk 3).
|
|
48
|
+
const byKey = new Map();
|
|
49
|
+
for (const r of rows) {
|
|
50
|
+
const key = `${r.language ?? "unknown"}:${rel(r.file_path)}#${r.name}`;
|
|
51
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
52
|
+
byKey.get(key).push(r);
|
|
53
|
+
}
|
|
54
|
+
const anchorOf = new Map(); // node rowid -> anchor
|
|
55
|
+
for (const [key, list] of byKey) {
|
|
56
|
+
list.sort((a, b) => (a.line_start ?? 0) - (b.line_start ?? 0) || a.id - b.id);
|
|
57
|
+
list.forEach((r, i) => {
|
|
58
|
+
const base = r.kind === "File" ? key.slice(0, key.lastIndexOf("#")) : key;
|
|
59
|
+
anchorOf.set(r.id, i === 0 ? base : `${base}~${i + 1}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Navigation flags from engine-specific tables.
|
|
64
|
+
const mains = new Set(
|
|
65
|
+
db.prepare("SELECT id FROM nodes WHERE kind='Function' AND name='main'").all().map((r) => r.id),
|
|
66
|
+
);
|
|
67
|
+
const flowCrit = new Map();
|
|
68
|
+
try {
|
|
69
|
+
for (const r of db.prepare(
|
|
70
|
+
"SELECT entry_point_id id, max(criticality) c FROM flows GROUP BY 1 HAVING c >= 0.6",
|
|
71
|
+
).all()) flowCrit.set(r.id, r.c);
|
|
72
|
+
} catch { /* no flows table */ }
|
|
73
|
+
|
|
74
|
+
const symbols = rows.map((r) => {
|
|
75
|
+
const s = {
|
|
76
|
+
anchor: anchorOf.get(r.id),
|
|
77
|
+
lang: r.language ?? "unknown",
|
|
78
|
+
kind: r.kind,
|
|
79
|
+
name: r.name,
|
|
80
|
+
file: rel(r.file_path),
|
|
81
|
+
line_start: r.line_start ?? undefined,
|
|
82
|
+
line_end: r.line_end ?? undefined,
|
|
83
|
+
is_test: r.is_test ? true : undefined,
|
|
84
|
+
entry: mains.has(r.id) ? true : undefined,
|
|
85
|
+
flow_crit: flowCrit.get(r.id),
|
|
86
|
+
resolution: "heuristic",
|
|
87
|
+
};
|
|
88
|
+
return s;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const idByQual = new Map(rows.map((r) => [r.qualified_name, r.id]));
|
|
92
|
+
const edges = [];
|
|
93
|
+
for (const e of db.prepare(
|
|
94
|
+
"SELECT kind, source_qualified, target_qualified, file_path, line FROM edges",
|
|
95
|
+
).all()) {
|
|
96
|
+
const kind = EDGE_KIND[e.kind];
|
|
97
|
+
if (!kind) continue; // CONTAINS and anything unknown
|
|
98
|
+
const fromId = idByQual.get(e.source_qualified);
|
|
99
|
+
if (fromId === undefined) continue; // dangling source: nothing to attach to
|
|
100
|
+
const toId = idByQual.get(e.target_qualified);
|
|
101
|
+
const edge = {
|
|
102
|
+
kind,
|
|
103
|
+
from: anchorOf.get(fromId),
|
|
104
|
+
resolution: "heuristic",
|
|
105
|
+
site: { file: rel(e.file_path), line: e.line ?? 0 },
|
|
106
|
+
};
|
|
107
|
+
if (toId !== undefined) {
|
|
108
|
+
edge.to = anchorOf.get(toId);
|
|
109
|
+
edge.confidence = "medium";
|
|
110
|
+
} else {
|
|
111
|
+
// Keep only a readable short name for the unresolved target (§5.2's
|
|
112
|
+
// calls-unresolved line) — qualified names here are often paths.
|
|
113
|
+
edge.to_text = String(e.target_qualified).replace(/\\/g, "/").split("/").pop();
|
|
114
|
+
edge.confidence = "low";
|
|
115
|
+
}
|
|
116
|
+
edges.push(edge);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { symbols, edges };
|
|
120
|
+
}
|
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
// geml-code-graph adapter: Joern CPG export → exchange format (DESIGN §3.2/3.4).
|
|
2
|
-
//
|
|
3
|
-
// Consumes the raw JSONL written by joern-export.sc (methods.jsonl +
|
|
4
|
-
// calls.jsonl) — so the node side never touches the JVM. Everything here is
|
|
5
|
-
// resolution:"cpg". Confidence mapping (§3.4):
|
|
6
|
-
// exactly one internal callee → high
|
|
7
|
-
// several internal callees (dispatch) → first as `to` + the rest as
|
|
8
|
-
// `candidates`, medium
|
|
9
|
-
// no internal callee (external/pointer) → to_text, low (unresolved)
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
|
|
13
|
-
const LANG_BY_EXT = {
|
|
14
|
-
c: "c", h: "c", cc: "cpp", cpp: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp",
|
|
15
|
-
java: "java", js: "javascript", mjs: "javascript", ts: "typescript",
|
|
16
|
-
py: "python", kt: "kotlin", go: "go", rb: "ruby", swift: "swift",
|
|
17
|
-
cs: "csharp", php: "php",
|
|
18
|
-
};
|
|
19
|
-
const langOf = (file) => LANG_BY_EXT[file.split(".").pop()?.toLowerCase()] ?? "unknown";
|
|
20
|
-
|
|
21
|
-
const readJsonl = (p) => readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
22
|
-
|
|
23
|
-
export function extract({ raw, root }) {
|
|
24
|
-
const rootFs = root ? root.replace(/\\/g, "/").replace(/\/?$/, "/") : "";
|
|
25
|
-
const rel = (p) => {
|
|
26
|
-
p = String(p).replace(/\\/g, "/");
|
|
27
|
-
return rootFs && p.startsWith(rootFs) ? p.slice(rootFs.length) : p.replace(/^\//, "");
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const methods = readJsonl(join(raw, "methods.jsonl"));
|
|
31
|
-
const calls = readJsonl(join(raw, "calls.jsonl"));
|
|
32
|
-
|
|
33
|
-
// Identity: fullName|signature|file — the same tuple joern-export.sc keys on.
|
|
34
|
-
const keyOf = (m) => `${m.fullName}|${m.signature}|${rel(m.file)}`;
|
|
35
|
-
|
|
36
|
-
// Some frontends (javasrc2cpg: constructors, overload/bridge pairs) emit
|
|
37
|
-
// several method records with an IDENTICAL fullName|signature|file — one
|
|
38
|
-
// logical method must become one symbol, so dedupe by key first, keeping the
|
|
39
|
-
// record with the widest line span (the real body over a synthetic stub).
|
|
40
|
-
const byKey = new Map();
|
|
41
|
-
for (const m of methods) {
|
|
42
|
-
const k = keyOf(m);
|
|
43
|
-
const prev = byKey.get(k);
|
|
44
|
-
if (!prev || (m.lineEnd ?? 0) - (m.lineStart ?? 0) > (prev.lineEnd ?? 0) - (prev.lineStart ?? 0)) {
|
|
45
|
-
byKey.set(k, m);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
const uniqueMethods = [...byKey.values()];
|
|
49
|
-
|
|
50
|
-
// Anchors: "<lang>:<relfile>#<name>(<sig>)"; same-file same-name-and-sig
|
|
51
|
-
// duplicates that remain distinct (different fullName) get ~2, ~3 by line order.
|
|
52
|
-
const byAnchorBase = new Map();
|
|
53
|
-
for (const m of uniqueMethods) {
|
|
54
|
-
const base = `${langOf(m.file)}:${rel(m.file)}#${m.name}(${m.signature})`;
|
|
55
|
-
if (!byAnchorBase.has(base)) byAnchorBase.set(base, []);
|
|
56
|
-
byAnchorBase.get(base).push(m);
|
|
57
|
-
}
|
|
58
|
-
const anchorByKey = new Map();
|
|
59
|
-
for (const [base, list] of byAnchorBase) {
|
|
60
|
-
list.sort((a, b) => (a.lineStart ?? 0) - (b.lineStart ?? 0));
|
|
61
|
-
list.forEach((m, i) => anchorByKey.set(keyOf(m), i === 0 ? base : `${base}~${i + 1}`));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Class-qualified display names: in a class language a method's natural
|
|
65
|
-
// short name is `Cls.method` — bare names ("invoke", "execute") repeat in
|
|
66
|
-
// every other class and read as noise. Constructors are `Cls.new`, static
|
|
67
|
-
// initialisers `Cls.static{}`. C (no classes: fullName has no dot) keeps
|
|
68
|
-
// plain names. The anchor keeps the raw identity — presentation only.
|
|
69
|
-
const displayName = (m) => {
|
|
70
|
-
const head = String(m.fullName ?? "").split(":")[0]; // pkg.Outer$Inner.method
|
|
71
|
-
const di = head.lastIndexOf(".");
|
|
72
|
-
const owner = di > 0 ? head.slice(0, di) : "";
|
|
73
|
-
const simple = (owner.split(".").pop() || "").split("$").pop() || "";
|
|
74
|
-
if (m.name === "<init>") return simple ? `${simple}.new` : "new";
|
|
75
|
-
if (m.name === "<clinit>") return simple ? `${simple}.static{}` : "static{}";
|
|
76
|
-
return simple ? `${simple}.${m.name}` : m.name;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const symbols = [];
|
|
80
|
-
const seenFiles = new Set();
|
|
81
|
-
for (const m of uniqueMethods) {
|
|
82
|
-
const file = rel(m.file);
|
|
83
|
-
symbols.push({
|
|
84
|
-
anchor: anchorByKey.get(keyOf(m)),
|
|
85
|
-
lang: langOf(file),
|
|
86
|
-
kind: "Function",
|
|
87
|
-
name: displayName(m),
|
|
88
|
-
file,
|
|
89
|
-
line_start: m.lineStart ?? undefined,
|
|
90
|
-
line_end: m.lineEnd ?? undefined,
|
|
91
|
-
signature: m.signature || undefined,
|
|
92
|
-
entry: m.name === "main" ? true : undefined,
|
|
93
|
-
resolution: "cpg",
|
|
94
|
-
});
|
|
95
|
-
// Derive one File symbol per source file so emit gets stable heading ids.
|
|
96
|
-
if (!seenFiles.has(file)) {
|
|
97
|
-
seenFiles.add(file);
|
|
98
|
-
symbols.push({
|
|
99
|
-
anchor: `${langOf(file)}:${file}`,
|
|
100
|
-
lang: langOf(file),
|
|
101
|
-
kind: "File",
|
|
102
|
-
name: file.split("/").pop(),
|
|
103
|
-
file,
|
|
104
|
-
resolution: "cpg",
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const edges = [];
|
|
110
|
-
for (const c of calls) {
|
|
111
|
-
const from = anchorByKey.get(`${c.callerFullName}|${c.callerSignature}|${rel(c.callerFile)}`);
|
|
112
|
-
if (!from) continue;
|
|
113
|
-
const site = { file: rel(c.callerFile), line: c.line ?? 0 };
|
|
114
|
-
const targets = (c.callees ?? [])
|
|
115
|
-
.map((t) => anchorByKey.get(`${t.fullName}|${t.signature}|${rel(t.file)}`))
|
|
116
|
-
.filter(Boolean);
|
|
117
|
-
if (targets.length === 0) {
|
|
118
|
-
edges.push({ kind: "calls", from, to_text: c.name, resolution: "cpg", confidence: "low", site });
|
|
119
|
-
} else if (targets.length === 1) {
|
|
120
|
-
edges.push({ kind: "calls", from, to: targets[0], resolution: "cpg", confidence: "high", site });
|
|
121
|
-
} else {
|
|
122
|
-
const [first, ...rest] = [...new Set(targets)].sort();
|
|
123
|
-
edges.push({
|
|
124
|
-
kind: "calls", from, to: first, resolution: "cpg", confidence: "medium",
|
|
125
|
-
note: `dispatch, ${targets.length} candidates`, candidates: rest, site,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return { symbols, edges };
|
|
131
|
-
}
|
|
1
|
+
// geml-code-graph adapter: Joern CPG export → exchange format (DESIGN §3.2/3.4).
|
|
2
|
+
//
|
|
3
|
+
// Consumes the raw JSONL written by joern-export.sc (methods.jsonl +
|
|
4
|
+
// calls.jsonl) — so the node side never touches the JVM. Everything here is
|
|
5
|
+
// resolution:"cpg". Confidence mapping (§3.4):
|
|
6
|
+
// exactly one internal callee → high
|
|
7
|
+
// several internal callees (dispatch) → first as `to` + the rest as
|
|
8
|
+
// `candidates`, medium
|
|
9
|
+
// no internal callee (external/pointer) → to_text, low (unresolved)
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const LANG_BY_EXT = {
|
|
14
|
+
c: "c", h: "c", cc: "cpp", cpp: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp",
|
|
15
|
+
java: "java", js: "javascript", mjs: "javascript", ts: "typescript",
|
|
16
|
+
py: "python", kt: "kotlin", go: "go", rb: "ruby", swift: "swift",
|
|
17
|
+
cs: "csharp", php: "php",
|
|
18
|
+
};
|
|
19
|
+
const langOf = (file) => LANG_BY_EXT[file.split(".").pop()?.toLowerCase()] ?? "unknown";
|
|
20
|
+
|
|
21
|
+
const readJsonl = (p) => readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
22
|
+
|
|
23
|
+
export function extract({ raw, root }) {
|
|
24
|
+
const rootFs = root ? root.replace(/\\/g, "/").replace(/\/?$/, "/") : "";
|
|
25
|
+
const rel = (p) => {
|
|
26
|
+
p = String(p).replace(/\\/g, "/");
|
|
27
|
+
return rootFs && p.startsWith(rootFs) ? p.slice(rootFs.length) : p.replace(/^\//, "");
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const methods = readJsonl(join(raw, "methods.jsonl"));
|
|
31
|
+
const calls = readJsonl(join(raw, "calls.jsonl"));
|
|
32
|
+
|
|
33
|
+
// Identity: fullName|signature|file — the same tuple joern-export.sc keys on.
|
|
34
|
+
const keyOf = (m) => `${m.fullName}|${m.signature}|${rel(m.file)}`;
|
|
35
|
+
|
|
36
|
+
// Some frontends (javasrc2cpg: constructors, overload/bridge pairs) emit
|
|
37
|
+
// several method records with an IDENTICAL fullName|signature|file — one
|
|
38
|
+
// logical method must become one symbol, so dedupe by key first, keeping the
|
|
39
|
+
// record with the widest line span (the real body over a synthetic stub).
|
|
40
|
+
const byKey = new Map();
|
|
41
|
+
for (const m of methods) {
|
|
42
|
+
const k = keyOf(m);
|
|
43
|
+
const prev = byKey.get(k);
|
|
44
|
+
if (!prev || (m.lineEnd ?? 0) - (m.lineStart ?? 0) > (prev.lineEnd ?? 0) - (prev.lineStart ?? 0)) {
|
|
45
|
+
byKey.set(k, m);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const uniqueMethods = [...byKey.values()];
|
|
49
|
+
|
|
50
|
+
// Anchors: "<lang>:<relfile>#<name>(<sig>)"; same-file same-name-and-sig
|
|
51
|
+
// duplicates that remain distinct (different fullName) get ~2, ~3 by line order.
|
|
52
|
+
const byAnchorBase = new Map();
|
|
53
|
+
for (const m of uniqueMethods) {
|
|
54
|
+
const base = `${langOf(m.file)}:${rel(m.file)}#${m.name}(${m.signature})`;
|
|
55
|
+
if (!byAnchorBase.has(base)) byAnchorBase.set(base, []);
|
|
56
|
+
byAnchorBase.get(base).push(m);
|
|
57
|
+
}
|
|
58
|
+
const anchorByKey = new Map();
|
|
59
|
+
for (const [base, list] of byAnchorBase) {
|
|
60
|
+
list.sort((a, b) => (a.lineStart ?? 0) - (b.lineStart ?? 0));
|
|
61
|
+
list.forEach((m, i) => anchorByKey.set(keyOf(m), i === 0 ? base : `${base}~${i + 1}`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Class-qualified display names: in a class language a method's natural
|
|
65
|
+
// short name is `Cls.method` — bare names ("invoke", "execute") repeat in
|
|
66
|
+
// every other class and read as noise. Constructors are `Cls.new`, static
|
|
67
|
+
// initialisers `Cls.static{}`. C (no classes: fullName has no dot) keeps
|
|
68
|
+
// plain names. The anchor keeps the raw identity — presentation only.
|
|
69
|
+
const displayName = (m) => {
|
|
70
|
+
const head = String(m.fullName ?? "").split(":")[0]; // pkg.Outer$Inner.method
|
|
71
|
+
const di = head.lastIndexOf(".");
|
|
72
|
+
const owner = di > 0 ? head.slice(0, di) : "";
|
|
73
|
+
const simple = (owner.split(".").pop() || "").split("$").pop() || "";
|
|
74
|
+
if (m.name === "<init>") return simple ? `${simple}.new` : "new";
|
|
75
|
+
if (m.name === "<clinit>") return simple ? `${simple}.static{}` : "static{}";
|
|
76
|
+
return simple ? `${simple}.${m.name}` : m.name;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const symbols = [];
|
|
80
|
+
const seenFiles = new Set();
|
|
81
|
+
for (const m of uniqueMethods) {
|
|
82
|
+
const file = rel(m.file);
|
|
83
|
+
symbols.push({
|
|
84
|
+
anchor: anchorByKey.get(keyOf(m)),
|
|
85
|
+
lang: langOf(file),
|
|
86
|
+
kind: "Function",
|
|
87
|
+
name: displayName(m),
|
|
88
|
+
file,
|
|
89
|
+
line_start: m.lineStart ?? undefined,
|
|
90
|
+
line_end: m.lineEnd ?? undefined,
|
|
91
|
+
signature: m.signature || undefined,
|
|
92
|
+
entry: m.name === "main" ? true : undefined,
|
|
93
|
+
resolution: "cpg",
|
|
94
|
+
});
|
|
95
|
+
// Derive one File symbol per source file so emit gets stable heading ids.
|
|
96
|
+
if (!seenFiles.has(file)) {
|
|
97
|
+
seenFiles.add(file);
|
|
98
|
+
symbols.push({
|
|
99
|
+
anchor: `${langOf(file)}:${file}`,
|
|
100
|
+
lang: langOf(file),
|
|
101
|
+
kind: "File",
|
|
102
|
+
name: file.split("/").pop(),
|
|
103
|
+
file,
|
|
104
|
+
resolution: "cpg",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const edges = [];
|
|
110
|
+
for (const c of calls) {
|
|
111
|
+
const from = anchorByKey.get(`${c.callerFullName}|${c.callerSignature}|${rel(c.callerFile)}`);
|
|
112
|
+
if (!from) continue;
|
|
113
|
+
const site = { file: rel(c.callerFile), line: c.line ?? 0 };
|
|
114
|
+
const targets = (c.callees ?? [])
|
|
115
|
+
.map((t) => anchorByKey.get(`${t.fullName}|${t.signature}|${rel(t.file)}`))
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
if (targets.length === 0) {
|
|
118
|
+
edges.push({ kind: "calls", from, to_text: c.name, resolution: "cpg", confidence: "low", site });
|
|
119
|
+
} else if (targets.length === 1) {
|
|
120
|
+
edges.push({ kind: "calls", from, to: targets[0], resolution: "cpg", confidence: "high", site });
|
|
121
|
+
} else {
|
|
122
|
+
const [first, ...rest] = [...new Set(targets)].sort();
|
|
123
|
+
edges.push({
|
|
124
|
+
kind: "calls", from, to: first, resolution: "cpg", confidence: "medium",
|
|
125
|
+
note: `dispatch, ${targets.length} candidates`, candidates: rest, site,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { symbols, edges };
|
|
131
|
+
}
|