devlensio 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/dist/clustering/index.d.ts +27 -0
- package/dist/clustering/index.js +149 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +78 -0
- package/dist/config/providers/file.d.ts +19 -0
- package/dist/config/providers/file.js +215 -0
- package/dist/config/providers/request.d.ts +2 -0
- package/dist/config/providers/request.js +72 -0
- package/dist/config/types.d.ts +46 -0
- package/dist/config/types.js +81 -0
- package/dist/config/writer.d.ts +29 -0
- package/dist/config/writer.js +103 -0
- package/dist/filesystem/appRouter.d.ts +2 -0
- package/dist/filesystem/appRouter.js +126 -0
- package/dist/filesystem/backendRoutes.d.ts +2 -0
- package/dist/filesystem/backendRoutes.js +161 -0
- package/dist/filesystem/index.d.ts +2 -0
- package/dist/filesystem/index.js +28 -0
- package/dist/filesystem/index.test.d.ts +1 -0
- package/dist/filesystem/index.test.js +178 -0
- package/dist/filesystem/pagesRouter.d.ts +2 -0
- package/dist/filesystem/pagesRouter.js +109 -0
- package/dist/fingerprint/detectors.d.ts +8 -0
- package/dist/fingerprint/detectors.js +174 -0
- package/dist/fingerprint/index.d.ts +2 -0
- package/dist/fingerprint/index.js +41 -0
- package/dist/fingerprint/index.test.d.ts +1 -0
- package/dist/fingerprint/index.test.js +148 -0
- package/dist/graph/buildLookup.d.ts +10 -0
- package/dist/graph/buildLookup.js +32 -0
- package/dist/graph/edges/callEdges.d.ts +7 -0
- package/dist/graph/edges/callEdges.js +145 -0
- package/dist/graph/edges/eventEdges.d.ts +7 -0
- package/dist/graph/edges/eventEdges.js +203 -0
- package/dist/graph/edges/guardEdges.d.ts +3 -0
- package/dist/graph/edges/guardEdges.js +232 -0
- package/dist/graph/edges/hookEdges.d.ts +3 -0
- package/dist/graph/edges/hookEdges.js +54 -0
- package/dist/graph/edges/importEdges.d.ts +8 -0
- package/dist/graph/edges/importEdges.js +224 -0
- package/dist/graph/edges/propEdges.d.ts +3 -0
- package/dist/graph/edges/propEdges.js +142 -0
- package/dist/graph/edges/routeEdge.d.ts +3 -0
- package/dist/graph/edges/routeEdge.js +124 -0
- package/dist/graph/edges/stateEdges.d.ts +3 -0
- package/dist/graph/edges/stateEdges.js +206 -0
- package/dist/graph/edges/testEdges.d.ts +3 -0
- package/dist/graph/edges/testEdges.js +143 -0
- package/dist/graph/edges/utils.d.ts +2 -0
- package/dist/graph/edges/utils.js +25 -0
- package/dist/graph/index.d.ts +6 -0
- package/dist/graph/index.js +65 -0
- package/dist/graph/index.test.d.ts +1 -0
- package/dist/graph/index.test.js +542 -0
- package/dist/graph/thirdPartyLibs.d.ts +8 -0
- package/dist/graph/thirdPartyLibs.js +162 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/jobs/index.d.ts +5 -0
- package/dist/jobs/index.js +11 -0
- package/dist/jobs/queue/interface.d.ts +13 -0
- package/dist/jobs/queue/interface.js +1 -0
- package/dist/jobs/queue/memory.d.ts +24 -0
- package/dist/jobs/queue/memory.js +291 -0
- package/dist/jobs/runner.d.ts +3 -0
- package/dist/jobs/runner.js +136 -0
- package/dist/jobs/types.d.ts +112 -0
- package/dist/jobs/types.js +33 -0
- package/dist/parser/directives.d.ts +4 -0
- package/dist/parser/directives.js +31 -0
- package/dist/parser/extractors/components.d.ts +5 -0
- package/dist/parser/extractors/components.js +240 -0
- package/dist/parser/extractors/functions.d.ts +4 -0
- package/dist/parser/extractors/functions.js +240 -0
- package/dist/parser/extractors/hooks.d.ts +4 -0
- package/dist/parser/extractors/hooks.js +128 -0
- package/dist/parser/extractors/stores.d.ts +3 -0
- package/dist/parser/extractors/stores.js +181 -0
- package/dist/parser/index.d.ts +14 -0
- package/dist/parser/index.js +168 -0
- package/dist/parser/index.test.d.ts +1 -0
- package/dist/parser/index.test.js +319 -0
- package/dist/parser/typeUtils.d.ts +9 -0
- package/dist/parser/typeUtils.js +46 -0
- package/dist/pipeline/index.d.ts +50 -0
- package/dist/pipeline/index.js +249 -0
- package/dist/scoring/connectionCounter.d.ts +28 -0
- package/dist/scoring/connectionCounter.js +134 -0
- package/dist/scoring/fileScorer.d.ts +2 -0
- package/dist/scoring/fileScorer.js +44 -0
- package/dist/scoring/index.d.ts +22 -0
- package/dist/scoring/index.js +130 -0
- package/dist/scoring/index.test.d.ts +1 -0
- package/dist/scoring/index.test.js +453 -0
- package/dist/scoring/nodeScorer.d.ts +3 -0
- package/dist/scoring/nodeScorer.js +108 -0
- package/dist/scoring/noiseFilter.d.ts +18 -0
- package/dist/scoring/noiseFilter.js +92 -0
- package/dist/storage/fileStorage.d.ts +117 -0
- package/dist/storage/fileStorage.js +616 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/interface.d.ts +27 -0
- package/dist/storage/interface.js +1 -0
- package/dist/summarizer/checkpoint.d.ts +15 -0
- package/dist/summarizer/checkpoint.js +110 -0
- package/dist/summarizer/index.d.ts +2 -0
- package/dist/summarizer/index.js +281 -0
- package/dist/summarizer/mapreduce.d.ts +4 -0
- package/dist/summarizer/mapreduce.js +87 -0
- package/dist/summarizer/prompts.d.ts +22 -0
- package/dist/summarizer/prompts.js +205 -0
- package/dist/summarizer/providers/anthropic.d.ts +9 -0
- package/dist/summarizer/providers/anthropic.js +78 -0
- package/dist/summarizer/providers/gemini.d.ts +9 -0
- package/dist/summarizer/providers/gemini.js +79 -0
- package/dist/summarizer/providers/index.d.ts +3 -0
- package/dist/summarizer/providers/index.js +43 -0
- package/dist/summarizer/providers/ollama.d.ts +9 -0
- package/dist/summarizer/providers/ollama.js +23 -0
- package/dist/summarizer/providers/openRouter.d.ts +9 -0
- package/dist/summarizer/providers/openRouter.js +19 -0
- package/dist/summarizer/providers/openai.d.ts +9 -0
- package/dist/summarizer/providers/openai.js +72 -0
- package/dist/summarizer/providers/types.d.ts +32 -0
- package/dist/summarizer/providers/types.js +1 -0
- package/dist/summarizer/retry.d.ts +7 -0
- package/dist/summarizer/retry.js +51 -0
- package/dist/summarizer/topological.d.ts +3 -0
- package/dist/summarizer/topological.js +105 -0
- package/dist/summarizer/types.d.ts +57 -0
- package/dist/summarizer/types.js +17 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ─── detectHookEdges ──────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Creates CALLS edges for hook usage relationships:
|
|
4
|
+
//
|
|
5
|
+
// COMPONENT → HOOK (component uses a hook) — reads metadata.hooks
|
|
6
|
+
// HOOK → HOOK (hook uses another hook) — reads metadata.dependencies
|
|
7
|
+
// FUNCTION → HOOK (function uses a hook) — reads metadata.hookCalls
|
|
8
|
+
//
|
|
9
|
+
// callEdges.ts explicitly skips hook calls so these would otherwise be missed.
|
|
10
|
+
// We reuse CALLS edge type with isHookCall: true in metadata for distinction.
|
|
11
|
+
export function detectHookEdges(nodes, lookup) {
|
|
12
|
+
const edges = [];
|
|
13
|
+
for (const node of nodes) {
|
|
14
|
+
if (node.type !== "COMPONENT" &&
|
|
15
|
+
node.type !== "HOOK" &&
|
|
16
|
+
node.type !== "FUNCTION")
|
|
17
|
+
continue;
|
|
18
|
+
const hookNames = (node.type === "COMPONENT" ? node.metadata.hooks :
|
|
19
|
+
node.type === "HOOK" ? node.metadata.dependencies :
|
|
20
|
+
node.metadata.hookCalls // FUNCTION
|
|
21
|
+
);
|
|
22
|
+
if (!hookNames || hookNames.length === 0)
|
|
23
|
+
continue;
|
|
24
|
+
for (const hookName of hookNames) {
|
|
25
|
+
if (!hookName.startsWith("use"))
|
|
26
|
+
continue;
|
|
27
|
+
const targets = lookup.nodesByName.get(hookName);
|
|
28
|
+
if (!targets || targets.length === 0)
|
|
29
|
+
continue;
|
|
30
|
+
for (const target of targets) {
|
|
31
|
+
// Only connect to HOOK nodes
|
|
32
|
+
if (target.type !== "HOOK")
|
|
33
|
+
continue;
|
|
34
|
+
// No self-loops
|
|
35
|
+
if (target.id === node.id)
|
|
36
|
+
continue;
|
|
37
|
+
// No duplicates
|
|
38
|
+
const alreadyExists = edges.some(e => e.from === node.id && e.to === target.id && e.type === "CALLS");
|
|
39
|
+
if (alreadyExists)
|
|
40
|
+
continue;
|
|
41
|
+
edges.push({
|
|
42
|
+
from: node.id,
|
|
43
|
+
to: target.id,
|
|
44
|
+
type: "CALLS",
|
|
45
|
+
metadata: {
|
|
46
|
+
calledName: hookName,
|
|
47
|
+
isHookCall: true,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return edges;
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CodeEdge, CodeNode } from "../../types.js";
|
|
2
|
+
import type { LookupMaps } from "../buildLookup.js";
|
|
3
|
+
export declare function isLocalImport(importPath: string): boolean;
|
|
4
|
+
export interface ImportEdgeResult {
|
|
5
|
+
edges: CodeEdge[];
|
|
6
|
+
thirdPartyMethodNodes: CodeNode[];
|
|
7
|
+
}
|
|
8
|
+
export declare function detectImportEdges(lookupMp: LookupMaps, repoPath: string): ImportEdgeResult;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { extractPackageName } from "../thirdPartyLibs.js";
|
|
4
|
+
import { Project } from "ts-morph";
|
|
5
|
+
// Aliases we consider as local imports
|
|
6
|
+
const LOCAL_PREFIXES = ["./", "../", "@/", "~/", "#/"];
|
|
7
|
+
// Check if an import is local (not third party)
|
|
8
|
+
export function isLocalImport(importPath) {
|
|
9
|
+
return LOCAL_PREFIXES.some(prefix => importPath.startsWith(prefix));
|
|
10
|
+
}
|
|
11
|
+
//Get config file path - tsconfig first, jsconfig as fallback
|
|
12
|
+
function getConfigPath(repoPath) {
|
|
13
|
+
const tsconfig = path.join(repoPath, "tsconfig.json");
|
|
14
|
+
const jsconfig = path.join(repoPath, "jsconfig.json");
|
|
15
|
+
if (fs.existsSync(tsconfig))
|
|
16
|
+
return tsconfig;
|
|
17
|
+
if (fs.existsSync(jsconfig))
|
|
18
|
+
return jsconfig;
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
// Recursively walk directory and add files to project
|
|
22
|
+
// Same approach as parser — reliable on Windows
|
|
23
|
+
function addFilesRecursively(dir, project) {
|
|
24
|
+
const IGNORE_DIRS = [
|
|
25
|
+
"node_modules", "dist", "build",
|
|
26
|
+
".next", "coverage", ".git",
|
|
27
|
+
];
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const fullPath = path.join(dir, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
if (IGNORE_DIRS.includes(entry.name))
|
|
39
|
+
continue;
|
|
40
|
+
addFilesRecursively(fullPath, project);
|
|
41
|
+
}
|
|
42
|
+
else if (entry.isFile()) {
|
|
43
|
+
if (!/\.(ts|tsx|js|jsx)$/.test(entry.name))
|
|
44
|
+
continue;
|
|
45
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name))
|
|
46
|
+
continue;
|
|
47
|
+
if (/\.d\.ts$/.test(entry.name))
|
|
48
|
+
continue;
|
|
49
|
+
project.addSourceFileAtPath(fullPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function detectImportEdges(lookupMp, repoPath) {
|
|
54
|
+
const edges = [];
|
|
55
|
+
const createdEdges = new Set();
|
|
56
|
+
// Dedup method nodes across all files — same named import in multiple files
|
|
57
|
+
// produces only ONE node (e.g. a single [npm]/react::useState node).
|
|
58
|
+
const methodNodesMap = new Map();
|
|
59
|
+
const configPath = getConfigPath(repoPath);
|
|
60
|
+
const project = configPath
|
|
61
|
+
? new Project({ tsConfigFilePath: configPath, skipAddingFilesFromTsConfig: true })
|
|
62
|
+
: new Project({
|
|
63
|
+
compilerOptions: {
|
|
64
|
+
allowJs: true,
|
|
65
|
+
checkJs: false,
|
|
66
|
+
jsx: 4,
|
|
67
|
+
strict: false,
|
|
68
|
+
},
|
|
69
|
+
skipAddingFilesFromTsConfig: true,
|
|
70
|
+
});
|
|
71
|
+
addFilesRecursively(repoPath, project);
|
|
72
|
+
for (const file of project.getSourceFiles()) {
|
|
73
|
+
const absFilePath = file.getFilePath();
|
|
74
|
+
const sourceRelative = path.relative(repoPath, absFilePath).replace(/\\/g, "/");
|
|
75
|
+
const sourceFileNode = lookupMp.fileNodesByPath.get(sourceRelative);
|
|
76
|
+
if (!sourceFileNode)
|
|
77
|
+
continue;
|
|
78
|
+
const importDeclarations = file.getImportDeclarations();
|
|
79
|
+
for (const importDecl of importDeclarations) {
|
|
80
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
81
|
+
if (!isLocalImport(moduleSpecifier)) {
|
|
82
|
+
// ─── Third-party import ───────────────────────────────────
|
|
83
|
+
const pkgName = extractPackageName(moduleSpecifier);
|
|
84
|
+
const thirdPartyNode = lookupMp.thirdPartyNodesByName.get(pkgName);
|
|
85
|
+
if (thirdPartyNode) {
|
|
86
|
+
const fileAliasMap = lookupMp.thirdPartyImportAliases.get(sourceRelative) ?? new Map();
|
|
87
|
+
// ── Named imports → one method node per imported name ──────
|
|
88
|
+
// e.g. import { useState, useEffect } from 'react'
|
|
89
|
+
// → nodes [npm]/react::useState, [npm]/react::useEffect
|
|
90
|
+
// → fileAliasMap: "useState" → "[npm]/react::useState"
|
|
91
|
+
for (const specifier of importDecl.getNamedImports()) {
|
|
92
|
+
const localAlias = specifier.getAliasNode()?.getText() ?? specifier.getName();
|
|
93
|
+
const importedName = specifier.getName();
|
|
94
|
+
const methodNodeId = `[npm]/${pkgName}::${importedName}`;
|
|
95
|
+
if (!methodNodesMap.has(methodNodeId)) {
|
|
96
|
+
methodNodesMap.set(methodNodeId, {
|
|
97
|
+
id: methodNodeId,
|
|
98
|
+
name: `${pkgName}.${importedName}`,
|
|
99
|
+
type: "THIRD_PARTY",
|
|
100
|
+
filePath: `[npm]/${pkgName}`,
|
|
101
|
+
startLine: 0,
|
|
102
|
+
endLine: 0,
|
|
103
|
+
rawCode: undefined,
|
|
104
|
+
codeHash: undefined,
|
|
105
|
+
metadata: {
|
|
106
|
+
isThirdParty: true,
|
|
107
|
+
packageVersion: thirdPartyNode.metadata.packageVersion,
|
|
108
|
+
category: thirdPartyNode.metadata.category,
|
|
109
|
+
parentPackageId: thirdPartyNode.id,
|
|
110
|
+
methodName: importedName,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Map the local alias to the method node so callEdges can resolve it
|
|
115
|
+
fileAliasMap.set(localAlias, methodNodeId);
|
|
116
|
+
// IMPORTS edge: source file → method node
|
|
117
|
+
const edgeKey = `${sourceFileNode.id}→${methodNodeId}:IMPORTS`;
|
|
118
|
+
if (!createdEdges.has(edgeKey)) {
|
|
119
|
+
createdEdges.add(edgeKey);
|
|
120
|
+
edges.push({
|
|
121
|
+
from: sourceFileNode.id,
|
|
122
|
+
to: methodNodeId,
|
|
123
|
+
type: "IMPORTS",
|
|
124
|
+
metadata: { importPath: moduleSpecifier, isThirdParty: true, importedName },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ── Default import → package node ─────────────────────────
|
|
129
|
+
// e.g. import axios from 'axios'
|
|
130
|
+
// → fileAliasMap: "axios" → "[npm]/axios"
|
|
131
|
+
// Method nodes for member-access calls (axios.get) are created
|
|
132
|
+
// lazily in callEdges.ts when the actual calls are encountered.
|
|
133
|
+
const defaultImport = importDecl.getDefaultImport();
|
|
134
|
+
if (defaultImport) {
|
|
135
|
+
fileAliasMap.set(defaultImport.getText(), thirdPartyNode.id);
|
|
136
|
+
const edgeKey = `${sourceFileNode.id}→${thirdPartyNode.id}:IMPORTS`;
|
|
137
|
+
if (!createdEdges.has(edgeKey)) {
|
|
138
|
+
createdEdges.add(edgeKey);
|
|
139
|
+
edges.push({
|
|
140
|
+
from: sourceFileNode.id,
|
|
141
|
+
to: thirdPartyNode.id,
|
|
142
|
+
type: "IMPORTS",
|
|
143
|
+
metadata: { importPath: moduleSpecifier, isThirdParty: true },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ── Namespace import → package node ───────────────────────
|
|
148
|
+
// e.g. import * as ReactQuery from '@tanstack/react-query'
|
|
149
|
+
// → fileAliasMap: "ReactQuery" → "[npm]/@tanstack/react-query"
|
|
150
|
+
const namespaceImport = importDecl.getNamespaceImport();
|
|
151
|
+
if (namespaceImport) {
|
|
152
|
+
fileAliasMap.set(namespaceImport.getText(), thirdPartyNode.id);
|
|
153
|
+
const edgeKey = `${sourceFileNode.id}→${thirdPartyNode.id}:IMPORTS`;
|
|
154
|
+
if (!createdEdges.has(edgeKey)) {
|
|
155
|
+
createdEdges.add(edgeKey);
|
|
156
|
+
edges.push({
|
|
157
|
+
from: sourceFileNode.id,
|
|
158
|
+
to: thirdPartyNode.id,
|
|
159
|
+
type: "IMPORTS",
|
|
160
|
+
metadata: { importPath: moduleSpecifier, isThirdParty: true },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
lookupMp.thirdPartyImportAliases.set(sourceRelative, fileAliasMap);
|
|
165
|
+
}
|
|
166
|
+
continue; // always skip local-resolution path for non-local imports
|
|
167
|
+
}
|
|
168
|
+
// ─── Resolve the imported file path ───────────────────────────
|
|
169
|
+
let resolvedPath;
|
|
170
|
+
// First try ts-morph automatic resolution
|
|
171
|
+
// Works when tsconfig/jsconfig exists
|
|
172
|
+
const resolvedFile = importDecl.getModuleSpecifierSourceFile();
|
|
173
|
+
if (resolvedFile) {
|
|
174
|
+
resolvedPath = resolvedFile.getFilePath();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Fallback — manually resolve relative and alias paths
|
|
178
|
+
// Used when no tsconfig exists (e.g. in tests)
|
|
179
|
+
const currentDir = path.dirname(absFilePath);
|
|
180
|
+
let basePath = moduleSpecifier;
|
|
181
|
+
// Handle @/ ~/ #/ aliases → resolve to src/
|
|
182
|
+
if (moduleSpecifier.startsWith("@/") ||
|
|
183
|
+
moduleSpecifier.startsWith("~/") ||
|
|
184
|
+
moduleSpecifier.startsWith("#/")) {
|
|
185
|
+
basePath = path.join(repoPath, "src", moduleSpecifier.slice(2));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Relative import — resolve from current file's directory
|
|
189
|
+
basePath = path.join(currentDir, moduleSpecifier);
|
|
190
|
+
}
|
|
191
|
+
// Try all possible extensions and index file patterns
|
|
192
|
+
const candidates = [
|
|
193
|
+
basePath,
|
|
194
|
+
basePath + ".ts",
|
|
195
|
+
basePath + ".tsx",
|
|
196
|
+
basePath + ".js",
|
|
197
|
+
basePath + ".jsx",
|
|
198
|
+
path.join(basePath, "index.ts"),
|
|
199
|
+
path.join(basePath, "index.tsx"),
|
|
200
|
+
path.join(basePath, "index.js"),
|
|
201
|
+
path.join(basePath, "index.jsx"),
|
|
202
|
+
];
|
|
203
|
+
resolvedPath = candidates.find((c) => fs.existsSync(c));
|
|
204
|
+
}
|
|
205
|
+
if (!resolvedPath)
|
|
206
|
+
continue;
|
|
207
|
+
const targetRelative = path.relative(repoPath, resolvedPath).replace(/\\/g, "/");
|
|
208
|
+
const targetFileNode = lookupMp.fileNodesByPath.get(targetRelative);
|
|
209
|
+
if (!targetFileNode)
|
|
210
|
+
continue;
|
|
211
|
+
const edgeKey = `${sourceFileNode.id}→${targetFileNode.id}`;
|
|
212
|
+
if (createdEdges.has(edgeKey))
|
|
213
|
+
continue;
|
|
214
|
+
createdEdges.add(edgeKey);
|
|
215
|
+
edges.push({
|
|
216
|
+
from: sourceFileNode.id,
|
|
217
|
+
to: targetFileNode.id,
|
|
218
|
+
type: "IMPORTS",
|
|
219
|
+
metadata: { importPath: moduleSpecifier },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { edges, thirdPartyMethodNodes: [...methodNodesMap.values()] };
|
|
224
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
4
|
+
import { closestByPath } from "./utils.js";
|
|
5
|
+
// Recursively walk directory and add files to project
|
|
6
|
+
// Same approach as parser — reliable on Windows
|
|
7
|
+
function addFilesRecursively(dir, project) {
|
|
8
|
+
const IGNORE_DIRS = [
|
|
9
|
+
"node_modules", "dist", "build",
|
|
10
|
+
".next", "coverage", ".git",
|
|
11
|
+
];
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const fullPath = path.join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
if (IGNORE_DIRS.includes(entry.name))
|
|
23
|
+
continue;
|
|
24
|
+
addFilesRecursively(fullPath, project);
|
|
25
|
+
}
|
|
26
|
+
else if (entry.isFile()) {
|
|
27
|
+
if (!/\.(ts|tsx|js|jsx)$/.test(entry.name))
|
|
28
|
+
continue;
|
|
29
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name))
|
|
30
|
+
continue;
|
|
31
|
+
if (/\.d\.ts$/.test(entry.name))
|
|
32
|
+
continue;
|
|
33
|
+
project.addSourceFileAtPath(fullPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function detectPropEdges(nodes, lookupMp, repoPath) {
|
|
38
|
+
const edges = [];
|
|
39
|
+
// Track created edges for deduplication
|
|
40
|
+
// Key: "fromId→toId" — value: index in edges array
|
|
41
|
+
// So we can update renderCount on duplicates
|
|
42
|
+
const edgeIndex = new Map();
|
|
43
|
+
const project = new Project({
|
|
44
|
+
compilerOptions: {
|
|
45
|
+
allowJs: true,
|
|
46
|
+
checkJs: false,
|
|
47
|
+
jsx: 4,
|
|
48
|
+
strict: false,
|
|
49
|
+
},
|
|
50
|
+
skipAddingFilesFromTsConfig: true,
|
|
51
|
+
});
|
|
52
|
+
addFilesRecursively(repoPath, project);
|
|
53
|
+
const componentNodes = nodes.filter(n => n.type === "COMPONENT");
|
|
54
|
+
for (const component of componentNodes) {
|
|
55
|
+
const absPath = path.resolve(repoPath, component.filePath).replace(/\\/g, "/");
|
|
56
|
+
const sourceFile = project.getSourceFile(absPath);
|
|
57
|
+
if (!sourceFile)
|
|
58
|
+
continue;
|
|
59
|
+
// Find all JSX elements in the file
|
|
60
|
+
const jsxOpeningElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement);
|
|
61
|
+
const jsxSelfClosingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
|
|
62
|
+
// Filter to only JSX within this component's line range (meaning which lies between this component's startLine and endLine)
|
|
63
|
+
const allJsxElements = [...jsxOpeningElements, ...jsxSelfClosingElements]
|
|
64
|
+
.filter(el => {
|
|
65
|
+
const line = el.getStartLineNumber();
|
|
66
|
+
return line >= component.startLine && line <= component.endLine;
|
|
67
|
+
});
|
|
68
|
+
for (const jsxElement of allJsxElements) {
|
|
69
|
+
const tagName = jsxElement.getTagNameNode().getText();
|
|
70
|
+
// Skip HTML native elements — they start with lowercase
|
|
71
|
+
if (/^[a-z]/.test(tagName))
|
|
72
|
+
continue;
|
|
73
|
+
// ── Context.Provider detection ─────────────────────────────────
|
|
74
|
+
// <AuthContext.Provider value={...}> signals this component
|
|
75
|
+
// provides that context to its subtree → WRITES_TO edge.
|
|
76
|
+
if (tagName.endsWith(".Provider")) {
|
|
77
|
+
const contextName = tagName.slice(0, -".Provider".length);
|
|
78
|
+
const ctxStore = lookupMp.storeNodes.find(n => n.name === contextName && n.metadata.storeType === "context");
|
|
79
|
+
if (ctxStore) {
|
|
80
|
+
edges.push({
|
|
81
|
+
from: component.id,
|
|
82
|
+
to: ctxStore.id,
|
|
83
|
+
type: "WRITES_TO",
|
|
84
|
+
metadata: { isContextProvider: true },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
continue; // not a renderable component node — skip normal lookup
|
|
88
|
+
}
|
|
89
|
+
const targetNodes = lookupMp.nodesByName.get(tagName);
|
|
90
|
+
if (!targetNodes || targetNodes.length === 0)
|
|
91
|
+
continue; // this means that this is not a component we extracted
|
|
92
|
+
//Extract prop names passed to the component
|
|
93
|
+
const attributes = jsxElement.getAttributes();
|
|
94
|
+
const props = [];
|
|
95
|
+
for (const attr of attributes) {
|
|
96
|
+
// Regular prop: user={currentUser} or disabled
|
|
97
|
+
// Spread prop: {...props} — we skip these
|
|
98
|
+
if (attr.getKind() === SyntaxKind.JsxAttribute) {
|
|
99
|
+
const propName = attr.getFirstChild()?.getText();
|
|
100
|
+
if (propName)
|
|
101
|
+
props.push(propName);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// When multiple components share the same name, pick the one
|
|
105
|
+
// physically closest to the rendering file to avoid false edges.
|
|
106
|
+
const targetNode = targetNodes.length === 1
|
|
107
|
+
? targetNodes[0]
|
|
108
|
+
: closestByPath(targetNodes, component.filePath);
|
|
109
|
+
if (targetNode.id === component.id)
|
|
110
|
+
continue; //skip self referencing
|
|
111
|
+
const edgeKey = `${component.id}→${targetNode.id}`;
|
|
112
|
+
if (edgeIndex.has(edgeKey)) {
|
|
113
|
+
// Edge already exists — update renderCount
|
|
114
|
+
const idx = edgeIndex.get(edgeKey);
|
|
115
|
+
const existing = edges[idx];
|
|
116
|
+
const currentCount = existing.metadata?.renderCount ?? 1;
|
|
117
|
+
edges[idx] = {
|
|
118
|
+
...existing,
|
|
119
|
+
metadata: {
|
|
120
|
+
...existing.metadata,
|
|
121
|
+
renderCount: currentCount + 1,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// create new edge
|
|
127
|
+
const newEdge = {
|
|
128
|
+
from: component.id,
|
|
129
|
+
to: targetNode.id,
|
|
130
|
+
type: "PROP_PASS",
|
|
131
|
+
metadata: {
|
|
132
|
+
props,
|
|
133
|
+
renderCount: 1,
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
edgeIndex.set(edgeKey, edges.length);
|
|
137
|
+
edges.push(newEdge);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return edges;
|
|
142
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { closestByPath } from "./utils.js";
|
|
2
|
+
// ─── detectRouteEdges ─────────────────────────────────────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// Creates HANDLES edges from ROUTE nodes → their handler CodeNodes.
|
|
5
|
+
//
|
|
6
|
+
// Resolution strategy per route kind:
|
|
7
|
+
//
|
|
8
|
+
// Backend (Express/Fastify/Koa):
|
|
9
|
+
// 1. metadata.handlerName + same filePath → nodesByFile exact match
|
|
10
|
+
// 2. metadata.handlerName only → nodesByName fallback
|
|
11
|
+
// (handler imported from another file)
|
|
12
|
+
//
|
|
13
|
+
// Next.js API route (route.ts, routeNodeType = "API_ROUTE"):
|
|
14
|
+
// metadata.httpMethod (e.g. "GET") → find node in same file
|
|
15
|
+
// with metadata.isHttpHandler === true && metadata.httpMethod === method
|
|
16
|
+
//
|
|
17
|
+
// Next.js page route (routeNodeType = "PAGE"):
|
|
18
|
+
// → find node in same file with metadata.exportType === "default"
|
|
19
|
+
//
|
|
20
|
+
// Next.js layout/loading/error/not-found:
|
|
21
|
+
// → same as page — default export is the handler
|
|
22
|
+
//
|
|
23
|
+
// If no handler is resolved the route node is still valid in the graph,
|
|
24
|
+
// it just has no outgoing HANDLES edge. This is intentional — orphan
|
|
25
|
+
// route nodes are useful for "unconnected entry points" analysis.
|
|
26
|
+
export function detectRouteEdges(nodes, lookup) {
|
|
27
|
+
const edges = [];
|
|
28
|
+
// Only process ROUTE nodes
|
|
29
|
+
const routeNodes = nodes.filter(n => n.type === "ROUTE");
|
|
30
|
+
for (const routeNode of routeNodes) {
|
|
31
|
+
const meta = routeNode.metadata;
|
|
32
|
+
const filePath = routeNode.filePath;
|
|
33
|
+
const routeKind = meta.routeKind;
|
|
34
|
+
let handlerNode;
|
|
35
|
+
if (routeKind === "backend") {
|
|
36
|
+
handlerNode = resolveBackendHandler(routeNode, lookup);
|
|
37
|
+
}
|
|
38
|
+
else if (routeKind === "nextjs") {
|
|
39
|
+
const routeNodeType = meta.routeNodeType;
|
|
40
|
+
if (routeNodeType === "API_ROUTE") {
|
|
41
|
+
handlerNode = resolveNextjsApiHandler(routeNode, lookup);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// PAGE, LAYOUT, LOADING, ERROR, NOT_FOUND — all point to default export
|
|
45
|
+
handlerNode = resolveDefaultExport(filePath, lookup);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!handlerNode)
|
|
49
|
+
continue;
|
|
50
|
+
if (handlerNode.id === routeNode.id)
|
|
51
|
+
continue; // safety: no self-loop
|
|
52
|
+
edges.push({
|
|
53
|
+
from: routeNode.id,
|
|
54
|
+
to: handlerNode.id,
|
|
55
|
+
type: "HANDLES",
|
|
56
|
+
metadata: {
|
|
57
|
+
urlPath: meta.urlPath,
|
|
58
|
+
httpMethod: meta.httpMethod ?? null,
|
|
59
|
+
routeKind,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return edges;
|
|
64
|
+
}
|
|
65
|
+
// ─── Resolution helpers ───────────────────────────────────────────────────────
|
|
66
|
+
function resolveBackendHandler(routeNode, lookup) {
|
|
67
|
+
// check for the inline handler first. synthetic node for the inline function was created in the routesToCodeNodes function in the backendRoutes.ts extractor
|
|
68
|
+
const inlineHandlerId = routeNode.metadata.inlineHandlerId;
|
|
69
|
+
if (inlineHandlerId) {
|
|
70
|
+
return lookup.nodesByFile.get(routeNode.filePath)?.find(n => n.id === inlineHandlerId);
|
|
71
|
+
}
|
|
72
|
+
//if no inline handler, then proceed with the regular handler name resolution
|
|
73
|
+
const handlerName = routeNode.metadata.handlerName;
|
|
74
|
+
if (!handlerName)
|
|
75
|
+
return undefined;
|
|
76
|
+
const filePath = routeNode.filePath;
|
|
77
|
+
// Strategy 1 — handler defined in the same file as the route registration
|
|
78
|
+
const nodesInFile = lookup.nodesByFile.get(filePath) ?? [];
|
|
79
|
+
const sameFile = nodesInFile.find(n => n.name === handlerName); //same file won't have duplicates, so no need for checking id I think
|
|
80
|
+
if (sameFile)
|
|
81
|
+
return sameFile;
|
|
82
|
+
// Strategy 2 — handler imported from another file, look up by name only.
|
|
83
|
+
// If multiple nodes share the name, prefer the one closest to the route file
|
|
84
|
+
// (shortest relative path difference — simple heuristic).
|
|
85
|
+
const byName = lookup.nodesByName.get(handlerName) ?? [];
|
|
86
|
+
if (byName.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (byName.length === 1)
|
|
89
|
+
return byName[0];
|
|
90
|
+
// Multiple candidates — pick the one whose filePath shares the most
|
|
91
|
+
// path segments with the route file
|
|
92
|
+
return closestByPath(byName, filePath);
|
|
93
|
+
}
|
|
94
|
+
function resolveNextjsApiHandler(routeNode, lookup) {
|
|
95
|
+
const httpMethod = routeNode.metadata.httpMethod;
|
|
96
|
+
if (!httpMethod)
|
|
97
|
+
return undefined;
|
|
98
|
+
const filePath = routeNode.filePath;
|
|
99
|
+
const nodesInFile = lookup.nodesByFile.get(filePath) ?? [];
|
|
100
|
+
// Find a node in the same file that was flagged as an HTTP handler
|
|
101
|
+
// with the matching method name (set by functions.ts extractor)
|
|
102
|
+
const handler = nodesInFile.find(n => n.metadata.isHttpHandler === true &&
|
|
103
|
+
n.metadata.httpMethod === httpMethod);
|
|
104
|
+
// console.log("routeNode filePath:", filePath, "and handler => ", handler, "and http method => ", httpMethod);
|
|
105
|
+
// nodesInFile.forEach(n => console.log("candidate handler in file:", filePath, "for node name: ", n.name, "with metadata => ", n.metadata));
|
|
106
|
+
if (handler)
|
|
107
|
+
return handler;
|
|
108
|
+
// Fallback — look for a node whose name exactly matches the HTTP method
|
|
109
|
+
// (handles cases where isHttpHandler flag wasn't set, e.g. older parse)
|
|
110
|
+
return nodesInFile.find(n => n.name === httpMethod);
|
|
111
|
+
}
|
|
112
|
+
function resolveDefaultExport(filePath, lookup) {
|
|
113
|
+
const nodesInFile = lookup.nodesByFile.get(filePath) ?? [];
|
|
114
|
+
// Find the node explicitly marked as default export
|
|
115
|
+
const defaultExport = nodesInFile.find(n => n.metadata.exportType === "default");
|
|
116
|
+
if (defaultExport)
|
|
117
|
+
return defaultExport;
|
|
118
|
+
// Fallback — if only one component/function in the file, it's likely
|
|
119
|
+
// the default export even if metadata.exportType wasn't captured
|
|
120
|
+
const candidates = nodesInFile.filter(n => n.type === "COMPONENT" || n.type === "FUNCTION");
|
|
121
|
+
if (candidates.length === 1)
|
|
122
|
+
return candidates[0];
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|