lattice-graph 0.1.0 → 0.3.0
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 +69 -25
- package/package.json +8 -9
- package/scripts/postinstall.ts +69 -0
- package/src/commands/build.ts +44 -173
- package/src/commands/init.ts +39 -17
- package/src/commands/lint.ts +145 -53
- package/src/commands/populate.ts +14 -3
- package/src/commands/update.ts +75 -137
- package/src/config.ts +0 -2
- package/src/extract/tag-scanner.ts +94 -0
- package/src/files.ts +56 -0
- package/src/graph/database.ts +6 -8
- package/src/graph/writer.ts +17 -21
- package/src/lsp/builder.ts +248 -0
- package/src/lsp/calls.ts +84 -0
- package/src/lsp/client.ts +211 -0
- package/src/lsp/symbols.ts +146 -0
- package/src/lsp/types.ts +73 -0
- package/src/main.ts +2 -18
- package/src/types/config.ts +0 -2
- package/src/types/graph.ts +6 -34
- package/src/types/lint.ts +0 -1
- package/src/extract/extractor.ts +0 -13
- package/src/extract/parser.ts +0 -117
- package/src/extract/python/calls.ts +0 -121
- package/src/extract/python/extractor.ts +0 -171
- package/src/extract/python/frameworks.ts +0 -142
- package/src/extract/python/imports.ts +0 -115
- package/src/extract/python/symbols.ts +0 -121
- package/src/extract/tags.ts +0 -77
- package/src/extract/typescript/calls.ts +0 -110
- package/src/extract/typescript/extractor.ts +0 -130
- package/src/extract/typescript/imports.ts +0 -71
- package/src/extract/typescript/symbols.ts +0 -252
package/src/files.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if a file or directory name matches any exclusion pattern.
|
|
6
|
+
*
|
|
7
|
+
* @param name - File or directory name to check
|
|
8
|
+
* @param excludePatterns - Patterns to exclude
|
|
9
|
+
* @returns True if the name matches any pattern
|
|
10
|
+
*/
|
|
11
|
+
function isExcluded(name: string, excludePatterns: readonly string[]): boolean {
|
|
12
|
+
return excludePatterns.some((pattern) => name.includes(pattern));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Recursively discovers files matching given extensions, excluding directories by pattern.
|
|
17
|
+
*
|
|
18
|
+
* @param root - Root directory to search
|
|
19
|
+
* @param extensions - File extensions to include (e.g., [".ts", ".tsx"])
|
|
20
|
+
* @param exclude - Directory name patterns to skip
|
|
21
|
+
* @returns Array of absolute file paths
|
|
22
|
+
*/
|
|
23
|
+
function discoverFiles(
|
|
24
|
+
root: string,
|
|
25
|
+
extensions: readonly string[],
|
|
26
|
+
exclude: readonly string[],
|
|
27
|
+
): readonly string[] {
|
|
28
|
+
const files: string[] = [];
|
|
29
|
+
|
|
30
|
+
function walk(dir: string): void {
|
|
31
|
+
let names: string[];
|
|
32
|
+
try {
|
|
33
|
+
names = readdirSync(dir) as string[];
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
const fullPath = join(dir, name);
|
|
39
|
+
try {
|
|
40
|
+
const stat = statSync(fullPath);
|
|
41
|
+
if (stat.isDirectory()) {
|
|
42
|
+
if (!isExcluded(name, exclude)) walk(fullPath);
|
|
43
|
+
} else if (extensions.some((ext) => name.endsWith(ext))) {
|
|
44
|
+
files.push(fullPath);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// skip inaccessible files
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
walk(root);
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { discoverFiles, isExcluded };
|
package/src/graph/database.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { err, ok, type Result } from "../types/result.ts";
|
|
3
3
|
|
|
4
|
-
const SCHEMA_VERSION = "
|
|
4
|
+
const SCHEMA_VERSION = "2";
|
|
5
5
|
|
|
6
6
|
const SCHEMA_SQL = `
|
|
7
7
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
@@ -21,7 +21,6 @@ CREATE TABLE IF NOT EXISTS edges (
|
|
|
21
21
|
source_id TEXT NOT NULL,
|
|
22
22
|
target_id TEXT NOT NULL,
|
|
23
23
|
kind TEXT NOT NULL,
|
|
24
|
-
certainty TEXT DEFAULT 'certain',
|
|
25
24
|
PRIMARY KEY (source_id, target_id, kind)
|
|
26
25
|
);
|
|
27
26
|
|
|
@@ -32,12 +31,11 @@ CREATE TABLE IF NOT EXISTS tags (
|
|
|
32
31
|
PRIMARY KEY (node_id, kind, value)
|
|
33
32
|
);
|
|
34
33
|
|
|
35
|
-
CREATE TABLE IF NOT EXISTS
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
PRIMARY KEY (file, line, expression)
|
|
34
|
+
CREATE TABLE IF NOT EXISTS external_calls (
|
|
35
|
+
node_id TEXT NOT NULL,
|
|
36
|
+
package TEXT NOT NULL,
|
|
37
|
+
symbol TEXT NOT NULL,
|
|
38
|
+
PRIMARY KEY (node_id, package, symbol)
|
|
41
39
|
);
|
|
42
40
|
|
|
43
41
|
CREATE TABLE IF NOT EXISTS meta (
|
package/src/graph/writer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
-
import type { Edge, Node, Tag
|
|
2
|
+
import type { Edge, ExternalCall, Node, Tag } from "../types/graph.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Inserts nodes into the graph database.
|
|
@@ -40,11 +40,11 @@ function insertNodes(db: Database, nodes: readonly Node[]): void {
|
|
|
40
40
|
*/
|
|
41
41
|
function insertEdges(db: Database, edges: readonly Edge[]): void {
|
|
42
42
|
const stmt = db.prepare(
|
|
43
|
-
"INSERT OR IGNORE INTO edges (source_id, target_id, kind
|
|
43
|
+
"INSERT OR IGNORE INTO edges (source_id, target_id, kind) VALUES (?, ?, ?)",
|
|
44
44
|
);
|
|
45
45
|
const tx = db.transaction(() => {
|
|
46
46
|
for (const edge of edges) {
|
|
47
|
-
stmt.run(edge.sourceId, edge.targetId, edge.kind
|
|
47
|
+
stmt.run(edge.sourceId, edge.targetId, edge.kind);
|
|
48
48
|
}
|
|
49
49
|
});
|
|
50
50
|
tx();
|
|
@@ -68,26 +68,26 @@ function insertTags(db: Database, tags: readonly Tag[]): void {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Inserts
|
|
71
|
+
* Inserts external call records for lint boundary detection.
|
|
72
72
|
* Uses INSERT OR IGNORE to skip duplicates.
|
|
73
73
|
*
|
|
74
74
|
* @param db - An open Database handle
|
|
75
|
-
* @param
|
|
75
|
+
* @param calls - External calls to insert
|
|
76
76
|
*/
|
|
77
|
-
function
|
|
77
|
+
function insertExternalCalls(db: Database, calls: readonly ExternalCall[]): void {
|
|
78
78
|
const stmt = db.prepare(
|
|
79
|
-
"INSERT OR IGNORE INTO
|
|
79
|
+
"INSERT OR IGNORE INTO external_calls (node_id, package, symbol) VALUES (?, ?, ?)",
|
|
80
80
|
);
|
|
81
81
|
const tx = db.transaction(() => {
|
|
82
|
-
for (const
|
|
83
|
-
stmt.run(
|
|
82
|
+
for (const call of calls) {
|
|
83
|
+
stmt.run(call.nodeId, call.package, call.symbol);
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
tx();
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* Deletes all nodes, edges, tags, and
|
|
90
|
+
* Deletes all nodes, edges, tags, and external calls for a given file.
|
|
91
91
|
* Edges where the file's nodes are either source or target are removed.
|
|
92
92
|
*
|
|
93
93
|
* @param db - An open Database handle
|
|
@@ -95,23 +95,19 @@ function insertUnresolved(db: Database, refs: readonly UnresolvedReference[]): v
|
|
|
95
95
|
*/
|
|
96
96
|
function deleteFileData(db: Database, file: string): void {
|
|
97
97
|
const tx = db.transaction(() => {
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
const nodeIds = db.query("SELECT id FROM nodes WHERE file = ?").all(file) as {
|
|
99
|
+
id: string;
|
|
100
|
+
}[];
|
|
100
101
|
const ids = nodeIds.map((n) => n.id);
|
|
101
102
|
|
|
102
103
|
if (ids.length > 0) {
|
|
103
104
|
const placeholders = ids.map(() => "?").join(",");
|
|
104
|
-
// Delete tags for these nodes
|
|
105
105
|
db.run(`DELETE FROM tags WHERE node_id IN (${placeholders})`, ids);
|
|
106
|
-
// Delete edges where these nodes are source or target
|
|
107
106
|
db.run(`DELETE FROM edges WHERE source_id IN (${placeholders})`, ids);
|
|
108
107
|
db.run(`DELETE FROM edges WHERE target_id IN (${placeholders})`, ids);
|
|
109
|
-
|
|
108
|
+
db.run(`DELETE FROM external_calls WHERE node_id IN (${placeholders})`, ids);
|
|
110
109
|
db.run(`DELETE FROM nodes WHERE id IN (${placeholders})`, ids);
|
|
111
110
|
}
|
|
112
|
-
|
|
113
|
-
// Delete unresolved references for this file
|
|
114
|
-
db.run("DELETE FROM unresolved WHERE file = ?", [file]);
|
|
115
111
|
});
|
|
116
112
|
tx();
|
|
117
113
|
}
|
|
@@ -127,8 +123,8 @@ function synthesizeEventEdges(db: Database): void {
|
|
|
127
123
|
const tx = db.transaction(() => {
|
|
128
124
|
db.run("DELETE FROM edges WHERE kind = 'event'");
|
|
129
125
|
db.run(`
|
|
130
|
-
INSERT OR IGNORE INTO edges (source_id, target_id, kind
|
|
131
|
-
SELECT e.node_id, h.node_id, 'event'
|
|
126
|
+
INSERT OR IGNORE INTO edges (source_id, target_id, kind)
|
|
127
|
+
SELECT e.node_id, h.node_id, 'event'
|
|
132
128
|
FROM tags e
|
|
133
129
|
JOIN tags h ON e.value = h.value
|
|
134
130
|
WHERE e.kind = 'emits' AND h.kind = 'handles'
|
|
@@ -140,8 +136,8 @@ function synthesizeEventEdges(db: Database): void {
|
|
|
140
136
|
export {
|
|
141
137
|
deleteFileData,
|
|
142
138
|
insertEdges,
|
|
139
|
+
insertExternalCalls,
|
|
143
140
|
insertNodes,
|
|
144
141
|
insertTags,
|
|
145
|
-
insertUnresolved,
|
|
146
142
|
synthesizeEventEdges,
|
|
147
143
|
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { scanTags } from "../extract/tag-scanner.ts";
|
|
5
|
+
import { discoverFiles } from "../files.ts";
|
|
6
|
+
import {
|
|
7
|
+
insertEdges,
|
|
8
|
+
insertExternalCalls,
|
|
9
|
+
insertNodes,
|
|
10
|
+
insertTags,
|
|
11
|
+
synthesizeEventEdges,
|
|
12
|
+
} from "../graph/writer.ts";
|
|
13
|
+
import type { Edge, ExternalCall, Node, Tag } from "../types/graph.ts";
|
|
14
|
+
import { outgoingCallsToEdges } from "./calls.ts";
|
|
15
|
+
import { createLspClient } from "./client.ts";
|
|
16
|
+
import { documentSymbolsToNodesWithPositions, type NodeWithPosition } from "./symbols.ts";
|
|
17
|
+
|
|
18
|
+
/** Per-language extraction configuration. */
|
|
19
|
+
type LanguageConfig = {
|
|
20
|
+
readonly language: string;
|
|
21
|
+
readonly extensions: readonly string[];
|
|
22
|
+
readonly sourceRoots: readonly string[];
|
|
23
|
+
readonly testPaths: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Options for building the graph. */
|
|
27
|
+
type BuildGraphOptions = {
|
|
28
|
+
readonly projectRoot: string;
|
|
29
|
+
readonly db: Database;
|
|
30
|
+
readonly languageConfigs: readonly LanguageConfig[];
|
|
31
|
+
readonly exclude: readonly string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Statistics from a graph build. */
|
|
35
|
+
type BuildStats = {
|
|
36
|
+
readonly fileCount: number;
|
|
37
|
+
readonly nodeCount: number;
|
|
38
|
+
readonly edgeCount: number;
|
|
39
|
+
readonly tagCount: number;
|
|
40
|
+
readonly durationMs: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Resolves the LSP server binary for a language, checking bundled paths first. */
|
|
44
|
+
function resolveLspServer(
|
|
45
|
+
language: string,
|
|
46
|
+
): { command: string; args: readonly string[]; languageId: string } | undefined {
|
|
47
|
+
if (language === "typescript") {
|
|
48
|
+
// Check node_modules/.bin/ first (bundled with lattice-graph)
|
|
49
|
+
const bundled = join(
|
|
50
|
+
import.meta.dir,
|
|
51
|
+
"..",
|
|
52
|
+
"..",
|
|
53
|
+
"node_modules",
|
|
54
|
+
".bin",
|
|
55
|
+
"typescript-language-server",
|
|
56
|
+
);
|
|
57
|
+
const command = existsSync(bundled) ? bundled : "typescript-language-server";
|
|
58
|
+
return { command, args: ["--stdio"], languageId: "typescript" };
|
|
59
|
+
}
|
|
60
|
+
if (language === "python") {
|
|
61
|
+
const bundled = join(import.meta.dir, "..", "..", "vendor", "venv", "bin", "zubanls");
|
|
62
|
+
const command = existsSync(bundled) ? bundled : "zubanls";
|
|
63
|
+
return { command, args: [], languageId: "python" };
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds the knowledge graph by querying LSP servers for symbols and call hierarchy,
|
|
70
|
+
* scanning for @lattice: tags, and writing everything to SQLite.
|
|
71
|
+
* Spawns one LSP server per language. Uses both outgoingCalls and references
|
|
72
|
+
* strategies to maximize edge coverage.
|
|
73
|
+
*
|
|
74
|
+
* @param opts - Build configuration
|
|
75
|
+
* @returns Build statistics
|
|
76
|
+
*/
|
|
77
|
+
async function buildGraph(opts: BuildGraphOptions): Promise<BuildStats> {
|
|
78
|
+
const start = performance.now();
|
|
79
|
+
const { projectRoot, db } = opts;
|
|
80
|
+
|
|
81
|
+
let totalFiles = 0;
|
|
82
|
+
const allNodes: Node[] = [];
|
|
83
|
+
const allEdges: Edge[] = [];
|
|
84
|
+
const allTags: Tag[] = [];
|
|
85
|
+
const allExternalCalls: ExternalCall[] = [];
|
|
86
|
+
|
|
87
|
+
for (const langConfig of opts.languageConfigs) {
|
|
88
|
+
const files: string[] = [];
|
|
89
|
+
for (const srcRoot of langConfig.sourceRoots) {
|
|
90
|
+
const absRoot = resolve(projectRoot, srcRoot);
|
|
91
|
+
files.push(...discoverFiles(absRoot, langConfig.extensions, opts.exclude));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (files.length === 0) continue;
|
|
95
|
+
totalFiles += files.length;
|
|
96
|
+
|
|
97
|
+
const lsp = resolveLspServer(langConfig.language);
|
|
98
|
+
if (!lsp) continue;
|
|
99
|
+
|
|
100
|
+
const client = await createLspClient({
|
|
101
|
+
command: lsp.command,
|
|
102
|
+
args: [...lsp.args],
|
|
103
|
+
rootUri: `file://${projectRoot}`,
|
|
104
|
+
languageId: lsp.languageId,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await client.waitForReady(files[0] as string);
|
|
109
|
+
|
|
110
|
+
// Phase 1: extract symbols and tags from all files
|
|
111
|
+
type FileData = {
|
|
112
|
+
filePath: string;
|
|
113
|
+
relativePath: string;
|
|
114
|
+
nodesWithPos: readonly NodeWithPosition[];
|
|
115
|
+
};
|
|
116
|
+
const fileDataList: FileData[] = [];
|
|
117
|
+
|
|
118
|
+
for (const filePath of files) {
|
|
119
|
+
const relativePath = relative(projectRoot, filePath);
|
|
120
|
+
const isTest = langConfig.testPaths.some((tp) => relativePath.startsWith(tp));
|
|
121
|
+
const source = await Bun.file(filePath).text();
|
|
122
|
+
|
|
123
|
+
const symbols = await client.documentSymbol(filePath);
|
|
124
|
+
const nodesWithPos = documentSymbolsToNodesWithPositions(
|
|
125
|
+
symbols,
|
|
126
|
+
relativePath,
|
|
127
|
+
langConfig.language,
|
|
128
|
+
isTest,
|
|
129
|
+
);
|
|
130
|
+
const nodes = nodesWithPos.map((nwp) => nwp.node);
|
|
131
|
+
allNodes.push(...nodes);
|
|
132
|
+
|
|
133
|
+
const { tags } = scanTags(source, nodes, langConfig.language);
|
|
134
|
+
allTags.push(...tags);
|
|
135
|
+
|
|
136
|
+
fileDataList.push({ filePath, relativePath, nodesWithPos });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Phase 2a: outgoingCalls — "what does each function call?"
|
|
140
|
+
for (const fd of fileDataList) {
|
|
141
|
+
for (const nwp of fd.nodesWithPos) {
|
|
142
|
+
if (nwp.node.kind !== "function" && nwp.node.kind !== "method") continue;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const items = await client.prepareCallHierarchy(
|
|
146
|
+
fd.filePath,
|
|
147
|
+
nwp.selectionLine,
|
|
148
|
+
nwp.selectionCharacter,
|
|
149
|
+
);
|
|
150
|
+
if (items.length === 0) continue;
|
|
151
|
+
const item = items[0];
|
|
152
|
+
if (!item) continue;
|
|
153
|
+
|
|
154
|
+
const calls = await client.outgoingCalls(item);
|
|
155
|
+
const { edges, externalCalls } = outgoingCallsToEdges(nwp.node.id, calls, projectRoot);
|
|
156
|
+
allEdges.push(...edges);
|
|
157
|
+
allExternalCalls.push(...externalCalls);
|
|
158
|
+
} catch {
|
|
159
|
+
// outgoingCalls not supported by this server — skip silently
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Phase 2b: references — "who references each function?"
|
|
165
|
+
const nodesByFile = new Map<string, readonly Node[]>();
|
|
166
|
+
for (const fd of fileDataList) {
|
|
167
|
+
nodesByFile.set(
|
|
168
|
+
fd.relativePath,
|
|
169
|
+
fd.nodesWithPos
|
|
170
|
+
.filter((nwp) => nwp.node.kind === "function" || nwp.node.kind === "method")
|
|
171
|
+
.map((nwp) => nwp.node),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const fd of fileDataList) {
|
|
176
|
+
for (const nwp of fd.nodesWithPos) {
|
|
177
|
+
if (nwp.node.kind !== "function" && nwp.node.kind !== "method") continue;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const refs = await client.references(
|
|
181
|
+
fd.filePath,
|
|
182
|
+
nwp.selectionLine,
|
|
183
|
+
nwp.selectionCharacter,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
for (const ref of refs) {
|
|
187
|
+
const refFile = ref.uri.startsWith(`file://${projectRoot}/`)
|
|
188
|
+
? ref.uri.slice(`file://${projectRoot}/`.length)
|
|
189
|
+
: undefined;
|
|
190
|
+
if (!refFile) continue;
|
|
191
|
+
|
|
192
|
+
const fileFunctions = nodesByFile.get(refFile);
|
|
193
|
+
if (!fileFunctions) continue;
|
|
194
|
+
|
|
195
|
+
const refLine = ref.range.start.line + 1;
|
|
196
|
+
const caller = fileFunctions.find(
|
|
197
|
+
(n) => refLine >= n.lineStart && refLine <= n.lineEnd,
|
|
198
|
+
);
|
|
199
|
+
if (!caller || caller.id === nwp.node.id) continue;
|
|
200
|
+
|
|
201
|
+
allEdges.push({ sourceId: caller.id, targetId: nwp.node.id, kind: "calls" });
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// references not supported by this server — skip silently
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
await client.shutdown();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
insertNodes(db, allNodes);
|
|
214
|
+
insertEdges(db, allEdges);
|
|
215
|
+
insertTags(db, allTags);
|
|
216
|
+
insertExternalCalls(db, allExternalCalls);
|
|
217
|
+
synthesizeEventEdges(db);
|
|
218
|
+
|
|
219
|
+
const durationMs = Math.round(performance.now() - start);
|
|
220
|
+
return {
|
|
221
|
+
fileCount: totalFiles,
|
|
222
|
+
nodeCount: allNodes.length,
|
|
223
|
+
edgeCount: allEdges.length,
|
|
224
|
+
tagCount: allTags.length,
|
|
225
|
+
durationMs,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Builds a LanguageConfig from a language name and config sections.
|
|
231
|
+
* Uses sensible defaults for LSP commands.
|
|
232
|
+
*/
|
|
233
|
+
function buildLanguageConfig(
|
|
234
|
+
language: string,
|
|
235
|
+
sourceRoots: readonly string[],
|
|
236
|
+
testPaths: readonly string[],
|
|
237
|
+
): LanguageConfig {
|
|
238
|
+
const extensions = language === "python" ? [".py"] : [".ts", ".tsx"];
|
|
239
|
+
return { language, extensions, sourceRoots, testPaths };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export {
|
|
243
|
+
type BuildGraphOptions,
|
|
244
|
+
type BuildStats,
|
|
245
|
+
buildGraph,
|
|
246
|
+
buildLanguageConfig,
|
|
247
|
+
type LanguageConfig,
|
|
248
|
+
};
|
package/src/lsp/calls.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Edge, ExternalCall } from "../types/graph.ts";
|
|
2
|
+
import type { CallHierarchyOutgoingCall } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Result of converting outgoing calls to edges. */
|
|
5
|
+
type CallConversionResult = {
|
|
6
|
+
readonly edges: readonly Edge[];
|
|
7
|
+
readonly externalCalls: readonly ExternalCall[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Converts LSP CallHierarchyOutgoingCall responses into Lattice Edges.
|
|
12
|
+
* Calls to symbols outside the project root are collected as external calls.
|
|
13
|
+
*
|
|
14
|
+
* @param sourceId - The Lattice node ID of the calling function
|
|
15
|
+
* @param calls - Outgoing calls from LSP
|
|
16
|
+
* @param projectRoot - Absolute path to the project root
|
|
17
|
+
* @returns Internal edges and external call records
|
|
18
|
+
*/
|
|
19
|
+
function outgoingCallsToEdges(
|
|
20
|
+
sourceId: string,
|
|
21
|
+
calls: readonly CallHierarchyOutgoingCall[],
|
|
22
|
+
projectRoot: string,
|
|
23
|
+
): CallConversionResult {
|
|
24
|
+
const edges: Edge[] = [];
|
|
25
|
+
const externalCalls: ExternalCall[] = [];
|
|
26
|
+
const projectFilePrefix = `file://${projectRoot}/`;
|
|
27
|
+
|
|
28
|
+
for (const call of calls) {
|
|
29
|
+
const uri = call.to.uri;
|
|
30
|
+
|
|
31
|
+
if (!uri.startsWith(projectFilePrefix) || uri.includes("/node_modules/")) {
|
|
32
|
+
// Skip type declarations — not runtime calls
|
|
33
|
+
if (isTypeDeclaration(uri)) continue;
|
|
34
|
+
|
|
35
|
+
const pkg = extractPackageName(uri);
|
|
36
|
+
if (pkg) {
|
|
37
|
+
externalCalls.push({ nodeId: sourceId, package: pkg, symbol: call.to.name });
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const relativePath = uri.slice(projectFilePrefix.length);
|
|
43
|
+
const targetId = `${relativePath}::${call.to.name}`;
|
|
44
|
+
edges.push({ sourceId, targetId, kind: "calls" });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { edges, externalCalls };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extracts the package name from a node_modules URI.
|
|
52
|
+
* Handles scoped packages (@scope/package).
|
|
53
|
+
*/
|
|
54
|
+
function extractPackageName(uri: string): string | undefined {
|
|
55
|
+
const decoded = decodeURIComponent(uri);
|
|
56
|
+
const nodeModulesIdx = decoded.indexOf("/node_modules/");
|
|
57
|
+
if (nodeModulesIdx === -1) return undefined;
|
|
58
|
+
const afterNm = decoded.slice(nodeModulesIdx + "/node_modules/".length);
|
|
59
|
+
if (afterNm.startsWith("@")) {
|
|
60
|
+
const parts = afterNm.split("/");
|
|
61
|
+
return `${parts[0]}/${parts[1]}`;
|
|
62
|
+
}
|
|
63
|
+
return afterNm.split("/")[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Checks if a URI points to a type-only package (not a runtime dependency).
|
|
68
|
+
* Type definition packages (@types/*, typescript, bun-types) are filtered out.
|
|
69
|
+
* Actual library .d.ts stubs (e.g., stripe/index.d.ts) are kept — they represent runtime deps.
|
|
70
|
+
*/
|
|
71
|
+
function isTypeDeclaration(uri: string): boolean {
|
|
72
|
+
// TypeScript type-only packages
|
|
73
|
+
if (uri.includes("/node_modules/@types/")) return true;
|
|
74
|
+
if (uri.includes("/node_modules/%40types/")) return true;
|
|
75
|
+
if (uri.includes("/node_modules/typescript/")) return true;
|
|
76
|
+
if (uri.includes("/node_modules/bun-types/")) return true;
|
|
77
|
+
// Python type stubs
|
|
78
|
+
if (uri.includes("/typeshed/")) return true;
|
|
79
|
+
if (uri.includes("/typestubs/")) return true;
|
|
80
|
+
if (uri.includes("-stubs/")) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { type CallConversionResult, extractPackageName, isTypeDeclaration, outgoingCallsToEdges };
|