devlensio 0.4.3 → 0.4.4

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.
@@ -9,8 +9,10 @@ import { detectStateEdges } from "./edges/stateEdges.js";
9
9
  import { detectPropEdges } from "./edges/propEdges.js";
10
10
  import { detectEventEdges } from "./edges/eventEdges.js";
11
11
  import { detectGuardEdges } from "./edges/guardEdges.js";
12
+ import { detectRouteEdges } from "./edges/routeEdge.js";
12
13
  import { detectEdges } from "./index.js";
13
- // ─── Helpers ──────────────────────────────────────────────────────────────────
14
+ import { detectNavigationEdges } from "./edges/navigationEdges.js";
15
+ // Helpers
14
16
  function createFakeRepo(files) {
15
17
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-graph-test-"));
16
18
  for (const [filePath, content] of Object.entries(files)) {
@@ -46,7 +48,7 @@ function debugNodes(repoPath) {
46
48
  }
47
49
  return nodes;
48
50
  }
49
- // ─── CALLS edges ──────────────────────────────────────────────────────────────
51
+ // CALLS edges
50
52
  describe("detectCallEdges", () => {
51
53
  it("should detect a direct function call", () => {
52
54
  const repoPath = createFakeRepo({
@@ -107,7 +109,7 @@ describe("detectCallEdges", () => {
107
109
  deleteFakeRepo(repoPath);
108
110
  });
109
111
  });
110
- // ─── IMPORTS edges ────────────────────────────────────────────────────────────
112
+ // IMPORTS edges
111
113
  describe("detectImportEdges", () => {
112
114
  it("should detect relative imports between files", () => {
113
115
  // Both files in same src/ folder so relative import resolves cleanly
@@ -177,7 +179,7 @@ describe("detectImportEdges", () => {
177
179
  deleteFakeRepo(repoPath);
178
180
  });
179
181
  });
180
- // ─── STATE edges ──────────────────────────────────────────────────────────────
182
+ // STATE edges
181
183
  describe("detectStateEdges", () => {
182
184
  it("should detect zustand store usage in a component", () => {
183
185
  const repoPath = createFakeRepo({
@@ -264,7 +266,7 @@ describe("detectStateEdges", () => {
264
266
  deleteFakeRepo(repoPath);
265
267
  });
266
268
  });
267
- // ─── PROP_PASS edges ──────────────────────────────────────────────────────────
269
+ // PROP_PASS edges
268
270
  describe("detectPropEdges", () => {
269
271
  it("should detect prop passing from parent to child", () => {
270
272
  const repoPath = createFakeRepo({
@@ -346,7 +348,7 @@ describe("detectPropEdges", () => {
346
348
  deleteFakeRepo(repoPath);
347
349
  });
348
350
  });
349
- // ─── EVENT edges ──────────────────────────────────────────────────────────────
351
+ // EVENT edges
350
352
  describe("detectEventEdges", () => {
351
353
  it("should detect custom event emitter and create ghost node", () => {
352
354
  const repoPath = createFakeRepo({
@@ -406,7 +408,7 @@ describe("detectEventEdges", () => {
406
408
  deleteFakeRepo(repoPath);
407
409
  });
408
410
  });
409
- // ─── GUARDS edges ─────────────────────────────────────────────────────────────
411
+ // GUARDS edges
410
412
  describe("detectGuardEdges", () => {
411
413
  it("should detect Next.js middleware guards", () => {
412
414
  const repoPath = createFakeRepo({
@@ -501,7 +503,7 @@ describe("detectGuardEdges", () => {
501
503
  deleteFakeRepo(repoPath);
502
504
  });
503
505
  });
504
- // ─── Full pipeline ────────────────────────────────────────────────────────────
506
+ // Full pipeline
505
507
  describe("detectEdges", () => {
506
508
  it("should return edges and ghost nodes from full pipeline", () => {
507
509
  const repoPath = createFakeRepo({
@@ -540,3 +542,256 @@ describe("detectEdges", () => {
540
542
  deleteFakeRepo(repoPath);
541
543
  });
542
544
  });
545
+ // NAVIGATES_TO edges
546
+ describe("detectNavigationEdges", () => {
547
+ // Synthesizes a navigable ROUTE CodeNode the way routesToCodeNodes would,
548
+ // so detectNavigationEdges can build its route index from it.
549
+ function makeRouteCodeNode(urlPath, opts = {}) {
550
+ const filePath = opts.filePath ?? "src/router.tsx";
551
+ return {
552
+ id: `${filePath}::${urlPath}`,
553
+ name: urlPath,
554
+ type: "ROUTE",
555
+ filePath,
556
+ startLine: 1,
557
+ endLine: 1,
558
+ metadata: {
559
+ urlPath,
560
+ routeNodeType: "REACT_ROUTER_ROUTE",
561
+ isDynamic: opts.isDynamic ?? false,
562
+ framework: "react-router",
563
+ routeKind: "react-router",
564
+ },
565
+ };
566
+ }
567
+ it("creates a NAVIGATES_TO edge for navigate('/dashboard')", () => {
568
+ const repoPath = createFakeRepo({
569
+ "src/Page.tsx": `
570
+ import { useNavigate } from "react-router-dom";
571
+ export function Page() {
572
+ const navigate = useNavigate();
573
+ function go() { navigate('/dashboard'); }
574
+ return <button onClick={go}>Go</button>;
575
+ }
576
+ `,
577
+ });
578
+ const { nodes } = parseRepo(repoPath);
579
+ const routeNode = makeRouteCodeNode("/dashboard");
580
+ const { edges } = detectNavigationEdges([...nodes, routeNode], repoPath);
581
+ const navEdge = edges.find((e) => e.type === "NAVIGATES_TO" && e.to === routeNode.id);
582
+ expect(navEdge).toBeDefined();
583
+ expect(navEdge?.metadata?.matchType).toBe("exact");
584
+ deleteFakeRepo(repoPath);
585
+ });
586
+ it("matches router.push('/users/123') against a dynamic /users/:id route", () => {
587
+ const repoPath = createFakeRepo({
588
+ "src/Page.tsx": `
589
+ import { useRouter } from "next/navigation";
590
+ export function Page() {
591
+ const router = useRouter();
592
+ function go() { router.push('/users/123'); }
593
+ return null;
594
+ }
595
+ `,
596
+ });
597
+ const { nodes } = parseRepo(repoPath);
598
+ const routeNode = makeRouteCodeNode("/users/:id", { isDynamic: true });
599
+ const { edges } = detectNavigationEdges([...nodes, routeNode], repoPath);
600
+ const navEdge = edges.find((e) => e.type === "NAVIGATES_TO" && e.to === routeNode.id);
601
+ expect(navEdge).toBeDefined();
602
+ expect(navEdge?.metadata?.matchType).toBe("dynamic");
603
+ deleteFakeRepo(repoPath);
604
+ });
605
+ it("creates a NAVIGATES_TO edge for <Link to>", () => {
606
+ const repoPath = createFakeRepo({
607
+ "src/Nav.tsx": `
608
+ import { Link } from "react-router-dom";
609
+ export function Nav() {
610
+ return <Link to="/about">About</Link>;
611
+ }
612
+ `,
613
+ });
614
+ const { nodes } = parseRepo(repoPath);
615
+ const routeNode = makeRouteCodeNode("/about");
616
+ const { edges } = detectNavigationEdges([...nodes, routeNode], repoPath);
617
+ expect(edges.find((e) => e.type === "NAVIGATES_TO" && e.to === routeNode.id)).toBeDefined();
618
+ deleteFakeRepo(repoPath);
619
+ });
620
+ it("detects native window.location.href and window.history.pushState", () => {
621
+ const repoPath = createFakeRepo({
622
+ "src/nav.ts": `
623
+ export function login() {
624
+ window.location.href = '/login';
625
+ }
626
+ export function pushX() {
627
+ window.history.pushState(null, '', '/x');
628
+ }
629
+ `,
630
+ });
631
+ const { nodes } = parseRepo(repoPath);
632
+ const loginRoute = makeRouteCodeNode("/login");
633
+ const xRoute = makeRouteCodeNode("/x");
634
+ const { edges } = detectNavigationEdges([...nodes, loginRoute, xRoute], repoPath);
635
+ expect(edges.find((e) => e.type === "NAVIGATES_TO" && e.to === loginRoute.id)).toBeDefined();
636
+ expect(edges.find((e) => e.type === "NAVIGATES_TO" && e.to === xRoute.id)).toBeDefined();
637
+ deleteFakeRepo(repoPath);
638
+ });
639
+ it("creates an unresolved ghost route (never [npm]/-prefixed) for an unmatched path", () => {
640
+ const repoPath = createFakeRepo({
641
+ "src/Page.tsx": `
642
+ import { useNavigate } from "react-router-dom";
643
+ export function Page() {
644
+ const navigate = useNavigate();
645
+ function go() { navigate('/nope-not-a-route'); }
646
+ return null;
647
+ }
648
+ `,
649
+ });
650
+ const { nodes } = parseRepo(repoPath);
651
+ // No matching route node provided → should synthesize an unresolved ghost.
652
+ const { edges, ghostNodes } = detectNavigationEdges([...nodes], repoPath);
653
+ const ghost = ghostNodes.find((n) => n.id === "[route]::/nope-not-a-route");
654
+ expect(ghost).toBeDefined();
655
+ expect(ghost?.type).toBe("ROUTE");
656
+ expect(ghost?.id.startsWith("[npm]/")).toBe(false);
657
+ expect(ghost?.metadata.isUnresolved).toBe(true);
658
+ const edge = edges.find((e) => e.to === "[route]::/nope-not-a-route");
659
+ expect(edge?.metadata?.matchType).toBe("unresolved");
660
+ deleteFakeRepo(repoPath);
661
+ });
662
+ it("does NOT create a NAVIGATES_TO edge for <Link> from a non-routing lib (MUI)", () => {
663
+ const repoPath = createFakeRepo({
664
+ "src/Nav.tsx": `
665
+ import { Link } from "@mui/material";
666
+ export function Nav() {
667
+ return <Link href="/about">About</Link>;
668
+ }
669
+ `,
670
+ });
671
+ const { nodes } = parseRepo(repoPath);
672
+ const routeNode = makeRouteCodeNode("/about");
673
+ const { edges } = detectNavigationEdges([...nodes, routeNode], repoPath);
674
+ expect(edges.find((e) => e.type === "NAVIGATES_TO")).toBeUndefined();
675
+ deleteFakeRepo(repoPath);
676
+ });
677
+ it("route node and react-router-dom THIRD_PARTY node coexist — distinct ids, no duplicates", () => {
678
+ const repoPath = createFakeRepo({
679
+ "src/Page.tsx": `
680
+ import { useNavigate } from "react-router-dom";
681
+ export function Page() {
682
+ const navigate = useNavigate();
683
+ function go() { navigate('/users'); }
684
+ return null;
685
+ }
686
+ `,
687
+ });
688
+ const { nodes } = parseRepo(repoPath);
689
+ // The react-router-dom package THIRD_PARTY node — synthesized as thirdPartyLibs
690
+ // would create it (keyed by name) so importEdges materializes its method node.
691
+ const pkgNode = {
692
+ id: "[npm]/react-router-dom",
693
+ name: "react-router-dom",
694
+ type: "THIRD_PARTY",
695
+ filePath: "[npm]/react-router-dom",
696
+ startLine: 0,
697
+ endLine: 0,
698
+ metadata: { isThirdParty: true, packageVersion: "6.0.0", category: "routing" },
699
+ };
700
+ const routeNode = makeRouteCodeNode("/users");
701
+ const inputNodes = [...nodes, pkgNode, routeNode];
702
+ const fingerprint = makeFingerprint({ framework: "react", router: "react-router" });
703
+ const result = detectEdges(inputNodes, [], repoPath, fingerprint);
704
+ const allNodes = [...inputNodes, ...result.ghostNodes];
705
+ const tpMethod = allNodes.find((n) => n.id === "[npm]/react-router-dom::useNavigate");
706
+ const usersRoute = allNodes.find((n) => n.type === "ROUTE" && n.id === routeNode.id);
707
+ // both exist, with disjoint namespaces
708
+ expect(tpMethod).toBeDefined();
709
+ expect(tpMethod?.type).toBe("THIRD_PARTY");
710
+ expect(usersRoute).toBeDefined();
711
+ expect(usersRoute?.id.startsWith("[npm]/")).toBe(false);
712
+ expect(tpMethod?.id).not.toBe(usersRoute?.id);
713
+ // no duplicate ids anywhere in the combined node set
714
+ const ids = allNodes.map((n) => n.id);
715
+ expect(new Set(ids).size).toBe(ids.length);
716
+ // the NAVIGATES_TO edge coexists with the third-party CALLS/IMPORTS edges
717
+ expect(result.edges.find((e) => e.type === "NAVIGATES_TO" && e.to === routeNode.id)).toBeDefined();
718
+ deleteFakeRepo(repoPath);
719
+ });
720
+ });
721
+ // HANDLES edges for React Router routes
722
+ describe("detectRouteEdges — React Router", () => {
723
+ function makeRRRouteNode(urlPath, rendersComponent, filePath = "src/App.tsx") {
724
+ return {
725
+ id: `${filePath}::${urlPath}`,
726
+ name: urlPath,
727
+ type: "ROUTE",
728
+ filePath,
729
+ startLine: 1,
730
+ endLine: 1,
731
+ metadata: {
732
+ urlPath,
733
+ routeKind: "react-router",
734
+ routeNodeType: "REACT_ROUTER_ROUTE",
735
+ rendersComponent,
736
+ },
737
+ };
738
+ }
739
+ it("creates a HANDLES edge from a route to a component in the same file", () => {
740
+ const repoPath = createFakeRepo({
741
+ "src/App.tsx": `
742
+ import { Routes, Route } from "react-router-dom";
743
+ export function Home() { return <div>Home</div>; }
744
+ export default function App() {
745
+ return <Routes><Route path="/" element={<Home/>} /></Routes>;
746
+ }
747
+ `,
748
+ });
749
+ const { nodes } = parseRepo(repoPath);
750
+ const routeNode = makeRRRouteNode("/", "Home");
751
+ const allNodes = [...nodes, routeNode];
752
+ const lookup = buildLookupMaps(allNodes);
753
+ const edges = detectRouteEdges(allNodes, lookup);
754
+ const handles = edges.find((e) => e.type === "HANDLES" && e.from === routeNode.id && e.to.includes("Home"));
755
+ expect(handles).toBeDefined();
756
+ expect(handles?.metadata?.routeKind).toBe("react-router");
757
+ deleteFakeRepo(repoPath);
758
+ });
759
+ it("resolves a route component imported from another file", () => {
760
+ const repoPath = createFakeRepo({
761
+ "src/Home.tsx": `export default function Home() { return <div>Home</div>; }`,
762
+ "src/App.tsx": `
763
+ import { Routes, Route } from "react-router-dom";
764
+ import Home from "./Home";
765
+ export default function App() {
766
+ return <Routes><Route path="/" element={<Home/>} /></Routes>;
767
+ }
768
+ `,
769
+ });
770
+ const { nodes } = parseRepo(repoPath);
771
+ const routeNode = makeRRRouteNode("/", "Home");
772
+ const allNodes = [...nodes, routeNode];
773
+ const lookup = buildLookupMaps(allNodes);
774
+ const edges = detectRouteEdges(allNodes, lookup);
775
+ const handles = edges.find((e) => e.type === "HANDLES" && e.from === routeNode.id);
776
+ expect(handles).toBeDefined();
777
+ expect(handles?.to).toContain("Home.tsx"); // resolved across files, not App.tsx
778
+ deleteFakeRepo(repoPath);
779
+ });
780
+ it("leaves a route with no resolvable component as an orphan (no HANDLES edge)", () => {
781
+ const repoPath = createFakeRepo({
782
+ "src/App.tsx": `
783
+ import { Routes, Route } from "react-router-dom";
784
+ export default function App() {
785
+ return <Routes><Route path="/ghost" element={<Missing/>} /></Routes>;
786
+ }
787
+ `,
788
+ });
789
+ const { nodes } = parseRepo(repoPath);
790
+ const routeNode = makeRRRouteNode("/ghost", "Missing");
791
+ const allNodes = [...nodes, routeNode];
792
+ const lookup = buildLookupMaps(allNodes);
793
+ const edges = detectRouteEdges(allNodes, lookup);
794
+ expect(edges.find((e) => e.from === routeNode.id)).toBeUndefined();
795
+ deleteFakeRepo(repoPath);
796
+ });
797
+ });
@@ -87,7 +87,7 @@ function walkObject(file, objLiteral, prefix, fileDirective, out) {
87
87
  const kind = prop.getKind();
88
88
  // Shorthand method: streamingData(args) { ... } (incl. async)
89
89
  if (kind === SyntaxKind.MethodDeclaration) {
90
- const name = prop.getName?.();
90
+ const name = typeof prop.getName?.() !== "string" ? undefined : prop.getName().replace(/^['"`]|['"`]$/g, "");
91
91
  if (!name)
92
92
  continue;
93
93
  out.push(buildNode(file, `${prefix}.${name}`, prop, prop, fileDirective));
@@ -96,7 +96,7 @@ function walkObject(file, objLiteral, prefix, fileDirective, out) {
96
96
  // Everything else we handle needs a name + initializer.
97
97
  if (kind !== SyntaxKind.PropertyAssignment)
98
98
  continue; // skip spread/shorthand/get/set
99
- const name = prop.getName?.();
99
+ const name = typeof prop.getName?.() !== "string" ? undefined : prop.getName().replace(/^['"`]|['"`]$/g, "");
100
100
  const init = prop.getInitializer?.();
101
101
  if (!name || !init)
102
102
  continue;
@@ -296,16 +296,6 @@ describe("parseRepo", () => {
296
296
  expect(nodeModuleNode).toBeUndefined();
297
297
  deleteFakeRepo(repoPath);
298
298
  });
299
- it("should ignore test files", () => {
300
- const repoPath = createFakeRepo({
301
- "src/Button.tsx": `export function Button() { return <button />; }`,
302
- "src/Button.test.tsx": `export function FakeButton() { return <button />; }`,
303
- });
304
- const result = parseRepo(repoPath);
305
- const testNode = result.nodes.find((n) => n.filePath.includes(".test."));
306
- expect(testNode).toBeUndefined();
307
- deleteFakeRepo(repoPath);
308
- });
309
299
  it("should ignore config files", () => {
310
300
  const repoPath = createFakeRepo({
311
301
  "src/Button.tsx": `export function Button() { return <button />; }`,
@@ -123,6 +123,32 @@ function routesToCodeNodes(routes, repoPath) {
123
123
  },
124
124
  });
125
125
  }
126
+ else if (route.type === "REACT_ROUTER_ROUTE") {
127
+ // React Router / TanStack / wouter route — a single ROUTE node, no method.
128
+ const name = route.urlPath;
129
+ const id = `${relativeFilePath}::${name}`;
130
+ nodes.push({
131
+ id,
132
+ name,
133
+ type: "ROUTE",
134
+ filePath: relativeFilePath,
135
+ startLine: 1,
136
+ endLine: 1,
137
+ parentFile: `file::${relativeFilePath}`,
138
+ metadata: {
139
+ urlPath: route.urlPath,
140
+ httpMethod: null,
141
+ isDynamic: route.isDynamic,
142
+ isCatchAll: route.isCatchAll,
143
+ isGroupRoute: route.isGroupRoute,
144
+ params: route.params ?? [],
145
+ routeNodeType: "REACT_ROUTER_ROUTE",
146
+ framework: "react-router",
147
+ routeKind: "react-router",
148
+ rendersComponent: route.rendersComponent, // resolved to a HANDLES edge in routeEdge.ts
149
+ },
150
+ });
151
+ }
126
152
  else {
127
153
  // Next.js RouteNode — one node per HTTP method for API routes,
128
154
  // one node for page/layout/etc.
package/dist/types.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface ProjectFingerprint {
17
17
  databases: DatabaseLibrary[];
18
18
  rawDependencies: Record<string, string>;
19
19
  }
20
- export type RouteNodeType = "PAGE" | "LAYOUT" | "API_ROUTE" | "LOADING" | "ERROR" | "MIDDLEWARE" | "NOT_FOUND";
20
+ export type RouteNodeType = "PAGE" | "LAYOUT" | "API_ROUTE" | "LOADING" | "ERROR" | "MIDDLEWARE" | "NOT_FOUND" | "REACT_ROUTER_ROUTE";
21
21
  export interface RouteNode {
22
22
  type: RouteNodeType;
23
23
  nodeId?: string;
@@ -29,6 +29,7 @@ export interface RouteNode {
29
29
  layoutPath?: string;
30
30
  params?: string[];
31
31
  httpMethods?: string[];
32
+ rendersComponent?: string;
32
33
  }
33
34
  export interface BackendRouteNode {
34
35
  type: "BACKEND_ROUTE";
@@ -69,7 +70,7 @@ export interface CodeNode {
69
70
  score?: Number;
70
71
  metadata: Record<string, unknown>;
71
72
  }
72
- export type EdgeType = "CALLS" | "IMPORTS" | "READS_FROM" | "WRITES_TO" | "PROP_PASS" | "EMITS" | "LISTENS" | "WRAPPED_BY" | "GUARDS" | "HANDLES" | "TESTS" | "USES" | "NEXTJS_API_CALL";
73
+ export type EdgeType = "CALLS" | "IMPORTS" | "READS_FROM" | "WRITES_TO" | "PROP_PASS" | "EMITS" | "LISTENS" | "WRAPPED_BY" | "GUARDS" | "HANDLES" | "TESTS" | "USES" | "NEXTJS_API_CALL" | "NAVIGATES_TO";
73
74
  export interface CodeEdge {
74
75
  from: string;
75
76
  to: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlensio",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Codebase intelligence engine for TypeScript/JavaScript/Reactjs/Nextjs repositories.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",