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.
@@ -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
+ });
@@ -1,4 +1,9 @@
1
1
  import { SourceFile } from "ts-morph";
2
2
  import type { CodeNode } from "../../types.js";
3
3
  import { type RenderingBoundary } from "../directives.js";
4
+ export declare function extractFunctionCalls(node: any): string[];
5
+ export declare function extractHookCalls(node: any): string[];
6
+ export declare function extractApiCalls(node: any): string[];
7
+ export declare function hasErrorHandling(node: any): boolean;
8
+ export declare function extractThrowStatements(node: any): boolean;
4
9
  export declare function extractFunctions(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];
@@ -7,7 +7,7 @@ const HTTP_METHOD_EXPORTS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "H
7
7
  function makeId(filePath, name) {
8
8
  return `${filePath}::${name}`;
9
9
  }
10
- function extractFunctionCalls(node) {
10
+ export function extractFunctionCalls(node) {
11
11
  const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
12
12
  const names = [];
13
13
  for (const call of calls) {
@@ -22,7 +22,7 @@ function extractFunctionCalls(node) {
22
22
  }
23
23
  return [...new Set(names)];
24
24
  }
25
- function extractHookCalls(node) {
25
+ export function extractHookCalls(node) {
26
26
  const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
27
27
  const hooks = [];
28
28
  for (const call of calls) {
@@ -34,7 +34,7 @@ function extractHookCalls(node) {
34
34
  }
35
35
  return [...new Set(hooks)];
36
36
  }
37
- function extractApiCalls(node) {
37
+ export function extractApiCalls(node) {
38
38
  const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
39
39
  const apiCalls = [];
40
40
  for (const call of calls) {
@@ -75,11 +75,11 @@ function extractApiCalls(node) {
75
75
  }
76
76
  return [...new Set(apiCalls)];
77
77
  }
78
- function hasErrorHandling(node) {
78
+ export function hasErrorHandling(node) {
79
79
  const tryCatch = node.getDescendantsOfKind(SyntaxKind.TryStatement);
80
80
  return tryCatch.length > 0;
81
81
  }
82
- function extractThrowStatements(node) {
82
+ export function extractThrowStatements(node) {
83
83
  const throws = node.getDescendantsOfKind(SyntaxKind.ThrowStatement);
84
84
  return throws.length > 0;
85
85
  }
@@ -0,0 +1,4 @@
1
+ import { SourceFile } from "ts-morph";
2
+ import type { CodeNode } from "../../types.js";
3
+ import { type RenderingBoundary } from "../directives.js";
4
+ export declare function extractObjectMethods(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];
@@ -0,0 +1,141 @@
1
+ import { SyntaxKind } from "ts-morph";
2
+ import { detectFunctionDirective } from "../directives.js";
3
+ import { extractFunctionCalls, extractHookCalls, extractApiCalls, hasErrorHandling, extractThrowStatements, } from "./functions.js";
4
+ import { extractParams, extractReturnTypeAnnotation, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
5
+ // Extracts function-valued properties of object literals as FUNCTION nodes.
6
+ //
7
+ // Many React/Next.js modules keep their logic inside an exported object literal
8
+ // (provider adapters, route tables, command maps, strategy/config objects). The
9
+ // other extractors (declarations, var-assigned arrows, components, hooks) never
10
+ // descend into object literals, so those functions had no node to anchor edges
11
+ // to — every CALLS / NEXTJS_API_CALL / READS_FROM edge silently dropped.
12
+ //
13
+ // The node `name` encodes the dotted access path (e.g.
14
+ // "providersConfig.zoro.streamingData"). index.ts builds the id as
15
+ // `${relativePath}::${name}`, so this yields the desired node identity for free.
16
+ function makeId(filePath, name) {
17
+ return `${filePath}::${name}`;
18
+ }
19
+ // Unwraps `as const` / `as Foo` and parenthesized expressions, returning the
20
+ // underlying ObjectLiteralExpression node, or null if it isn't one.
21
+ function asObjectLiteral(node) {
22
+ let n = node;
23
+ while (n &&
24
+ (n.getKind() === SyntaxKind.AsExpression ||
25
+ n.getKind() === SyntaxKind.ParenthesizedExpression)) {
26
+ n = n.getExpression?.();
27
+ }
28
+ return n && n.getKind() === SyntaxKind.ObjectLiteralExpression ? n : null;
29
+ }
30
+ // True for `async (...) => {}`, `async function () {}`, `async key() {}`.
31
+ function isAsyncFn(fnNode) {
32
+ if (typeof fnNode.isAsync === "function")
33
+ return fnNode.isAsync();
34
+ return fnNode.getText().trimStart().startsWith("async");
35
+ }
36
+ // Builds a FUNCTION node from a function-like node + the span node that should
37
+ // own its line range / rawCode. The metadata shape mirrors functions.ts exactly
38
+ // so callEdges / apiFetchEdges treat these like any other FUNCTION node.
39
+ // fnNode — ArrowFunction | FunctionExpression | MethodDeclaration (params/body)
40
+ // spanNode— the PropertyAssignment (`key: fn`) or the MethodDeclaration itself,
41
+ // so the inner call's line falls inside [startLine, endLine].
42
+ function buildNode(file, dottedName, fnNode, spanNode, fileDirective) {
43
+ const filePath = file.getFilePath();
44
+ const typedParams = extractParams(fnNode);
45
+ const calls = extractFunctionCalls(fnNode);
46
+ const hookCalls = extractHookCalls(fnNode);
47
+ const apiCalls = extractApiCalls(fnNode);
48
+ const isAsync = isAsyncFn(fnNode);
49
+ const hasErrors = hasErrorHandling(fnNode);
50
+ const throws = extractThrowStatements(fnNode);
51
+ const renderingBoundary = detectFunctionDirective(fnNode.getBody?.()) ?? fileDirective;
52
+ const returnType = extractReturnTypeAnnotation(fnNode);
53
+ const bareTypeNames = extractBareTypeNames([...typedParams.map((p) => p.type), returnType]);
54
+ const referencedTypes = extractReferencedInterfaces(file, bareTypeNames);
55
+ return {
56
+ id: makeId(filePath, dottedName),
57
+ name: dottedName,
58
+ type: "FUNCTION",
59
+ filePath,
60
+ startLine: spanNode.getStartLineNumber(),
61
+ endLine: spanNode.getEndLineNumber(),
62
+ rawCode: spanNode.getText(),
63
+ metadata: {
64
+ params: typedParams.map((p) => p.name),
65
+ parameters: typedParams,
66
+ returnType,
67
+ referencedTypes,
68
+ calls,
69
+ hookCalls,
70
+ apiCalls,
71
+ isAsync,
72
+ hasErrorHandling: hasErrors,
73
+ throws,
74
+ lineCount: spanNode.getEndLineNumber() - spanNode.getStartLineNumber(),
75
+ isHttpHandler: false,
76
+ httpMethod: undefined,
77
+ ...(renderingBoundary !== null && { renderingBoundary }),
78
+ },
79
+ };
80
+ }
81
+ // Recursively walks an object literal, emitting a node per function-valued
82
+ // property and descending into nested object literals to arbitrary depth.
83
+ function walkObject(file, objLiteral, prefix, fileDirective, out) {
84
+ // getProperties() returns a union (PropertyAssignment | ShorthandPropertyAssignment
85
+ // | SpreadAssignment | MethodDeclaration | Get/SetAccessor) — guard every getter.
86
+ for (const prop of objLiteral.getProperties()) {
87
+ const kind = prop.getKind();
88
+ // Shorthand method: streamingData(args) { ... } (incl. async)
89
+ if (kind === SyntaxKind.MethodDeclaration) {
90
+ const name = 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
+ }
@@ -5,6 +5,7 @@ import { extractComponents } from "./extractors/components.js";
5
5
  import { extractHooks } from "./extractors/hooks.js";
6
6
  import { extractFunctions } from "./extractors/functions.js";
7
7
  import { extractStores } from "./extractors/stores.js";
8
+ import { extractObjectMethods } from "./extractors/objectMethods.js";
8
9
  import { detectFileDirective } from "./directives.js";
9
10
  import { createHash } from "crypto";
10
11
  // Directories to skip entirely while walking
@@ -107,7 +108,8 @@ export function parseRepo(repoPath) {
107
108
  const hooks = extractHooks(file, fileDirective);
108
109
  const functions = extractFunctions(file, fileDirective);
109
110
  const stores = extractStores(file);
110
- const extracted = [...components, ...hooks, ...functions, ...stores];
111
+ const objectMethods = extractObjectMethods(file, fileDirective);
112
+ const extracted = [...components, ...hooks, ...functions, ...stores, ...objectMethods];
111
113
  for (const node of extracted) {
112
114
  // Normalize all extracted nodes to relative paths so every node in the
113
115
  // graph uses the same coordinate system as the FILE nodes.