devlensio 0.4.2 → 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 CHANGED
@@ -29,7 +29,7 @@ Repo path
29
29
  [2] Filesystem scan extract routes (Next.js app/pages, Express, Fastify, Koa)
30
30
  [3] Parse (ts-morph) walk every .ts/.tsx/.js/.jsx → nodes (typed params, return types, prop types)
31
31
  [4] Edge detection many detectors → CALLS, IMPORTS, READS_FROM, WRITES_TO, PROP_PASS, EMITS,
32
- LISTENS, WRAPPED_BY, GUARDS, HANDLES, TESTS, USES
32
+ LISTENS, WRAPPED_BY, GUARDS, HANDLES, TESTS, USES, NEXTJS_API_CALL
33
33
  [5] Scoring multi-pass importance scoring + noise filtering (no AI)
34
34
  [6] Clustering cohesive cluster assignment
35
35
  [7] Summarize (optional) topologically-ordered LLM summaries, checkpoint/resume, MapReduce
@@ -73,7 +73,7 @@ Also exported: all core types (`CodeNode`, `CodeEdge`, `NodeType`, `EdgeType`,
73
73
  ### Node & edge types
74
74
 
75
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`.
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
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
78
 
79
79
  ---
@@ -207,6 +207,15 @@ function extractUrlFromArg(arg, sourceFile, project) {
207
207
  // For each call matching CALLER_CONFIG:
208
208
  // - infers the HTTP method
209
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.
210
219
  // The expensive AST traversal happens once per file here; callers then
211
220
  // attribute the resulting calls to nodes by line range (see detectNextjsApiCallEdges).
212
221
  // Note: useQuery/useMutation inner fetch/axios calls are captured naturally
@@ -270,6 +279,64 @@ function extractApiCallsFromFile(sourceFile, project) {
270
279
  },
271
280
  });
272
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
+ }
273
340
  return results;
274
341
  }
275
342
  //MAIN FUNCTION
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlensio",
3
- "version": "0.4.2",
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",