@@ -24,7 +24,8 @@ import {
2424 type VariableDeclaration ,
2525} from "ts-morph" ;
2626
27- import { resolveEndpoint , type ResolvedEndpoint } from "./endpoint.js" ;
27+ import { fetchMethod , resolveEndpoint , type ResolvedEndpoint } from "./endpoint.js" ;
28+ import { detectWrappers , type WrapperRegistry } from "./wrappers.js" ;
2829
2930export interface ScanOptions {
3031 /** Directory to scan. */
@@ -37,6 +38,11 @@ export interface ScanOptions {
3738 * environment-independent.
3839 */
3940 baseUrls ?: string [ ] ;
41+ /**
42+ * Explicitly-declared API wrapper callees (e.g. ["http.get", "api.post"])
43+ * for clients the heuristic can't see. Heuristic detection runs regardless.
44+ */
45+ apiWrappers ?: string [ ] ;
4046}
4147
4248type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression ;
@@ -90,6 +96,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
9096 ] ) ;
9197 }
9298
99+ const wrappers = detectWrappers ( project , options . apiWrappers ?? [ ] ) ;
93100 const nodes = new Map < string , LineageNode > ( ) ;
94101 const edges : LineageEdge [ ] = [ ] ;
95102 const pendingInstances : PendingInstance [ ] = [ ] ;
@@ -125,7 +132,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
125132 nodes . set ( id , { id, kind : "hook" , name : decl . name , loc : decl . loc , exportName : decl . exportName } ) ;
126133 }
127134
128- extractBodyFacts ( decl , id , file , nodes , addEdge , baseUrls ) ;
135+ extractBodyFacts ( decl , id , file , nodes , addEdge , baseUrls , wrappers ) ;
129136 }
130137 }
131138
@@ -266,11 +273,17 @@ function extractBodyFacts(
266273 nodes : Map < string , LineageNode > ,
267274 addEdge : ( edge : LineageEdge ) => void ,
268275 baseUrls : string [ ] ,
276+ wrappers : WrapperRegistry ,
269277) : void {
278+ // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a
279+ // data source emitted here would attribute ":path" to every consumer. Call
280+ // sites get the real, substituted endpoint instead.
281+ const declIsWrapper = wrappers . has ( decl . name ) ;
282+
270283 for ( const call of decl . fn . getDescendantsOfKind ( SyntaxKind . CallExpression ) ) {
271284 const callee = call . getExpression ( ) . getText ( ) ;
272285
273- const dataSource = detectDataSource ( call , callee , baseUrls ) ;
286+ const dataSource = declIsWrapper ? null : detectDataSource ( call , callee , baseUrls , wrappers ) ;
274287 if ( dataSource !== null ) {
275288 const dsId = nodeId ( "data-source" , file , `${ dataSource . sourceKind } :${ dataSource . endpoint } ` ) ;
276289 if ( ! nodes . has ( dsId ) ) {
@@ -341,9 +354,26 @@ function detectDataSource(
341354 call : CallExpression ,
342355 callee : string ,
343356 baseUrls : string [ ] ,
357+ wrappers : WrapperRegistry ,
344358) : ( { sourceKind : DataSourceKind ; method : string | null } & ResolvedEndpoint ) | null {
345359 const firstArg = call . getArguments ( ) [ 0 ] ;
346360
361+ const wrapper = wrappers . get ( callee ) ;
362+ if ( wrapper !== undefined ) {
363+ const pathArg = call . getArguments ( ) [ wrapper . pathParamIndex ] ;
364+ const resolved = resolveEndpoint ( pathArg , baseUrls ) ;
365+ const substitution =
366+ resolved . resolved === "none" ? `:${ wrapper . paramName } ` : resolved . endpoint ;
367+ const endpoint = wrapper . template . replace ( `:${ wrapper . paramName } ` , substitution ) ;
368+ return {
369+ sourceKind : wrapper . sourceKind ,
370+ method : wrapper . method ,
371+ endpoint,
372+ raw : call . getText ( ) . slice ( 0 , 120 ) ,
373+ resolved : endpoint . includes ( ":" ) ? "partial" : "full" ,
374+ } ;
375+ }
376+
347377 if ( callee === "fetch" ) {
348378 return {
349379 sourceKind : "fetch" ,
@@ -384,31 +414,6 @@ function detectDataSource(
384414 return null ;
385415}
386416
387- function fetchMethod ( call : CallExpression ) : string {
388- const optionsArg = call . getArguments ( ) [ 1 ] ;
389- if ( optionsArg !== undefined && Node . isObjectLiteralExpression ( optionsArg ) ) {
390- const methodProp = optionsArg . getProperty ( "method" ) ;
391- if ( methodProp !== undefined && Node . isPropertyAssignment ( methodProp ) ) {
392- const value = methodProp . getInitializer ( ) ;
393- const literal = literalText ( value ) ;
394- if ( literal !== null ) return literal . toUpperCase ( ) ;
395- }
396- }
397- return "GET" ;
398- }
399-
400- /** String literal or template text (with `${...}` placeholders preserved). */
401- function literalText ( node : Node | undefined ) : string | null {
402- if ( node === undefined ) return null ;
403- if ( Node . isStringLiteral ( node ) || Node . isNoSubstitutionTemplateLiteral ( node ) ) {
404- return node . getLiteralValue ( ) ;
405- }
406- if ( Node . isTemplateExpression ( node ) ) {
407- return node . getText ( ) . slice ( 1 , - 1 ) ; // keep ${...} placeholders, drop backticks
408- }
409- return null ;
410- }
411-
412417function detectState ( callee : string ) : "useState" | "useReducer" | "context" | "redux" | "zustand" | null {
413418 switch ( callee ) {
414419 case "useState" :
0 commit comments