devlensio 0.6.1 → 1.0.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.
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 +116 -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 +36 -48
  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
@@ -8,10 +8,11 @@ export function detectCallEdges(nodes, lookupMp) {
8
8
  // Lazily-created method nodes for default/namespace import member-access calls
9
9
  const createdMethodNodes = new Map();
10
10
  for (const node of nodes) {
11
- // Only functions, hooks, and components make direct calls
11
+ // Only functions, hooks, components, and class methods make direct calls
12
12
  if (node.type !== "FUNCTION" &&
13
13
  node.type !== "HOOK" &&
14
- node.type !== "COMPONENT")
14
+ node.type !== "COMPONENT" &&
15
+ node.type !== "METHOD")
15
16
  continue;
16
17
  const calls = node.metadata.calls;
17
18
  const uses = node.metadata.uses;
@@ -103,7 +104,20 @@ export function detectCallEdges(nodes, lookupMp) {
103
104
  // continue;
104
105
  // }
105
106
  // ── Local node lookup ─────────────────────────────────────────
106
- const targets = lookupMp.nodesByName.get(calledName);
107
+ // Direct name match first; if it misses, try substituting the
108
+ // root through the local-import alias map (`US.get()` where
109
+ // `import { UserService as US }` → resolve `UserService.get`).
110
+ let targets = lookupMp.nodesByName.get(calledName);
111
+ let resolvedName = calledName;
112
+ if ((!targets || targets.length === 0) && calledName.includes(".")) {
113
+ const rootName = calledName.split(".")[0];
114
+ const fileAliases = lookupMp.localImportSymbols.get(node.filePath);
115
+ const importedName = fileAliases?.get(rootName);
116
+ if (importedName) {
117
+ resolvedName = importedName + calledName.slice(rootName.length);
118
+ targets = lookupMp.nodesByName.get(resolvedName);
119
+ }
120
+ }
107
121
  if (!targets || targets.length === 0)
108
122
  continue;
109
123
  const target = targets.length === 1
@@ -115,13 +129,13 @@ export function detectCallEdges(nodes, lookupMp) {
115
129
  from: node.id,
116
130
  to: target.id,
117
131
  type: edgeType,
118
- metadata: { calledName },
132
+ metadata: { calledName: resolvedName },
119
133
  });
120
134
  if (!resolvedCallsMap.has(node.id))
121
135
  resolvedCallsMap.set(node.id, []);
122
136
  const existing = resolvedCallsMap.get(node.id);
123
137
  if (!existing.some(r => r.nodeId === target.id)) {
124
- existing.push({ name: calledName, nodeId: target.id });
138
+ existing.push({ name: resolvedName, nodeId: target.id });
125
139
  }
126
140
  }
127
141
  // ── Hook / dependency names: third-party edges only ──────────────
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,200 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { parseRepo } from "../../parser/index.js";
5
+ import { buildLookupMaps } from "../buildLookup.js";
6
+ import { detectImportEdges } from "./importEdges.js";
7
+ import { detectCallEdges } from "./callEdges.js";
8
+ import { buildThirdPartyNodes } from "../thirdPartyLibs.js";
9
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
10
+ function createFakeRepo(files) {
11
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-calls-test-"));
12
+ for (const [filePath, content] of Object.entries(files)) {
13
+ const fullPath = path.join(tmpDir, filePath);
14
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
15
+ fs.writeFileSync(fullPath, content);
16
+ }
17
+ return tmpDir;
18
+ }
19
+ function deleteFakeRepo(repoPath) {
20
+ fs.rmSync(repoPath, { recursive: true, force: true });
21
+ }
22
+ // Mirrors the real pipeline: parse → lookup maps → import edges (populates
23
+ // thirdPartyImportAliases + localImportSymbols) → call edges.
24
+ // Returns edges AND the mutated node list (detectCallEdges writes
25
+ // metadata.resolvedCalls back onto nodes in place).
26
+ function detectCalls(repoPath, extraNodes = []) {
27
+ const { nodes } = parseRepo(repoPath);
28
+ const all = [...nodes, ...extraNodes];
29
+ const lookup = buildLookupMaps(all);
30
+ detectImportEdges(lookup, repoPath);
31
+ const result = detectCallEdges(all, lookup);
32
+ return { ...result, all };
33
+ }
34
+ // ─── Tests ───────────────────────────────────────────────────────────────────
35
+ describe("detectCallEdges with class methods", () => {
36
+ // 21. this.save() inside a class → CALLS edge to Class.save
37
+ it("resolves this.method() calls to the class METHOD node", () => {
38
+ const repo = createFakeRepo({
39
+ "src/svc.ts": `
40
+ export class UserService {
41
+ save() { return 1; }
42
+ persist() { return this.save(); }
43
+ }
44
+ `,
45
+ });
46
+ const { edges } = detectCalls(repo);
47
+ expect(edges).toContainEqual(expect.objectContaining({
48
+ from: "src/svc.ts::UserService.persist",
49
+ to: "src/svc.ts::UserService.save",
50
+ type: "CALLS",
51
+ }));
52
+ deleteFakeRepo(repo);
53
+ });
54
+ // 22. Class.staticMethod() from a plain function
55
+ it("resolves static calls made from outside the class", () => {
56
+ const repo = createFakeRepo({
57
+ "src/stat.ts": `
58
+ export class MathUtils {
59
+ static add(a: number, b: number) { return a + b; }
60
+ }
61
+ export function compute() { return MathUtils.add(1, 2); }
62
+ `,
63
+ });
64
+ const { edges } = detectCalls(repo);
65
+ expect(edges).toContainEqual(expect.objectContaining({
66
+ from: "src/stat.ts::compute",
67
+ to: "src/stat.ts::MathUtils.add",
68
+ type: "CALLS",
69
+ }));
70
+ deleteFakeRepo(repo);
71
+ });
72
+ // 23. Local-variable instance calls are unresolvable (engine-wide limitation)
73
+ it("does not resolve instance calls through local variables", () => {
74
+ const repo = createFakeRepo({
75
+ "src/local.ts": `
76
+ export class UserService {
77
+ get() { return 1; }
78
+ }
79
+ export function fetchUser() {
80
+ const svc = new UserService();
81
+ return svc.get();
82
+ }
83
+ `,
84
+ });
85
+ const { edges } = detectCalls(repo);
86
+ expect(edges.some((e) => e.type === "CALLS" && e.to === "src/local.ts::UserService.get")).toBe(false);
87
+ // Metadata preserved for LLM context
88
+ const caller = parseRepo(repo).nodes.find((n) => n.name === "fetchUser");
89
+ expect(caller?.metadata.calls).toContain("svc.get");
90
+ deleteFakeRepo(repo);
91
+ });
92
+ // 24. Cross-file static call via named import
93
+ it("resolves cross-file class method calls via dotted names", () => {
94
+ const repo = createFakeRepo({
95
+ "src/services.ts": `
96
+ export class UserService {
97
+ static get(id: string) { return id; }
98
+ }
99
+ `,
100
+ "src/use.ts": `
101
+ import { UserService } from "./services";
102
+ export function load(id: string) { return UserService.get(id); }
103
+ `,
104
+ });
105
+ const { edges } = detectCalls(repo);
106
+ expect(edges).toContainEqual(expect.objectContaining({
107
+ from: "src/use.ts::load",
108
+ to: "src/services.ts::UserService.get",
109
+ type: "CALLS",
110
+ }));
111
+ deleteFakeRepo(repo);
112
+ });
113
+ // 25. Aliased named import → localImportSymbols substitution
114
+ it("resolves aliased imports via the local symbol map", () => {
115
+ const repo = createFakeRepo({
116
+ "src/services.ts": `
117
+ export class UserService {
118
+ static get(id: string) { return id; }
119
+ }
120
+ `,
121
+ "src/use.ts": `
122
+ import { UserService as US } from "./services";
123
+ export function load(id: string) { return US.get(id); }
124
+ `,
125
+ });
126
+ const { edges } = detectCalls(repo);
127
+ expect(edges).toContainEqual(expect.objectContaining({
128
+ from: "src/use.ts::load",
129
+ to: "src/services.ts::UserService.get",
130
+ type: "CALLS",
131
+ }));
132
+ deleteFakeRepo(repo);
133
+ });
134
+ // 26. Same method name in two files → closestByPath picks the nearest
135
+ it("uses closestByPath for same-name method collisions", () => {
136
+ const repo = createFakeRepo({
137
+ "a/repo.ts": `export class Repo { get() { return "a"; } }`,
138
+ "b/repo.ts": `export class Repo { get() { return "b"; } }`,
139
+ "b/use.ts": `
140
+ import { Repo } from "./repo";
141
+ export function read() { return Repo.get(); }
142
+ `,
143
+ });
144
+ const { edges } = detectCalls(repo);
145
+ const edge = edges.find((e) => e.type === "CALLS" && e.from === "b/use.ts::read");
146
+ expect(edge?.to).toBe("b/repo.ts::Repo.get");
147
+ deleteFakeRepo(repo);
148
+ });
149
+ // 27. Method → plain function in the same file
150
+ it("resolves calls from a method to a plain function", () => {
151
+ const repo = createFakeRepo({
152
+ "src/mix.ts": `
153
+ function normalize(x: number) { return x; }
154
+ export class Pipeline {
155
+ run(x: number) { return normalize(x); }
156
+ }
157
+ `,
158
+ });
159
+ const { edges } = detectCalls(repo);
160
+ expect(edges).toContainEqual(expect.objectContaining({
161
+ from: "src/mix.ts::Pipeline.run",
162
+ to: "src/mix.ts::normalize",
163
+ type: "CALLS",
164
+ }));
165
+ deleteFakeRepo(repo);
166
+ });
167
+ // 28. Third-party call inside a method (axios.get → [npm]/axios::get)
168
+ it("creates third-party edges for member calls inside methods", () => {
169
+ const repo = createFakeRepo({
170
+ "src/api.ts": `
171
+ import axios from "axios";
172
+ export class ApiClient {
173
+ fetchUsers() { return axios.get("/users"); }
174
+ }
175
+ `,
176
+ });
177
+ const thirdParty = buildThirdPartyNodes(repo, ["axios"]);
178
+ const { edges } = detectCalls(repo, thirdParty);
179
+ const edge = edges.find((e) => e.type === "CALLS" && e.from === "src/api.ts::ApiClient.fetchUsers");
180
+ expect(edge).toBeDefined();
181
+ expect(edge?.to).toBe("[npm]/axios::get");
182
+ expect(edge?.metadata?.isThirdParty).toBe(true);
183
+ deleteFakeRepo(repo);
184
+ });
185
+ // resolvedCalls writeback on METHOD nodes
186
+ it("writes resolvedCalls back onto METHOD nodes", () => {
187
+ const repo = createFakeRepo({
188
+ "src/wb.ts": `
189
+ export class Counter {
190
+ inc() { return 1; }
191
+ bump() { return this.inc(); }
192
+ }
193
+ `,
194
+ });
195
+ const { all } = detectCalls(repo);
196
+ const bump = all.find((n) => n.name === "Counter.bump");
197
+ expect(bump?.metadata.resolvedCalls).toEqual(expect.arrayContaining([expect.objectContaining({ name: "Counter.inc", nodeId: "src/wb.ts::Counter.inc" })]));
198
+ deleteFakeRepo(repo);
199
+ });
200
+ });
@@ -165,6 +165,18 @@ export function detectImportEdges(lookupMp, repoPath) {
165
165
  }
166
166
  continue; // always skip local-resolution path for non-local imports
167
167
  }
168
+ // ─── Local named-import symbol map ────────────────────────────
169
+ // Records localAlias → importedName so callEdges can resolve
170
+ // `US.get()` when `import { UserService as US } from "./services"`
171
+ // (the dotted METHOD node is named `UserService.get`).
172
+ const localAliases = lookupMp.localImportSymbols.get(sourceRelative) ?? new Map();
173
+ for (const specifier of importDecl.getNamedImports()) {
174
+ const localAlias = specifier.getAliasNode()?.getText() ?? specifier.getName();
175
+ localAliases.set(localAlias, specifier.getName());
176
+ }
177
+ if (localAliases.size > 0) {
178
+ lookupMp.localImportSymbols.set(sourceRelative, localAliases);
179
+ }
168
180
  // ─── Resolve the imported file path ───────────────────────────
169
181
  let resolvedPath;
170
182
  // First try ts-morph automatic resolution
@@ -0,0 +1,3 @@
1
+ import type { CodeEdge, CodeNode } from "../../types.js";
2
+ import type { LookupMaps } from "../buildLookup.js";
3
+ export declare function detectInheritanceEdges(nodes: CodeNode[], lookupMp: LookupMaps): CodeEdge[];
@@ -0,0 +1,73 @@
1
+ // inheritanceEdges.ts — EXTENDS / IMPLEMENTS edge detector for the inline
2
+ // JS/TS path.
3
+ //
4
+ // The classes extractor records `metadata.extendsType` (raw text, generics
5
+ // included) and `metadata.implementsTypes` (string[]) on CLASS nodes. This
6
+ // detector resolves those names against the lookup maps — mirroring how
7
+ // callEdges reads `metadata.calls` — and emits:
8
+ //
9
+ // class X extends Y → EXTENDS (Y must resolve to a local CLASS node)
10
+ // class X implements Y → IMPLEMENTS (Y must resolve to a local CLASS node;
11
+ // interface targets are ignored — JS/TS
12
+ // deliberately has no INTERFACE nodes)
13
+ //
14
+ // Rules:
15
+ // - Extends generics are stripped (`React.Component<Props>` → `React.Component`),
16
+ // so third-party bases (no local node) are skipped, never dangling.
17
+ // - Dotted bases (`ns.Base`) don't match plain node names → skipped.
18
+ // - Name collisions resolve via closestByPath (same as callEdges).
19
+ // - Edges are deduped by from→to:type.
20
+ import { closestByPath } from "./utils.js";
21
+ import { stripGenerics } from "../../parser/extractors/classes.js";
22
+ export function detectInheritanceEdges(nodes, lookupMp) {
23
+ const edges = [];
24
+ const created = new Set();
25
+ const resolveTarget = (typeName, fromFile) => {
26
+ const targets = lookupMp.nodesByName.get(typeName);
27
+ if (!targets || targets.length === 0)
28
+ return undefined;
29
+ return targets.length === 1 ? targets[0] : closestByPath(targets, fromFile);
30
+ };
31
+ for (const node of nodes) {
32
+ if (node.type !== "CLASS")
33
+ continue;
34
+ // ── EXTENDS ─────────────────────────────────────────────────────────
35
+ const extendsType = node.metadata.extendsType;
36
+ if (extendsType) {
37
+ const base = stripGenerics(extendsType);
38
+ const target = resolveTarget(base, node.filePath);
39
+ if (target && target.type === "CLASS" && target.id !== node.id) {
40
+ const key = `${node.id}→${target.id}:EXTENDS`;
41
+ if (!created.has(key)) {
42
+ created.add(key);
43
+ edges.push({
44
+ from: node.id,
45
+ to: target.id,
46
+ type: "EXTENDS",
47
+ metadata: { extendsType },
48
+ });
49
+ }
50
+ }
51
+ }
52
+ // ── IMPLEMENTS (local CLASS targets only) ───────────────────────────
53
+ const implementsTypes = node.metadata.implementsTypes;
54
+ if (implementsTypes && implementsTypes.length > 0) {
55
+ for (const impl of implementsTypes) {
56
+ const target = resolveTarget(impl, node.filePath);
57
+ if (!target || target.type !== "CLASS" || target.id === node.id)
58
+ continue;
59
+ const key = `${node.id}→${target.id}:IMPLEMENTS`;
60
+ if (!created.has(key)) {
61
+ created.add(key);
62
+ edges.push({
63
+ from: node.id,
64
+ to: target.id,
65
+ type: "IMPLEMENTS",
66
+ metadata: { implementsType: impl },
67
+ });
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return edges;
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { parseRepo } from "../../parser/index.js";
5
+ import { buildLookupMaps } from "../buildLookup.js";
6
+ import { detectInheritanceEdges } from "./inheritanceEdges.js";
7
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
8
+ function createFakeRepo(files) {
9
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-inh-test-"));
10
+ for (const [filePath, content] of Object.entries(files)) {
11
+ const fullPath = path.join(tmpDir, filePath);
12
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
13
+ fs.writeFileSync(fullPath, content);
14
+ }
15
+ return tmpDir;
16
+ }
17
+ function deleteFakeRepo(repoPath) {
18
+ fs.rmSync(repoPath, { recursive: true, force: true });
19
+ }
20
+ function detect(repoPath) {
21
+ const { nodes } = parseRepo(repoPath);
22
+ return detectInheritanceEdges(nodes, buildLookupMaps(nodes));
23
+ }
24
+ // ─── Tests ───────────────────────────────────────────────────────────────────
25
+ describe("detectInheritanceEdges", () => {
26
+ // 29. Same-file class → class EXTENDS
27
+ it("emits EXTENDS for a same-file class-to-class inheritance", () => {
28
+ const repo = createFakeRepo({
29
+ "src/inh.ts": `
30
+ export class Base {}
31
+ export class Child extends Base {}
32
+ `,
33
+ });
34
+ const edges = detect(repo);
35
+ expect(edges).toContainEqual(expect.objectContaining({ from: "src/inh.ts::Child", to: "src/inh.ts::Base", type: "EXTENDS" }));
36
+ expect(edges.filter((e) => e.type === "EXTENDS")).toHaveLength(1);
37
+ deleteFakeRepo(repo);
38
+ });
39
+ // 30. Cross-file EXTENDS
40
+ it("emits EXTENDS across files via name lookup", () => {
41
+ const repo = createFakeRepo({
42
+ "src/base.ts": `export class Base {}`,
43
+ "src/child.ts": `
44
+ import { Base } from "./base";
45
+ export class Child extends Base {}
46
+ `,
47
+ });
48
+ const edges = detect(repo);
49
+ expect(edges).toContainEqual(expect.objectContaining({ from: "src/child.ts::Child", to: "src/base.ts::Base", type: "EXTENDS" }));
50
+ deleteFakeRepo(repo);
51
+ });
52
+ // 31. Third-party extends → no edge (never dangling)
53
+ it("does not emit EXTENDS for third-party bases", () => {
54
+ const repo = createFakeRepo({
55
+ "src/react.tsx": `
56
+ import React from "react";
57
+ export class Dashboard extends React.Component<{ title: string }, {}> {
58
+ render() { return <div />; }
59
+ }
60
+ `,
61
+ });
62
+ const edges = detect(repo);
63
+ expect(edges.filter((e) => e.type === "EXTENDS")).toHaveLength(0);
64
+ deleteFakeRepo(repo);
65
+ });
66
+ // 32. Generic local base → generics stripped, EXTENDS emitted
67
+ it("strips generics from extends before resolving", () => {
68
+ const repo = createFakeRepo({
69
+ "src/gen.ts": `
70
+ export class Base<T> {}
71
+ export class Child extends Base<string> {}
72
+ `,
73
+ });
74
+ const edges = detect(repo);
75
+ expect(edges).toContainEqual(expect.objectContaining({ from: "src/gen.ts::Child", to: "src/gen.ts::Base", type: "EXTENDS" }));
76
+ expect(edges[0].metadata).toEqual({ extendsType: "Base<string>" }); // raw preserved
77
+ deleteFakeRepo(repo);
78
+ });
79
+ // 33+34. implements → only when target is a local CLASS
80
+ it("emits IMPLEMENTS only for local class targets, ignores interfaces", () => {
81
+ const repo = createFakeRepo({
82
+ "src/impl.ts": `
83
+ interface Contract { run(): void; }
84
+ export class BaseClass { run() {} }
85
+ export class Worker implements Contract, BaseClass {}
86
+ `,
87
+ });
88
+ const edges = detect(repo);
89
+ const implementsEdges = edges.filter((e) => e.type === "IMPLEMENTS");
90
+ expect(implementsEdges).toHaveLength(1); // only BaseClass — Contract has no node
91
+ expect(implementsEdges[0]).toEqual(expect.objectContaining({ from: "src/impl.ts::Worker", to: "src/impl.ts::BaseClass" }));
92
+ deleteFakeRepo(repo);
93
+ });
94
+ it("emits no IMPLEMENTS edge when every target is an interface", () => {
95
+ const repo = createFakeRepo({
96
+ "src/iface.ts": `
97
+ interface A { a(): void; }
98
+ interface B { b(): void; }
99
+ export class OnlyIfaces implements A, B {}
100
+ `,
101
+ });
102
+ const edges = detect(repo);
103
+ expect(edges.filter((e) => e.type === "IMPLEMENTS")).toHaveLength(0);
104
+ deleteFakeRepo(repo);
105
+ });
106
+ // 35. Dedup
107
+ it("dedupes repeated implements of the same class", () => {
108
+ const repo = createFakeRepo({
109
+ "src/dup.ts": `
110
+ export class Base {}
111
+ export class Dup implements Base, Base {}
112
+ `,
113
+ });
114
+ const edges = detect(repo);
115
+ const implementsEdges = edges.filter((e) => e.type === "IMPLEMENTS");
116
+ expect(implementsEdges).toHaveLength(1);
117
+ deleteFakeRepo(repo);
118
+ });
119
+ // Dotted / unresolvable names → skipped
120
+ it("skips dotted and unresolvable base names", () => {
121
+ const repo = createFakeRepo({
122
+ "src/dotted.ts": `
123
+ export class Local {}
124
+ export class A extends SomeMissing {}
125
+ export class B extends ns.Local {}
126
+ `,
127
+ });
128
+ const edges = detect(repo);
129
+ expect(edges.filter((e) => e.type === "EXTENDS")).toHaveLength(0);
130
+ deleteFakeRepo(repo);
131
+ });
132
+ // Classes without heritage produce nothing
133
+ it("emits nothing for standalone classes", () => {
134
+ const repo = createFakeRepo({
135
+ "src/plain.ts": `export class Standalone { run() {} }`,
136
+ });
137
+ expect(detect(repo)).toHaveLength(0);
138
+ deleteFakeRepo(repo);
139
+ });
140
+ });
@@ -10,6 +10,7 @@ import { detectPropEdges } from "./edges/propEdges.js";
10
10
  import { detectRouteEdges } from "./edges/routeEdge.js";
11
11
  import { detectStateEdges } from "./edges/stateEdges.js";
12
12
  import { detectTestEdges } from "./edges/testEdges.js";
13
+ import { detectInheritanceEdges } from "./edges/inheritanceEdges.js";
13
14
  export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
14
15
  console.log(`Building lookup maps for edge detection for ${nodes.length} nodes...`);
15
16
  //building lookup maps
@@ -29,6 +30,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
29
30
  // GUARDS — middleware to route protection
30
31
  const guardEdges = detectGuardEdges(nodes, lookupMp, routeNodes, repoPath, fingerprint);
31
32
  const testEdges = detectTestEdges(lookupMp, repoPath); // This does not needs nodes, as it detect edges from the file
33
+ const inheritanceEdges = detectInheritanceEdges(nodes, lookupMp);
32
34
  const nextjsApiCallEdges = detectNextjsApiCallEdges(nodes, repoPath);
33
35
  const navResult = detectNavigationEdges(nodes, repoPath);
34
36
  // Collect all dynamically-created third-party method nodes (dedup by id)
@@ -48,6 +50,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
48
50
  console.log(` ROUTE edges: ${routeEdges.length}`);
49
51
  console.log(` GUARD edges: ${guardEdges.length}`);
50
52
  console.log(` TEST edges: ${testEdges.length}`);
53
+ console.log(` INHERITANCE edges (EXTENDS/IMPLEMENTS): ${inheritanceEdges.length}`);
51
54
  console.log(` Ghost nodes created: ${eventResults.ghostNodes.length}`);
52
55
  console.log(` Third-party method nodes: ${newThirdPartyNodes.length}`);
53
56
  console.log(` NEXTJS_API_CALL edges: ${nextjsApiCallEdges.length}`);
@@ -63,6 +66,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
63
66
  ...routeEdges,
64
67
  ...guardEdges,
65
68
  ...testEdges,
69
+ ...inheritanceEdges,
66
70
  ...nextjsApiCallEdges,
67
71
  ...navResult.edges,
68
72
  ];
@@ -0,0 +1 @@
1
+ export {};