devlensio 0.6.2 → 1.0.1
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 +89 -31
- package/dist/extractors/detectLanguage.d.ts +2 -0
- package/dist/extractors/detectLanguage.js +33 -0
- package/dist/extractors/index.d.ts +6 -0
- package/dist/extractors/index.js +155 -0
- package/dist/extractors/runner.d.ts +7 -0
- package/dist/extractors/runner.js +194 -0
- package/dist/extractors/types.d.ts +37 -0
- package/dist/extractors/types.js +8 -0
- package/dist/graph/buildLookup.d.ts +1 -0
- package/dist/graph/buildLookup.js +2 -1
- package/dist/graph/edges/callEdges.js +19 -5
- package/dist/graph/edges/callEdges.test.d.ts +1 -0
- package/dist/graph/edges/callEdges.test.js +200 -0
- package/dist/graph/edges/importEdges.js +12 -0
- package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
- package/dist/graph/edges/inheritanceEdges.js +73 -0
- package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
- package/dist/graph/edges/inheritanceEdges.test.js +140 -0
- package/dist/graph/index.js +4 -0
- package/dist/parser/classes.test.d.ts +1 -0
- package/dist/parser/classes.test.js +360 -0
- package/dist/parser/extractors/classes.d.ts +5 -0
- package/dist/parser/extractors/classes.js +241 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.js +7 -1
- package/dist/pipeline/index.d.ts +2 -1
- package/dist/pipeline/index.js +30 -42
- package/dist/scoring/index.js +8 -0
- package/dist/scoring/index.test.js +43 -0
- package/dist/scoring/nodeScorer.js +2 -0
- package/dist/scoring/pruneDisconnected.d.ts +8 -0
- package/dist/scoring/pruneDisconnected.js +66 -0
- package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
- package/dist/scoring/pruneDisconnected.test.js +123 -0
- package/dist/summarizer/prompts.d.ts +1 -1
- package/dist/types.d.ts +5 -5
- package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
- package/extractors/go/build.mjs +43 -0
- package/extractors/go/calls.go +289 -0
- package/extractors/go/contract.go +140 -0
- package/extractors/go/extractor.go +161 -0
- package/extractors/go/fingerprint.go +167 -0
- package/extractors/go/go.mod +3 -0
- package/extractors/go/imports.go +90 -0
- package/extractors/go/inheritance.go +138 -0
- package/extractors/go/lookup.go +106 -0
- package/extractors/go/main.go +57 -0
- package/extractors/go/nodes.go +177 -0
- package/extractors/go/orm_edges.go +277 -0
- package/extractors/go/parser.go +469 -0
- package/extractors/go/routes.go +521 -0
- package/extractors/go/tests.go +45 -0
- package/extractors/go/thirdparty.go +138 -0
- package/extractors/go/typeload.go +178 -0
- package/extractors/go/walker.go +67 -0
- package/extractors/java/build.mjs +90 -0
- package/extractors/java/devlens_java_extractor.jar +0 -0
- package/extractors/java/src/devlens/extractor/Contract.java +171 -0
- package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
- package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
- package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
- package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
- package/extractors/java/src/devlens/extractor/Main.java +66 -0
- package/extractors/java/src/devlens/extractor/Parser.java +522 -0
- package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
- package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
- package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
- package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
- package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
- package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
- package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
- package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
- package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
- package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
- package/extractors/python/devlens_extractors_python/__init__.py +3 -0
- package/extractors/python/devlens_extractors_python/__main__.py +33 -0
- package/extractors/python/devlens_extractors_python/contract.py +94 -0
- package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
- package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
- package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
- package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
- package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
- package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
- package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
- package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
- package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
- package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
- package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
- package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
- package/extractors/python/devlens_extractors_python/extractor.py +107 -0
- package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
- package/extractors/python/devlens_extractors_python/lookup.py +72 -0
- package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
- package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
- package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
- package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
- package/extractors/python/devlens_extractors_python/third_party.py +103 -0
- package/extractors/python/pyproject.toml +16 -0
- package/extractors/python/setup.mjs +54 -0
- package/extractors/rust/Cargo.toml +29 -0
- package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
- package/extractors/rust/build.mjs +85 -0
- package/extractors/rust/src/calls.rs +295 -0
- package/extractors/rust/src/contract.rs +236 -0
- package/extractors/rust/src/enrich.rs +107 -0
- package/extractors/rust/src/extractor.rs +199 -0
- package/extractors/rust/src/fingerprint.rs +238 -0
- package/extractors/rust/src/imports.rs +101 -0
- package/extractors/rust/src/inheritance.rs +233 -0
- package/extractors/rust/src/lookup.rs +226 -0
- package/extractors/rust/src/main.rs +53 -0
- package/extractors/rust/src/module_map.rs +131 -0
- package/extractors/rust/src/nodes.rs +174 -0
- package/extractors/rust/src/orm_edges.rs +125 -0
- package/extractors/rust/src/parser.rs +859 -0
- package/extractors/rust/src/routes.rs +1265 -0
- package/extractors/rust/src/tests.rs +109 -0
- package/extractors/rust/src/thirdparty.rs +114 -0
- package/extractors/rust/src/walker.rs +82 -0
- package/package.json +22 -4
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""CALLS edge resolution — resolves metadata.calls names to node ids.
|
|
2
|
+
|
|
3
|
+
Consumes the shared LookupMaps (lookup.py). Resolution ladder:
|
|
4
|
+
self./cls. → same-class method (exact dotted name)
|
|
5
|
+
ClassName.method → same-file dotted lookup
|
|
6
|
+
alias.member → lazy [pip]/pkg::member node
|
|
7
|
+
module-alias.walk → submodule walk via module_map
|
|
8
|
+
plain name → global nodes_by_name → import alias refinement
|
|
9
|
+
Unknown/builtin names resolve to nothing (no edge) — metadata.calls keeps
|
|
10
|
+
them for LLM context.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..lookup import LookupMaps, closest_by_path, module_name
|
|
15
|
+
from ..third_party import ThirdPartyRegistry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_in_file(lookup: LookupMaps, rel: str, name: str) -> str | None:
|
|
19
|
+
return lookup.nodes_by_file.get(rel, {}).get(name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _walk_module(lookup: LookupMaps, alias_target: str, rest: str) -> str | None:
|
|
23
|
+
"""'user.User' with alias → file::models/__init__.py: consume submodule
|
|
24
|
+
segments via module_map, then look up the symbol in the final file."""
|
|
25
|
+
current_rel = alias_target[len("file::"):]
|
|
26
|
+
current_module = module_name(current_rel)
|
|
27
|
+
segments = rest.split(".")
|
|
28
|
+
consumed = 0
|
|
29
|
+
|
|
30
|
+
while consumed < len(segments):
|
|
31
|
+
candidate = f"{current_module}.{segments[consumed]}"
|
|
32
|
+
nxt = lookup.module_map.get(candidate)
|
|
33
|
+
if not nxt:
|
|
34
|
+
break
|
|
35
|
+
current_rel, current_module = nxt, candidate
|
|
36
|
+
consumed += 1
|
|
37
|
+
|
|
38
|
+
return _find_in_file(lookup, current_rel, ".".join(segments[consumed:]))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve(called: str, node: dict, lookup: LookupMaps,
|
|
42
|
+
registry: ThirdPartyRegistry) -> str | None:
|
|
43
|
+
rel = node["filePath"]
|
|
44
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
45
|
+
|
|
46
|
+
# ── 1. self.x() / cls.x() → method of the enclosing class ──
|
|
47
|
+
if called.startswith(("self.", "cls.")):
|
|
48
|
+
parent_class = node["metadata"].get("parentClass")
|
|
49
|
+
if parent_class:
|
|
50
|
+
return _find_in_file(lookup, rel,
|
|
51
|
+
f"{parent_class}.{called.split('.', 1)[1]}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# ── 2. dotted name ──
|
|
55
|
+
if "." in called:
|
|
56
|
+
root, rest = called.split(".", 1)
|
|
57
|
+
|
|
58
|
+
# 2a. ClassName.method defined in the same file
|
|
59
|
+
if root in lookup.nodes_by_file.get(rel, {}):
|
|
60
|
+
return _find_in_file(lookup, rel, called)
|
|
61
|
+
|
|
62
|
+
# 2b. member access through an import alias
|
|
63
|
+
alias_target = symbols.get(root)
|
|
64
|
+
if not alias_target:
|
|
65
|
+
return None
|
|
66
|
+
if alias_target.startswith("[pip]/"):
|
|
67
|
+
# third-party chain: requests.get → lazily create [pip]/requests::get
|
|
68
|
+
pkg = alias_target.split("/", 1)[1]
|
|
69
|
+
if "::" in pkg:
|
|
70
|
+
# chain root is already a named import — current_app.logger.info
|
|
71
|
+
# → [pip]/flask::current_app (the meaningful hop)
|
|
72
|
+
return alias_target
|
|
73
|
+
member = registry.method_node(pkg, rest.split(".")[0])
|
|
74
|
+
return member["id"] if member else None
|
|
75
|
+
if alias_target.startswith("file::"):
|
|
76
|
+
return _walk_module(lookup, alias_target, rest)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# ── 3. plain name — global lookup, closest-by-path on collision ──
|
|
80
|
+
candidates = lookup.nodes_by_name.get(called)
|
|
81
|
+
if candidates:
|
|
82
|
+
if len(candidates) == 1:
|
|
83
|
+
return candidates[0][0]
|
|
84
|
+
return closest_by_path(candidates, rel)
|
|
85
|
+
|
|
86
|
+
# ── 4. import alias — refine file targets to the actual symbol ──
|
|
87
|
+
alias_target = symbols.get(called)
|
|
88
|
+
if alias_target:
|
|
89
|
+
if alias_target.startswith("file::"):
|
|
90
|
+
return _find_in_file(lookup, alias_target[len("file::"):], called)
|
|
91
|
+
return alias_target # "[pip]/..." or "path.py::Symbol"
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_calls(lookup: LookupMaps, registry: ThirdPartyRegistry) -> list[dict]:
|
|
96
|
+
"""Resolve every FUNCTION/METHOD node's calls → CALLS edges.
|
|
97
|
+
Also writes metadata.resolvedCalls back onto nodes (JS callEdges.ts mirror)."""
|
|
98
|
+
edges: list[dict] = []
|
|
99
|
+
|
|
100
|
+
for node in lookup.node_by_id.values():
|
|
101
|
+
if node["type"] not in ("FUNCTION", "METHOD"):
|
|
102
|
+
continue
|
|
103
|
+
resolved: list[dict] = []
|
|
104
|
+
for called in node["metadata"].get("calls", []):
|
|
105
|
+
target = _resolve(called, node, lookup, registry)
|
|
106
|
+
if target and target != node["id"]:
|
|
107
|
+
edges.append({"from": node["id"], "to": target, "type": "CALLS",
|
|
108
|
+
"metadata": {"calledName": called}})
|
|
109
|
+
resolved.append({"name": called, "nodeId": target})
|
|
110
|
+
node["metadata"]["resolvedCalls"] = resolved
|
|
111
|
+
|
|
112
|
+
return edges
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Metadata enrichment — pydantic isSchema + celery isTask (Step 9).
|
|
2
|
+
|
|
3
|
+
Resolves parse-time facts (metadata.bases, metadata.decorators) via symbol
|
|
4
|
+
maps. No AST re-walks. IMPORTANT: each marker set needs its OWN memo dict —
|
|
5
|
+
a cached 'miss' from the model scan must not short-circuit the schema scan.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..lookup import LookupMaps
|
|
10
|
+
from .orm_edges import _base_chain_hit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _is_celery_task(decorators: list[str], lookup: LookupMaps, rel: str) -> bool:
|
|
14
|
+
"""@shared_task (celery's own name) · @app.task / @celery_app.task
|
|
15
|
+
(Celery instance pattern) · bare @task (requires celery import proof)."""
|
|
16
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
17
|
+
for dec in decorators:
|
|
18
|
+
root = dec.split("(")[0].strip() # strip args: "app.task()" → "app.task"
|
|
19
|
+
parts = root.split(".")
|
|
20
|
+
if not parts:
|
|
21
|
+
continue
|
|
22
|
+
if parts[-1] == "shared_task":
|
|
23
|
+
return True # celery-specific decorator name
|
|
24
|
+
if parts[-1] == "task" and len(parts) >= 2:
|
|
25
|
+
return True # @app.task — Celery instance pattern
|
|
26
|
+
if parts[-1] == "task" and "celery" in symbols.get("task", ""):
|
|
27
|
+
return True # bare @task, imported from celery
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def enrich_metadata(lookup: LookupMaps) -> None:
|
|
32
|
+
"""Mark pydantic schemas (CLASS nodes) and celery tasks (FUNCTION/METHOD).
|
|
33
|
+
|
|
34
|
+
Pure metadata mutation — no new nodes or edges."""
|
|
35
|
+
memo: dict[str, str | None] = {} # own memo — marker-set isolation
|
|
36
|
+
|
|
37
|
+
for node in lookup.node_by_id.values():
|
|
38
|
+
if node["type"] == "CLASS":
|
|
39
|
+
if _base_chain_hit(node, lookup, memo, ("pydantic", "sqlmodel")):
|
|
40
|
+
node["metadata"]["isSchema"] = True
|
|
41
|
+
if node["type"] in ("FUNCTION", "METHOD") \
|
|
42
|
+
and _is_celery_task(node["metadata"].get("decorators", []),
|
|
43
|
+
lookup, node["filePath"]):
|
|
44
|
+
node["metadata"]["isTask"] = True
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Import resolution — the sys.path simulation.
|
|
2
|
+
|
|
3
|
+
Resolves every import to a local file (IMPORTS edge) or a third-party
|
|
4
|
+
package node ([pip]/...). Also writes the per-file symbol map
|
|
5
|
+
(local name → target node id) into lookup.symbol_maps, which CALLS
|
|
6
|
+
resolution and route detection consume.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..lookup import LookupMaps
|
|
14
|
+
from ..parser import ParsedFile
|
|
15
|
+
from ..third_party import STDLIB_MODULES, ThirdPartyRegistry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ImportItem:
|
|
20
|
+
module: str | None # dotted module as written ("models.user", None for bare relative)
|
|
21
|
+
name: str | None # imported name for from-imports (None for `import x`)
|
|
22
|
+
alias: str # local binding ("User", "as_name", or top package for `import a.b`)
|
|
23
|
+
level: int # 0 = absolute, 1 = ".", 2 = ".."
|
|
24
|
+
is_star: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_imports(tree: ast.Module) -> list[ImportItem]:
|
|
28
|
+
items: list[ImportItem] = []
|
|
29
|
+
for node in ast.walk(tree):
|
|
30
|
+
if isinstance(node, ast.Import):
|
|
31
|
+
for alias in node.names:
|
|
32
|
+
items.append(ImportItem(
|
|
33
|
+
module=alias.name, name=None,
|
|
34
|
+
alias=alias.asname or alias.name.split(".")[0],
|
|
35
|
+
level=0, is_star=False))
|
|
36
|
+
elif isinstance(node, ast.ImportFrom):
|
|
37
|
+
for alias in node.names:
|
|
38
|
+
items.append(ImportItem(
|
|
39
|
+
module=node.module, name=None if alias.name == "*" else alias.name,
|
|
40
|
+
alias=alias.asname or alias.name,
|
|
41
|
+
level=node.level, is_star=alias.name == "*"))
|
|
42
|
+
return items
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_module_map(rel_paths: list[str]) -> dict[str, str]:
|
|
46
|
+
"""dotted module name → relative file path.
|
|
47
|
+
|
|
48
|
+
'models/user.py' → 'models.user' · 'models/__init__.py' → 'models'
|
|
49
|
+
src/ layout: 'src/models/user.py' also registers as 'models.user'.
|
|
50
|
+
First registration wins (deterministic)."""
|
|
51
|
+
module_map: dict[str, str] = {}
|
|
52
|
+
|
|
53
|
+
def register(rel: str) -> None:
|
|
54
|
+
parts = rel.split("/")
|
|
55
|
+
if parts[-1] == "__init__.py":
|
|
56
|
+
module = ".".join(parts[:-1])
|
|
57
|
+
else:
|
|
58
|
+
module = ".".join(parts[:-1] + [parts[-1][:-3]])
|
|
59
|
+
module_map.setdefault(module, rel)
|
|
60
|
+
|
|
61
|
+
for rel in rel_paths:
|
|
62
|
+
register(rel)
|
|
63
|
+
if rel.startswith("src/"):
|
|
64
|
+
register(rel[4:])
|
|
65
|
+
|
|
66
|
+
return module_map
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _current_package(rel_path: str) -> str:
|
|
70
|
+
"""Dotted package of a file: 'models/sub/views.py' → 'models.sub'."""
|
|
71
|
+
return ".".join(rel_path.split("/")[:-1])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_file_imports(parsed: ParsedFile, lookup: LookupMaps,
|
|
75
|
+
registry: ThirdPartyRegistry) -> tuple[list[dict], list[str]]:
|
|
76
|
+
"""Resolve one file's imports.
|
|
77
|
+
|
|
78
|
+
Returns (edges, imported_local_paths). Writes into lookup.symbol_maps
|
|
79
|
+
(local alias → target node id), used by CALLS + route resolution.
|
|
80
|
+
"""
|
|
81
|
+
rel = parsed.rel_path
|
|
82
|
+
file_id = parsed.file_node["id"]
|
|
83
|
+
file_symbols: dict[str, str] = {}
|
|
84
|
+
edges: list[dict] = []
|
|
85
|
+
imported_paths: list[str] = []
|
|
86
|
+
module_map = lookup.module_map
|
|
87
|
+
symbol_map = lookup.symbol_maps
|
|
88
|
+
|
|
89
|
+
for item in extract_imports(parsed.tree):
|
|
90
|
+
# ── 1. Expand relative imports to an absolute module name ──
|
|
91
|
+
if item.level == 0:
|
|
92
|
+
module = item.module or ""
|
|
93
|
+
else:
|
|
94
|
+
base_parts = _current_package(rel).split(".")
|
|
95
|
+
base_parts = base_parts[: max(0, len(base_parts) - (item.level - 1))]
|
|
96
|
+
base = ".".join(base_parts)
|
|
97
|
+
module = f"{base}.{item.module}" if item.module else base
|
|
98
|
+
|
|
99
|
+
# `from . import x` — x may be a submodule (base.x) or a symbol in base
|
|
100
|
+
if item.name and item.module is None:
|
|
101
|
+
sub = module_map.get(f"{module}.{item.name}")
|
|
102
|
+
if sub:
|
|
103
|
+
file_symbols[item.alias] = f"file::{sub}"
|
|
104
|
+
imported_paths.append(sub)
|
|
105
|
+
edges.append({"from": file_id, "to": f"file::{sub}", "type": "IMPORTS",
|
|
106
|
+
"metadata": {"importPath": module, "importedName": item.name}})
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# ── 2. Try to resolve inside the repo ──
|
|
110
|
+
target_rel = module_map.get(module)
|
|
111
|
+
if target_rel:
|
|
112
|
+
target_file_id = f"file::{target_rel}"
|
|
113
|
+
imported_paths.append(target_rel)
|
|
114
|
+
|
|
115
|
+
if item.is_star:
|
|
116
|
+
# Can't know which names — edge to the module file, tag ambiguous
|
|
117
|
+
edges.append({"from": file_id, "to": target_file_id, "type": "IMPORTS",
|
|
118
|
+
"metadata": {"importPath": module, "isStarImport": True}})
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if item.name:
|
|
122
|
+
# from X import Y — Y may be a submodule (X/Y.py) or a symbol in X
|
|
123
|
+
sub = module_map.get(f"{module}.{item.name}")
|
|
124
|
+
if sub:
|
|
125
|
+
target_file_id = f"file::{sub}"
|
|
126
|
+
imported_paths.append(sub)
|
|
127
|
+
|
|
128
|
+
file_symbols[item.alias] = target_file_id
|
|
129
|
+
edges.append({"from": file_id, "to": target_file_id, "type": "IMPORTS",
|
|
130
|
+
"metadata": {"importPath": module,
|
|
131
|
+
"importedName": item.name}})
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# ── 3. Not local → stdlib (skip) or third-party (node) ──
|
|
135
|
+
if item.level > 0:
|
|
136
|
+
continue # relative import that failed locally — skip silently
|
|
137
|
+
|
|
138
|
+
pkg = module.split(".")[0]
|
|
139
|
+
if not module or pkg in STDLIB_MODULES:
|
|
140
|
+
continue # stdlib / builtin — noise, no edge, no node
|
|
141
|
+
|
|
142
|
+
if item.name and not item.is_star:
|
|
143
|
+
target = registry.method_node(pkg, item.name)
|
|
144
|
+
else:
|
|
145
|
+
target = registry.package_node(pkg)
|
|
146
|
+
if target is None:
|
|
147
|
+
continue # third-party not permitted (options.includedThirdPartyLibs)
|
|
148
|
+
|
|
149
|
+
file_symbols[item.alias] = target["id"]
|
|
150
|
+
edges.append({"from": file_id, "to": target["id"], "type": "IMPORTS",
|
|
151
|
+
"metadata": {"importPath": pkg, "isThirdParty": True,
|
|
152
|
+
"importedName": item.name}})
|
|
153
|
+
|
|
154
|
+
symbol_map[rel] = file_symbols
|
|
155
|
+
return edges, sorted(set(imported_paths))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Inheritance edges — EXTENDS / IMPLEMENTS (class → base).
|
|
2
|
+
|
|
3
|
+
Python's interface mechanism is ABC/Protocol inheritance, so:
|
|
4
|
+
- base resolves to a CONCRETE class (or permitted [pip] node) → EXTENDS
|
|
5
|
+
- base resolves to an ABSTRACT class (chain hits ABC/Protocol/ABCMeta)
|
|
6
|
+
→ IMPLEMENTS
|
|
7
|
+
Bases that resolve to nothing (stdlib, unresolved imports) get no edge —
|
|
8
|
+
an edge must always point at a real node (never dangling).
|
|
9
|
+
|
|
10
|
+
Engine note: CodeEdgeType already defines EXTENDS + IMPLEMENTS ("NEW
|
|
11
|
+
LANGUAGES") — Python is the first extractor to emit them.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ..lookup import LookupMaps
|
|
16
|
+
|
|
17
|
+
ABSTRACT_MARKERS = {"ABC", "ABCMeta", "Protocol", "RuntimeProtocol"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_abstract(node: dict, lookup: LookupMaps, memo: dict,
|
|
21
|
+
depth: int = 0) -> bool:
|
|
22
|
+
"""Class inherits (transitively) from ABC/Protocol/ABCMeta."""
|
|
23
|
+
if depth > 4:
|
|
24
|
+
return False
|
|
25
|
+
node_id = node["id"]
|
|
26
|
+
if node_id in memo:
|
|
27
|
+
return memo[node_id]
|
|
28
|
+
|
|
29
|
+
rel = node["filePath"]
|
|
30
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
31
|
+
abstract = False
|
|
32
|
+
|
|
33
|
+
for base in node["metadata"].get("bases", []):
|
|
34
|
+
parts = base.split(".")
|
|
35
|
+
root = parts[-1]
|
|
36
|
+
if root in ABSTRACT_MARKERS:
|
|
37
|
+
abstract = True
|
|
38
|
+
break
|
|
39
|
+
alias = symbols.get(parts[0], "")
|
|
40
|
+
target = None
|
|
41
|
+
if alias.startswith("file::"):
|
|
42
|
+
target = lookup.nodes_by_file.get(alias[len("file::"):], {}).get(root)
|
|
43
|
+
if not target:
|
|
44
|
+
target = lookup.nodes_by_file.get(rel, {}).get(root)
|
|
45
|
+
if target:
|
|
46
|
+
child = lookup.node_by_id.get(target)
|
|
47
|
+
if child and child["type"] == "CLASS" \
|
|
48
|
+
and _is_abstract(child, lookup, memo, depth + 1):
|
|
49
|
+
abstract = True
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
memo[node_id] = abstract
|
|
53
|
+
return abstract
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_inheritance(lookup: LookupMaps, registry) -> list[dict]:
|
|
57
|
+
"""EXTENDS / IMPLEMENTS edges for every CLASS node's bases."""
|
|
58
|
+
edges: list[dict] = []
|
|
59
|
+
abstract_memo: dict[str, bool] = {}
|
|
60
|
+
|
|
61
|
+
for node in lookup.node_by_id.values():
|
|
62
|
+
if node["type"] != "CLASS":
|
|
63
|
+
continue
|
|
64
|
+
rel = node["filePath"]
|
|
65
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
66
|
+
|
|
67
|
+
for base in node["metadata"].get("bases", []):
|
|
68
|
+
parts = base.split(".")
|
|
69
|
+
root = parts[-1]
|
|
70
|
+
alias = symbols.get(parts[0], "")
|
|
71
|
+
|
|
72
|
+
# third-party base → [pip] node (only when permitted)
|
|
73
|
+
if alias.startswith("[pip]/"):
|
|
74
|
+
target_id = alias
|
|
75
|
+
# imported module base → local class in that file
|
|
76
|
+
elif alias.startswith("file::"):
|
|
77
|
+
target_id = lookup.nodes_by_file.get(
|
|
78
|
+
alias[len("file::"):], {}).get(root)
|
|
79
|
+
# same-file base
|
|
80
|
+
else:
|
|
81
|
+
target_id = lookup.nodes_by_file.get(rel, {}).get(root)
|
|
82
|
+
|
|
83
|
+
if not target_id or target_id == node["id"]:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
abstract = False
|
|
87
|
+
target = lookup.node_by_id.get(target_id)
|
|
88
|
+
if target and target["type"] == "CLASS":
|
|
89
|
+
abstract = _is_abstract(target, lookup, abstract_memo)
|
|
90
|
+
|
|
91
|
+
edges.append({
|
|
92
|
+
"from": node["id"], "to": target_id,
|
|
93
|
+
"type": "IMPLEMENTS" if abstract else "EXTENDS",
|
|
94
|
+
"metadata": {"baseName": root},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return edges
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""ORM data-layer edges — model detection + READS_FROM/WRITES_TO.
|
|
2
|
+
|
|
3
|
+
Optimized:
|
|
4
|
+
- ORM call patterns were collected at PARSE time (metadata.ormOps) —
|
|
5
|
+
this module only RESOLVES names via lookup maps. No AST re-walks.
|
|
6
|
+
- Model detection: ONE memoized pass over lookup.node_by_id; base
|
|
7
|
+
chains resolved through symbol maps + local class links (depth-capped).
|
|
8
|
+
- All resolutions are dict lookups (symbol maps, nodes_by_name).
|
|
9
|
+
|
|
10
|
+
Direction: consumer → store (mirrors stateEdges.ts) — the function/class
|
|
11
|
+
that reads or writes points AT the model CLASS node.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
|
|
17
|
+
from ..lookup import LookupMaps, closest_by_path
|
|
18
|
+
|
|
19
|
+
MAX_BASE_DEPTH = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _flask_sqlalchemy_instances(parsed_files: list, lookup: LookupMaps) -> set:
|
|
23
|
+
"""{(rel_path, var)} for `db = SQLAlchemy()` (Flask-SQLAlchemy) — enables
|
|
24
|
+
`class User(db.Model)` base resolution through a local instance."""
|
|
25
|
+
instances: set = set()
|
|
26
|
+
for pf in parsed_files:
|
|
27
|
+
symbols = lookup.symbol_maps.get(pf.rel_path, {})
|
|
28
|
+
if not any("flask_sqlalchemy" in v for v in symbols.values()):
|
|
29
|
+
continue
|
|
30
|
+
for stmt in pf.tree.body:
|
|
31
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call) \
|
|
32
|
+
and isinstance(stmt.value.func, ast.Name) \
|
|
33
|
+
and stmt.value.func.id == "SQLAlchemy":
|
|
34
|
+
for t in stmt.targets:
|
|
35
|
+
if isinstance(t, ast.Name):
|
|
36
|
+
instances.add((pf.rel_path, t.id))
|
|
37
|
+
return instances
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _marker_hit(alias: str, markers: tuple[str, ...]) -> str | None:
|
|
41
|
+
"""Marker match against a resolved [pip]/ target.
|
|
42
|
+
|
|
43
|
+
'django' uses a PREDICATE (not substring) to avoid false positives like
|
|
44
|
+
django_filters::FilterSet: the target must actually be a django model-ish
|
|
45
|
+
thing (django.db.models, a *.models module, or ::Model/::models member).
|
|
46
|
+
Other markers stay substring-based (single-package ecosystems).
|
|
47
|
+
"""
|
|
48
|
+
for marker in markers:
|
|
49
|
+
if marker == "django":
|
|
50
|
+
if any(p in alias for p in
|
|
51
|
+
("django.db.models", "::Model", ".models", "::models")):
|
|
52
|
+
return "django"
|
|
53
|
+
elif marker in alias:
|
|
54
|
+
return marker
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _base_chain_hit(node: dict, lookup: LookupMaps, memo: dict,
|
|
59
|
+
markers: tuple[str, ...], depth: int = 0,
|
|
60
|
+
flask_db: frozenset = frozenset()) -> str | None:
|
|
61
|
+
"""Walk the base chain; return the first marker matched in [pip]/ aliases.
|
|
62
|
+
|
|
63
|
+
markers are substrings tested against resolved third-party targets,
|
|
64
|
+
e.g. ('django', 'sqlalchemy', 'sqlmodel') for models or ('pydantic',) for
|
|
65
|
+
schemas. Handles local chains: class Base(DeclarativeBase) → class User(Base),
|
|
66
|
+
and Flask-SQLAlchemy: class User(db.Model) with db = SQLAlchemy().
|
|
67
|
+
NOTE: each marker set needs its OWN memo dict — a cached miss from one
|
|
68
|
+
scan must not short-circuit a different scan (memo-isolation).
|
|
69
|
+
"""
|
|
70
|
+
if depth > MAX_BASE_DEPTH:
|
|
71
|
+
return None
|
|
72
|
+
node_id = node["id"]
|
|
73
|
+
if node_id in memo:
|
|
74
|
+
return memo[node_id]
|
|
75
|
+
|
|
76
|
+
rel = node["filePath"]
|
|
77
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
78
|
+
result = None
|
|
79
|
+
|
|
80
|
+
for base in node["metadata"].get("bases", []):
|
|
81
|
+
parts = base.split(".")
|
|
82
|
+
first, last = parts[0], parts[-1]
|
|
83
|
+
|
|
84
|
+
# Flask-SQLAlchemy: db.Model where db = SQLAlchemy() in this file
|
|
85
|
+
if last == "Model" and (rel, first) in flask_db:
|
|
86
|
+
result = "sqlalchemy"
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
alias = symbols.get(first, "")
|
|
90
|
+
|
|
91
|
+
if alias.startswith("[pip]/"):
|
|
92
|
+
result = _marker_hit(alias, markers)
|
|
93
|
+
if result:
|
|
94
|
+
break
|
|
95
|
+
continue
|
|
96
|
+
# local class (same file or imported file) → recurse the chain
|
|
97
|
+
target = None
|
|
98
|
+
if alias.startswith("file::"):
|
|
99
|
+
target_rel = alias[len("file::"):]
|
|
100
|
+
# db.Model with db = SQLAlchemy() in the imported file
|
|
101
|
+
if last == "Model" and (target_rel, first) in flask_db:
|
|
102
|
+
result = "sqlalchemy"
|
|
103
|
+
break
|
|
104
|
+
target = lookup.nodes_by_file.get(target_rel, {}).get(last)
|
|
105
|
+
if not target:
|
|
106
|
+
target = lookup.nodes_by_file.get(rel, {}).get(last)
|
|
107
|
+
if target:
|
|
108
|
+
child = lookup.node_by_id.get(target)
|
|
109
|
+
if child and child["type"] == "CLASS":
|
|
110
|
+
result = _base_chain_hit(child, lookup, memo, markers,
|
|
111
|
+
depth + 1, flask_db)
|
|
112
|
+
if result:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
memo[node_id] = result
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def detect_models(lookup: LookupMaps,
|
|
120
|
+
parsed_files: list | None = None) -> dict[str, str]:
|
|
121
|
+
"""Mark model CLASS nodes (metadata.isModel/modelType); return name → id map."""
|
|
122
|
+
models: dict[str, str] = {}
|
|
123
|
+
memo: dict[str, str | None] = {}
|
|
124
|
+
flask_db = frozenset(_flask_sqlalchemy_instances(parsed_files or [], lookup))
|
|
125
|
+
|
|
126
|
+
for node in lookup.node_by_id.values():
|
|
127
|
+
if node["type"] != "CLASS":
|
|
128
|
+
continue
|
|
129
|
+
kind = _base_chain_hit(node, lookup, memo,
|
|
130
|
+
("django", "sqlalchemy", "sqlmodel"),
|
|
131
|
+
flask_db=flask_db)
|
|
132
|
+
if kind:
|
|
133
|
+
node["metadata"]["isModel"] = True
|
|
134
|
+
node["metadata"]["modelType"] = kind
|
|
135
|
+
models.setdefault(node["name"], node["id"])
|
|
136
|
+
|
|
137
|
+
return models
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_name(lookup: LookupMaps, rel: str, name: str) -> str | None:
|
|
141
|
+
"""Name → node id via symbol map (refined), then global closest-by-path."""
|
|
142
|
+
alias = lookup.symbol_maps.get(rel, {}).get(name)
|
|
143
|
+
if alias:
|
|
144
|
+
if alias.startswith("file::"):
|
|
145
|
+
return lookup.nodes_by_file.get(alias[len("file::"):], {}).get(name)
|
|
146
|
+
return alias
|
|
147
|
+
candidates = lookup.nodes_by_name.get(name)
|
|
148
|
+
if candidates:
|
|
149
|
+
return candidates[0][0] if len(candidates) == 1 else closest_by_path(candidates, rel)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _match_instance_var(var: str, models: dict[str, str]) -> str | None:
|
|
154
|
+
"""user.save() / session.add(user) — var name ↔ model name heuristic.
|
|
155
|
+
Exact → singularized → case-insensitive (user ↔ User, users ↔ User)."""
|
|
156
|
+
for key in (var, var.rstrip("s")):
|
|
157
|
+
if key in models:
|
|
158
|
+
return models[key]
|
|
159
|
+
lower = var.lower()
|
|
160
|
+
for name, node_id in models.items():
|
|
161
|
+
if name.lower() == lower or name.lower() == lower.rstrip("s"):
|
|
162
|
+
return node_id
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_orm_target(op: dict, node: dict, lookup: LookupMaps,
|
|
167
|
+
models: dict[str, str]) -> str | None:
|
|
168
|
+
rel = node["filePath"]
|
|
169
|
+
|
|
170
|
+
# bare sqlalchemy statements (select/insert/update/delete) — verify the
|
|
171
|
+
# function name is actually sqlalchemy's, else skip (avoid false positives
|
|
172
|
+
# on generic update()/delete() helpers)
|
|
173
|
+
if op.get("root"):
|
|
174
|
+
alias = lookup.symbol_maps.get(rel, {}).get(op["root"], "")
|
|
175
|
+
if "sqlalchemy" not in alias:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
if op.get("target"):
|
|
179
|
+
target = models.get(op["target"]) # fast path: name IS a model
|
|
180
|
+
if not target:
|
|
181
|
+
target = _resolve_name(lookup, rel, op["target"])
|
|
182
|
+
if target:
|
|
183
|
+
tnode = lookup.node_by_id.get(target)
|
|
184
|
+
if tnode and tnode["metadata"].get("isModel"):
|
|
185
|
+
return target
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
if op.get("arg"): # instance heuristic
|
|
189
|
+
return _match_instance_var(op["arg"], models)
|
|
190
|
+
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def resolve_orm_edges(lookup: LookupMaps, models: dict[str, str]) -> list[dict]:
|
|
195
|
+
"""metadata.ormOps → READS_FROM/WRITES_TO edges; serializer Meta.model links."""
|
|
196
|
+
edges: list[dict] = []
|
|
197
|
+
|
|
198
|
+
for node in lookup.node_by_id.values():
|
|
199
|
+
if node["type"] not in ("FUNCTION", "METHOD", "CLASS"):
|
|
200
|
+
continue
|
|
201
|
+
for op in node["metadata"].get("ormOps", []):
|
|
202
|
+
target = _resolve_orm_target(op, node, lookup, models)
|
|
203
|
+
if not target or target == node["id"]:
|
|
204
|
+
continue
|
|
205
|
+
edges.append({
|
|
206
|
+
"from": node["id"],
|
|
207
|
+
"to": target,
|
|
208
|
+
"type": "READS_FROM" if op["kind"] == "read" else "WRITES_TO",
|
|
209
|
+
"metadata": {"ormType": op["pattern"],
|
|
210
|
+
"heuristic": bool(op.get("arg"))},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
# DRF serializer: class Meta: model = User → serializer READS_FROM model
|
|
214
|
+
for node in lookup.node_by_id.values():
|
|
215
|
+
if node["type"] != "CLASS":
|
|
216
|
+
continue
|
|
217
|
+
linked = node["metadata"].get("linkedModel")
|
|
218
|
+
if linked and linked in models and models[linked] != node["id"]:
|
|
219
|
+
edges.append({
|
|
220
|
+
"from": node["id"], "to": models[linked],
|
|
221
|
+
"type": "READS_FROM",
|
|
222
|
+
"metadata": {"ormType": "serializer", "callPattern": "meta_model"},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
return edges
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Route detection — split by framework style:
|
|
2
|
+
|
|
3
|
+
decorators.py FastAPI/Flask decorator routes (+ include_router prefixes)
|
|
4
|
+
django_urls.py Django urlpatterns (path/re_path/url + include recursion)
|
|
5
|
+
drf.py DRF routers (DefaultRouter().register → CRUD expansion)
|
|
6
|
+
common.py shared builders (RouteResult, ROUTE node, HANDLES edge)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ...lookup import LookupMaps
|
|
11
|
+
from .common import RouteResult
|
|
12
|
+
from .decorators import detect_decorator_routes
|
|
13
|
+
from .django_urls import detect_django_urlpatterns
|
|
14
|
+
from .drf import detect_drf_routers
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_routes(parsed_files: list, lookup: LookupMaps, fingerprint) -> RouteResult:
|
|
18
|
+
"""Detect FastAPI/Flask decorator routes + Django urlpatterns + DRF routers.
|
|
19
|
+
Self-describing: runs whichever patterns the code actually uses;
|
|
20
|
+
a repo with neither produces an empty RouteResult (never crashes)."""
|
|
21
|
+
result = RouteResult()
|
|
22
|
+
detect_decorator_routes(parsed_files, lookup, fingerprint, result)
|
|
23
|
+
detect_django_urlpatterns(parsed_files, lookup, result)
|
|
24
|
+
detect_drf_routers(parsed_files, lookup, result)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["detect_routes", "RouteResult"]
|