devlensio 0.4.2 → 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.
- package/README.md +6 -4
- package/dist/filesystem/index.js +6 -0
- package/dist/filesystem/index.test.js +170 -0
- package/dist/filesystem/reactRouterRoutes.d.ts +2 -0
- package/dist/filesystem/reactRouterRoutes.js +243 -0
- package/dist/graph/edges/apiFetchEdges.js +68 -137
- package/dist/graph/edges/helpers/routeMatching.d.ts +8 -0
- package/dist/graph/edges/helpers/routeMatching.js +122 -0
- package/dist/graph/edges/navigationEdges.d.ts +5 -0
- package/dist/graph/edges/navigationEdges.js +339 -0
- package/dist/graph/edges/routeEdge.js +25 -0
- package/dist/graph/index.js +12 -1
- package/dist/graph/index.test.js +263 -8
- package/dist/parser/extractors/functions.d.ts +5 -0
- package/dist/parser/extractors/functions.js +5 -5
- package/dist/parser/extractors/objectMethods.d.ts +4 -0
- package/dist/parser/extractors/objectMethods.js +141 -0
- package/dist/parser/index.js +3 -1
- package/dist/parser/index.test.js +129 -9
- package/dist/pipeline/index.js +26 -0
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
package/dist/graph/index.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
});
|
|
@@ -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,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 = typeof prop.getName?.() !== "string" ? undefined : prop.getName().replace(/^['"`]|['"`]$/g, "");
|
|
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 = typeof prop.getName?.() !== "string" ? undefined : prop.getName().replace(/^['"`]|['"`]$/g, "");
|
|
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
|
+
}
|
package/dist/parser/index.js
CHANGED
|
@@ -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
|
|
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.
|