devlensio 0.4.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +212 -72
- package/dist/config/index.d.ts +14 -2
- package/dist/config/index.js +77 -2
- package/dist/config/providers/catalog.d.ts +27 -0
- package/dist/config/providers/catalog.js +130 -0
- package/dist/config/providers/file.d.ts +4 -2
- package/dist/config/providers/file.js +198 -32
- package/dist/config/providers/paths.d.ts +2 -0
- package/dist/config/providers/paths.js +4 -0
- package/dist/config/providers/providers.default.d.ts +3 -0
- package/dist/config/providers/providers.default.js +12 -0
- package/dist/config/providers/request.js +21 -9
- package/dist/config/types.d.ts +25 -2
- package/dist/config/types.js +22 -7
- package/dist/config/writer.d.ts +8 -2
- package/dist/config/writer.js +59 -12
- 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 +1 -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/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/parser/extractors/objectMethods.js +2 -2
- package/dist/parser/index.test.js +0 -10
- package/dist/pipeline/index.js +26 -0
- package/dist/summarizer/providers/anthropic.d.ts +2 -1
- package/dist/summarizer/providers/anthropic.js +3 -2
- package/dist/summarizer/providers/index.js +19 -35
- package/dist/summarizer/providers/models.d.ts +11 -0
- package/dist/summarizer/providers/models.js +113 -0
- package/dist/summarizer/providers/openai.d.ts +2 -1
- package/dist/summarizer/providers/openai.js +2 -1
- package/dist/summarizer/providers/types.d.ts +1 -6
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
- package/dist/summarizer/providers/gemini.d.ts +0 -9
- package/dist/summarizer/providers/gemini.js +0 -79
- package/dist/summarizer/providers/ollama.d.ts +0 -9
- package/dist/summarizer/providers/ollama.js +0 -23
- package/dist/summarizer/providers/openRouter.d.ts +0 -9
- package/dist/summarizer/providers/openRouter.js +0 -19
package/dist/graph/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { detectEventEdges } from "./edges/eventEdges.js";
|
|
|
5
5
|
import { detectGuardEdges } from "./edges/guardEdges.js";
|
|
6
6
|
import { detectHookEdges } from "./edges/hookEdges.js";
|
|
7
7
|
import { detectImportEdges } from "./edges/importEdges.js";
|
|
8
|
+
import { detectNavigationEdges } from "./edges/navigationEdges.js";
|
|
8
9
|
import { detectPropEdges } from "./edges/propEdges.js";
|
|
9
10
|
import { detectRouteEdges } from "./edges/routeEdge.js";
|
|
10
11
|
import { detectStateEdges } from "./edges/stateEdges.js";
|
|
@@ -29,6 +30,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
|
|
|
29
30
|
const guardEdges = detectGuardEdges(nodes, lookupMp, routeNodes, repoPath, fingerprint);
|
|
30
31
|
const testEdges = detectTestEdges(lookupMp, repoPath); // This does not needs nodes, as it detect edges from the file
|
|
31
32
|
const nextjsApiCallEdges = detectNextjsApiCallEdges(nodes, repoPath);
|
|
33
|
+
const navResult = detectNavigationEdges(nodes, repoPath);
|
|
32
34
|
// Collect all dynamically-created third-party method nodes (dedup by id)
|
|
33
35
|
const newThirdPartyNodesMap = new Map();
|
|
34
36
|
for (const n of [...importResult.thirdPartyMethodNodes, ...callResult.newThirdPartyNodes]) {
|
|
@@ -49,6 +51,8 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
|
|
|
49
51
|
console.log(` Ghost nodes created: ${eventResults.ghostNodes.length}`);
|
|
50
52
|
console.log(` Third-party method nodes: ${newThirdPartyNodes.length}`);
|
|
51
53
|
console.log(` NEXTJS_API_CALL edges: ${nextjsApiCallEdges.length}`);
|
|
54
|
+
console.log(` NAVIGATES_TO edges: ${navResult.edges.length}`);
|
|
55
|
+
console.log(` Navigation ghost route nodes: ${navResult.ghostNodes.length}`);
|
|
52
56
|
const allEdges = [
|
|
53
57
|
...callEdges,
|
|
54
58
|
...importEdges,
|
|
@@ -60,10 +64,17 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
|
|
|
60
64
|
...guardEdges,
|
|
61
65
|
...testEdges,
|
|
62
66
|
...nextjsApiCallEdges,
|
|
67
|
+
...navResult.edges,
|
|
63
68
|
];
|
|
64
69
|
console.log(`Total edges detected: ${allEdges.length}`);
|
|
70
|
+
// Merge nav ghost route nodes, deduped by id (never overwrite a real node).
|
|
71
|
+
const allGhostMap = new Map();
|
|
72
|
+
for (const n of [...eventResults.ghostNodes, ...newThirdPartyNodes, ...navResult.ghostNodes]) {
|
|
73
|
+
if (!allGhostMap.has(n.id))
|
|
74
|
+
allGhostMap.set(n.id, n);
|
|
75
|
+
}
|
|
65
76
|
return {
|
|
66
77
|
edges: allEdges,
|
|
67
|
-
ghostNodes: [...
|
|
78
|
+
ghostNodes: [...allGhostMap.values()],
|
|
68
79
|
};
|
|
69
80
|
}
|
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
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export * from "./pipeline/index.js";
|
|
|
9
9
|
export * from "./config/types.js";
|
|
10
10
|
export { queue } from "./jobs/index.js";
|
|
11
11
|
export { storage } from "./storage/index.js";
|
|
12
|
-
export { resolveConfig, initConfig, maskConfig, writeConfig } from "./config/index.js";
|
|
12
|
+
export { resolveConfig, initConfig, maskConfig, writeConfig, resolveAllProviders, setActiveProvider, removeProvider as removeProviderConfig, } from "./config/index.js";
|
|
13
|
+
export type { AllProvidersResult } from "./config/index.js";
|
|
13
14
|
export { readPackageDependencies, categorizeLibrary } from "./graph/thirdPartyLibs.js";
|
|
14
15
|
export { analyzePipeline } from "./pipeline/index.js";
|
|
15
16
|
export { runSummarization } from "./summarizer/index.js";
|
|
@@ -18,3 +19,7 @@ export { EDGE_LABELS } from "./summarizer/prompts.js";
|
|
|
18
19
|
export { CycleGroup, TopologicalResult } from "./summarizer/types.js";
|
|
19
20
|
export { buildGraphIndex, getBlastRadius, getKHop, getSubgraph, findCycles, } from "./graph/traversal.js";
|
|
20
21
|
export type { GraphIndex, TraversalHit, TraversalResult, TraversalOpts, SubgraphResult, } from "./graph/traversal.js";
|
|
22
|
+
export { loadCatalog, findProvider, saveProvider, removeProvider, resetUserCatalog, listUserProviders, } from "./config/providers/catalog.js";
|
|
23
|
+
export type { CatalogProvider, CatalogProviderInput } from "./config/providers/catalog.js";
|
|
24
|
+
export { listModels } from "./summarizer/providers/models.js";
|
|
25
|
+
export type { ListModelsOpts } from "./summarizer/providers/models.js";
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export * from "./config/types.js";
|
|
|
11
11
|
export { queue } from "./jobs/index.js";
|
|
12
12
|
export { storage } from "./storage/index.js";
|
|
13
13
|
// Config helpers
|
|
14
|
-
export { resolveConfig, initConfig, maskConfig, writeConfig } from "./config/index.js";
|
|
14
|
+
export { resolveConfig, initConfig, maskConfig, writeConfig, resolveAllProviders, setActiveProvider, removeProvider as removeProviderConfig, } from "./config/index.js";
|
|
15
15
|
// Pre-scan helpers
|
|
16
16
|
export { readPackageDependencies, categorizeLibrary } from "./graph/thirdPartyLibs.js";
|
|
17
17
|
// Pipeline & analysis
|
|
@@ -21,3 +21,7 @@ export { computeClusters } from "./clustering/index.js";
|
|
|
21
21
|
export { EDGE_LABELS } from "./summarizer/prompts.js";
|
|
22
22
|
// Graph traversal & queries (consumed by the MCP server)
|
|
23
23
|
export { buildGraphIndex, getBlastRadius, getKHop, getSubgraph, findCycles, } from "./graph/traversal.js";
|
|
24
|
+
// Provider catalog (consumed by DevLens OSS frontend & CLI)
|
|
25
|
+
export { loadCatalog, findProvider, saveProvider, removeProvider, resetUserCatalog, listUserProviders, } from "./config/providers/catalog.js";
|
|
26
|
+
// Dynamic model discovery (consumed by DevLens OSS CLI)
|
|
27
|
+
export { listModels } from "./summarizer/providers/models.js";
|
|
@@ -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 />; }`,
|
package/dist/pipeline/index.js
CHANGED
|
@@ -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.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { LLMClient, LLMRequest, NodeSummaryOutput } from "./types.js";
|
|
2
2
|
export declare class AnthropicClient implements LLMClient {
|
|
3
3
|
readonly provider: "anthropic";
|
|
4
|
+
readonly providerName?: string;
|
|
4
5
|
readonly model: string;
|
|
5
6
|
private client;
|
|
6
|
-
constructor(apiKey: string, model: string);
|
|
7
|
+
constructor(apiKey: string, model: string, baseURL?: string, providerName?: string);
|
|
7
8
|
summarize(request: LLMRequest): Promise<NodeSummaryOutput>;
|
|
8
9
|
validateConnection(): Promise<void>;
|
|
9
10
|
}
|
|
@@ -33,10 +33,11 @@ function parseResponse(raw) {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
export class AnthropicClient {
|
|
36
|
-
constructor(apiKey, model) {
|
|
36
|
+
constructor(apiKey, model, baseURL, providerName) {
|
|
37
37
|
this.provider = "anthropic";
|
|
38
38
|
this.model = model;
|
|
39
|
-
this.
|
|
39
|
+
this.providerName = providerName;
|
|
40
|
+
this.client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
|
40
41
|
}
|
|
41
42
|
async summarize(request) {
|
|
42
43
|
const systemMessage = request.messages.find(m => m.role === "system");
|
|
@@ -1,43 +1,27 @@
|
|
|
1
|
-
// providers/index.ts has one job — given the config, return the right LLMClient instance.
|
|
1
|
+
// providers/index.ts has one job — given the config, return the right LLMClient instance.
|
|
2
|
+
// It's the factory that the batch loop calls so it never has to know which provider is being used.
|
|
3
|
+
// The switch has exactly 2 arms: the two wire protocols (openai, anthropic).
|
|
4
|
+
// Brand identity (providerName) is data — resolved from the catalog or user input.
|
|
5
|
+
import { findProvider } from "../../config/providers/catalog.js";
|
|
2
6
|
import { AnthropicClient } from "./anthropic.js";
|
|
3
|
-
import { GeminiClient } from "./gemini.js";
|
|
4
|
-
import { OllamaClient } from "./ollama.js";
|
|
5
7
|
import { OpenAIClient } from "./openai.js";
|
|
6
|
-
import { OpenRouterClient } from "./openRouter.js";
|
|
7
|
-
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
8
|
-
//
|
|
9
|
-
// Single entry point for the batch loop — returns the right LLMClient
|
|
10
|
-
// based on config. Caller never imports individual provider classes.
|
|
11
|
-
//
|
|
12
|
-
// Throws clearly if:
|
|
13
|
-
// - provider is unknown
|
|
14
|
-
// - apiKey is missing for cloud providers (fail fast before the batch loop)
|
|
15
8
|
export function createLLMClient(config) {
|
|
16
|
-
const { provider, model, apiKey, baseUrl } = config;
|
|
9
|
+
const { provider, providerName, model, apiKey, baseUrl } = config;
|
|
10
|
+
const entry = findProvider(providerName ?? "");
|
|
11
|
+
const needsKey = entry?.requiresKey ?? true; // unknown/custom → require key
|
|
12
|
+
const effectiveBase = baseUrl ?? entry?.baseUrl;
|
|
17
13
|
switch (provider) {
|
|
18
|
-
case "
|
|
14
|
+
case "openai": {
|
|
15
|
+
if (needsKey && !apiKey)
|
|
16
|
+
throw new Error(`Provider "${providerName}" requires an API key`);
|
|
17
|
+
return new OpenAIClient(apiKey ?? "ollama", model, effectiveBase, providerName);
|
|
18
|
+
}
|
|
19
|
+
case "anthropic": {
|
|
19
20
|
if (!apiKey)
|
|
20
|
-
throw new Error("
|
|
21
|
-
return new AnthropicClient(apiKey, model);
|
|
22
|
-
|
|
23
|
-
if (!apiKey)
|
|
24
|
-
throw new Error("OpenAI provider requires an API key");
|
|
25
|
-
return new OpenAIClient(apiKey, model);
|
|
26
|
-
case "openrouter":
|
|
27
|
-
if (!apiKey)
|
|
28
|
-
throw new Error("OpenRouter provider requires an API key");
|
|
29
|
-
return new OpenRouterClient(apiKey, model);
|
|
30
|
-
case "gemini":
|
|
31
|
-
if (!apiKey)
|
|
32
|
-
throw new Error("Gemini provider requires an API key");
|
|
33
|
-
return new GeminiClient(apiKey, model);
|
|
34
|
-
case "ollama":
|
|
35
|
-
return new OllamaClient(model, baseUrl);
|
|
36
|
-
case "managed":
|
|
37
|
-
// Cloud SaaS only — platform injects the key via request headers.
|
|
38
|
-
// Should never reach here in local mode.
|
|
39
|
-
throw new Error("Managed provider is only available in cloud mode");
|
|
21
|
+
throw new Error(`Provider "${providerName}" requires an API key`);
|
|
22
|
+
return new AnthropicClient(apiKey, model, effectiveBase, providerName);
|
|
23
|
+
}
|
|
40
24
|
default:
|
|
41
|
-
throw new Error(`Unknown
|
|
25
|
+
throw new Error(`Unknown provider protocol: ${provider}`);
|
|
42
26
|
}
|
|
43
27
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ListModelsOpts {
|
|
2
|
+
protocol: "openai" | "anthropic";
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Fetches the live model list from a provider's models endpoint.
|
|
8
|
+
* Returns deduped (case-insensitive), sorted model ID strings.
|
|
9
|
+
* Throws on auth/network errors with a clear message.
|
|
10
|
+
*/
|
|
11
|
+
export declare function listModels(opts: ListModelsOpts): Promise<string[]>;
|