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.
Files changed (129) hide show
  1. package/README.md +89 -31
  2. package/dist/extractors/detectLanguage.d.ts +2 -0
  3. package/dist/extractors/detectLanguage.js +33 -0
  4. package/dist/extractors/index.d.ts +6 -0
  5. package/dist/extractors/index.js +155 -0
  6. package/dist/extractors/runner.d.ts +7 -0
  7. package/dist/extractors/runner.js +194 -0
  8. package/dist/extractors/types.d.ts +37 -0
  9. package/dist/extractors/types.js +8 -0
  10. package/dist/graph/buildLookup.d.ts +1 -0
  11. package/dist/graph/buildLookup.js +2 -1
  12. package/dist/graph/edges/callEdges.js +19 -5
  13. package/dist/graph/edges/callEdges.test.d.ts +1 -0
  14. package/dist/graph/edges/callEdges.test.js +200 -0
  15. package/dist/graph/edges/importEdges.js +12 -0
  16. package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
  17. package/dist/graph/edges/inheritanceEdges.js +73 -0
  18. package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
  19. package/dist/graph/edges/inheritanceEdges.test.js +140 -0
  20. package/dist/graph/index.js +4 -0
  21. package/dist/parser/classes.test.d.ts +1 -0
  22. package/dist/parser/classes.test.js +360 -0
  23. package/dist/parser/extractors/classes.d.ts +5 -0
  24. package/dist/parser/extractors/classes.js +241 -0
  25. package/dist/parser/index.d.ts +2 -0
  26. package/dist/parser/index.js +7 -1
  27. package/dist/pipeline/index.d.ts +2 -1
  28. package/dist/pipeline/index.js +30 -42
  29. package/dist/scoring/index.js +8 -0
  30. package/dist/scoring/index.test.js +43 -0
  31. package/dist/scoring/nodeScorer.js +2 -0
  32. package/dist/scoring/pruneDisconnected.d.ts +8 -0
  33. package/dist/scoring/pruneDisconnected.js +66 -0
  34. package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
  35. package/dist/scoring/pruneDisconnected.test.js +123 -0
  36. package/dist/summarizer/prompts.d.ts +1 -1
  37. package/dist/types.d.ts +5 -5
  38. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  39. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  40. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  41. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  42. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  43. package/extractors/go/build.mjs +43 -0
  44. package/extractors/go/calls.go +289 -0
  45. package/extractors/go/contract.go +140 -0
  46. package/extractors/go/extractor.go +161 -0
  47. package/extractors/go/fingerprint.go +167 -0
  48. package/extractors/go/go.mod +3 -0
  49. package/extractors/go/imports.go +90 -0
  50. package/extractors/go/inheritance.go +138 -0
  51. package/extractors/go/lookup.go +106 -0
  52. package/extractors/go/main.go +57 -0
  53. package/extractors/go/nodes.go +177 -0
  54. package/extractors/go/orm_edges.go +277 -0
  55. package/extractors/go/parser.go +469 -0
  56. package/extractors/go/routes.go +521 -0
  57. package/extractors/go/tests.go +45 -0
  58. package/extractors/go/thirdparty.go +138 -0
  59. package/extractors/go/typeload.go +178 -0
  60. package/extractors/go/walker.go +67 -0
  61. package/extractors/java/build.mjs +90 -0
  62. package/extractors/java/devlens_java_extractor.jar +0 -0
  63. package/extractors/java/src/devlens/extractor/Contract.java +171 -0
  64. package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
  65. package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
  66. package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
  67. package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
  68. package/extractors/java/src/devlens/extractor/Main.java +66 -0
  69. package/extractors/java/src/devlens/extractor/Parser.java +522 -0
  70. package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
  71. package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
  72. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
  73. package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
  74. package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
  75. package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
  76. package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
  77. package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
  78. package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
  79. package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
  80. package/extractors/python/devlens_extractors_python/__init__.py +3 -0
  81. package/extractors/python/devlens_extractors_python/__main__.py +33 -0
  82. package/extractors/python/devlens_extractors_python/contract.py +94 -0
  83. package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
  84. package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
  85. package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
  86. package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
  87. package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
  88. package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
  89. package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
  90. package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
  91. package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
  92. package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
  93. package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
  94. package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
  95. package/extractors/python/devlens_extractors_python/extractor.py +107 -0
  96. package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
  97. package/extractors/python/devlens_extractors_python/lookup.py +72 -0
  98. package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
  99. package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
  100. package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
  101. package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
  102. package/extractors/python/devlens_extractors_python/third_party.py +103 -0
  103. package/extractors/python/pyproject.toml +16 -0
  104. package/extractors/python/setup.mjs +54 -0
  105. package/extractors/rust/Cargo.toml +29 -0
  106. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  107. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  108. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  109. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  110. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  111. package/extractors/rust/build.mjs +85 -0
  112. package/extractors/rust/src/calls.rs +295 -0
  113. package/extractors/rust/src/contract.rs +236 -0
  114. package/extractors/rust/src/enrich.rs +107 -0
  115. package/extractors/rust/src/extractor.rs +199 -0
  116. package/extractors/rust/src/fingerprint.rs +238 -0
  117. package/extractors/rust/src/imports.rs +101 -0
  118. package/extractors/rust/src/inheritance.rs +233 -0
  119. package/extractors/rust/src/lookup.rs +226 -0
  120. package/extractors/rust/src/main.rs +53 -0
  121. package/extractors/rust/src/module_map.rs +131 -0
  122. package/extractors/rust/src/nodes.rs +174 -0
  123. package/extractors/rust/src/orm_edges.rs +125 -0
  124. package/extractors/rust/src/parser.rs +859 -0
  125. package/extractors/rust/src/routes.rs +1265 -0
  126. package/extractors/rust/src/tests.rs +109 -0
  127. package/extractors/rust/src/thirdparty.rs +114 -0
  128. package/extractors/rust/src/walker.rs +82 -0
  129. 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"]