devlensio 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # devlensio — the DevLens analysis engine
2
+
3
+ [![npm: devlensio](https://img.shields.io/badge/npm-devlensio-cb3837?logo=npm)](https://www.npmjs.com/package/devlensio)
4
+ [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
5
+
6
+ The core engine behind [DevLens](https://github.com/devlensio/devlensOSS). It turns a TypeScript / JavaScript / React / Next.js / Node.js repository into a **typed code graph** — nodes (components, hooks, functions, stores, routes, files, …) joined by typed edges — scores every node by architectural importance, optionally **summarizes** each node with an LLM (technical / business / security), and exposes a **traversal/query API**.
7
+
8
+ `devlensio` is a **library + local server**. The user-facing tools — the `devlens` CLI, the MCP server, the Agent Skill, and the Web UI — live in [DevLens OSS](https://github.com/devlensio/devlensOSS) and consume this package.
9
+
10
+ ---
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install devlensio
16
+ # or: bun add devlensio
17
+ ```
18
+
19
+ Requires Node 18+ (or Bun). An LLM provider key is needed only for summarization, not for structural analysis.
20
+
21
+ ---
22
+
23
+ ## What it does (the pipeline)
24
+
25
+ ```
26
+ Repo path
27
+ │
28
+ [1] Fingerprint detect language, framework, router, state manager, data layer, databases
29
+ [2] Filesystem scan extract routes (Next.js app/pages, Express, Fastify, Koa)
30
+ [3] Parse (ts-morph) walk every .ts/.tsx/.js/.jsx → nodes (typed params, return types, prop types)
31
+ [4] Edge detection many detectors → CALLS, IMPORTS, READS_FROM, WRITES_TO, PROP_PASS, EMITS,
32
+ LISTENS, WRAPPED_BY, GUARDS, HANDLES, TESTS, USES, NEXTJS_API_CALL
33
+ [5] Scoring multi-pass importance scoring + noise filtering (no AI)
34
+ [6] Clustering cohesive cluster assignment
35
+ [7] Summarize (optional) topologically-ordered LLM summaries, checkpoint/resume, MapReduce
36
+ │
37
+ ▼
38
+ Graph persisted to ~/.devlens → queried via the traversal API / CLI / MCP / UI
39
+ ```
40
+
41
+ Structural analysis is fast and deterministic; summarization is the only step that calls an LLM and reuses unchanged nodes across commits.
42
+
43
+ ---
44
+
45
+ ## Public API
46
+
47
+ ```ts
48
+ import {
49
+ analyzePipeline, // build the graph (nodes, edges, scores)
50
+ runSummarization, // generate technical/business/security summaries
51
+ computeClusters, // cohesive clustering
52
+ buildGraphIndex, // index nodes+edges for traversal
53
+ getBlastRadius, // upstream dependents ("what breaks if I change this")
54
+ getKHop, // downstream dependencies ("what this needs")
55
+ getSubgraph, // cohesive cluster around a seed
56
+ findCycles, // circular-dependency groups
57
+ resolveConfig, initConfig, // LLM provider config (~/.devlens/config.json)
58
+ storage, queue, // file-based graph storage + job queue singletons
59
+ } from "devlensio";
60
+
61
+ // Analyze a repo → graph
62
+ const result = await analyzePipeline("/path/to/repo", /* isGithubRepo */ false);
63
+ // result.allNodes, result.allEdges, result.nodeScores
64
+
65
+ // Query the graph
66
+ const index = buildGraphIndex(result.allNodes, result.allEdges);
67
+ const impact = getBlastRadius(index, "src/auth/login.ts::login", { radius: 2 });
68
+ const cycles = findCycles(result.allNodes, result.allEdges);
69
+ ```
70
+
71
+ Also exported: all core types (`CodeNode`, `CodeEdge`, `NodeType`, `EdgeType`, …), config helpers (`maskConfig`, `writeConfig`), pre-scan helpers (`readPackageDependencies`, `categorizeLibrary`), and `EDGE_LABELS`. See `dist/index.d.ts` for the full surface.
72
+
73
+ ### Node & edge types
74
+
75
+ - **Node types:** `COMPONENT`, `HOOK`, `FUNCTION`, `STATE_STORE`, `UTILITY`, `FILE`, `ROUTE`, `TEST`, `STORY`, `THIRD_PARTY` (+ internal `GHOST`).
76
+ - **Edge types:** `CALLS`, `IMPORTS`, `READS_FROM`, `WRITES_TO`, `PROP_PASS`, `EMITS`, `LISTENS`, `WRAPPED_BY`, `GUARDS`, `HANDLES`, `TESTS`, `USES`, `NEXTJS_API_CALL` (frontend `fetch`/`axios`/`useSWR` call site → Next.js API route).
77
+ - Each node carries an importance score and (after summarization) a technical summary, a business summary, and a security assessment (`none|low|medium|high` + notes).
78
+
79
+ ---
80
+
81
+ ## Configuration
82
+
83
+ Summarization config lives in `~/.devlens/config.json` (set via `initConfig`/`writeConfig`, or env vars loaded with dotenv). Supported providers: **Anthropic**, **OpenAI**, **OpenRouter**, **Gemini**, **Ollama** (local).
84
+
85
+ ```env
86
+ LLM_PROVIDER=openrouter # ollama | openai | anthropic | openrouter | gemini
87
+ LLM_MODEL=grok-4.1-fast
88
+ LLM_API_KEY=your_api_key # not needed for ollama
89
+ LLM_BASE_URL= # e.g. http://localhost:11434 for Ollama
90
+ ```
91
+
92
+ Graphs and config are stored under `~/.devlens` and shared with all DevLens tools.
93
+
94
+ ---
95
+
96
+ ## Repo layout
97
+
98
+ ```
99
+ src/
100
+ ├── fingerprint/ # detect framework, language, router, state, data layer, databases
101
+ ├── filesystem/ # route detection (Next.js app/pages, Express, Fastify, Koa)
102
+ ├── parser/ # ts-morph AST extraction → nodes
103
+ ├── graph/ # edge detectors, traversal API, third-party libs, lookup maps
104
+ ├── scoring/ # multi-pass importance scoring + noise filtering
105
+ ├── clustering/ # cohesive cluster computation
106
+ ├── summarizer/ # LLM summarization (technical/business/security), prompts, checkpoints
107
+ ├── pipeline/ # analyzePipeline — orchestrates the whole analysis
108
+ ├── jobs/ # job queue, concurrency, SSE progress events
109
+ ├── storage/ # file-based graph persistence (~/.devlens)
110
+ ├── config/ # provider config resolution
111
+ ├── server/ # HTTP API server (consumed by the DevLens Web UI)
112
+ └── debug/ # exportGraph and dev utilities
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Scripts
118
+
119
+ | Script | Does |
120
+ | :-- | :-- |
121
+ | `bun run dev` | watch-mode HTTP server (`src/server/index.ts`) |
122
+ | `bun run start` | run the HTTP server |
123
+ | `bun run build` | `tsc --project tsconfig.build.json` → `dist/` (the published artifact) |
124
+ | `bun test` | run the test suite |
125
+ | `bun run export-graph` | dump a graph for debugging |
126
+
127
+ ---
128
+
129
+ ## Relationship to DevLens OSS
130
+
131
+ `devlensio` is published to npm and consumed by [DevLens OSS](https://github.com/devlensio/devlensOSS), which provides the `devlens` CLI (`@devlensio/cli`), the MCP server, the `/devlens` Agent Skill, and the Web UI on top of this engine. The CLI binaries bundle whatever version of `devlensio` resolves at build time, so engine fixes ship to users after a `devlensio` release **and** a bump of the dependency pin in DevLens OSS.
132
+
133
+ ---
134
+
135
+ ## License
136
+
137
+ [GNU Affero General Public License v3.0](LICENSE). If you run a modified version as a hosted service, you must release your modifications under the same license.
@@ -0,0 +1,8 @@
1
+ import { CodeEdge, CodeNode } from "../../types.js";
2
+ export interface ApiFetchCall {
3
+ callerType: string;
4
+ rawUrl: string;
5
+ resolvedUrl: string;
6
+ method: string;
7
+ }
8
+ export declare function detectNextjsApiCallEdges(nodes: CodeNode[], repoPath: string): CodeEdge[];
@@ -0,0 +1,437 @@
1
+ // This file detects the backend routes of the NEXTjs only
2
+ import { Project, SyntaxKind } from "ts-morph";
3
+ import path from "node:path";
4
+ const CALLER_CONFIG = {
5
+ "fetch": { inferMethod: "from-options", defaultMethod: "GET" },
6
+ "axios.get": { inferMethod: "fixed", method: "GET" },
7
+ "axios.post": { inferMethod: "fixed", method: "POST" },
8
+ "axios.put": { inferMethod: "fixed", method: "PUT" },
9
+ "axios.delete": { inferMethod: "fixed", method: "DELETE" },
10
+ "axios.patch": { inferMethod: "fixed", method: "PATCH" },
11
+ // axios(...) called as a function. Default verb is GET (matching axios),
12
+ // and the first arg may be either a URL string or a config object — both
13
+ // handled in extractApiCallsFromFile.
14
+ "axios": { inferMethod: "from-options", defaultMethod: "GET" },
15
+ // useSWR(key, fetcher) / useSWRMutation(key, fetcher): the first arg IS the
16
+ // key/URL, so URL extraction works. useQuery/useSuspenseQuery/useMutation
17
+ // are intentionally omitted — their first arg is a query-key array or an
18
+ // options object, never a URL. The real request they fire is the inner
19
+ // fetch/axios call, which is captured on its own as a CallExpression.
20
+ "useSWR": { inferMethod: "fixed", method: "GET" },
21
+ "useSWRMutation": { inferMethod: "fixed", method: "UNKNOWN" },
22
+ };
23
+ function urlPathtoRegex(urlPath) {
24
+ const pattern = urlPath.split("/").map(segment => {
25
+ if (segment.startsWith(":") && segment.endsWith("*"))
26
+ return ".+"; // catch-all :slug*
27
+ if (segment.startsWith(":"))
28
+ return "[^/]+"; // dynamic :id
29
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // escape static segments
30
+ }).join("\\/");
31
+ return new RegExp(`^${pattern}$`);
32
+ }
33
+ // Groups Next.js API ROUTE nodes by HTTP method for efficient lookup.
34
+ // Each route node carries a concrete method, so it is indexed under that one
35
+ // method key. Callers with an UNKNOWN method (e.g. useSWRMutation) are handled
36
+ // on the query side in matchRouteEntries by scanning every method bucket.
37
+ function buildRouteIndex(nodes) {
38
+ const index = new Map();
39
+ const apiRouteNodes = nodes.filter(n => n.type === "ROUTE" &&
40
+ n.metadata.routeKind === "nextjs" &&
41
+ n.metadata.routeNodeType === "API_ROUTE" &&
42
+ typeof n.metadata.httpMethod === "string" &&
43
+ typeof n.metadata.urlPath === "string");
44
+ for (const routeNode of apiRouteNodes) {
45
+ const httpMethod = routeNode.metadata.httpMethod.toUpperCase();
46
+ const urlPath = routeNode.metadata.urlPath;
47
+ const isDynamic = routeNode.metadata.isDynamic;
48
+ const entry = {
49
+ routeNode, urlPath, httpMethod, isDynamic, urlRegex: urlPathtoRegex(urlPath),
50
+ };
51
+ if (!index.has(httpMethod))
52
+ index.set(httpMethod, []);
53
+ index.get(httpMethod).push(entry);
54
+ }
55
+ return index;
56
+ }
57
+ /*
58
+ Extracts string value from an initializer node.
59
+ Handles 3 cases:
60
+ 'string literal' → returns value directly
61
+ `no-substitution template` → returns value directly
62
+ `template ${expr} literal` → preserves ${...} as-is so normalizeUrl handles it later
63
+ */
64
+ function resolveVariableValue(initializer) {
65
+ const kind = initializer.getKind();
66
+ if (kind === SyntaxKind.StringLiteral) {
67
+ return initializer.getLiteralText();
68
+ }
69
+ if (kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
70
+ return initializer.getLiteralText();
71
+ }
72
+ if (kind === SyntaxKind.TemplateExpression) {
73
+ const head = initializer.getHead().getLiteralText();
74
+ const spans = initializer.getTemplateSpans().map((span) => `\${${span.getExpression().getText()}}${span.getLiteral().getLiteralText()}`);
75
+ return head + spans.join("");
76
+ }
77
+ return null; // object, function, computed — can't resolve
78
+ }
79
+ /*
80
+ Resolves a variable name to its string value.
81
+ Strategy:
82
+ 1. Look in the same file
83
+ 2. Walk named imports → find the source file → look there
84
+ Files not yet in the project are added lazily so we don't pre-load everything.
85
+ */
86
+ function resolveUrlVariable(varName, sourceFile, project) {
87
+ //case 1 -> Variable exists in same file
88
+ const localDecl = sourceFile.getVariableDeclaration(varName);
89
+ if (localDecl) {
90
+ const init = localDecl.getInitializer();
91
+ if (init)
92
+ return resolveVariableValue(init);
93
+ }
94
+ //case 2 -> named imports
95
+ for (const importDecl of sourceFile.getImportDeclarations()) {
96
+ const match = importDecl.getNamedImports().find(n => n.getName() === varName);
97
+ if (!match)
98
+ continue;
99
+ // Try to get the source file — it may not be in the project yet
100
+ let importedFile = importDecl.getModuleSpecifierSourceFile();
101
+ if (!importedFile) {
102
+ // Lazily add the file to the project
103
+ const specifier = importDecl.getModuleSpecifierValue();
104
+ const currentDir = path.dirname(sourceFile.getFilePath());
105
+ const base = path.resolve(currentDir, specifier);
106
+ for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
107
+ try {
108
+ importedFile = project.addSourceFileAtPath(base + ext);
109
+ break;
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ }
115
+ }
116
+ if (!importedFile)
117
+ continue;
118
+ const importedDecl = importedFile.getVariableDeclaration(varName);
119
+ if (importedDecl) {
120
+ const init = importedDecl.getInitializer();
121
+ if (init)
122
+ return resolveVariableValue(init);
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ // URL Normalization
128
+ // Converts a raw URL string from the call site into a normalized form
129
+ // that can be compared against route index entries.
130
+ // Examples:
131
+ // `/api/users/${id}` → /api/users/:dynamic
132
+ // `/api/users/${org}/${id}` → /api/users/:dynamic/:dynamic
133
+ // /api/users?foo=bar → /api/users
134
+ // /api/users/ → /api/users
135
+ function normalizeUrl(rawUrl) {
136
+ // Skip external URLs
137
+ if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://"))
138
+ return null;
139
+ // Must start with /
140
+ if (!rawUrl.startsWith("/"))
141
+ return null;
142
+ return rawUrl
143
+ .split("?")[0] // strip query string
144
+ .replace(/\$\{[^}]+\}/g, ":dynamic") // ${anything} → :dynamic
145
+ .replace(/\/+$/, "") // strip trailing slash
146
+ || "/"; // fallback to root if empty
147
+ }
148
+ // Route Matching
149
+ // Matches a normalized URL + HTTP method against the route index.
150
+ // Rules:
151
+ // 1. Static routes (isDynamic=false) are matched first using === equality
152
+ // 2. Dynamic routes are only tried if no static match found
153
+ // 3. UNKNOWN method (useSWRMutation etc.) tries all entries across all methods
154
+ function matchRouteEntries(normalizedUrl, method, routeIndex) {
155
+ // Gather candidate entries for this method
156
+ const candidates = method === "UNKNOWN" ? [...routeIndex.values()].flat() : routeIndex.get(method) ?? [];
157
+ if (candidates.length === 0)
158
+ return [];
159
+ // Pass 1: static exact match
160
+ const staticMatches = candidates.filter(e => !e.isDynamic && e.urlPath === normalizedUrl);
161
+ if (staticMatches.length > 0)
162
+ return staticMatches;
163
+ // Pass 2: dynamic regex match
164
+ // normalizeUrl has already turned every interpolated segment into the
165
+ // literal token ":dynamic", which the route regex's [^/]+ (or .+ for
166
+ // catch-alls) matches directly — so testing the normalized URL is enough.
167
+ // e.g. "/api/users/:dynamic" matches the regex for route "/api/users/:id".
168
+ return candidates.filter(e => e.isDynamic && e.urlRegex.test(normalizedUrl));
169
+ }
170
+ // URL Argument Extraction
171
+ // Shared helper — reconstructs a template literal preserving ${...} as-is
172
+ // so normalizeUrl can replace them with :dynamic later.
173
+ // Same logic as resolveVariableValue's TemplateExpression branch.
174
+ function reconstructTemplate(templateExpr) {
175
+ const head = templateExpr.getHead().getLiteralText();
176
+ const spans = templateExpr.getTemplateSpans().map((span) => `\${${span.getExpression().getText()}}${span.getLiteral().getLiteralText()}`);
177
+ return head + spans.join("");
178
+ }
179
+ // Extracts a URL string from a call argument.
180
+ // Handles 4 cases:
181
+ // '/api/users' → string literal → return directly
182
+ // `/api/users` → no-sub template → return directly
183
+ // `/api/users/${id}` → template expression → preserve ${...}
184
+ // API_URL → identifier → resolve via resolveUrlVariable
185
+ // Returns null if the arg is an object / array / expression we can't resolve.
186
+ function extractUrlFromArg(arg, sourceFile, project) {
187
+ const kind = arg.getKind();
188
+ if (kind === SyntaxKind.StringLiteral ||
189
+ kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
190
+ const url = arg.getLiteralText();
191
+ return { rawUrl: url, resolvedUrl: url };
192
+ }
193
+ if (kind === SyntaxKind.TemplateExpression) {
194
+ const url = reconstructTemplate(arg);
195
+ return { rawUrl: url, resolvedUrl: url };
196
+ }
197
+ if (kind === SyntaxKind.Identifier) {
198
+ const varName = arg.getText();
199
+ const resolved = resolveUrlVariable(varName, sourceFile, project);
200
+ if (!resolved)
201
+ return null;
202
+ return { rawUrl: varName, resolvedUrl: resolved };
203
+ }
204
+ return null;
205
+ }
206
+ // Scans EVERY call expression in a source file exactly once.
207
+ // For each call matching CALLER_CONFIG:
208
+ // - infers the HTTP method
209
+ // - extracts and resolves the URL argument
210
+ // It additionally scans for backend routes used as a resource URL:
211
+ // - JSX src/href attributes — tag-agnostic, so it covers native elements
212
+ // (<video src="/api/..">, <iframe src={url}>, <a href={`/api/${id}`}>)
213
+ // AND third-party player components (<HlsPlayer src={url} />,
214
+ // <ReactHlsPlayer src={...} />).
215
+ // - object-literal src/href properties — config-object style usage where a
216
+ // route is handed to a third-party player/function, e.g.
217
+ // loadPlayer({ src: '/api/stream' }).
218
+ // The browser issues a GET for any such resource, so these are emitted as GET.
219
+ // The expensive AST traversal happens once per file here; callers then
220
+ // attribute the resulting calls to nodes by line range (see detectNextjsApiCallEdges).
221
+ // Note: useQuery/useMutation inner fetch/axios calls are captured naturally
222
+ // since they are also CallExpressions in the file.
223
+ function extractApiCallsFromFile(sourceFile, project) {
224
+ const results = [];
225
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
226
+ const callerType = call.getExpression().getText();
227
+ const config = CALLER_CONFIG[callerType];
228
+ if (!config)
229
+ continue;
230
+ const args = call.getArguments();
231
+ if (args.length === 0)
232
+ continue;
233
+ // Infer HTTP method + extract URL
234
+ let method;
235
+ let urlResult;
236
+ if (callerType === "axios" &&
237
+ args[0].getKind() === SyntaxKind.ObjectLiteralExpression) {
238
+ // axios({ url, method }) — both url and method live in the config object.
239
+ // axios defaults to GET when no method is given.
240
+ const cfg = args[0];
241
+ const urlInit = cfg.getProperty("url")?.getInitializer?.();
242
+ urlResult = urlInit ? extractUrlFromArg(urlInit, sourceFile, project) : null;
243
+ method = "GET";
244
+ const methodInit = cfg.getProperty("method")?.getInitializer?.();
245
+ if (methodInit && methodInit.getKind() === SyntaxKind.StringLiteral) {
246
+ method = methodInit.getLiteralText().toUpperCase();
247
+ }
248
+ }
249
+ else if (config.inferMethod === "fixed") {
250
+ method = config.method;
251
+ urlResult = extractUrlFromArg(args[0], sourceFile, project);
252
+ }
253
+ else {
254
+ method = config.defaultMethod;
255
+ // fetch('/api', { method: 'POST' }) / axios('/api', { method }) — check 2nd arg
256
+ if (args.length >= 2) {
257
+ const optsArg = args[1];
258
+ if (optsArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
259
+ const methodProp = optsArg.getProperty("method");
260
+ if (methodProp) {
261
+ const init = methodProp.getInitializer?.();
262
+ if (init && init.getKind() === SyntaxKind.StringLiteral) {
263
+ method = init.getLiteralText().toUpperCase();
264
+ }
265
+ }
266
+ }
267
+ }
268
+ urlResult = extractUrlFromArg(args[0], sourceFile, project);
269
+ }
270
+ if (!urlResult)
271
+ continue;
272
+ results.push({
273
+ startLine: call.getStartLineNumber(),
274
+ call: {
275
+ callerType,
276
+ rawUrl: urlResult.rawUrl,
277
+ resolvedUrl: urlResult.resolvedUrl,
278
+ method,
279
+ },
280
+ });
281
+ }
282
+ // JSX src/href attributes — tag-agnostic, so native elements (video, iframe,
283
+ // a) and third-party player components (HlsPlayer, ReactHlsPlayer, …) are all
284
+ // covered. The browser fetches the resource with GET.
285
+ for (const attr of sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
286
+ const attrName = attr.getNameNode().getText();
287
+ if (attrName !== "src" && attrName !== "href")
288
+ continue;
289
+ const initializer = attr.getInitializer();
290
+ if (!initializer)
291
+ continue;
292
+ // value is either a bare string (src="/api/x") or a JSX expression
293
+ // container (src={url} / src={`/api/${id}`})
294
+ let valueNode;
295
+ const initKind = initializer.getKind();
296
+ if (initKind === SyntaxKind.StringLiteral) {
297
+ valueNode = initializer;
298
+ }
299
+ else if (initKind === SyntaxKind.JsxExpression) {
300
+ valueNode = initializer.getExpression();
301
+ }
302
+ if (!valueNode)
303
+ continue;
304
+ const urlResult = extractUrlFromArg(valueNode, sourceFile, project);
305
+ if (!urlResult)
306
+ continue;
307
+ results.push({
308
+ startLine: attr.getStartLineNumber(),
309
+ call: {
310
+ callerType: `jsx-${attrName}`,
311
+ rawUrl: urlResult.rawUrl,
312
+ resolvedUrl: urlResult.resolvedUrl,
313
+ method: "GET",
314
+ },
315
+ });
316
+ }
317
+ // Object-literal src/href properties — config-object style usage where a
318
+ // backend route is passed to a third-party player/function, e.g.
319
+ // loadPlayer({ src: '/api/stream' }). Same GET semantics.
320
+ for (const prop of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
321
+ const name = prop.getName();
322
+ if (name !== "src" && name !== "href")
323
+ continue;
324
+ const init = prop.getInitializer();
325
+ if (!init)
326
+ continue;
327
+ const urlResult = extractUrlFromArg(init, sourceFile, project);
328
+ if (!urlResult)
329
+ continue;
330
+ results.push({
331
+ startLine: prop.getStartLineNumber(),
332
+ call: {
333
+ callerType: `prop-${name}`,
334
+ rawUrl: urlResult.rawUrl,
335
+ resolvedUrl: urlResult.resolvedUrl,
336
+ method: "GET",
337
+ },
338
+ });
339
+ }
340
+ return results;
341
+ }
342
+ //MAIN FUNCTION
343
+ export function detectNextjsApiCallEdges(nodes, repoPath) {
344
+ const edges = [];
345
+ const dedupSet = new Set();
346
+ // Build route index — bail early if no Next.js API routes exist in this repo
347
+ const routeIndex = buildRouteIndex(nodes);
348
+ if (routeIndex.size === 0)
349
+ return [];
350
+ // One shared project instance — source files are added lazily per node
351
+ // so we never load the entire repo into ts-morph upfront
352
+ const project = new Project({
353
+ compilerOptions: {
354
+ allowJs: true,
355
+ checkJs: false,
356
+ strict: false,
357
+ },
358
+ skipAddingFilesFromTsConfig: true,
359
+ });
360
+ // Only scan nodes that:
361
+ // - are not ROUTE or THIRD_PARTY nodes themselves
362
+ // - have rawCode (we need the AST, not just metadata)
363
+ const candidateNodes = nodes.filter(n => n.type !== "ROUTE" &&
364
+ n.type !== "THIRD_PARTY" &&
365
+ typeof n.rawCode === "string" &&
366
+ n.rawCode.length > 0);
367
+ // Group candidate nodes by absolute file path so each file is parsed and
368
+ // its AST traversed exactly once — previously every node re-scanned the
369
+ // entire file's call expressions (O(calls × nodes per file)).
370
+ const nodesByFile = new Map();
371
+ for (const node of candidateNodes) {
372
+ const absolutePath = path.resolve(repoPath, node.filePath);
373
+ if (!nodesByFile.has(absolutePath))
374
+ nodesByFile.set(absolutePath, []);
375
+ nodesByFile.get(absolutePath).push(node);
376
+ }
377
+ for (const [absolutePath, fileNodes] of nodesByFile) {
378
+ // Reuse already-loaded file if present, otherwise add it lazily
379
+ let sourceFile = project.getSourceFile(absolutePath);
380
+ if (!sourceFile) {
381
+ try {
382
+ sourceFile = project.addSourceFileAtPath(absolutePath);
383
+ }
384
+ catch {
385
+ continue; // file missing, non-parseable — skip silently
386
+ }
387
+ }
388
+ // Scan the whole file's API calls once, then attribute by line range.
389
+ const locatedCalls = extractApiCallsFromFile(sourceFile, project);
390
+ if (locatedCalls.length === 0)
391
+ continue;
392
+ for (const located of locatedCalls) {
393
+ // Attribute each call to the single innermost node whose line range
394
+ // contains it. Nodes can nest (e.g. a component and an inner function
395
+ // both span the call); without picking the smallest containing range a
396
+ // call would be counted once per containing node, producing duplicate
397
+ // edges with different `from` nodes.
398
+ let owner = null;
399
+ for (const node of fileNodes) {
400
+ if (located.startLine < node.startLine || located.startLine > node.endLine)
401
+ continue;
402
+ if (owner === null || (node.endLine - node.startLine) < (owner.endLine - owner.startLine)) {
403
+ owner = node;
404
+ }
405
+ }
406
+ if (owner === null)
407
+ continue;
408
+ const call = located.call;
409
+ const normalizedUrl = normalizeUrl(call.resolvedUrl);
410
+ if (!normalizedUrl)
411
+ continue;
412
+ const matches = matchRouteEntries(normalizedUrl, call.method, routeIndex);
413
+ if (matches.length === 0)
414
+ continue;
415
+ for (const match of matches) {
416
+ // One edge per unique (caller node, route node, method) combination
417
+ const dedupKey = `${owner.id}→${match.routeNode.id}:${call.method}`;
418
+ if (dedupSet.has(dedupKey))
419
+ continue;
420
+ dedupSet.add(dedupKey);
421
+ edges.push({
422
+ from: owner.id,
423
+ to: match.routeNode.id,
424
+ type: "NEXTJS_API_CALL",
425
+ metadata: {
426
+ url: call.resolvedUrl,
427
+ rawUrl: call.rawUrl,
428
+ method: call.method,
429
+ callerType: call.callerType,
430
+ matchType: match.isDynamic ? "dynamic" : "exact",
431
+ },
432
+ });
433
+ }
434
+ }
435
+ }
436
+ return edges;
437
+ }
@@ -1,4 +1,5 @@
1
1
  import { buildLookupMaps } from "./buildLookup.js";
2
+ import { detectNextjsApiCallEdges } from "./edges/apiFetchEdges.js";
2
3
  import { detectCallEdges } from "./edges/callEdges.js";
3
4
  import { detectEventEdges } from "./edges/eventEdges.js";
4
5
  import { detectGuardEdges } from "./edges/guardEdges.js";
@@ -27,6 +28,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
27
28
  // GUARDS — middleware to route protection
28
29
  const guardEdges = detectGuardEdges(nodes, lookupMp, routeNodes, repoPath, fingerprint);
29
30
  const testEdges = detectTestEdges(lookupMp, repoPath); // This does not needs nodes, as it detect edges from the file
31
+ const nextjsApiCallEdges = detectNextjsApiCallEdges(nodes, repoPath);
30
32
  // Collect all dynamically-created third-party method nodes (dedup by id)
31
33
  const newThirdPartyNodesMap = new Map();
32
34
  for (const n of [...importResult.thirdPartyMethodNodes, ...callResult.newThirdPartyNodes]) {
@@ -46,6 +48,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
46
48
  console.log(` TEST edges: ${testEdges.length}`);
47
49
  console.log(` Ghost nodes created: ${eventResults.ghostNodes.length}`);
48
50
  console.log(` Third-party method nodes: ${newThirdPartyNodes.length}`);
51
+ console.log(` NEXTJS_API_CALL edges: ${nextjsApiCallEdges.length}`);
49
52
  const allEdges = [
50
53
  ...callEdges,
51
54
  ...importEdges,
@@ -56,6 +59,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
56
59
  ...routeEdges,
57
60
  ...guardEdges,
58
61
  ...testEdges,
62
+ ...nextjsApiCallEdges,
59
63
  ];
60
64
  console.log(`Total edges detected: ${allEdges.length}`);
61
65
  return {
@@ -1,4 +1,9 @@
1
1
  import { SourceFile } from "ts-morph";
2
2
  import type { CodeNode } from "../../types.js";
3
3
  import { type RenderingBoundary } from "../directives.js";
4
+ export declare function extractFunctionCalls(node: any): string[];
5
+ export declare function extractHookCalls(node: any): string[];
6
+ export declare function extractApiCalls(node: any): string[];
7
+ export declare function hasErrorHandling(node: any): boolean;
8
+ export declare function extractThrowStatements(node: any): boolean;
4
9
  export declare function extractFunctions(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];
@@ -7,7 +7,7 @@ const HTTP_METHOD_EXPORTS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "H
7
7
  function makeId(filePath, name) {
8
8
  return `${filePath}::${name}`;
9
9
  }
10
- function extractFunctionCalls(node) {
10
+ export function extractFunctionCalls(node) {
11
11
  const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
12
12
  const names = [];
13
13
  for (const call of calls) {
@@ -22,7 +22,7 @@ function extractFunctionCalls(node) {
22
22
  }
23
23
  return [...new Set(names)];
24
24
  }
25
- function extractHookCalls(node) {
25
+ export function extractHookCalls(node) {
26
26
  const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
27
27
  const hooks = [];
28
28
  for (const call of calls) {
@@ -34,7 +34,7 @@ function extractHookCalls(node) {
34
34
  }
35
35
  return [...new Set(hooks)];
36
36
  }
37
- function extractApiCalls(node) {
37
+ export function extractApiCalls(node) {
38
38
  const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
39
39
  const apiCalls = [];
40
40
  for (const call of calls) {
@@ -75,11 +75,11 @@ function extractApiCalls(node) {
75
75
  }
76
76
  return [...new Set(apiCalls)];
77
77
  }
78
- function hasErrorHandling(node) {
78
+ export function hasErrorHandling(node) {
79
79
  const tryCatch = node.getDescendantsOfKind(SyntaxKind.TryStatement);
80
80
  return tryCatch.length > 0;
81
81
  }
82
- function extractThrowStatements(node) {
82
+ export function extractThrowStatements(node) {
83
83
  const throws = node.getDescendantsOfKind(SyntaxKind.ThrowStatement);
84
84
  return throws.length > 0;
85
85
  }
@@ -0,0 +1,4 @@
1
+ import { SourceFile } from "ts-morph";
2
+ import type { CodeNode } from "../../types.js";
3
+ import { type RenderingBoundary } from "../directives.js";
4
+ export declare function extractObjectMethods(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];
@@ -0,0 +1,141 @@
1
+ import { SyntaxKind } from "ts-morph";
2
+ import { detectFunctionDirective } from "../directives.js";
3
+ import { extractFunctionCalls, extractHookCalls, extractApiCalls, hasErrorHandling, extractThrowStatements, } from "./functions.js";
4
+ import { extractParams, extractReturnTypeAnnotation, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
5
+ // Extracts function-valued properties of object literals as FUNCTION nodes.
6
+ //
7
+ // Many React/Next.js modules keep their logic inside an exported object literal
8
+ // (provider adapters, route tables, command maps, strategy/config objects). The
9
+ // other extractors (declarations, var-assigned arrows, components, hooks) never
10
+ // descend into object literals, so those functions had no node to anchor edges
11
+ // to — every CALLS / NEXTJS_API_CALL / READS_FROM edge silently dropped.
12
+ //
13
+ // The node `name` encodes the dotted access path (e.g.
14
+ // "providersConfig.zoro.streamingData"). index.ts builds the id as
15
+ // `${relativePath}::${name}`, so this yields the desired node identity for free.
16
+ function makeId(filePath, name) {
17
+ return `${filePath}::${name}`;
18
+ }
19
+ // Unwraps `as const` / `as Foo` and parenthesized expressions, returning the
20
+ // underlying ObjectLiteralExpression node, or null if it isn't one.
21
+ function asObjectLiteral(node) {
22
+ let n = node;
23
+ while (n &&
24
+ (n.getKind() === SyntaxKind.AsExpression ||
25
+ n.getKind() === SyntaxKind.ParenthesizedExpression)) {
26
+ n = n.getExpression?.();
27
+ }
28
+ return n && n.getKind() === SyntaxKind.ObjectLiteralExpression ? n : null;
29
+ }
30
+ // True for `async (...) => {}`, `async function () {}`, `async key() {}`.
31
+ function isAsyncFn(fnNode) {
32
+ if (typeof fnNode.isAsync === "function")
33
+ return fnNode.isAsync();
34
+ return fnNode.getText().trimStart().startsWith("async");
35
+ }
36
+ // Builds a FUNCTION node from a function-like node + the span node that should
37
+ // own its line range / rawCode. The metadata shape mirrors functions.ts exactly
38
+ // so callEdges / apiFetchEdges treat these like any other FUNCTION node.
39
+ // fnNode — ArrowFunction | FunctionExpression | MethodDeclaration (params/body)
40
+ // spanNode— the PropertyAssignment (`key: fn`) or the MethodDeclaration itself,
41
+ // so the inner call's line falls inside [startLine, endLine].
42
+ function buildNode(file, dottedName, fnNode, spanNode, fileDirective) {
43
+ const filePath = file.getFilePath();
44
+ const typedParams = extractParams(fnNode);
45
+ const calls = extractFunctionCalls(fnNode);
46
+ const hookCalls = extractHookCalls(fnNode);
47
+ const apiCalls = extractApiCalls(fnNode);
48
+ const isAsync = isAsyncFn(fnNode);
49
+ const hasErrors = hasErrorHandling(fnNode);
50
+ const throws = extractThrowStatements(fnNode);
51
+ const renderingBoundary = detectFunctionDirective(fnNode.getBody?.()) ?? fileDirective;
52
+ const returnType = extractReturnTypeAnnotation(fnNode);
53
+ const bareTypeNames = extractBareTypeNames([...typedParams.map((p) => p.type), returnType]);
54
+ const referencedTypes = extractReferencedInterfaces(file, bareTypeNames);
55
+ return {
56
+ id: makeId(filePath, dottedName),
57
+ name: dottedName,
58
+ type: "FUNCTION",
59
+ filePath,
60
+ startLine: spanNode.getStartLineNumber(),
61
+ endLine: spanNode.getEndLineNumber(),
62
+ rawCode: spanNode.getText(),
63
+ metadata: {
64
+ params: typedParams.map((p) => p.name),
65
+ parameters: typedParams,
66
+ returnType,
67
+ referencedTypes,
68
+ calls,
69
+ hookCalls,
70
+ apiCalls,
71
+ isAsync,
72
+ hasErrorHandling: hasErrors,
73
+ throws,
74
+ lineCount: spanNode.getEndLineNumber() - spanNode.getStartLineNumber(),
75
+ isHttpHandler: false,
76
+ httpMethod: undefined,
77
+ ...(renderingBoundary !== null && { renderingBoundary }),
78
+ },
79
+ };
80
+ }
81
+ // Recursively walks an object literal, emitting a node per function-valued
82
+ // property and descending into nested object literals to arbitrary depth.
83
+ function walkObject(file, objLiteral, prefix, fileDirective, out) {
84
+ // getProperties() returns a union (PropertyAssignment | ShorthandPropertyAssignment
85
+ // | SpreadAssignment | MethodDeclaration | Get/SetAccessor) — guard every getter.
86
+ for (const prop of objLiteral.getProperties()) {
87
+ const kind = prop.getKind();
88
+ // Shorthand method: streamingData(args) { ... } (incl. async)
89
+ if (kind === SyntaxKind.MethodDeclaration) {
90
+ const name = prop.getName?.();
91
+ if (!name)
92
+ continue;
93
+ out.push(buildNode(file, `${prefix}.${name}`, prop, prop, fileDirective));
94
+ continue;
95
+ }
96
+ // Everything else we handle needs a name + initializer.
97
+ if (kind !== SyntaxKind.PropertyAssignment)
98
+ continue; // skip spread/shorthand/get/set
99
+ const name = prop.getName?.();
100
+ const init = prop.getInitializer?.();
101
+ if (!name || !init)
102
+ continue;
103
+ const ik = init.getKind();
104
+ if (ik === SyntaxKind.ArrowFunction || ik === SyntaxKind.FunctionExpression) {
105
+ // span = the PropertyAssignment so the range wraps `key: <fn>`.
106
+ out.push(buildNode(file, `${prefix}.${name}`, init, prop, fileDirective));
107
+ continue;
108
+ }
109
+ // Nested object → recurse (unwrap `as const` / parens).
110
+ const nested = asObjectLiteral(init);
111
+ if (nested) {
112
+ walkObject(file, nested, `${prefix}.${name}`, fileDirective, out);
113
+ }
114
+ }
115
+ }
116
+ export function extractObjectMethods(file, fileDirective = null) {
117
+ const out = [];
118
+ // Roots: named object literals — `const x = { ... }` (incl. `as const`).
119
+ for (const variable of file.getVariableDeclarations()) {
120
+ const init = variable.getInitializer();
121
+ if (!init)
122
+ continue;
123
+ const obj = asObjectLiteral(init);
124
+ if (!obj)
125
+ continue;
126
+ walkObject(file, obj, variable.getName(), fileDirective, out);
127
+ }
128
+ // Root: `export default { ... }` — fall back to the "default" prefix.
129
+ for (const assign of file.getExportAssignments()) {
130
+ if (assign.isExportEquals?.())
131
+ continue; // skip `export = ...`
132
+ const expr = assign.getExpression?.();
133
+ if (!expr)
134
+ continue;
135
+ const obj = asObjectLiteral(expr);
136
+ if (!obj)
137
+ continue;
138
+ walkObject(file, obj, "default", fileDirective, out);
139
+ }
140
+ return out;
141
+ }
@@ -5,6 +5,7 @@ import { extractComponents } from "./extractors/components.js";
5
5
  import { extractHooks } from "./extractors/hooks.js";
6
6
  import { extractFunctions } from "./extractors/functions.js";
7
7
  import { extractStores } from "./extractors/stores.js";
8
+ import { extractObjectMethods } from "./extractors/objectMethods.js";
8
9
  import { detectFileDirective } from "./directives.js";
9
10
  import { createHash } from "crypto";
10
11
  // Directories to skip entirely while walking
@@ -107,7 +108,8 @@ export function parseRepo(repoPath) {
107
108
  const hooks = extractHooks(file, fileDirective);
108
109
  const functions = extractFunctions(file, fileDirective);
109
110
  const stores = extractStores(file);
110
- const extracted = [...components, ...hooks, ...functions, ...stores];
111
+ const objectMethods = extractObjectMethods(file, fileDirective);
112
+ const extracted = [...components, ...hooks, ...functions, ...stores, ...objectMethods];
111
113
  for (const node of extracted) {
112
114
  // Normalize all extracted nodes to relative paths so every node in the
113
115
  // graph uses the same coordinate system as the FILE nodes.
@@ -316,4 +316,134 @@ describe("parseRepo", () => {
316
316
  expect(configNode).toBeUndefined();
317
317
  deleteFakeRepo(repoPath);
318
318
  });
319
+ // ─── Object-literal Method Extraction ──────────────────────────────────────
320
+ it("should detect an arrow-function value in an object literal", () => {
321
+ const repoPath = createFakeRepo({
322
+ "src/cfg.ts": `
323
+ export const cfg = {
324
+ load: () => doThing(),
325
+ };
326
+ `,
327
+ });
328
+ const result = parseRepo(repoPath);
329
+ const fn = result.nodes.find((n) => n.name === "cfg.load");
330
+ expect(fn).toBeDefined();
331
+ expect(fn?.type).toBe("FUNCTION");
332
+ expect(fn?.startLine).toBeGreaterThanOrEqual(1);
333
+ expect(fn?.endLine).toBeGreaterThanOrEqual(fn.startLine);
334
+ expect(fn?.metadata.calls).toContain("doThing");
335
+ deleteFakeRepo(repoPath);
336
+ });
337
+ it("should detect an async arrow value calling axios (nested object)", () => {
338
+ const repoPath = createFakeRepo({
339
+ "src/providersConfig.js": `
340
+ export const providersConfig = {
341
+ zoro: {
342
+ streamingData: async (episodeId) => {
343
+ const response = await axios.get('/api/v2/zoro/watch/' + episodeId);
344
+ return response.data;
345
+ },
346
+ },
347
+ };
348
+ `,
349
+ });
350
+ const result = parseRepo(repoPath);
351
+ const fn = result.nodes.find((n) => n.name === "providersConfig.zoro.streamingData");
352
+ expect(fn).toBeDefined();
353
+ expect(fn?.type).toBe("FUNCTION");
354
+ expect(fn?.metadata.isAsync).toBe(true);
355
+ expect((fn?.metadata.apiCalls).length).toBeGreaterThan(0);
356
+ deleteFakeRepo(repoPath);
357
+ });
358
+ it("should detect a function-expression value in an object literal", () => {
359
+ const repoPath = createFakeRepo({
360
+ "src/cfg.ts": `
361
+ export const cfg = {
362
+ run: function (x) { return helper(x); },
363
+ };
364
+ `,
365
+ });
366
+ const result = parseRepo(repoPath);
367
+ const fn = result.nodes.find((n) => n.name === "cfg.run");
368
+ expect(fn).toBeDefined();
369
+ expect(fn?.type).toBe("FUNCTION");
370
+ expect(fn?.metadata.params).toContain("x");
371
+ deleteFakeRepo(repoPath);
372
+ });
373
+ it("should detect a shorthand method in an object literal", () => {
374
+ const repoPath = createFakeRepo({
375
+ "src/cfg.ts": `
376
+ export const cfg = {
377
+ fetchData(url) { return fetch(url); },
378
+ };
379
+ `,
380
+ });
381
+ const result = parseRepo(repoPath);
382
+ const fn = result.nodes.find((n) => n.name === "cfg.fetchData");
383
+ expect(fn).toBeDefined();
384
+ expect(fn?.type).toBe("FUNCTION");
385
+ deleteFakeRepo(repoPath);
386
+ });
387
+ it("should detect a concise-body arrow value in an object literal", () => {
388
+ const repoPath = createFakeRepo({
389
+ "src/cfg.ts": `
390
+ export const cfg = {
391
+ pick: (u) => u.id,
392
+ };
393
+ `,
394
+ });
395
+ const result = parseRepo(repoPath);
396
+ const fn = result.nodes.find((n) => n.name === "cfg.pick");
397
+ expect(fn).toBeDefined();
398
+ expect(fn?.type).toBe("FUNCTION");
399
+ deleteFakeRepo(repoPath);
400
+ });
401
+ it("should detect a function in a two-level nested object", () => {
402
+ const repoPath = createFakeRepo({
403
+ "src/api.ts": `
404
+ export const api = {
405
+ v2: {
406
+ users: {
407
+ list: async () => fetch('/api/users'),
408
+ },
409
+ },
410
+ };
411
+ `,
412
+ });
413
+ const result = parseRepo(repoPath);
414
+ const fn = result.nodes.find((n) => n.name === "api.v2.users.list");
415
+ expect(fn).toBeDefined();
416
+ expect(fn?.type).toBe("FUNCTION");
417
+ deleteFakeRepo(repoPath);
418
+ });
419
+ it("should detect a function in an export-default object", () => {
420
+ const repoPath = createFakeRepo({
421
+ "src/handler.ts": `
422
+ export default {
423
+ handler: async () => fetch('/api/x'),
424
+ };
425
+ `,
426
+ });
427
+ const result = parseRepo(repoPath);
428
+ const fn = result.nodes.find((n) => n.name === "default.handler");
429
+ expect(fn).toBeDefined();
430
+ expect(fn?.type).toBe("FUNCTION");
431
+ deleteFakeRepo(repoPath);
432
+ });
433
+ it("should not double-extract or collide with top-level hooks", () => {
434
+ const repoPath = createFakeRepo({
435
+ "src/mix.ts": `
436
+ export const useThing = () => { return 1; };
437
+ export const cfg = {
438
+ a: () => doA(),
439
+ };
440
+ `,
441
+ });
442
+ const result = parseRepo(repoPath);
443
+ const objMethods = result.nodes.filter((n) => n.name === "cfg.a");
444
+ expect(objMethods.length).toBe(1);
445
+ const hook = result.nodes.find((n) => n.name === "useThing");
446
+ expect(hook?.type).toBe("HOOK");
447
+ deleteFakeRepo(repoPath);
448
+ });
319
449
  });
@@ -7,7 +7,7 @@ import { parseRepo } from "../parser/index.js";
7
7
  import { detectEdges } from "../graph/index.js";
8
8
  import { buildThirdPartyNodes } from "../graph/thirdPartyLibs.js";
9
9
  import { scoreAndFilter } from "../scoring/index.js";
10
- // ─── Helpers ──────────────────────────────────────────────────────────────────
10
+ // Helpers
11
11
  // Deterministic graphId — same repo always produces same id
12
12
  // This ensures multiple analyses of the same repo go into the same folder
13
13
  function generateGraphId(repoPath, isGithubRepo) {
@@ -162,7 +162,7 @@ function routesToCodeNodes(routes, repoPath) {
162
162
  }
163
163
  return nodes;
164
164
  }
165
- // ─── analyzePipeline ──────────────────────────────────────────────────────────
165
+ // analyzePipeline
166
166
  export async function analyzePipeline(repoPath, isGithubRepo, options) {
167
167
  const absoluteRepoPath = path.resolve(repoPath);
168
168
  const graphId = generateGraphId(repoPath, isGithubRepo); // stable, deterministic ID based on repo path
@@ -172,11 +172,11 @@ export async function analyzePipeline(repoPath, isGithubRepo, options) {
172
172
  console.log(` Graph ID: ${graphId}`);
173
173
  console.log(` Commit: ${gitInfo.commitHash} (${gitInfo.branch})`);
174
174
  console.log(` Message: ${gitInfo.message}`);
175
- // ── Step 1: Fingerprint ───────────────────────────────────────
175
+ // Step 1: Fingerprint
176
176
  console.log("\n[1/5] Fingerprinting project...");
177
177
  const fingerprint = analyzeFingerprint(absoluteRepoPath);
178
178
  console.log(` Framework: ${fingerprint.framework} | Language: ${fingerprint.language} | Type: ${fingerprint.projectType}`);
179
- // ── Step 2: Filesystem / routes ───────────────────────────────
179
+ // Step 2: Filesystem / routes
180
180
  console.log("\n[2/5] Analyzing filesystem routes...");
181
181
  const routes = analyzeFilesystem(absoluteRepoPath, fingerprint);
182
182
  console.log(` Routes found: ${routes.length}`);
@@ -184,21 +184,20 @@ export async function analyzePipeline(repoPath, isGithubRepo, options) {
184
184
  // It is important to add here before the detection of the edges
185
185
  let routeNodes = routesToCodeNodes(routes, absoluteRepoPath);
186
186
  console.log(` Route nodes created: ${routeNodes.length}`);
187
- // ── Step 3: Parse source files into nodes ─────────────────────
187
+ // Step 3: Parse source files into nodes
188
188
  console.log("\n[3/5] Parsing source files...");
189
189
  const parserResult = parseRepo(absoluteRepoPath);
190
190
  console.log(` Files: ${parserResult.stats.totalFiles} | Nodes: ${parserResult.stats.totalNodes} | Skipped: ${parserResult.stats.skippedFiles}`);
191
- // ── Step 3.5: Build third-party nodes ────────────────────────
191
+ // Step 3.5: Build third-party nodes
192
192
  const thirdPartyNodes = options?.includedThirdPartyLibs?.length
193
193
  ? buildThirdPartyNodes(absoluteRepoPath, options.includedThirdPartyLibs)
194
194
  : [];
195
195
  if (thirdPartyNodes.length) {
196
196
  console.log(` Third-party nodes: ${thirdPartyNodes.length}`);
197
197
  }
198
- // ── Step 4: Detect edges ──────────────────────────────────────
198
+ // Step 4: Detect edges
199
199
  console.log("\n[4/5] Detecting edges...");
200
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
201
  routeNodes = routeNodes.filter(routeNode => {
203
202
  if (routeNode.metadata.routeNodeType === "API_ROUTE") {
204
203
  const hasHandler = edgeResult.edges.some(edge => edge.type === "HANDLES" && edge.from === routeNode.id);
@@ -208,7 +207,7 @@ export async function analyzePipeline(repoPath, isGithubRepo, options) {
208
207
  });
209
208
  const allNodes = [...parserResult.nodes, ...routeNodes, ...thirdPartyNodes, ...edgeResult.ghostNodes];
210
209
  const allEdges = edgeResult.edges;
211
- // ── Step 5: Score and filter ──────────────────────────────────
210
+ // Step 5: Score and filter
212
211
  console.log("\n[5/5] Scoring and filtering...");
213
212
  const scoringResult = scoreAndFilter(allNodes, allEdges, options?.thresholds);
214
213
  const nodeScores = mapToRecord(scoringResult.nodeScores);
@@ -236,7 +235,7 @@ export async function analyzePipeline(repoPath, isGithubRepo, options) {
236
235
  gitInfo,
237
236
  };
238
237
  }
239
- // ─── refilterPipeline ─────────────────────────────────────────────────────────
238
+ // refilterPipeline
240
239
  export function refilterPipeline(stored, thresholds) {
241
240
  const existingScores = new Map(Object.entries(stored.nodeScores));
242
241
  const scoringResult = scoreAndFilter(stored.allNodes, stored.allEdges, thresholds, existingScores);
package/dist/types.d.ts CHANGED
@@ -69,7 +69,7 @@ export interface CodeNode {
69
69
  score?: Number;
70
70
  metadata: Record<string, unknown>;
71
71
  }
72
- export type EdgeType = "CALLS" | "IMPORTS" | "READS_FROM" | "WRITES_TO" | "PROP_PASS" | "EMITS" | "LISTENS" | "WRAPPED_BY" | "GUARDS" | "HANDLES" | "TESTS" | "USES";
72
+ export type EdgeType = "CALLS" | "IMPORTS" | "READS_FROM" | "WRITES_TO" | "PROP_PASS" | "EMITS" | "LISTENS" | "WRAPPED_BY" | "GUARDS" | "HANDLES" | "TESTS" | "USES" | "NEXTJS_API_CALL";
73
73
  export interface CodeEdge {
74
74
  from: string;
75
75
  to: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlensio",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Codebase intelligence engine for TypeScript/JavaScript/Reactjs/Nextjs repositories.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",