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,249 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { analyzeFingerprint } from "../fingerprint/index.js";
|
|
5
|
+
import { analyzeFilesystem } from "../filesystem/index.js";
|
|
6
|
+
import { parseRepo } from "../parser/index.js";
|
|
7
|
+
import { detectEdges } from "../graph/index.js";
|
|
8
|
+
import { buildThirdPartyNodes } from "../graph/thirdPartyLibs.js";
|
|
9
|
+
import { scoreAndFilter } from "../scoring/index.js";
|
|
10
|
+
// āāā Helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
11
|
+
// Deterministic graphId ā same repo always produces same id
|
|
12
|
+
// This ensures multiple analyses of the same repo go into the same folder
|
|
13
|
+
function generateGraphId(repoPath, isGithubRepo) {
|
|
14
|
+
const normalized = isGithubRepo
|
|
15
|
+
? repoPath.toLowerCase().trim() // normalize GitHub URL
|
|
16
|
+
: path.resolve(repoPath).toLowerCase(); // normalize local path
|
|
17
|
+
return createHash("sha256")
|
|
18
|
+
.update(normalized)
|
|
19
|
+
.digest("hex")
|
|
20
|
+
.slice(0, 16);
|
|
21
|
+
}
|
|
22
|
+
// Gets current git state of the repo
|
|
23
|
+
// Falls back gracefully if git is not initialized
|
|
24
|
+
function getGitInfo(repoPath) {
|
|
25
|
+
try {
|
|
26
|
+
const commitHash = execSync("git rev-parse HEAD", { cwd: repoPath })
|
|
27
|
+
.toString().trim();
|
|
28
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath })
|
|
29
|
+
.toString().trim();
|
|
30
|
+
const message = execSync("git log -1 --pretty=%s", { cwd: repoPath })
|
|
31
|
+
.toString().trim();
|
|
32
|
+
return { commitHash, branch, message, hasGit: true };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// No git, or no commits yet ā use timestamp as version key
|
|
36
|
+
return {
|
|
37
|
+
commitHash: Date.now().toString(),
|
|
38
|
+
branch: "unknown",
|
|
39
|
+
message: "no git history",
|
|
40
|
+
hasGit: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function buildStats(scoringResult, allNodes) {
|
|
45
|
+
const topScoringFiles = allNodes
|
|
46
|
+
.filter((n) => n.type === "FILE")
|
|
47
|
+
.map((n) => ({
|
|
48
|
+
name: n.name,
|
|
49
|
+
score: scoringResult.nodeScores.get(n.id) ?? 0,
|
|
50
|
+
filePath: n.filePath,
|
|
51
|
+
}))
|
|
52
|
+
.sort((a, b) => b.score - a.score)
|
|
53
|
+
.slice(0, 10);
|
|
54
|
+
return {
|
|
55
|
+
...scoringResult.stats,
|
|
56
|
+
topScoringFiles,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function mapToRecord(map) {
|
|
60
|
+
const record = {};
|
|
61
|
+
for (const [k, v] of map)
|
|
62
|
+
record[k] = v;
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
// routesToCodeNodes
|
|
66
|
+
//
|
|
67
|
+
// Converts RouteNode[] / BackendRouteNode[] into ROUTE CodeNodes so they
|
|
68
|
+
// participate in the graph as first-class nodes.
|
|
69
|
+
//
|
|
70
|
+
// Route-specific fields go into metadata ā CodeNode schema stays clean.
|
|
71
|
+
// IDs follow the same filePath::name convention as all other nodes so
|
|
72
|
+
// the lookup maps in buildLookupMaps() work without any changes.
|
|
73
|
+
//
|
|
74
|
+
// Naming convention:
|
|
75
|
+
// Next.js page/layout/API: "GET /api/users" ā id = "app/api/users/route.ts::GET /api/users"
|
|
76
|
+
// Backend (Express etc.): "POST /users" ā id = "src/routes/users.ts::POST /users"
|
|
77
|
+
function routesToCodeNodes(routes, repoPath) {
|
|
78
|
+
const nodes = [];
|
|
79
|
+
for (const route of routes) {
|
|
80
|
+
// Make filePath relative ā same as parser does for all other nodes
|
|
81
|
+
const relativeFilePath = path.relative(repoPath, route.filePath).replace(/\\/g, "/");
|
|
82
|
+
if (route.type === "BACKEND_ROUTE") {
|
|
83
|
+
// Each backend route = one node per HTTP method + path combination
|
|
84
|
+
const name = `${route.httpMethod} ${route.urlPath}`;
|
|
85
|
+
const id = `${relativeFilePath}::${name}`;
|
|
86
|
+
// create synthetic function type nodes for the inline handlers
|
|
87
|
+
let inlineHandlerId;
|
|
88
|
+
if (route.inlineHandler) {
|
|
89
|
+
inlineHandlerId = `${relativeFilePath}::${name}::handler`;
|
|
90
|
+
nodes.push({
|
|
91
|
+
id: inlineHandlerId,
|
|
92
|
+
name: `${name} handler`,
|
|
93
|
+
type: "FUNCTION",
|
|
94
|
+
filePath: relativeFilePath,
|
|
95
|
+
startLine: route.inlineHandler.startLine,
|
|
96
|
+
endLine: route.inlineHandler.endLine,
|
|
97
|
+
rawCode: route.inlineHandler.rawCode,
|
|
98
|
+
codeHash: createHash("sha256").update(route.inlineHandler.rawCode).digest("hex").slice(0, 16),
|
|
99
|
+
parentFile: `file::${relativeFilePath}`,
|
|
100
|
+
metadata: {
|
|
101
|
+
isHttpHandler: true,
|
|
102
|
+
httpMethod: route.httpMethod,
|
|
103
|
+
isInlineHandler: true,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
nodes.push({
|
|
108
|
+
id,
|
|
109
|
+
name,
|
|
110
|
+
type: "ROUTE",
|
|
111
|
+
filePath: relativeFilePath,
|
|
112
|
+
startLine: 1,
|
|
113
|
+
endLine: 1,
|
|
114
|
+
parentFile: `file::${relativeFilePath}`,
|
|
115
|
+
metadata: {
|
|
116
|
+
urlPath: route.urlPath,
|
|
117
|
+
httpMethod: route.httpMethod,
|
|
118
|
+
isDynamic: route.isDynamic,
|
|
119
|
+
params: route.params ?? [],
|
|
120
|
+
framework: route.framework,
|
|
121
|
+
handlerName: route.handlerName, // used by routeEdges to resolve handler
|
|
122
|
+
routeKind: "backend",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Next.js RouteNode ā one node per HTTP method for API routes,
|
|
128
|
+
// one node for page/layout/etc.
|
|
129
|
+
const httpMethods = route.httpMethods && route.httpMethods.length > 0
|
|
130
|
+
? route.httpMethods
|
|
131
|
+
: route.type === "API_ROUTE"
|
|
132
|
+
? ["GET", "PUT", "POST", "DELETE", "PATCH"] // fallback ā we'll refine via routeEdges handler lookup
|
|
133
|
+
: [null]; // non-API routes (PAGE, LAYOUT etc.) have no method
|
|
134
|
+
for (const method of httpMethods) {
|
|
135
|
+
const name = method
|
|
136
|
+
? `${method} ${route.urlPath}`
|
|
137
|
+
: route.urlPath;
|
|
138
|
+
const id = `${relativeFilePath}::${name}`;
|
|
139
|
+
nodes.push({
|
|
140
|
+
id,
|
|
141
|
+
name,
|
|
142
|
+
type: "ROUTE",
|
|
143
|
+
filePath: relativeFilePath,
|
|
144
|
+
startLine: 1,
|
|
145
|
+
endLine: 1,
|
|
146
|
+
parentFile: `file::${relativeFilePath}`,
|
|
147
|
+
metadata: {
|
|
148
|
+
urlPath: route.urlPath,
|
|
149
|
+
httpMethod: method ?? null,
|
|
150
|
+
isDynamic: route.isDynamic,
|
|
151
|
+
isCatchAll: route.isCatchAll,
|
|
152
|
+
isGroupRoute: route.isGroupRoute,
|
|
153
|
+
params: route.params ?? [],
|
|
154
|
+
routeNodeType: route.type, // PAGE | LAYOUT | API_ROUTE | etc.
|
|
155
|
+
layoutPath: route.layoutPath,
|
|
156
|
+
framework: "nextjs",
|
|
157
|
+
routeKind: "nextjs",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return nodes;
|
|
164
|
+
}
|
|
165
|
+
// āāā analyzePipeline āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
166
|
+
export async function analyzePipeline(repoPath, isGithubRepo, options) {
|
|
167
|
+
const absoluteRepoPath = path.resolve(repoPath);
|
|
168
|
+
const graphId = generateGraphId(repoPath, isGithubRepo); // stable, deterministic ID based on repo path
|
|
169
|
+
const gitInfo = getGitInfo(absoluteRepoPath);
|
|
170
|
+
const analyzedAt = new Date().toISOString();
|
|
171
|
+
console.log(`\nš devlens ā analyzing ${absoluteRepoPath}`);
|
|
172
|
+
console.log(` Graph ID: ${graphId}`);
|
|
173
|
+
console.log(` Commit: ${gitInfo.commitHash} (${gitInfo.branch})`);
|
|
174
|
+
console.log(` Message: ${gitInfo.message}`);
|
|
175
|
+
// āā Step 1: Fingerprint āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
176
|
+
console.log("\n[1/5] Fingerprinting project...");
|
|
177
|
+
const fingerprint = analyzeFingerprint(absoluteRepoPath);
|
|
178
|
+
console.log(` Framework: ${fingerprint.framework} | Language: ${fingerprint.language} | Type: ${fingerprint.projectType}`);
|
|
179
|
+
// āā Step 2: Filesystem / routes āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
180
|
+
console.log("\n[2/5] Analyzing filesystem routes...");
|
|
181
|
+
const routes = analyzeFilesystem(absoluteRepoPath, fingerprint);
|
|
182
|
+
console.log(` Routes found: ${routes.length}`);
|
|
183
|
+
// Convert routes -> CodeNodes so they join the graph as nodes as well
|
|
184
|
+
// It is important to add here before the detection of the edges
|
|
185
|
+
let routeNodes = routesToCodeNodes(routes, absoluteRepoPath);
|
|
186
|
+
console.log(` Route nodes created: ${routeNodes.length}`);
|
|
187
|
+
// āā Step 3: Parse source files into nodes āāāāāāāāāāāāāāāāāāāāā
|
|
188
|
+
console.log("\n[3/5] Parsing source files...");
|
|
189
|
+
const parserResult = parseRepo(absoluteRepoPath);
|
|
190
|
+
console.log(` Files: ${parserResult.stats.totalFiles} | Nodes: ${parserResult.stats.totalNodes} | Skipped: ${parserResult.stats.skippedFiles}`);
|
|
191
|
+
// āā Step 3.5: Build third-party nodes āāāāāāāāāāāāāāāāāāāāāāāā
|
|
192
|
+
const thirdPartyNodes = options?.includedThirdPartyLibs?.length
|
|
193
|
+
? buildThirdPartyNodes(absoluteRepoPath, options.includedThirdPartyLibs)
|
|
194
|
+
: [];
|
|
195
|
+
if (thirdPartyNodes.length) {
|
|
196
|
+
console.log(` Third-party nodes: ${thirdPartyNodes.length}`);
|
|
197
|
+
}
|
|
198
|
+
// āā Step 4: Detect edges āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
199
|
+
console.log("\n[4/5] Detecting edges...");
|
|
200
|
+
const edgeResult = detectEdges([...parserResult.nodes, ...routeNodes, ...thirdPartyNodes], routes, absoluteRepoPath, fingerprint);
|
|
201
|
+
// filter API_ROUTE nodes without handlers - because at the time of converting routes to code nodes, POST and GET both possibilties are taken for the API_ROUTE nodes, however it is possible that only one of them is being used for that route. Meaning only one handler and for the second method undefined handler.
|
|
202
|
+
routeNodes = routeNodes.filter(routeNode => {
|
|
203
|
+
if (routeNode.metadata.routeNodeType === "API_ROUTE") {
|
|
204
|
+
const hasHandler = edgeResult.edges.some(edge => edge.type === "HANDLES" && edge.from === routeNode.id);
|
|
205
|
+
return hasHandler;
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
const allNodes = [...parserResult.nodes, ...routeNodes, ...thirdPartyNodes, ...edgeResult.ghostNodes];
|
|
210
|
+
const allEdges = edgeResult.edges;
|
|
211
|
+
// āā Step 5: Score and filter āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
212
|
+
console.log("\n[5/5] Scoring and filtering...");
|
|
213
|
+
const scoringResult = scoreAndFilter(allNodes, allEdges, options?.thresholds);
|
|
214
|
+
const nodeScores = mapToRecord(scoringResult.nodeScores);
|
|
215
|
+
const stats = buildStats(scoringResult, allNodes);
|
|
216
|
+
// Embed score directly onto every node ā allNodes and filteredNodes both.
|
|
217
|
+
// nodeScores map stays for diffCommits and refilterPipeline which need it,
|
|
218
|
+
// but consumers (frontend, Neo4j, summarizer) get score on the node itself.
|
|
219
|
+
for (const node of allNodes) {
|
|
220
|
+
node.score = nodeScores[node.id] ?? 0;
|
|
221
|
+
}
|
|
222
|
+
console.log(`\nā
Analysis complete ā graph ${graphId} @ commit ${gitInfo.commitHash}`);
|
|
223
|
+
return {
|
|
224
|
+
graphId,
|
|
225
|
+
repoPath: absoluteRepoPath,
|
|
226
|
+
analyzedAt,
|
|
227
|
+
fingerprint,
|
|
228
|
+
routes,
|
|
229
|
+
nodes: scoringResult.filteredNodes,
|
|
230
|
+
edges: scoringResult.filteredEdges,
|
|
231
|
+
allNodes,
|
|
232
|
+
allEdges,
|
|
233
|
+
nodeScores,
|
|
234
|
+
stats,
|
|
235
|
+
isGithubRepo,
|
|
236
|
+
gitInfo,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// āāā refilterPipeline āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
240
|
+
export function refilterPipeline(stored, thresholds) {
|
|
241
|
+
const existingScores = new Map(Object.entries(stored.nodeScores));
|
|
242
|
+
const scoringResult = scoreAndFilter(stored.allNodes, stored.allEdges, thresholds, existingScores);
|
|
243
|
+
const stats = buildStats(scoringResult, stored.allNodes);
|
|
244
|
+
return {
|
|
245
|
+
nodes: scoringResult.filteredNodes,
|
|
246
|
+
edges: scoringResult.filteredEdges,
|
|
247
|
+
stats,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CodeNode, CodeEdge } from "../types.js";
|
|
2
|
+
export interface ConnectionProfile {
|
|
3
|
+
incomingCalls: number;
|
|
4
|
+
outgoingCalls: number;
|
|
5
|
+
incomingReads: number;
|
|
6
|
+
incomingWrites: number;
|
|
7
|
+
incomingProps: number;
|
|
8
|
+
outgoingProps: number;
|
|
9
|
+
importedBy: number;
|
|
10
|
+
}
|
|
11
|
+
export interface ConnectionMaxima {
|
|
12
|
+
maxIncomingCalls: number;
|
|
13
|
+
maxOutgoingCalls: number;
|
|
14
|
+
maxIncomingReads: number;
|
|
15
|
+
maxIncomingWrites: number;
|
|
16
|
+
maxIncomingProps: number;
|
|
17
|
+
maxOutgoingProps: number;
|
|
18
|
+
maxImportedBy: number;
|
|
19
|
+
p75IncomingCalls: number;
|
|
20
|
+
p75OutgoingCalls: number;
|
|
21
|
+
p75IncomingReads: number;
|
|
22
|
+
p75IncomingProps: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ConnectionCountResult {
|
|
25
|
+
profiles: Map<string, ConnectionProfile>;
|
|
26
|
+
maxima: ConnectionMaxima;
|
|
27
|
+
}
|
|
28
|
+
export declare function countConnections(nodes: CodeNode[], edges: CodeEdge[]): ConnectionCountResult;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
function emptyProfile() {
|
|
2
|
+
return {
|
|
3
|
+
incomingCalls: 0,
|
|
4
|
+
outgoingCalls: 0,
|
|
5
|
+
incomingReads: 0,
|
|
6
|
+
incomingWrites: 0,
|
|
7
|
+
incomingProps: 0,
|
|
8
|
+
outgoingProps: 0,
|
|
9
|
+
importedBy: 0,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
// Computes the 75th percentile of a number array
|
|
13
|
+
function percentile75(values) {
|
|
14
|
+
if (values.length === 0)
|
|
15
|
+
return 1;
|
|
16
|
+
// Build frequency map ā O(n)
|
|
17
|
+
// key: connection count value, value: how many nodes have that count
|
|
18
|
+
const freq = new Map();
|
|
19
|
+
for (const v of values) {
|
|
20
|
+
freq.set(v, (freq.get(v) ?? 0) + 1);
|
|
21
|
+
}
|
|
22
|
+
// Sort only the UNIQUE keys ā O(k log k) where k << n
|
|
23
|
+
// In practice k is tiny ā most codebases have counts 1-20
|
|
24
|
+
// even if they have 500+ nodes
|
|
25
|
+
const uniqueKeys = Array.from(freq.keys()).sort((a, b) => a - b);
|
|
26
|
+
// Walk cumulative count until we reach the 75th percentile position
|
|
27
|
+
const target = Math.floor(values.length * 0.75);
|
|
28
|
+
let cumulative = 0;
|
|
29
|
+
for (const key of uniqueKeys) {
|
|
30
|
+
cumulative += freq.get(key);
|
|
31
|
+
if (cumulative >= target) {
|
|
32
|
+
return Math.max(1, key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Fallback ā return the largest value
|
|
36
|
+
return Math.max(1, uniqueKeys[uniqueKeys.length - 1]);
|
|
37
|
+
}
|
|
38
|
+
export function countConnections(nodes, edges) {
|
|
39
|
+
const profiles = new Map();
|
|
40
|
+
// Initialize a profile for every node
|
|
41
|
+
for (const node of nodes) {
|
|
42
|
+
profiles.set(node.id, emptyProfile());
|
|
43
|
+
}
|
|
44
|
+
// āāā Pass 1 ā Count edges āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
45
|
+
for (const edge of edges) {
|
|
46
|
+
const fromProfile = profiles.get(edge.from);
|
|
47
|
+
const toProfile = profiles.get(edge.to);
|
|
48
|
+
switch (edge.type) {
|
|
49
|
+
case "CALLS":
|
|
50
|
+
if (fromProfile)
|
|
51
|
+
fromProfile.outgoingCalls += 1;
|
|
52
|
+
if (toProfile)
|
|
53
|
+
toProfile.incomingCalls += 1;
|
|
54
|
+
break;
|
|
55
|
+
case "READS_FROM":
|
|
56
|
+
if (toProfile)
|
|
57
|
+
toProfile.incomingReads += 1;
|
|
58
|
+
break;
|
|
59
|
+
case "WRITES_TO":
|
|
60
|
+
if (toProfile)
|
|
61
|
+
toProfile.incomingWrites += 1;
|
|
62
|
+
break;
|
|
63
|
+
case "PROP_PASS":
|
|
64
|
+
if (fromProfile)
|
|
65
|
+
fromProfile.outgoingProps += 1;
|
|
66
|
+
if (toProfile)
|
|
67
|
+
toProfile.incomingProps += 1;
|
|
68
|
+
break;
|
|
69
|
+
case "IMPORTS":
|
|
70
|
+
if (toProfile)
|
|
71
|
+
toProfile.importedBy += 1;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// āāā Pass 2 ā Find true maxima āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
76
|
+
let maxIncomingCalls = 1;
|
|
77
|
+
let maxOutgoingCalls = 1;
|
|
78
|
+
let maxIncomingReads = 1;
|
|
79
|
+
let maxIncomingWrites = 1;
|
|
80
|
+
let maxIncomingProps = 1;
|
|
81
|
+
let maxOutgoingProps = 1;
|
|
82
|
+
let maxImportedBy = 1;
|
|
83
|
+
for (const profile of profiles.values()) {
|
|
84
|
+
if (profile.incomingCalls > maxIncomingCalls)
|
|
85
|
+
maxIncomingCalls = profile.incomingCalls;
|
|
86
|
+
if (profile.outgoingCalls > maxOutgoingCalls)
|
|
87
|
+
maxOutgoingCalls = profile.outgoingCalls;
|
|
88
|
+
if (profile.incomingReads > maxIncomingReads)
|
|
89
|
+
maxIncomingReads = profile.incomingReads;
|
|
90
|
+
if (profile.incomingWrites > maxIncomingWrites)
|
|
91
|
+
maxIncomingWrites = profile.incomingWrites;
|
|
92
|
+
if (profile.incomingProps > maxIncomingProps)
|
|
93
|
+
maxIncomingProps = profile.incomingProps;
|
|
94
|
+
if (profile.outgoingProps > maxOutgoingProps)
|
|
95
|
+
maxOutgoingProps = profile.outgoingProps;
|
|
96
|
+
if (profile.importedBy > maxImportedBy)
|
|
97
|
+
maxImportedBy = profile.importedBy;
|
|
98
|
+
}
|
|
99
|
+
// āāā Pass 3 ā Compute 75th percentiles āāāāāāāāāāāāāāāāāāāāāāāā
|
|
100
|
+
// Collect all non-zero values per signal type
|
|
101
|
+
// We only include non-zero values so nodes with no connections
|
|
102
|
+
// don't drag the percentile down to zero
|
|
103
|
+
const allIncomingCalls = [];
|
|
104
|
+
const allOutgoingCalls = [];
|
|
105
|
+
const allIncomingReads = [];
|
|
106
|
+
const allIncomingProps = [];
|
|
107
|
+
for (const profile of profiles.values()) {
|
|
108
|
+
if (profile.incomingCalls > 0)
|
|
109
|
+
allIncomingCalls.push(profile.incomingCalls);
|
|
110
|
+
if (profile.outgoingCalls > 0)
|
|
111
|
+
allOutgoingCalls.push(profile.outgoingCalls);
|
|
112
|
+
if (profile.incomingReads > 0)
|
|
113
|
+
allIncomingReads.push(profile.incomingReads);
|
|
114
|
+
if (profile.incomingProps > 0)
|
|
115
|
+
allIncomingProps.push(profile.incomingProps);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
profiles,
|
|
119
|
+
maxima: {
|
|
120
|
+
maxIncomingCalls,
|
|
121
|
+
maxOutgoingCalls,
|
|
122
|
+
maxIncomingReads,
|
|
123
|
+
maxIncomingWrites,
|
|
124
|
+
maxIncomingProps,
|
|
125
|
+
maxOutgoingProps,
|
|
126
|
+
maxImportedBy,
|
|
127
|
+
// 75th percentile ā used for normalization in nodeScorer
|
|
128
|
+
p75IncomingCalls: percentile75(allIncomingCalls),
|
|
129
|
+
p75OutgoingCalls: percentile75(allOutgoingCalls),
|
|
130
|
+
p75IncomingReads: percentile75(allIncomingReads),
|
|
131
|
+
p75IncomingProps: percentile75(allIncomingProps),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// āāā Internal Gravity (G_int) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2
|
+
// Formula: Σ(S²) / ΣS
|
|
3
|
+
// Favors one high scorer over many averages
|
|
4
|
+
function calcInternalGravity(childScores) {
|
|
5
|
+
if (childScores.length === 0)
|
|
6
|
+
return 0;
|
|
7
|
+
const sumOfSquares = childScores.reduce((acc, s) => acc + s * s, 0);
|
|
8
|
+
const sumOfScores = childScores.reduce((acc, s) => acc + s, 0);
|
|
9
|
+
if (sumOfScores === 0)
|
|
10
|
+
return 0;
|
|
11
|
+
return sumOfSquares / sumOfScores;
|
|
12
|
+
}
|
|
13
|
+
// āāā Reputation Boost (R_ext) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
14
|
+
// Formula: (10 - G_int) Ć (1 - 1/log10(importedBy + 10))
|
|
15
|
+
// Log-scaled import popularity
|
|
16
|
+
function calcReputationBoost(gInt, importedBy) {
|
|
17
|
+
const gap = 10 - gInt;
|
|
18
|
+
const multiplier = 1 - (1 / Math.log10(importedBy + 10));
|
|
19
|
+
return gap * multiplier;
|
|
20
|
+
}
|
|
21
|
+
// āāā Best Child Floor āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
22
|
+
// fileScore >= bestChild Ć 0.90 (dilution protection)
|
|
23
|
+
const BEST_CHILD_FLOOR_RATIO = 0.90;
|
|
24
|
+
// āāā File Score: gravity + floor + reputation āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
25
|
+
export function scoreFile(fileNode, children, nodeScores, importedBy) {
|
|
26
|
+
if (fileNode.type !== "FILE")
|
|
27
|
+
return 0;
|
|
28
|
+
// Empty files (types/barrels): reputation only
|
|
29
|
+
if (children.length === 0) {
|
|
30
|
+
const gInt = 0;
|
|
31
|
+
const rExt = calcReputationBoost(gInt, importedBy);
|
|
32
|
+
return Math.min(10, Math.max(0, rExt));
|
|
33
|
+
}
|
|
34
|
+
const childScores = children.map((n) => nodeScores.get(n.id) ?? 0);
|
|
35
|
+
// 1. Internal gravity
|
|
36
|
+
const gInt = calcInternalGravity(childScores);
|
|
37
|
+
// 2. Apply best-child floor
|
|
38
|
+
const maxChildScore = Math.max(...childScores);
|
|
39
|
+
const floor = maxChildScore * BEST_CHILD_FLOOR_RATIO;
|
|
40
|
+
const adjustedGInt = Math.max(gInt, floor);
|
|
41
|
+
// 3. Reputation boost
|
|
42
|
+
const rExt = calcReputationBoost(adjustedGInt, importedBy);
|
|
43
|
+
return Math.min(10, Math.max(0, adjustedGInt + rExt));
|
|
44
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CodeNode, CodeEdge } from "../types.js";
|
|
2
|
+
import { type FilterThresholds } from "./noiseFilter.js";
|
|
3
|
+
export interface ScoringResult {
|
|
4
|
+
filteredNodes: CodeNode[];
|
|
5
|
+
filteredEdges: CodeEdge[];
|
|
6
|
+
nodeScores: Map<string, number>;
|
|
7
|
+
stats: {
|
|
8
|
+
totalNodesBeforeFilter: number;
|
|
9
|
+
totalEdgesBeforeFilter: number;
|
|
10
|
+
totalNodesAfterFilter: number;
|
|
11
|
+
totalEdgesAfterFilter: number;
|
|
12
|
+
removedNodeCount: number;
|
|
13
|
+
removedEdgeCount: number;
|
|
14
|
+
averageNodeScore: number;
|
|
15
|
+
topScoringNodes: {
|
|
16
|
+
name: string;
|
|
17
|
+
score: number;
|
|
18
|
+
type: string;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare function scoreAndFilter(nodes: CodeNode[], edges: CodeEdge[], thresholds?: FilterThresholds, existingScores?: Map<string, number>): ScoringResult;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { countConnections } from "./connectionCounter.js";
|
|
2
|
+
import { scoreNode } from "./nodeScorer.js";
|
|
3
|
+
import { scoreFile } from "./fileScorer.js";
|
|
4
|
+
import { filterNoise } from "./noiseFilter.js";
|
|
5
|
+
export function scoreAndFilter(nodes, edges, thresholds, existingScores // when provided, skip Passes 1-4
|
|
6
|
+
) {
|
|
7
|
+
let nodeScores;
|
|
8
|
+
if (existingScores) {
|
|
9
|
+
// Re-filter only ā reuse scores from a previous analysis run
|
|
10
|
+
nodeScores = existingScores;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
console.log(`\nš Scoring ${nodes.length} nodes...`);
|
|
14
|
+
// Pass 1 + 2 ā Count connections and find maxima
|
|
15
|
+
const { profiles, maxima } = countConnections(nodes, edges);
|
|
16
|
+
// Build childrenByFile map ā O(n)
|
|
17
|
+
// Maps fileNode.id ā all child nodes in that file
|
|
18
|
+
const childrenByFile = new Map();
|
|
19
|
+
for (const node of nodes) {
|
|
20
|
+
if (node.type === "FILE")
|
|
21
|
+
continue;
|
|
22
|
+
if (!node.parentFile)
|
|
23
|
+
continue;
|
|
24
|
+
if (!childrenByFile.has(node.parentFile)) {
|
|
25
|
+
childrenByFile.set(node.parentFile, []);
|
|
26
|
+
}
|
|
27
|
+
childrenByFile.get(node.parentFile).push(node);
|
|
28
|
+
}
|
|
29
|
+
// Pass 3 ā Score all non-FILE nodes
|
|
30
|
+
nodeScores = new Map();
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
if (node.type === "FILE")
|
|
33
|
+
continue; // scored in pass 4
|
|
34
|
+
const profile = profiles.get(node.id) ?? {
|
|
35
|
+
incomingCalls: 0,
|
|
36
|
+
outgoingCalls: 0,
|
|
37
|
+
incomingReads: 0,
|
|
38
|
+
incomingWrites: 0,
|
|
39
|
+
incomingProps: 0,
|
|
40
|
+
outgoingProps: 0,
|
|
41
|
+
importedBy: 0,
|
|
42
|
+
};
|
|
43
|
+
const score = scoreNode(node, profile, maxima);
|
|
44
|
+
nodeScores.set(node.id, score);
|
|
45
|
+
}
|
|
46
|
+
// āāā Pass 4 ā Score FILE nodes using child scores āāāāāāāāāāāāā
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
if (node.type !== "FILE")
|
|
49
|
+
continue;
|
|
50
|
+
const children = childrenByFile.get(node.id) ?? [];
|
|
51
|
+
const profile = profiles.get(node.id) ?? {
|
|
52
|
+
incomingCalls: 0,
|
|
53
|
+
outgoingCalls: 0,
|
|
54
|
+
incomingReads: 0,
|
|
55
|
+
incomingWrites: 0,
|
|
56
|
+
incomingProps: 0,
|
|
57
|
+
outgoingProps: 0,
|
|
58
|
+
importedBy: 0,
|
|
59
|
+
};
|
|
60
|
+
const score = scoreFile(node, children, nodeScores, profile.importedBy);
|
|
61
|
+
nodeScores.set(node.id, score);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Pass 4.5 ā Score ROUTE nodes from their handler
|
|
65
|
+
//
|
|
66
|
+
// A route's significance is entirely determined by the handler it
|
|
67
|
+
// delegates to. We find the HANDLES edge from each route node and
|
|
68
|
+
// assign it the handler's score directly.
|
|
69
|
+
//
|
|
70
|
+
// If a route has multiple HANDLES edges (shouldn't happen but
|
|
71
|
+
// defensive) we take the max. If no handler is resolved, the
|
|
72
|
+
// route keeps its base type bonus score.
|
|
73
|
+
// Build a quick lookup: routeNodeId ā handler scores via HANDLES edges
|
|
74
|
+
const handlesEdges = edges.filter(e => e.type === "HANDLES");
|
|
75
|
+
for (const node of nodes) {
|
|
76
|
+
if (node.type !== "ROUTE")
|
|
77
|
+
continue;
|
|
78
|
+
const handlerScores = handlesEdges
|
|
79
|
+
.filter(e => e.from === node.id)
|
|
80
|
+
.map(e => nodeScores.get(e.to) ?? 0);
|
|
81
|
+
if (handlerScores.length === 0)
|
|
82
|
+
continue;
|
|
83
|
+
const handlerScore = Math.max(...handlerScores);
|
|
84
|
+
nodeScores.set(node.id, handlerScore);
|
|
85
|
+
}
|
|
86
|
+
// āāā Pass 5 ā Filter noise
|
|
87
|
+
const filterResult = filterNoise(nodes, edges, nodeScores, thresholds);
|
|
88
|
+
// āāā Build stats āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
89
|
+
const allScores = Array.from(nodeScores.values());
|
|
90
|
+
const averageScore = allScores.length > 0
|
|
91
|
+
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
|
92
|
+
: 0;
|
|
93
|
+
// Top 10 scoring nodes ā useful for UI and debugging
|
|
94
|
+
const topScoringNodes = nodes
|
|
95
|
+
.filter((n) => n.type !== "FILE")
|
|
96
|
+
.map((n) => ({
|
|
97
|
+
name: n.name,
|
|
98
|
+
type: n.type,
|
|
99
|
+
score: nodeScores.get(n.id) ?? 0,
|
|
100
|
+
}))
|
|
101
|
+
.sort((a, b) => b.score - a.score)
|
|
102
|
+
.slice(0, 10);
|
|
103
|
+
// āāā Log summary āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
104
|
+
console.log(` Nodes before filter: ${nodes.length}`);
|
|
105
|
+
console.log(` Nodes after filter: ${filterResult.nodes.length}`);
|
|
106
|
+
console.log(` Edges before filter: ${edges.length}`);
|
|
107
|
+
console.log(` Edges after filter: ${filterResult.edges.length}`);
|
|
108
|
+
console.log(` Removed nodes: ${filterResult.removedNodeCount}`);
|
|
109
|
+
console.log(` Removed edges: ${filterResult.removedEdgeCount}`);
|
|
110
|
+
console.log(` Average score: ${averageScore.toFixed(2)}`);
|
|
111
|
+
console.log(`\n š Top scoring nodes:`);
|
|
112
|
+
for (const n of topScoringNodes) {
|
|
113
|
+
console.log(` ${n.score.toFixed(2).padStart(5)} [${n.type}] ${n.name}`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
filteredNodes: filterResult.nodes,
|
|
117
|
+
filteredEdges: filterResult.edges,
|
|
118
|
+
nodeScores,
|
|
119
|
+
stats: {
|
|
120
|
+
totalNodesBeforeFilter: nodes.length,
|
|
121
|
+
totalEdgesBeforeFilter: edges.length,
|
|
122
|
+
totalNodesAfterFilter: filterResult.nodes.length,
|
|
123
|
+
totalEdgesAfterFilter: filterResult.edges.length,
|
|
124
|
+
removedNodeCount: filterResult.removedNodeCount,
|
|
125
|
+
removedEdgeCount: filterResult.removedEdgeCount,
|
|
126
|
+
averageNodeScore: parseFloat(averageScore.toFixed(2)),
|
|
127
|
+
topScoringNodes,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|