devlensio 0.2.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 (136) hide show
  1. package/LICENSE +674 -0
  2. package/dist/clustering/index.d.ts +27 -0
  3. package/dist/clustering/index.js +149 -0
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +78 -0
  6. package/dist/config/providers/file.d.ts +19 -0
  7. package/dist/config/providers/file.js +215 -0
  8. package/dist/config/providers/request.d.ts +2 -0
  9. package/dist/config/providers/request.js +72 -0
  10. package/dist/config/types.d.ts +46 -0
  11. package/dist/config/types.js +81 -0
  12. package/dist/config/writer.d.ts +29 -0
  13. package/dist/config/writer.js +103 -0
  14. package/dist/filesystem/appRouter.d.ts +2 -0
  15. package/dist/filesystem/appRouter.js +126 -0
  16. package/dist/filesystem/backendRoutes.d.ts +2 -0
  17. package/dist/filesystem/backendRoutes.js +161 -0
  18. package/dist/filesystem/index.d.ts +2 -0
  19. package/dist/filesystem/index.js +28 -0
  20. package/dist/filesystem/index.test.d.ts +1 -0
  21. package/dist/filesystem/index.test.js +178 -0
  22. package/dist/filesystem/pagesRouter.d.ts +2 -0
  23. package/dist/filesystem/pagesRouter.js +109 -0
  24. package/dist/fingerprint/detectors.d.ts +8 -0
  25. package/dist/fingerprint/detectors.js +174 -0
  26. package/dist/fingerprint/index.d.ts +2 -0
  27. package/dist/fingerprint/index.js +41 -0
  28. package/dist/fingerprint/index.test.d.ts +1 -0
  29. package/dist/fingerprint/index.test.js +148 -0
  30. package/dist/graph/buildLookup.d.ts +10 -0
  31. package/dist/graph/buildLookup.js +32 -0
  32. package/dist/graph/edges/callEdges.d.ts +7 -0
  33. package/dist/graph/edges/callEdges.js +145 -0
  34. package/dist/graph/edges/eventEdges.d.ts +7 -0
  35. package/dist/graph/edges/eventEdges.js +203 -0
  36. package/dist/graph/edges/guardEdges.d.ts +3 -0
  37. package/dist/graph/edges/guardEdges.js +232 -0
  38. package/dist/graph/edges/hookEdges.d.ts +3 -0
  39. package/dist/graph/edges/hookEdges.js +54 -0
  40. package/dist/graph/edges/importEdges.d.ts +8 -0
  41. package/dist/graph/edges/importEdges.js +224 -0
  42. package/dist/graph/edges/propEdges.d.ts +3 -0
  43. package/dist/graph/edges/propEdges.js +142 -0
  44. package/dist/graph/edges/routeEdge.d.ts +3 -0
  45. package/dist/graph/edges/routeEdge.js +124 -0
  46. package/dist/graph/edges/stateEdges.d.ts +3 -0
  47. package/dist/graph/edges/stateEdges.js +206 -0
  48. package/dist/graph/edges/testEdges.d.ts +3 -0
  49. package/dist/graph/edges/testEdges.js +143 -0
  50. package/dist/graph/edges/utils.d.ts +2 -0
  51. package/dist/graph/edges/utils.js +25 -0
  52. package/dist/graph/index.d.ts +6 -0
  53. package/dist/graph/index.js +65 -0
  54. package/dist/graph/index.test.d.ts +1 -0
  55. package/dist/graph/index.test.js +542 -0
  56. package/dist/graph/thirdPartyLibs.d.ts +8 -0
  57. package/dist/graph/thirdPartyLibs.js +162 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +15 -0
  60. package/dist/jobs/index.d.ts +5 -0
  61. package/dist/jobs/index.js +11 -0
  62. package/dist/jobs/queue/interface.d.ts +13 -0
  63. package/dist/jobs/queue/interface.js +1 -0
  64. package/dist/jobs/queue/memory.d.ts +24 -0
  65. package/dist/jobs/queue/memory.js +291 -0
  66. package/dist/jobs/runner.d.ts +3 -0
  67. package/dist/jobs/runner.js +136 -0
  68. package/dist/jobs/types.d.ts +112 -0
  69. package/dist/jobs/types.js +33 -0
  70. package/dist/parser/directives.d.ts +4 -0
  71. package/dist/parser/directives.js +31 -0
  72. package/dist/parser/extractors/components.d.ts +5 -0
  73. package/dist/parser/extractors/components.js +240 -0
  74. package/dist/parser/extractors/functions.d.ts +4 -0
  75. package/dist/parser/extractors/functions.js +240 -0
  76. package/dist/parser/extractors/hooks.d.ts +4 -0
  77. package/dist/parser/extractors/hooks.js +128 -0
  78. package/dist/parser/extractors/stores.d.ts +3 -0
  79. package/dist/parser/extractors/stores.js +181 -0
  80. package/dist/parser/index.d.ts +14 -0
  81. package/dist/parser/index.js +168 -0
  82. package/dist/parser/index.test.d.ts +1 -0
  83. package/dist/parser/index.test.js +319 -0
  84. package/dist/parser/typeUtils.d.ts +9 -0
  85. package/dist/parser/typeUtils.js +46 -0
  86. package/dist/pipeline/index.d.ts +50 -0
  87. package/dist/pipeline/index.js +249 -0
  88. package/dist/scoring/connectionCounter.d.ts +28 -0
  89. package/dist/scoring/connectionCounter.js +134 -0
  90. package/dist/scoring/fileScorer.d.ts +2 -0
  91. package/dist/scoring/fileScorer.js +44 -0
  92. package/dist/scoring/index.d.ts +22 -0
  93. package/dist/scoring/index.js +130 -0
  94. package/dist/scoring/index.test.d.ts +1 -0
  95. package/dist/scoring/index.test.js +453 -0
  96. package/dist/scoring/nodeScorer.d.ts +3 -0
  97. package/dist/scoring/nodeScorer.js +108 -0
  98. package/dist/scoring/noiseFilter.d.ts +18 -0
  99. package/dist/scoring/noiseFilter.js +92 -0
  100. package/dist/storage/fileStorage.d.ts +117 -0
  101. package/dist/storage/fileStorage.js +616 -0
  102. package/dist/storage/index.d.ts +4 -0
  103. package/dist/storage/index.js +2 -0
  104. package/dist/storage/interface.d.ts +27 -0
  105. package/dist/storage/interface.js +1 -0
  106. package/dist/summarizer/checkpoint.d.ts +15 -0
  107. package/dist/summarizer/checkpoint.js +110 -0
  108. package/dist/summarizer/index.d.ts +2 -0
  109. package/dist/summarizer/index.js +281 -0
  110. package/dist/summarizer/mapreduce.d.ts +4 -0
  111. package/dist/summarizer/mapreduce.js +87 -0
  112. package/dist/summarizer/prompts.d.ts +22 -0
  113. package/dist/summarizer/prompts.js +205 -0
  114. package/dist/summarizer/providers/anthropic.d.ts +9 -0
  115. package/dist/summarizer/providers/anthropic.js +78 -0
  116. package/dist/summarizer/providers/gemini.d.ts +9 -0
  117. package/dist/summarizer/providers/gemini.js +79 -0
  118. package/dist/summarizer/providers/index.d.ts +3 -0
  119. package/dist/summarizer/providers/index.js +43 -0
  120. package/dist/summarizer/providers/ollama.d.ts +9 -0
  121. package/dist/summarizer/providers/ollama.js +23 -0
  122. package/dist/summarizer/providers/openRouter.d.ts +9 -0
  123. package/dist/summarizer/providers/openRouter.js +19 -0
  124. package/dist/summarizer/providers/openai.d.ts +9 -0
  125. package/dist/summarizer/providers/openai.js +72 -0
  126. package/dist/summarizer/providers/types.d.ts +32 -0
  127. package/dist/summarizer/providers/types.js +1 -0
  128. package/dist/summarizer/retry.d.ts +7 -0
  129. package/dist/summarizer/retry.js +51 -0
  130. package/dist/summarizer/topological.d.ts +3 -0
  131. package/dist/summarizer/topological.js +105 -0
  132. package/dist/summarizer/types.d.ts +57 -0
  133. package/dist/summarizer/types.js +17 -0
  134. package/dist/types.d.ts +78 -0
  135. package/dist/types.js +1 -0
  136. package/package.json +48 -0
@@ -0,0 +1,136 @@
1
+ import { analyzePipeline } from "../pipeline/index.js";
2
+ import { storage } from "../storage/index.js";
3
+ // ─── runJob ───────────────────────────────────────────────────────────────────
4
+ //
5
+ // Entry point — called by InMemoryQueue.startJob().
6
+ // Orchestrates Phase 1 (analysis) and Phase 2 (summarization) for one job.
7
+ //
8
+ // The runner is stateless — all state lives on the Job object in the queue.
9
+ // This is what makes resume work: runner re-reads job state on every call.
10
+ export async function runJob(job, queue) {
11
+ const q = queue; // safe — only InMemoryQueue used locally
12
+ queue.updateJob(job.jobId, {
13
+ status: "running",
14
+ startedAt: new Date().toISOString(),
15
+ });
16
+ // ── Phase 1 — Analysis ────────────────────────────────────────────────────
17
+ let graphId;
18
+ let commitHash;
19
+ try {
20
+ queue.updateJob(job.jobId, { phase: "analysis" });
21
+ queue.emitEvent(job.jobId, { event: "analysis_started", jobId: job.jobId });
22
+ const result = await analyzePipeline(job.repoPath, job.isGithubRepo, {
23
+ thresholds: job.thresholds,
24
+ includedThirdPartyLibs: job.includedThirdPartyLibs ?? [],
25
+ onStep: (step) => {
26
+ queue.emitEvent(job.jobId, {
27
+ event: "analysis_progress",
28
+ jobId: job.jobId,
29
+ step,
30
+ });
31
+ },
32
+ });
33
+ storage.saveGraph(result, { force: job.forceSummarize });
34
+ graphId = result.graphId;
35
+ commitHash = result.gitInfo.commitHash;
36
+ queue.updateJob(job.jobId, { graphId });
37
+ queue.emitEvent(job.jobId, {
38
+ event: "analysis_complete",
39
+ jobId: job.jobId,
40
+ graphId,
41
+ nodeCount: result.nodes.length,
42
+ edgeCount: result.edges.length,
43
+ });
44
+ console.log(`\n📊 Phase 1 complete for job ${job.jobId} — graph ${graphId}`);
45
+ console.log(` Nodes: ${result.nodes.length} | Edges: ${result.edges.length}`);
46
+ }
47
+ catch (err) {
48
+ const message = err instanceof Error ? err.message : "Analysis failed";
49
+ q._markFailed(job.jobId, message);
50
+ return;
51
+ }
52
+ // ── Cancel check after Phase 1 ────────────────────────────────────────────
53
+ const jobAfterPhase1 = queue.getJob(job.jobId);
54
+ if (jobAfterPhase1.cancelRequested) {
55
+ q._markCancelled(job.jobId, true);
56
+ return;
57
+ }
58
+ // ── skipSummarization — stop here, mark completed ─────────────────────────
59
+ // User chose analysis-only mode. Summaries can be triggered later via
60
+ // POST /api/graph/:graphId/:commitHash/summarize
61
+ if (job.skipSummarization) {
62
+ console.log(`⏭️ Job ${job.jobId} — skipSummarization=true, stopping after Phase 1`);
63
+ q._markCompleted(job.jobId, graphId);
64
+ return;
65
+ }
66
+ //if force summarization then remove the commit entirely from the meta
67
+ console.log("Force Summarized ? : ", job.forceSummarize ?? "false");
68
+ if (job.forceSummarize) {
69
+ console.log("Removing Summarized Commits!");
70
+ storage.removeFromSummarizedCommits(graphId, commitHash);
71
+ }
72
+ // ── Phase 2 — Summarization ───────────────────────────────────────────────
73
+ try {
74
+ queue.updateJob(job.jobId, { phase: "summarization" });
75
+ const { runSummarization } = await import("../summarizer/index.js");
76
+ // Find previous summarized commit for smart reuse
77
+ const previousCommitHash = await storage.findLastSummarizedAncestor(graphId, commitHash, job.repoPath);
78
+ // Fetch routes and fingerprint from saved graph for summarizer context
79
+ const savedResult = storage.getGraph(graphId, commitHash);
80
+ await runSummarization({
81
+ job,
82
+ queue,
83
+ graphId,
84
+ commitHash,
85
+ repoPath: job.repoPath,
86
+ previousCommitHash,
87
+ routes: savedResult.routes,
88
+ callbacks: {
89
+ onStarted: (totalNodes) => {
90
+ queue.updateJob(job.jobId, {
91
+ summarizationTotal: totalNodes,
92
+ summarizationCompleted: 0,
93
+ });
94
+ queue.emitEvent(job.jobId, {
95
+ event: "summarization_started",
96
+ jobId: job.jobId,
97
+ totalNodes,
98
+ });
99
+ },
100
+ onProgress: (completed, total, nodeName) => {
101
+ queue.updateJob(job.jobId, {
102
+ summarizationCompleted: completed,
103
+ summarizationTotal: total,
104
+ });
105
+ queue.emitEvent(job.jobId, {
106
+ event: "summarization_progress",
107
+ jobId: job.jobId,
108
+ completed,
109
+ total,
110
+ nodeName,
111
+ });
112
+ },
113
+ onPause: () => {
114
+ q._markPaused(job.jobId);
115
+ },
116
+ onCancel: (cleanedUp) => {
117
+ q._markCancelled(job.jobId, cleanedUp);
118
+ },
119
+ onComplete: () => {
120
+ queue.emitEvent(job.jobId, {
121
+ event: "summarization_complete",
122
+ jobId: job.jobId,
123
+ });
124
+ q._markCompleted(job.jobId, graphId);
125
+ },
126
+ onError: (error) => {
127
+ q._markFailed(job.jobId, error);
128
+ },
129
+ },
130
+ });
131
+ }
132
+ catch (err) {
133
+ const message = err instanceof Error ? err.message : "Summarization failed";
134
+ q._markFailed(job.jobId, message);
135
+ }
136
+ }
@@ -0,0 +1,112 @@
1
+ import type { DevLensConfig } from "../config/index.js";
2
+ import type { FilterThresholds } from "../pipeline/index.js";
3
+ export type JobStatus = "queued" | "running" | "paused" | "completed" | "cancelled" | "failed";
4
+ export type JobPhase = "analysis" | "summarization";
5
+ export type AnalysisStep = "fingerprint" | "filesystem" | "parse" | "edges" | "scoring";
6
+ export type ProgressEvent = {
7
+ event: "queued";
8
+ jobId: string;
9
+ position: number;
10
+ } | {
11
+ event: "analysis_started";
12
+ jobId: string;
13
+ } | {
14
+ event: "analysis_progress";
15
+ jobId: string;
16
+ step: AnalysisStep;
17
+ } | {
18
+ event: "analysis_complete";
19
+ jobId: string;
20
+ graphId: string;
21
+ nodeCount: number;
22
+ edgeCount: number;
23
+ } | {
24
+ event: "summarization_started";
25
+ jobId: string;
26
+ totalNodes: number;
27
+ } | {
28
+ event: "summarization_progress";
29
+ jobId: string;
30
+ completed: number;
31
+ total: number;
32
+ nodeName: string;
33
+ } | {
34
+ event: "summarization_complete";
35
+ jobId: string;
36
+ } | {
37
+ event: "paused";
38
+ jobId: string;
39
+ completedNodes: number;
40
+ totalNodes: number;
41
+ } | {
42
+ event: "resumed";
43
+ jobId: string;
44
+ completedNodes: number;
45
+ totalNodes: number;
46
+ } | {
47
+ event: "cancelled";
48
+ jobId: string;
49
+ cleanedUp: boolean;
50
+ } | {
51
+ event: "completed";
52
+ jobId: string;
53
+ graphId: string;
54
+ } | {
55
+ event: "failed";
56
+ jobId: string;
57
+ error: string;
58
+ };
59
+ export interface Job {
60
+ jobId: string;
61
+ status: JobStatus;
62
+ phase: JobPhase | null;
63
+ repoPath: string;
64
+ isGithubRepo: boolean;
65
+ thresholds?: FilterThresholds;
66
+ config: DevLensConfig;
67
+ skipSummarization?: boolean;
68
+ graphId?: string;
69
+ events: ProgressEvent[];
70
+ pauseRequested: boolean;
71
+ cancelRequested: boolean;
72
+ summarizationTotal?: number;
73
+ summarizationCompleted?: number;
74
+ createdAt: string;
75
+ startedAt?: string;
76
+ pausedAt?: string;
77
+ cancelledAt?: string;
78
+ completedAt?: string;
79
+ failedAt?: string;
80
+ error?: string;
81
+ forceSummarize?: boolean;
82
+ includedThirdPartyLibs?: string[];
83
+ }
84
+ export interface JobInput {
85
+ repoPath: string;
86
+ isGithubRepo?: boolean;
87
+ skipSummarization: boolean;
88
+ thresholds?: FilterThresholds;
89
+ config: DevLensConfig;
90
+ forceSummarize?: boolean;
91
+ includedThirdPartyLibs?: string[];
92
+ }
93
+ export interface JobSummary {
94
+ jobId: string;
95
+ status: JobStatus;
96
+ phase: JobPhase | null;
97
+ repoPath: string;
98
+ graphId?: string;
99
+ summarizationTotal?: number;
100
+ summarizationCompleted?: number;
101
+ createdAt: string;
102
+ startedAt?: string;
103
+ pausedAt?: string;
104
+ cancelledAt?: string;
105
+ completedAt?: string;
106
+ failedAt?: string;
107
+ error?: string;
108
+ }
109
+ export declare const TERMINAL_STATUSES: Set<JobStatus>;
110
+ export declare function isTerminal(status: JobStatus): boolean;
111
+ export declare function isResumable(status: JobStatus): boolean;
112
+ export declare function toJobSummary(job: Job): JobSummary;
@@ -0,0 +1,33 @@
1
+ // Jobs in these statuses are done — they will never change state again.
2
+ // Used by the queue to decide when to clean up SSE subscribers.
3
+ export const TERMINAL_STATUSES = new Set([
4
+ "completed",
5
+ "failed",
6
+ "cancelled",
7
+ ]);
8
+ export function isTerminal(status) {
9
+ return TERMINAL_STATUSES.has(status);
10
+ }
11
+ // Only paused jobs can be resumed.
12
+ // Cancelled jobs cannot — their checkpoints are deleted.
13
+ export function isResumable(status) {
14
+ return status === "paused";
15
+ }
16
+ export function toJobSummary(job) {
17
+ return {
18
+ jobId: job.jobId,
19
+ status: job.status,
20
+ phase: job.phase,
21
+ repoPath: job.repoPath,
22
+ graphId: job.graphId,
23
+ summarizationTotal: job.summarizationTotal,
24
+ summarizationCompleted: job.summarizationCompleted,
25
+ createdAt: job.createdAt,
26
+ startedAt: job.startedAt,
27
+ pausedAt: job.pausedAt,
28
+ cancelledAt: job.cancelledAt,
29
+ completedAt: job.completedAt,
30
+ failedAt: job.failedAt,
31
+ error: job.error,
32
+ };
33
+ }
@@ -0,0 +1,4 @@
1
+ import { SourceFile, Node } from "ts-morph";
2
+ export type RenderingBoundary = "client" | "server" | null;
3
+ export declare function detectFileDirective(sourceFile: SourceFile): RenderingBoundary;
4
+ export declare function detectFunctionDirective(bodyNode: Node | undefined): RenderingBoundary;
@@ -0,0 +1,31 @@
1
+ import { SyntaxKind } from "ts-morph";
2
+ function readDirectiveFromStatements(statements) {
3
+ const first = statements[0];
4
+ if (!first)
5
+ return null;
6
+ if (first.getKind() !== SyntaxKind.ExpressionStatement)
7
+ return null;
8
+ const expr = first.getExpression();
9
+ if (!expr || expr.getKind() !== SyntaxKind.StringLiteral)
10
+ return null;
11
+ const value = expr.getLiteralText();
12
+ if (value === "use client")
13
+ return "client";
14
+ if (value === "use server")
15
+ return "server";
16
+ return null;
17
+ }
18
+ export function detectFileDirective(sourceFile) {
19
+ return readDirectiveFromStatements(sourceFile.getStatements());
20
+ }
21
+ // Only "use server" is valid inside a function body (Next.js Server Actions).
22
+ // Returns null for anything else.
23
+ export function detectFunctionDirective(bodyNode) {
24
+ if (!bodyNode)
25
+ return null;
26
+ if (bodyNode.getKind() !== SyntaxKind.Block)
27
+ return null;
28
+ const result = readDirectiveFromStatements(bodyNode.getStatements());
29
+ // "use client" inside a function body is not valid per Next.js spec — ignore it.
30
+ return result === "server" ? "server" : null;
31
+ }
@@ -0,0 +1,5 @@
1
+ import { SourceFile, Node } from "ts-morph";
2
+ import type { CodeNode } from "../../types.js";
3
+ import { type RenderingBoundary } from "../directives.js";
4
+ export declare function returnsJSX(node: Node): boolean;
5
+ export declare function extractComponents(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];
@@ -0,0 +1,240 @@
1
+ //This file will extract the React Components from the files
2
+ import { SyntaxKind } from "ts-morph";
3
+ import { detectFunctionDirective } from "../directives.js";
4
+ import { extractReturnTypeAnnotation, extractReferencedInterfaces, } from "../typeUtils.js";
5
+ // Generates a unique id for a node
6
+ function makeId(filePath, name) {
7
+ return `${filePath}::${name}`;
8
+ }
9
+ // Checks if a node returns JSX by looking for JSX elements in its body
10
+ export function returnsJSX(node) {
11
+ const jsxElements = node.getDescendantsOfKind(SyntaxKind.JsxElement);
12
+ const jsxSelfClosing = node.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
13
+ const jsxFragments = node.getDescendantsOfKind(SyntaxKind.JsxFragment);
14
+ return jsxElements.length > 0 || jsxSelfClosing.length > 0 || jsxFragments.length > 0;
15
+ }
16
+ // Extracts hooks used inside a node (store every expression starting with use, e.g. useState, useEffect, useCustomHook)
17
+ function extractHooks(node) {
18
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
19
+ const hooks = [];
20
+ for (const call of calls) {
21
+ const name = call.getExpression().getText();
22
+ if (name.startsWith("use")) {
23
+ hooks.push(name);
24
+ }
25
+ }
26
+ return [...new Set(hooks)]; // deduplicate
27
+ }
28
+ // Extracts the context variable names passed to useContext() calls.
29
+ // e.g. useContext(AuthContext) → ["AuthContext"]
30
+ // This is stored separately from hooks so stateEdges.ts can do a direct
31
+ // name lookup instead of relying on a fragile heuristic.
32
+ function extractContextRefs(node) {
33
+ const refs = [];
34
+ for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
35
+ if (call.getExpression().getText() === "useContext") {
36
+ const arg = call.getArguments()[0];
37
+ if (arg)
38
+ refs.push(arg.getText());
39
+ }
40
+ }
41
+ return [...new Set(refs)];
42
+ }
43
+ // This will return all the external calls made inside the component (meaning all the calls except the calls to the inner functions and the hooks)
44
+ function extractAllCalls(node) {
45
+ const innerFunctionNames = new Set();
46
+ // variable declearations like const fn = () => {} or const fn = function() {}
47
+ for (const varDecl of node.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
48
+ const name = varDecl.getName();
49
+ const init = varDecl.getInitializer();
50
+ if (!init)
51
+ continue;
52
+ if (init.getKind() === SyntaxKind.ArrowFunction || init.getKind() === SyntaxKind.FunctionExpression) {
53
+ innerFunctionNames.add(name);
54
+ }
55
+ }
56
+ // Function declarations like function fn() {}
57
+ for (const fn of node.getDescendantsOfKind(SyntaxKind.FunctionDeclaration)) {
58
+ const name = fn.getName();
59
+ if (name)
60
+ innerFunctionNames.add(name);
61
+ }
62
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
63
+ const externalCalls = new Set();
64
+ for (const call of calls) {
65
+ const expr = call.getExpression();
66
+ const fullExpr = expr.getText();
67
+ const rootName = fullExpr.split(".")[0];
68
+ if (rootName.startsWith("use"))
69
+ continue; // skip hooks
70
+ if (rootName.startsWith("React"))
71
+ continue;
72
+ if (innerFunctionNames.has(rootName))
73
+ continue; // skip calls to inner functions
74
+ // Capture the full expression (e.g. "axios.get" not just "axios") so
75
+ // callEdges.ts can create a per-method third-party node when needed.
76
+ externalCalls.add(fullExpr);
77
+ }
78
+ return [...externalCalls];
79
+ }
80
+ // Checks if a component has any state (useState or useReducer)
81
+ function hasState(hooks) {
82
+ return hooks.includes("useState") || hooks.includes("useReducer");
83
+ }
84
+ // Extracts prop types from the first parameter of a component function.
85
+ // Handles three patterns:
86
+ // 1. function Button({ color }: ButtonProps) — named ref → look up interface
87
+ // 2. function Button({ color }: { color: string }) — inline object type literal
88
+ // 3. const Button: React.FC<ButtonProps> = ... — generic type argument (regex)
89
+ function extractPropTypes(fn, sourceFile) {
90
+ const params = fn.getParameters ? fn.getParameters() : [];
91
+ if (!params.length)
92
+ return undefined;
93
+ const firstParam = params[0];
94
+ const typeNode = firstParam.getTypeNode();
95
+ if (!typeNode)
96
+ return undefined;
97
+ const typeText = typeNode.getText();
98
+ // Pattern 2: inline object literal `{ color: string; onClick: () => void }`
99
+ if (typeText.startsWith("{")) {
100
+ try {
101
+ const typeLiteral = typeNode.asKind(SyntaxKind.TypeLiteral);
102
+ if (typeLiteral) {
103
+ const props = {};
104
+ for (const member of typeLiteral.getProperties()) {
105
+ props[member.getName()] = member.getTypeNode()?.getText() ?? "unknown";
106
+ }
107
+ return Object.keys(props).length ? props : undefined;
108
+ }
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ // Pattern 1: named type reference like `ButtonProps`
115
+ const trimmed = typeText.trim();
116
+ if (/^[A-Z][A-Za-z0-9_]*$/.test(trimmed)) {
117
+ const result = extractReferencedInterfaces(sourceFile, [trimmed]);
118
+ return result[trimmed] ?? undefined;
119
+ }
120
+ return undefined;
121
+ }
122
+ export function extractComponents(file, fileDirective = null) {
123
+ const nodes = [];
124
+ const filePath = file.getFilePath();
125
+ // ─── Function Declarations ─────────────────────────────────────────────────
126
+ // e.g. function MyComponent() { return <div /> }
127
+ for (const fn of file.getFunctions()) {
128
+ const name = fn.getName();
129
+ if (!name)
130
+ continue;
131
+ // React components start with uppercase
132
+ if (!/^[A-Z]/.test(name))
133
+ continue;
134
+ // Must return JSX
135
+ if (!returnsJSX(fn))
136
+ continue;
137
+ const hooks = extractHooks(fn);
138
+ const externalCalls = extractAllCalls(fn);
139
+ const contextRefs = extractContextRefs(fn);
140
+ const renderingBoundary = detectFunctionDirective(fn.getBody()) ?? fileDirective;
141
+ const propTypes = extractPropTypes(fn, file);
142
+ const returnType = extractReturnTypeAnnotation(fn) ?? "JSX.Element";
143
+ nodes.push({
144
+ id: makeId(filePath, name),
145
+ name,
146
+ type: "COMPONENT",
147
+ filePath,
148
+ startLine: fn.getStartLineNumber(),
149
+ endLine: fn.getEndLineNumber(),
150
+ rawCode: fn.getText(),
151
+ metadata: {
152
+ hooks,
153
+ contextRefs,
154
+ uses: externalCalls,
155
+ hasState: hasState(hooks),
156
+ exportType: fn.isDefaultExport() ? "default" : "named",
157
+ propTypes,
158
+ returnType,
159
+ ...(renderingBoundary !== null && { renderingBoundary }),
160
+ },
161
+ });
162
+ }
163
+ // ─── Arrow Function Components ─────────────────────────────────────────────
164
+ // e.g. const MyComponent = () => <div />
165
+ // e.g. export const MyComponent = () => { return <div /> }
166
+ for (const variable of file.getVariableDeclarations()) {
167
+ const name = variable.getName();
168
+ // React components start with uppercase
169
+ if (!/^[A-Z]/.test(name))
170
+ continue;
171
+ const initializer = variable.getInitializer();
172
+ if (!initializer)
173
+ continue;
174
+ const isArrow = initializer.getKind() === SyntaxKind.ArrowFunction;
175
+ // Check for React.memo and React.forwardRef wrappers
176
+ const isMemoOrForwardRef = initializer.getKind() === SyntaxKind.CallExpression &&
177
+ (initializer.getText().startsWith("React.memo") ||
178
+ initializer.getText().startsWith("React.forwardRef") ||
179
+ initializer.getText().startsWith("memo(") ||
180
+ initializer.getText().startsWith("forwardRef("));
181
+ // Must be either a direct arrow function or a wrapped component
182
+ if (!isArrow && !isMemoOrForwardRef)
183
+ continue;
184
+ // For wrapped components we need to look inside the wrapper
185
+ // to find the actual arrow function to check for JSX
186
+ let nodeToAnalyze = initializer;
187
+ if (isMemoOrForwardRef) {
188
+ const callExpr = initializer.asKind(SyntaxKind.CallExpression);
189
+ const firstArg = callExpr?.getArguments()[0];
190
+ if (!firstArg)
191
+ continue;
192
+ // Inner component can be arrow function or regular function expression
193
+ const asArrow = firstArg.asKind(SyntaxKind.ArrowFunction);
194
+ const asFunctionExpr = firstArg.asKind(SyntaxKind.FunctionExpression);
195
+ const inner = asArrow ?? asFunctionExpr;
196
+ if (!inner)
197
+ continue;
198
+ nodeToAnalyze = inner;
199
+ }
200
+ // Must return JSX
201
+ if (!returnsJSX(nodeToAnalyze))
202
+ continue;
203
+ const hooks = extractHooks(nodeToAnalyze);
204
+ const externalCalls = extractAllCalls(nodeToAnalyze);
205
+ const contextRefs = extractContextRefs(nodeToAnalyze);
206
+ const renderingBoundary = detectFunctionDirective(nodeToAnalyze.getKind() === SyntaxKind.ArrowFunction
207
+ ? nodeToAnalyze.getBody()
208
+ : undefined) ?? fileDirective;
209
+ const propTypes = extractPropTypes(nodeToAnalyze, file);
210
+ const returnType = extractReturnTypeAnnotation(nodeToAnalyze) ?? "JSX.Element";
211
+ const variableStatement = variable.getVariableStatement();
212
+ const isExported = variableStatement
213
+ ? variableStatement.isExported()
214
+ : false;
215
+ const isDefault = variableStatement
216
+ ? variableStatement.isDefaultExport()
217
+ : false;
218
+ nodes.push({
219
+ id: makeId(filePath, name),
220
+ name,
221
+ type: "COMPONENT",
222
+ filePath,
223
+ startLine: variable.getStartLineNumber(),
224
+ endLine: variable.getEndLineNumber(),
225
+ rawCode: variable.getText(),
226
+ metadata: {
227
+ hooks,
228
+ contextRefs,
229
+ uses: externalCalls,
230
+ hasState: hasState(hooks),
231
+ exportType: isDefault ? "default" : isExported ? "named" : "none",
232
+ isMemoized: isMemoOrForwardRef,
233
+ propTypes,
234
+ returnType,
235
+ ...(renderingBoundary !== null && { renderingBoundary }),
236
+ },
237
+ });
238
+ }
239
+ return nodes;
240
+ }
@@ -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 extractFunctions(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];