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
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { TreeSitterNode, TreeSitterTree } from "../parser.ts";
|
|
2
|
-
|
|
3
|
-
/** A raw call detected in the AST before resolution to graph edges. */
|
|
4
|
-
type RawCall = {
|
|
5
|
-
readonly sourceId: string;
|
|
6
|
-
readonly callee: string;
|
|
7
|
-
readonly line: number;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Extracts function calls from a Python AST.
|
|
12
|
-
* Each call is scoped to its enclosing function or method.
|
|
13
|
-
*
|
|
14
|
-
* @param tree - Parsed tree-sitter tree
|
|
15
|
-
* @param filePath - Relative file path for source ID construction
|
|
16
|
-
* @returns Raw calls with caller ID and callee expression
|
|
17
|
-
*/
|
|
18
|
-
function extractPythonCalls(tree: TreeSitterTree, filePath: string): readonly RawCall[] {
|
|
19
|
-
const calls: RawCall[] = [];
|
|
20
|
-
visitForCalls(tree.rootNode, filePath, [], calls);
|
|
21
|
-
return calls;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Scope entry for tracking the enclosing function/class context. */
|
|
25
|
-
type ScopeEntry = { readonly name: string };
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Recursively walks the AST to find call expressions inside function bodies.
|
|
29
|
-
* Tracks the enclosing function scope to produce correct source IDs.
|
|
30
|
-
*/
|
|
31
|
-
function visitForCalls(
|
|
32
|
-
node: TreeSitterNode,
|
|
33
|
-
filePath: string,
|
|
34
|
-
scopeStack: readonly ScopeEntry[],
|
|
35
|
-
results: RawCall[],
|
|
36
|
-
): void {
|
|
37
|
-
// Enter a new scope for function/class definitions
|
|
38
|
-
if (node.type === "function_definition" || node.type === "class_definition") {
|
|
39
|
-
const nameNode = node.childForFieldName("name");
|
|
40
|
-
if (nameNode) {
|
|
41
|
-
const newScope = [...scopeStack, { name: nameNode.text }];
|
|
42
|
-
for (const child of node.children) {
|
|
43
|
-
visitForCalls(child, filePath, newScope, results);
|
|
44
|
-
}
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Handle decorated definitions — unwrap to the inner definition
|
|
50
|
-
if (node.type === "decorated_definition") {
|
|
51
|
-
for (const child of node.children) {
|
|
52
|
-
if (child.type === "function_definition" || child.type === "class_definition") {
|
|
53
|
-
visitForCalls(child, filePath, scopeStack, results);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Detect call expressions inside a function scope
|
|
60
|
-
if (node.type === "call" && scopeStack.length > 0) {
|
|
61
|
-
const callee = extractCalleeName(node);
|
|
62
|
-
if (callee) {
|
|
63
|
-
const sourceId = `${filePath}::${scopeStack.map((s) => s.name).join(".")}`;
|
|
64
|
-
results.push({
|
|
65
|
-
sourceId,
|
|
66
|
-
callee,
|
|
67
|
-
line: node.startPosition.row + 1,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Recurse into children
|
|
73
|
-
for (const child of node.children) {
|
|
74
|
-
visitForCalls(child, filePath, scopeStack, results);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Extracts the callee name from a call node.
|
|
80
|
-
* Handles: simple calls (foo()), attribute calls (obj.method()), chained calls (a.b.c()).
|
|
81
|
-
*/
|
|
82
|
-
function extractCalleeName(callNode: TreeSitterNode): string | undefined {
|
|
83
|
-
const funcNode = callNode.children[0];
|
|
84
|
-
if (!funcNode) return undefined;
|
|
85
|
-
|
|
86
|
-
if (funcNode.type === "identifier") {
|
|
87
|
-
return funcNode.text;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (funcNode.type === "attribute") {
|
|
91
|
-
return flattenAttribute(funcNode);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Flattens a nested attribute access into a dotted string.
|
|
99
|
-
* e.g., attribute(attribute(identifier("stripe"), "charges"), "create") → "stripe.charges.create"
|
|
100
|
-
*/
|
|
101
|
-
function flattenAttribute(node: TreeSitterNode): string {
|
|
102
|
-
const parts: string[] = [];
|
|
103
|
-
let current: TreeSitterNode | null = node;
|
|
104
|
-
|
|
105
|
-
while (current?.type === "attribute") {
|
|
106
|
-
const attrName = current.children.at(-1);
|
|
107
|
-
if (attrName?.type === "identifier") {
|
|
108
|
-
parts.unshift(attrName.text);
|
|
109
|
-
}
|
|
110
|
-
current = current.children[0] ?? null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// The leftmost part is an identifier
|
|
114
|
-
if (current?.type === "identifier") {
|
|
115
|
-
parts.unshift(current.text);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return parts.join(".");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export { extractPythonCalls, type RawCall };
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import type { Edge, ExtractionResult, Tag } from "../../types/graph.ts";
|
|
2
|
-
import { isOk, unwrap } from "../../types/result.ts";
|
|
3
|
-
import type { Extractor } from "../extractor.ts";
|
|
4
|
-
import { createParser, type TreeSitterParser } from "../parser.ts";
|
|
5
|
-
import { parseTags } from "../tags.ts";
|
|
6
|
-
import { extractPythonCalls } from "./calls.ts";
|
|
7
|
-
import { detectPythonFrameworks } from "./frameworks.ts";
|
|
8
|
-
import { extractPythonSymbols } from "./symbols.ts";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Creates a Python extractor with an initialized tree-sitter parser.
|
|
12
|
-
* Must be called after initTreeSitter().
|
|
13
|
-
*
|
|
14
|
-
* @returns An Extractor configured for Python source files
|
|
15
|
-
*/
|
|
16
|
-
async function createPythonExtractor(): Promise<Extractor> {
|
|
17
|
-
const parser = await createParser("python");
|
|
18
|
-
|
|
19
|
-
return {
|
|
20
|
-
language: "python",
|
|
21
|
-
fileExtensions: [".py"],
|
|
22
|
-
extract: (filePath: string, source: string): Promise<ExtractionResult> =>
|
|
23
|
-
extractPython(parser, filePath, source),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Extracts symbols, calls, imports, tags, and framework metadata from a Python file.
|
|
29
|
-
*
|
|
30
|
-
* @param parser - Initialized tree-sitter parser for Python
|
|
31
|
-
* @param filePath - Relative file path for node ID construction
|
|
32
|
-
* @param source - Raw source code
|
|
33
|
-
* @returns Complete extraction result with nodes, edges, tags, and unresolved references
|
|
34
|
-
*/
|
|
35
|
-
async function extractPython(
|
|
36
|
-
parser: TreeSitterParser,
|
|
37
|
-
filePath: string,
|
|
38
|
-
source: string,
|
|
39
|
-
): Promise<ExtractionResult> {
|
|
40
|
-
if (!source.trim()) {
|
|
41
|
-
return { nodes: [], edges: [], tags: [], unresolved: [] };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const tree = parser.parse(source);
|
|
45
|
-
|
|
46
|
-
// 1. Extract symbols (functions, classes, methods)
|
|
47
|
-
const nodes = [...extractPythonSymbols(tree, filePath, source)];
|
|
48
|
-
|
|
49
|
-
// 2. Extract calls and convert to edges
|
|
50
|
-
const rawCalls = extractPythonCalls(tree, filePath);
|
|
51
|
-
const edges: Edge[] = [];
|
|
52
|
-
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
53
|
-
|
|
54
|
-
for (const call of rawCalls) {
|
|
55
|
-
// Try to resolve the callee to a known node in this file
|
|
56
|
-
const targetId = resolveCalleeInFile(call.callee, filePath, nodeIds);
|
|
57
|
-
if (targetId) {
|
|
58
|
-
edges.push({
|
|
59
|
-
sourceId: call.sourceId,
|
|
60
|
-
targetId,
|
|
61
|
-
kind: "calls",
|
|
62
|
-
certainty: "certain",
|
|
63
|
-
});
|
|
64
|
-
} else {
|
|
65
|
-
// The callee is external or unresolvable within this file.
|
|
66
|
-
// Store as an edge with the raw callee expression as target.
|
|
67
|
-
// Cross-file resolution happens in the build command.
|
|
68
|
-
edges.push({
|
|
69
|
-
sourceId: call.sourceId,
|
|
70
|
-
targetId: call.callee,
|
|
71
|
-
kind: "calls",
|
|
72
|
-
certainty: "uncertain",
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 3. Parse lattice tags from comments above functions
|
|
78
|
-
const tags = extractTagsFromSource(source, filePath, nodes);
|
|
79
|
-
|
|
80
|
-
// 4. Detect framework patterns and attach route metadata to nodes
|
|
81
|
-
const frameworks = detectPythonFrameworks(tree, filePath);
|
|
82
|
-
for (const detection of frameworks) {
|
|
83
|
-
if (detection.route) {
|
|
84
|
-
const node = nodes.find((n) => n.name === detection.functionName);
|
|
85
|
-
if (node) {
|
|
86
|
-
const idx = nodes.indexOf(node);
|
|
87
|
-
nodes[idx] = {
|
|
88
|
-
...node,
|
|
89
|
-
metadata: { ...node.metadata, route: detection.route },
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return { nodes, edges, tags, unresolved: [] };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Resolves a callee name to a node ID within the same file.
|
|
100
|
-
* Handles simple names (bar → filePath::bar) and self.method references.
|
|
101
|
-
*/
|
|
102
|
-
function resolveCalleeInFile(
|
|
103
|
-
callee: string,
|
|
104
|
-
filePath: string,
|
|
105
|
-
nodeIds: Set<string>,
|
|
106
|
-
): string | undefined {
|
|
107
|
-
// Direct match: callee is a function name in this file
|
|
108
|
-
const directId = `${filePath}::${callee}`;
|
|
109
|
-
if (nodeIds.has(directId)) return directId;
|
|
110
|
-
|
|
111
|
-
// self.method → try ClassName.method for each class in this file
|
|
112
|
-
if (callee.startsWith("self.")) {
|
|
113
|
-
const methodName = callee.slice(5);
|
|
114
|
-
for (const id of nodeIds) {
|
|
115
|
-
if (id.endsWith(`.${methodName}`) && id.startsWith(`${filePath}::`)) {
|
|
116
|
-
return id;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Extracts lattice tags by finding comment blocks directly above function definitions.
|
|
126
|
-
* Associates each parsed tag with the node ID of the function below it.
|
|
127
|
-
*/
|
|
128
|
-
function extractTagsFromSource(
|
|
129
|
-
source: string,
|
|
130
|
-
_filePath: string,
|
|
131
|
-
nodes: readonly { readonly id: string; readonly lineStart: number }[],
|
|
132
|
-
): readonly Tag[] {
|
|
133
|
-
const lines = source.split("\n");
|
|
134
|
-
const tags: Tag[] = [];
|
|
135
|
-
|
|
136
|
-
for (const node of nodes) {
|
|
137
|
-
// Collect comment lines directly above the function (no blank lines between)
|
|
138
|
-
const commentLines: string[] = [];
|
|
139
|
-
let lineIdx = node.lineStart - 2; // lineStart is 1-based, array is 0-based
|
|
140
|
-
while (lineIdx >= 0) {
|
|
141
|
-
const line = lines[lineIdx]?.trim();
|
|
142
|
-
if (!line) break;
|
|
143
|
-
if (line.startsWith("#") || line.startsWith("//") || line.startsWith("/*")) {
|
|
144
|
-
commentLines.unshift(line);
|
|
145
|
-
lineIdx--;
|
|
146
|
-
} else if (line.startsWith("@")) {
|
|
147
|
-
// Skip decorators — they're between the tags and the function
|
|
148
|
-
lineIdx--;
|
|
149
|
-
} else {
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (commentLines.length === 0) continue;
|
|
155
|
-
|
|
156
|
-
const parseResult = parseTags(commentLines.join("\n"));
|
|
157
|
-
if (isOk(parseResult)) {
|
|
158
|
-
for (const parsed of unwrap(parseResult)) {
|
|
159
|
-
tags.push({
|
|
160
|
-
nodeId: node.id,
|
|
161
|
-
kind: parsed.kind,
|
|
162
|
-
value: parsed.value,
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return tags;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export { createPythonExtractor };
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import type { TreeSitterNode, TreeSitterTree } from "../parser.ts";
|
|
2
|
-
|
|
3
|
-
/** A detected framework pattern on a function. */
|
|
4
|
-
type FrameworkDetection = {
|
|
5
|
-
readonly functionName: string;
|
|
6
|
-
readonly route: string | undefined;
|
|
7
|
-
readonly isEntryPoint: boolean;
|
|
8
|
-
readonly line: number;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
/** HTTP methods recognized in route decorators. */
|
|
12
|
-
const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "head", "options"]);
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Detects framework patterns (FastAPI, Flask, Celery) on decorated functions.
|
|
16
|
-
* Used by the linter to flag untagged entry points and by populate to suggest tags.
|
|
17
|
-
*
|
|
18
|
-
* @param tree - Parsed tree-sitter tree
|
|
19
|
-
* @param _filePath - Relative file path (unused, kept for interface consistency)
|
|
20
|
-
* @returns Detected framework patterns with route info and entry point flags
|
|
21
|
-
*/
|
|
22
|
-
function detectPythonFrameworks(
|
|
23
|
-
tree: TreeSitterTree,
|
|
24
|
-
_filePath: string,
|
|
25
|
-
): readonly FrameworkDetection[] {
|
|
26
|
-
const results: FrameworkDetection[] = [];
|
|
27
|
-
|
|
28
|
-
for (const child of tree.rootNode.children) {
|
|
29
|
-
if (child.type === "decorated_definition") {
|
|
30
|
-
processDecoratedDefinition(child, results);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return results;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Processes a decorated_definition node to check for framework patterns. */
|
|
38
|
-
function processDecoratedDefinition(node: TreeSitterNode, results: FrameworkDetection[]): void {
|
|
39
|
-
const funcDef = node.children.find((c) => c.type === "function_definition");
|
|
40
|
-
if (!funcDef) return;
|
|
41
|
-
|
|
42
|
-
const funcName = funcDef.childForFieldName("name")?.text;
|
|
43
|
-
if (!funcName) return;
|
|
44
|
-
|
|
45
|
-
const decorators = node.children.filter((c) => c.type === "decorator");
|
|
46
|
-
|
|
47
|
-
for (const decorator of decorators) {
|
|
48
|
-
const detection = analyzeDecorator(decorator, funcName, node.startPosition.row + 1);
|
|
49
|
-
if (detection) {
|
|
50
|
-
results.push(detection);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Analyzes a single decorator to determine if it matches a framework pattern.
|
|
57
|
-
* Recognizes: @app.get("/path"), @router.post("/path"), @app.route("/path"),
|
|
58
|
-
* @bp.route("/path"), @app.task, @shared_task
|
|
59
|
-
*/
|
|
60
|
-
function analyzeDecorator(
|
|
61
|
-
decorator: TreeSitterNode,
|
|
62
|
-
funcName: string,
|
|
63
|
-
line: number,
|
|
64
|
-
): FrameworkDetection | undefined {
|
|
65
|
-
// The decorator's child after '@' is either a call or an attribute/identifier
|
|
66
|
-
const content = decorator.children.find(
|
|
67
|
-
(c) => c.type === "call" || c.type === "attribute" || c.type === "identifier",
|
|
68
|
-
);
|
|
69
|
-
if (!content) return undefined;
|
|
70
|
-
|
|
71
|
-
// Case 1: @shared_task (bare identifier)
|
|
72
|
-
if (content.type === "identifier" && content.text === "shared_task") {
|
|
73
|
-
return { functionName: funcName, route: undefined, isEntryPoint: true, line };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Case 2: @app.task (bare attribute, no call)
|
|
77
|
-
if (content.type === "attribute") {
|
|
78
|
-
const attrName = content.children.at(-1)?.text;
|
|
79
|
-
if (attrName === "task") {
|
|
80
|
-
return { functionName: funcName, route: undefined, isEntryPoint: true, line };
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Case 3: @app.get("/path") or @router.post("/path") or @app.route("/path") — a call
|
|
85
|
-
if (content.type === "call") {
|
|
86
|
-
return analyzeDecoratorCall(content, funcName, line);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return undefined;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Analyzes a decorator that is a function call, e.g., @app.post("/api/checkout"). */
|
|
93
|
-
function analyzeDecoratorCall(
|
|
94
|
-
callNode: TreeSitterNode,
|
|
95
|
-
funcName: string,
|
|
96
|
-
line: number,
|
|
97
|
-
): FrameworkDetection | undefined {
|
|
98
|
-
const callee = callNode.children[0];
|
|
99
|
-
if (!callee || callee.type !== "attribute") return undefined;
|
|
100
|
-
|
|
101
|
-
const methodName = callee.children.at(-1)?.text;
|
|
102
|
-
if (!methodName) return undefined;
|
|
103
|
-
|
|
104
|
-
const args = callNode.children.find((c) => c.type === "argument_list");
|
|
105
|
-
const firstArg = args?.children.find((c) => c.type === "string");
|
|
106
|
-
const routePath = firstArg ? extractStringContent(firstArg) : undefined;
|
|
107
|
-
|
|
108
|
-
// @app.get("/path"), @router.post("/path"), etc.
|
|
109
|
-
if (HTTP_METHODS.has(methodName) && routePath) {
|
|
110
|
-
return {
|
|
111
|
-
functionName: funcName,
|
|
112
|
-
route: `${methodName.toUpperCase()} ${routePath}`,
|
|
113
|
-
isEntryPoint: true,
|
|
114
|
-
line,
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// @app.route("/path") or @bp.route("/path")
|
|
119
|
-
if (methodName === "route" && routePath) {
|
|
120
|
-
return {
|
|
121
|
-
functionName: funcName,
|
|
122
|
-
route: routePath,
|
|
123
|
-
isEntryPoint: true,
|
|
124
|
-
line,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// @app.task() with parentheses
|
|
129
|
-
if (methodName === "task") {
|
|
130
|
-
return { functionName: funcName, route: undefined, isEntryPoint: true, line };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return undefined;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Extracts the string content from a tree-sitter string node, stripping quotes. */
|
|
137
|
-
function extractStringContent(stringNode: TreeSitterNode): string | undefined {
|
|
138
|
-
const contentNode = stringNode.children.find((c) => c.type === "string_content");
|
|
139
|
-
return contentNode?.text;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export { detectPythonFrameworks, type FrameworkDetection };
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import type { TreeSitterNode, TreeSitterTree } from "../parser.ts";
|
|
2
|
-
|
|
3
|
-
/** A parsed import statement from Python source. */
|
|
4
|
-
type PythonImport = {
|
|
5
|
-
readonly module: string;
|
|
6
|
-
readonly names: readonly string[];
|
|
7
|
-
readonly isRelative: boolean;
|
|
8
|
-
readonly aliases: Map<string, string> | undefined;
|
|
9
|
-
readonly line: number;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Extracts import statements from a Python AST.
|
|
14
|
-
*
|
|
15
|
-
* @param tree - Parsed tree-sitter tree
|
|
16
|
-
* @param _filePath - Relative file path (unused, kept for interface consistency)
|
|
17
|
-
* @returns Parsed imports with module paths, imported names, and alias mappings
|
|
18
|
-
*/
|
|
19
|
-
function extractPythonImports(tree: TreeSitterTree, _filePath: string): readonly PythonImport[] {
|
|
20
|
-
const imports: PythonImport[] = [];
|
|
21
|
-
|
|
22
|
-
for (const child of tree.rootNode.children) {
|
|
23
|
-
if (child.type === "import_statement") {
|
|
24
|
-
parseImportStatement(child, imports);
|
|
25
|
-
} else if (child.type === "import_from_statement") {
|
|
26
|
-
parseImportFromStatement(child, imports);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return imports;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Parses `import x` or `import x as y` statements. */
|
|
34
|
-
function parseImportStatement(node: TreeSitterNode, results: PythonImport[]): void {
|
|
35
|
-
for (const child of node.children) {
|
|
36
|
-
if (child.type === "dotted_name") {
|
|
37
|
-
results.push({
|
|
38
|
-
module: child.text,
|
|
39
|
-
names: [],
|
|
40
|
-
isRelative: false,
|
|
41
|
-
aliases: undefined,
|
|
42
|
-
line: node.startPosition.row + 1,
|
|
43
|
-
});
|
|
44
|
-
} else if (child.type === "aliased_import") {
|
|
45
|
-
const moduleName = extractDottedName(child);
|
|
46
|
-
const alias = extractAlias(child);
|
|
47
|
-
const aliases = alias ? new Map([[moduleName, alias]]) : undefined;
|
|
48
|
-
results.push({
|
|
49
|
-
module: moduleName,
|
|
50
|
-
names: [],
|
|
51
|
-
isRelative: false,
|
|
52
|
-
aliases,
|
|
53
|
-
line: node.startPosition.row + 1,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Parses `from x import y` or `from .x import y as z` statements. */
|
|
60
|
-
function parseImportFromStatement(node: TreeSitterNode, results: PythonImport[]): void {
|
|
61
|
-
let module = "";
|
|
62
|
-
let isRelative = false;
|
|
63
|
-
const names: string[] = [];
|
|
64
|
-
const aliases = new Map<string, string>();
|
|
65
|
-
|
|
66
|
-
for (const child of node.children) {
|
|
67
|
-
if (child.type === "dotted_name" && !module) {
|
|
68
|
-
module = child.text;
|
|
69
|
-
} else if (child.type === "relative_import") {
|
|
70
|
-
isRelative = true;
|
|
71
|
-
const prefix = child.children.find((c) => c.type === "import_prefix");
|
|
72
|
-
const dottedName = child.children.find((c) => c.type === "dotted_name");
|
|
73
|
-
const dots = prefix?.text ?? ".";
|
|
74
|
-
module = dottedName ? `${dots}${dottedName.text}` : dots;
|
|
75
|
-
} else if (child.type === "dotted_name" && module) {
|
|
76
|
-
names.push(child.text);
|
|
77
|
-
} else if (child.type === "aliased_import") {
|
|
78
|
-
const name = extractDottedName(child);
|
|
79
|
-
names.push(name);
|
|
80
|
-
const alias = extractAlias(child);
|
|
81
|
-
if (alias) {
|
|
82
|
-
aliases.set(name, alias);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
results.push({
|
|
88
|
-
module,
|
|
89
|
-
names,
|
|
90
|
-
isRelative,
|
|
91
|
-
aliases: aliases.size > 0 ? aliases : undefined,
|
|
92
|
-
line: node.startPosition.row + 1,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Extracts the dotted name from an aliased_import node. */
|
|
97
|
-
function extractDottedName(aliasedNode: TreeSitterNode): string {
|
|
98
|
-
const dottedName = aliasedNode.children.find((c) => c.type === "dotted_name");
|
|
99
|
-
return dottedName?.text ?? "";
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Extracts the alias identifier from an aliased_import node. */
|
|
103
|
-
function extractAlias(aliasedNode: TreeSitterNode): string | undefined {
|
|
104
|
-
const children = aliasedNode.children;
|
|
105
|
-
const asIdx = children.findIndex((c) => c.type === "as");
|
|
106
|
-
if (asIdx >= 0) {
|
|
107
|
-
const aliasNode = children[asIdx + 1];
|
|
108
|
-
if (aliasNode?.type === "identifier") {
|
|
109
|
-
return aliasNode.text;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export { extractPythonImports, type PythonImport };
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { Node, NodeKind } from "../../types/graph.ts";
|
|
2
|
-
import type { TreeSitterNode, TreeSitterTree } from "../parser.ts";
|
|
3
|
-
|
|
4
|
-
/** Scope entry tracking the name and whether it's a class scope. */
|
|
5
|
-
type ScopeEntry = { readonly name: string; readonly isClass: boolean };
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Extracts symbols (functions, classes, methods) from a Python AST.
|
|
9
|
-
*
|
|
10
|
-
* @param tree - Parsed tree-sitter tree
|
|
11
|
-
* @param filePath - Relative file path for node ID construction
|
|
12
|
-
* @param _source - Original source text (unused but kept for interface consistency)
|
|
13
|
-
* @returns Extracted nodes with deterministic IDs
|
|
14
|
-
*/
|
|
15
|
-
function extractPythonSymbols(
|
|
16
|
-
tree: TreeSitterTree,
|
|
17
|
-
filePath: string,
|
|
18
|
-
_source: string,
|
|
19
|
-
): readonly Node[] {
|
|
20
|
-
const nodes: Node[] = [];
|
|
21
|
-
visitNode(tree.rootNode, filePath, [], nodes);
|
|
22
|
-
return nodes;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Recursively visits AST nodes to extract symbols.
|
|
27
|
-
* Tracks parent scope for generating nested IDs (e.g., Class.method, outer.inner).
|
|
28
|
-
*/
|
|
29
|
-
function visitNode(
|
|
30
|
-
node: TreeSitterNode,
|
|
31
|
-
filePath: string,
|
|
32
|
-
scopeStack: readonly ScopeEntry[],
|
|
33
|
-
results: Node[],
|
|
34
|
-
): void {
|
|
35
|
-
if (node.type === "decorated_definition") {
|
|
36
|
-
// Decorated definitions wrap a function/class. Find the inner definition
|
|
37
|
-
// and use the decorated_definition's line range (includes decorators).
|
|
38
|
-
const inner = node.children.find(
|
|
39
|
-
(c) => c.type === "function_definition" || c.type === "class_definition",
|
|
40
|
-
);
|
|
41
|
-
if (inner) {
|
|
42
|
-
extractDefinition(inner, filePath, scopeStack, results, node);
|
|
43
|
-
}
|
|
44
|
-
} else if (node.type === "function_definition" || node.type === "class_definition") {
|
|
45
|
-
extractDefinition(node, filePath, scopeStack, results, undefined);
|
|
46
|
-
} else {
|
|
47
|
-
for (const child of node.children) {
|
|
48
|
-
visitNode(child, filePath, scopeStack, results);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Extracts a function or class definition into a Node and recurses into its body.
|
|
55
|
-
* If a decorated parent is provided, uses its line range to include decorator lines.
|
|
56
|
-
*/
|
|
57
|
-
function extractDefinition(
|
|
58
|
-
node: TreeSitterNode,
|
|
59
|
-
filePath: string,
|
|
60
|
-
scopeStack: readonly ScopeEntry[],
|
|
61
|
-
results: Node[],
|
|
62
|
-
decoratedParent: TreeSitterNode | undefined,
|
|
63
|
-
): void {
|
|
64
|
-
const nameNode = node.childForFieldName("name");
|
|
65
|
-
if (!nameNode) return;
|
|
66
|
-
|
|
67
|
-
const name = nameNode.text;
|
|
68
|
-
const isClass = node.type === "class_definition";
|
|
69
|
-
const kind = resolveKind(isClass, scopeStack);
|
|
70
|
-
const qualifiedName = [...scopeStack.map((s) => s.name), name].join(".");
|
|
71
|
-
const id = `${filePath}::${qualifiedName}`;
|
|
72
|
-
|
|
73
|
-
const signature = kind !== "class" ? buildSignature(node, name) : undefined;
|
|
74
|
-
|
|
75
|
-
// Use the decorated parent's line range if it exists (includes decorator lines)
|
|
76
|
-
const rangeNode = decoratedParent ?? node;
|
|
77
|
-
|
|
78
|
-
results.push({
|
|
79
|
-
id,
|
|
80
|
-
kind,
|
|
81
|
-
name,
|
|
82
|
-
file: filePath,
|
|
83
|
-
lineStart: rangeNode.startPosition.row + 1,
|
|
84
|
-
lineEnd: rangeNode.endPosition.row + 1,
|
|
85
|
-
language: "python",
|
|
86
|
-
signature,
|
|
87
|
-
isTest: false,
|
|
88
|
-
metadata: undefined,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Recurse into children with updated scope
|
|
92
|
-
const newScope: readonly ScopeEntry[] = [...scopeStack, { name, isClass }];
|
|
93
|
-
for (const child of node.children) {
|
|
94
|
-
visitNode(child, filePath, newScope, results);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Determines the node kind based on whether this is a class or function, and the parent scope. */
|
|
99
|
-
function resolveKind(isClass: boolean, scopeStack: readonly ScopeEntry[]): NodeKind {
|
|
100
|
-
if (isClass) return "class";
|
|
101
|
-
// A function directly inside a class is a method
|
|
102
|
-
const parentScope = scopeStack.at(-1);
|
|
103
|
-
if (parentScope?.isClass) return "method";
|
|
104
|
-
return "function";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Builds a human-readable signature from a function definition node.
|
|
109
|
-
* Includes parameter names with type annotations and return type.
|
|
110
|
-
*/
|
|
111
|
-
function buildSignature(node: TreeSitterNode, name: string): string {
|
|
112
|
-
const paramsNode = node.childForFieldName("parameters");
|
|
113
|
-
const returnTypeNode = node.childForFieldName("return_type");
|
|
114
|
-
|
|
115
|
-
const params = paramsNode ? paramsNode.text : "()";
|
|
116
|
-
const returnType = returnTypeNode ? ` -> ${returnTypeNode.text}` : "";
|
|
117
|
-
|
|
118
|
-
return `${name}${params}${returnType}`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export { extractPythonSymbols };
|