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.
Files changed (50) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +212 -72
  3. package/dist/config/index.d.ts +14 -2
  4. package/dist/config/index.js +77 -2
  5. package/dist/config/providers/catalog.d.ts +27 -0
  6. package/dist/config/providers/catalog.js +130 -0
  7. package/dist/config/providers/file.d.ts +4 -2
  8. package/dist/config/providers/file.js +198 -32
  9. package/dist/config/providers/paths.d.ts +2 -0
  10. package/dist/config/providers/paths.js +4 -0
  11. package/dist/config/providers/providers.default.d.ts +3 -0
  12. package/dist/config/providers/providers.default.js +12 -0
  13. package/dist/config/providers/request.js +21 -9
  14. package/dist/config/types.d.ts +25 -2
  15. package/dist/config/types.js +22 -7
  16. package/dist/config/writer.d.ts +8 -2
  17. package/dist/config/writer.js +59 -12
  18. package/dist/filesystem/index.js +6 -0
  19. package/dist/filesystem/index.test.js +170 -0
  20. package/dist/filesystem/reactRouterRoutes.d.ts +2 -0
  21. package/dist/filesystem/reactRouterRoutes.js +243 -0
  22. package/dist/graph/edges/apiFetchEdges.js +1 -137
  23. package/dist/graph/edges/helpers/routeMatching.d.ts +8 -0
  24. package/dist/graph/edges/helpers/routeMatching.js +122 -0
  25. package/dist/graph/edges/navigationEdges.d.ts +5 -0
  26. package/dist/graph/edges/navigationEdges.js +339 -0
  27. package/dist/graph/edges/routeEdge.js +25 -0
  28. package/dist/graph/index.js +12 -1
  29. package/dist/graph/index.test.js +263 -8
  30. package/dist/index.d.ts +6 -1
  31. package/dist/index.js +5 -1
  32. package/dist/parser/extractors/objectMethods.js +2 -2
  33. package/dist/parser/index.test.js +0 -10
  34. package/dist/pipeline/index.js +26 -0
  35. package/dist/summarizer/providers/anthropic.d.ts +2 -1
  36. package/dist/summarizer/providers/anthropic.js +3 -2
  37. package/dist/summarizer/providers/index.js +19 -35
  38. package/dist/summarizer/providers/models.d.ts +11 -0
  39. package/dist/summarizer/providers/models.js +113 -0
  40. package/dist/summarizer/providers/openai.d.ts +2 -1
  41. package/dist/summarizer/providers/openai.js +2 -1
  42. package/dist/summarizer/providers/types.d.ts +1 -6
  43. package/dist/types.d.ts +3 -2
  44. package/package.json +1 -1
  45. package/dist/summarizer/providers/gemini.d.ts +0 -9
  46. package/dist/summarizer/providers/gemini.js +0 -79
  47. package/dist/summarizer/providers/ollama.d.ts +0 -9
  48. package/dist/summarizer/providers/ollama.js +0 -23
  49. package/dist/summarizer/providers/openRouter.d.ts +0 -9
  50. package/dist/summarizer/providers/openRouter.js +0 -19
@@ -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: [...eventResults.ghostNodes, ...newThirdPartyNodes],
78
+ ghostNodes: [...allGhostMap.values()],
68
79
  };
69
80
  }
@@ -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
+ });
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 />; }`,
@@ -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.client = new Anthropic({ apiKey });
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. It's the factory that the batch loop calls so it never has to know which provider is being used.
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 "anthropic":
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("Anthropic provider requires an API key");
21
- return new AnthropicClient(apiKey, model);
22
- case "openai":
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 LLM provider: ${provider}`);
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[]>;